feat: [PERFORMANCES] add a performances monitor

This commit is contained in:
Florent Champigny 2026-01-12 11:29:50 +01:00
parent 3631f0dc97
commit f02fc5718f
8 changed files with 67 additions and 45 deletions

View file

@ -3,16 +3,16 @@ package io.github.openflocon.flocondesktop.features.performance
import kotlinx.serialization.Serializable
@Serializable
data class MetricEvent(
data class MetricEventUiModel(
val timestamp: String,
val ramMb: String,
val ramMb: String?,
val fps: String,
val jankPercentage: String,
val battery: String,
val screenshotPath: String?,
)
fun previewMetricsEvent() = MetricEvent(
fun previewMetricsEvent() = MetricEventUiModel(
timestamp = "10:55:38.123",
ramMb = "150",
fps = "60.0",

View file

@ -18,7 +18,7 @@ internal sealed interface PerformanceRoutes : FloconRoute {
}
@Serializable
data class Detail(val event: MetricEvent) : PerformanceRoutes, WindowRoute {
data class Detail(val event: MetricEventUiModel) : PerformanceRoutes, WindowRoute {
override val singleTopKey = null
}
}

View file

@ -2,7 +2,9 @@ package io.github.openflocon.flocondesktop.features.performance
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import io.github.openflocon.domain.common.ByteFormatter
import io.github.openflocon.domain.common.DispatcherProvider
import io.github.openflocon.domain.device.usecase.GetCurrentDeviceIdAndPackageNameUseCase
import io.github.openflocon.domain.performance.usecase.FetchPerformanceMetricsUseCase
import io.github.openflocon.domain.performance.usecase.GetAdbDevicesUseCase
import io.github.openflocon.domain.performance.usecase.GetDeviceRefreshRateUseCase
@ -10,10 +12,7 @@ import io.github.openflocon.navigation.MainFloconNavigationState
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
@ -24,6 +23,7 @@ import java.time.format.DateTimeFormatter
class PerformanceViewModel(
private val fetchPerformanceMetricsUseCase: FetchPerformanceMetricsUseCase,
private val getCurrentDeviceIdAndPackageNameUseCase: GetCurrentDeviceIdAndPackageNameUseCase,
private val getAdbDevicesUseCase: GetAdbDevicesUseCase,
private val getDeviceRefreshRateUseCase: GetDeviceRefreshRateUseCase,
private val navigationState: MainFloconNavigationState,
@ -42,7 +42,7 @@ class PerformanceViewModel(
private val _intervalMs = MutableStateFlow(1000L)
val intervalMs = _intervalMs.asStateFlow()
private val _metrics = MutableStateFlow<List<MetricEvent>>(emptyList())
private val _metrics = MutableStateFlow<List<MetricEventUiModel>>(emptyList())
val metrics = _metrics.asStateFlow()
private val _isMonitoring = MutableStateFlow(false)
@ -62,6 +62,11 @@ class PerformanceViewModel(
_selectedDevice.value = deviceList.first()
}
}
viewModelScope.launch(dispatcherProvider.viewModel) {
getCurrentDeviceIdAndPackageNameUseCase()?.let { (_, packageName) ->
_packageName.value = packageName
}
}
}
fun onDeviceSelected(deviceId: String) {
@ -95,7 +100,7 @@ class PerformanceViewModel(
if (deviceSerial != null) {
refreshRate = getDeviceRefreshRateUseCase(deviceSerial)
}
while (isActive) {
fetchMetrics()
delay(_intervalMs.value)
@ -124,10 +129,13 @@ class PerformanceViewModel(
lastFrameCount = domainModel.totalFrames
lastFetchTime = domainModel.timestamp
val event = MetricEvent(
timestamp = LocalDateTime.ofInstant(Instant.ofEpochMilli(domainModel.timestamp), ZoneId.systemDefault())
val event = MetricEventUiModel(
timestamp = LocalDateTime.ofInstant(
Instant.ofEpochMilli(domainModel.timestamp),
ZoneId.systemDefault()
)
.format(DateTimeFormatter.ofPattern("HH:mm:ss.SSS")),
ramMb = domainModel.ramMb,
ramMb = domainModel.ramMb?.let { ByteFormatter.formatBytes(it) },
fps = if (domainModel.fps > 0) String.format("%.1f", domainModel.fps) else "0",
jankPercentage = String.format("%.1f%%", domainModel.jankPercentage),
battery = domainModel.battery,
@ -137,7 +145,7 @@ class PerformanceViewModel(
_metrics.update { listOf(event) + it }
}
fun onEventClicked(event: MetricEvent) {
fun onEventClicked(event: MetricEventUiModel) {
navigationState.navigate(PerformanceRoutes.Detail(event))
}
}

View file

@ -18,10 +18,9 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import io.github.openflocon.flocondesktop.common.ui.isInPreview
import io.github.openflocon.flocondesktop.features.performance.MetricEvent
import io.github.openflocon.flocondesktop.features.performance.MetricEventUiModel
import io.github.openflocon.flocondesktop.features.performance.previewMetricsEvent
import io.github.openflocon.library.designsystem.FloconTheme
import io.github.openflocon.library.designsystem.components.FloconCircularProgressIndicator
import io.github.openflocon.library.designsystem.components.FloconSurface
import org.jetbrains.compose.ui.tooling.preview.Preview
@ -29,8 +28,8 @@ private val imageSize = 40.dp
@Composable
internal fun MetricItemView(
event: MetricEvent,
onClick: (MetricEvent) -> Unit,
event: MetricEventUiModel,
onClick: (MetricEventUiModel) -> Unit,
) {
val bodySmall = FloconTheme.typography.bodySmall.copy(fontSize = 11.sp)
@ -81,7 +80,7 @@ internal fun MetricItemView(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Text("RAM: ${event.ramMb} MB", style = bodySmall)
event.ramMb?.let { Text("RAM: $it", style = bodySmall) }
Text("Battery: ${event.battery}", style = bodySmall)
}
}

View file

@ -21,14 +21,14 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import io.github.openflocon.flocondesktop.features.performance.MetricEvent
import io.github.openflocon.flocondesktop.features.performance.MetricEventUiModel
import io.github.openflocon.library.designsystem.FloconTheme
import org.jetbrains.compose.ui.tooling.preview.Preview
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PerformanceDetailScreen(
event: MetricEvent,
event: MetricEventUiModel,
) {
Scaffold(
topBar = {
@ -62,7 +62,7 @@ fun PerformanceDetailScreen(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
MetricText(label = "RAM Usage", value = "${event.ramMb} MB")
event.ramMb?.let { MetricText(label = "RAM Usage", value = "$it MB") }
MetricText(label = "FPS", value = event.fps)
MetricText(label = "Jank Percentage", value = event.jankPercentage)
MetricText(label = "Battery", value = event.battery)
@ -113,7 +113,7 @@ private fun MetricText(label: String, value: String) {
private fun PerformanceDetailScreenPreview() {
FloconTheme {
PerformanceDetailScreen(
event = MetricEvent(
event = MetricEventUiModel(
timestamp = "10:55:38.123",
ramMb = "150",
fps = "60.0",

View file

@ -2,9 +2,11 @@
package io.github.openflocon.flocondesktop.features.performance.view
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Remove
@ -12,16 +14,13 @@ import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage
import io.github.openflocon.flocondesktop.features.performance.MetricEvent
import io.github.openflocon.flocondesktop.features.performance.PerformanceViewModel
import io.github.openflocon.library.designsystem.FloconTheme
import io.github.openflocon.library.designsystem.components.*
import org.koin.compose.viewmodel.koinViewModel
import java.io.File
@Composable
fun PerformanceScreen() {
@ -33,7 +32,7 @@ fun PerformanceScreen() {
val metrics by viewModel.metrics.collectAsStateWithLifecycle()
val isMonitoring by viewModel.isMonitoring.collectAsStateWithLifecycle()
FloconScaffold() { paddingValues ->
FloconScaffold { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
@ -54,7 +53,8 @@ fun PerformanceScreen() {
onExpandedChange = { expanded = it }
) {
Text(
modifier = Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryNotEditable),
modifier = Modifier.fillMaxWidth()
.menuAnchor(MenuAnchorType.PrimaryNotEditable),
text = selectedDevice ?: "Select Device",
//onValueChange = {},
//readOnly = true,
@ -137,17 +137,34 @@ fun PerformanceScreen() {
HorizontalDivider()
LazyColumn(
modifier = Modifier.fillMaxWidth().weight(1f),
contentPadding = PaddingValues(vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
Box(
modifier = Modifier.fillMaxWidth()
.weight(1f)
.clip(FloconTheme.shapes.medium)
.background(FloconTheme.colorPalette.primary),
contentAlignment = Alignment.Center
) {
items(metrics) { event ->
MetricItemView(
event = event,
onClick = viewModel::onEventClicked
)
val listState = rememberLazyListState()
val scrollAdapter = rememberFloconScrollbarAdapter(listState)
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxSize(),
contentPadding = PaddingValues(vertical = 8.dp, horizontal = 6.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(metrics) { event ->
MetricItemView(
event = event,
onClick = viewModel::onEventClicked
)
}
}
FloconVerticalScrollbar(
adapter = scrollAdapter,
modifier = Modifier.fillMaxHeight()
.align(Alignment.TopEnd)
)
}
}
}

View file

@ -1,7 +1,7 @@
package io.github.openflocon.domain.performance.model
data class PerformanceMetricsDomainModel(
val ramMb: String,
val ramMb: Long?,
val fps: Double,
val jankPercentage: Double,
val battery: String,

View file

@ -7,19 +7,17 @@ import io.github.openflocon.domain.common.getOrNull
class GetRamUsageUseCase(
private val executeAdbCommandUseCase: ExecuteAdbCommandUseCase,
) {
suspend operator fun invoke(deviceSerial: String, packageName: String): String {
if (packageName.isEmpty()) return "N/A"
suspend operator fun invoke(deviceSerial: String, packageName: String): Long? {
if (packageName.isEmpty()) return null
return executeAdbCommandUseCase(
target = AdbCommandTargetDomainModel.DeviceSerial(deviceSerial),
command = "shell dumpsys meminfo $packageName"
).mapSuccess { output ->
val pssRegex = Regex("(?:TOTAL PSS:|TOTAL)\\s+(\\d+)", RegexOption.IGNORE_CASE)
val pssKb = pssRegex.find(output)?.groupValues?.get(1)?.toLongOrNull()
if (pssKb != null) {
(pssKb / 1024).toString()
} else {
"N/A"
pssKb?.let {
it * 1024
}
}.getOrNull() ?: "N/A"
}.getOrNull()
}
}