From c6d5080c5403a4b626fa7b06f49cd116accd6498 Mon Sep 17 00:00:00 2001 From: Mert Toptas Date: Mon, 16 Oct 2023 09:01:49 +0300 Subject: [PATCH] jank stats added. --- .../di/JankStatsModule.kt | 38 +++++++++ .../feature/appstate/MainAppState.kt | 21 +++++ .../feature/main/MainActivity.kt | 16 +++- .../samplecomposeandroid/AndroidCompose.kt | 10 ++- .../java/com/loodos/ui/JankStatsExtensions.kt | 80 +++++++++++++++++++ .../java/com/merttoptas/home/HomeScreen.kt | 7 ++ 6 files changed, 169 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/com/loodos/samplecomposeandroid/di/JankStatsModule.kt create mode 100644 core/ui/src/main/java/com/loodos/ui/JankStatsExtensions.kt diff --git a/app/src/main/java/com/loodos/samplecomposeandroid/di/JankStatsModule.kt b/app/src/main/java/com/loodos/samplecomposeandroid/di/JankStatsModule.kt new file mode 100644 index 0000000..74b659e --- /dev/null +++ b/app/src/main/java/com/loodos/samplecomposeandroid/di/JankStatsModule.kt @@ -0,0 +1,38 @@ +package com.loodos.samplecomposeandroid.di + +import android.app.Activity +import android.util.Log +import android.view.Window +import androidx.metrics.performance.JankStats +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityComponent + +@Module +@InstallIn(ActivityComponent::class) +object JankStatsModule { + @Provides + fun providesOnFrameListener(): JankStats.OnFrameListener { + return JankStats.OnFrameListener { frameData -> + // Make sure to only log janky frames. + if (frameData.isJank) { + // We're currently logging this but would better report it to a backend. + Log.v("SampleCompose Jank", frameData.toString()) + } + } + } + + @Provides + fun providesWindow(activity: Activity): Window { + return activity.window + } + + @Provides + fun providesJankStats( + window: Window, + frameListener: JankStats.OnFrameListener, + ): JankStats { + return JankStats.createAndTrack(window, frameListener) + } +} diff --git a/app/src/main/java/com/loodos/samplecomposeandroid/feature/appstate/MainAppState.kt b/app/src/main/java/com/loodos/samplecomposeandroid/feature/appstate/MainAppState.kt index d8591a9..a0247aa 100644 --- a/app/src/main/java/com/loodos/samplecomposeandroid/feature/appstate/MainAppState.kt +++ b/app/src/main/java/com/loodos/samplecomposeandroid/feature/appstate/MainAppState.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.navigation.NavController import androidx.navigation.NavDestination import androidx.navigation.NavDestination.Companion.hierarchy import androidx.navigation.NavHostController @@ -12,6 +13,7 @@ import androidx.navigation.compose.rememberNavController import androidx.navigation.navOptions import com.loodos.data.util.NetworkMonitor import com.loodos.samplecomposeandroid.navigation.TopLevelDestination +import com.loodos.ui.TrackDisposableJank import com.merttoptas.category.navigation.navigateToCategory import com.merttoptas.home.navigation.navigateToHome import com.merttoptas.profile.navigation.navigateToProfile @@ -30,6 +32,7 @@ fun rememberMainAppState( coroutineScope: CoroutineScope = rememberCoroutineScope(), navController: NavHostController = rememberNavController(), ): MainAppState { + NavigationTrackingSideEffect(navController) return remember(navController, coroutineScope, networkMonitor) { MainAppState(navController, coroutineScope, networkMonitor) } @@ -88,3 +91,21 @@ class MainAppState( navController.popBackStack() } } + +/** + * Stores information about navigation events to be used with JankStats + */ +@Composable +private fun NavigationTrackingSideEffect(navController: NavHostController) { + TrackDisposableJank(navController) { metricsHolder -> + val listener = NavController.OnDestinationChangedListener { _, destination, _ -> + metricsHolder.state?.putState("Navigation", destination.route.toString()) + } + + navController.addOnDestinationChangedListener(listener) + + onDispose { + navController.removeOnDestinationChangedListener(listener) + } + } +} diff --git a/app/src/main/java/com/loodos/samplecomposeandroid/feature/main/MainActivity.kt b/app/src/main/java/com/loodos/samplecomposeandroid/feature/main/MainActivity.kt index e8f6c9d..90cf5af 100644 --- a/app/src/main/java/com/loodos/samplecomposeandroid/feature/main/MainActivity.kt +++ b/app/src/main/java/com/loodos/samplecomposeandroid/feature/main/MainActivity.kt @@ -17,6 +17,7 @@ import androidx.core.view.WindowCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle +import androidx.metrics.performance.JankStats import com.loodos.data.util.NetworkMonitor import com.loodos.samplecomposeandroid.feature.appstate.MainApp import dagger.hilt.android.AndroidEntryPoint @@ -29,7 +30,10 @@ import javax.inject.Inject class MainActivity : ComponentActivity() { @Inject - lateinit var networkMonitor: com.loodos.data.util.NetworkMonitor + lateinit var networkMonitor: NetworkMonitor + + @Inject + lateinit var lazyStats: dagger.Lazy companion object { const val splashFadeDurationMillis = 1000L @@ -88,6 +92,16 @@ class MainActivity : ComponentActivity() { } } } + + override fun onResume() { + super.onResume() + lazyStats.get().isTrackingEnabled = true + } + + override fun onPause() { + super.onPause() + lazyStats.get().isTrackingEnabled = false + } } @Composable diff --git a/build-logic/convention/src/main/kotlin/samplecomposeandroid/AndroidCompose.kt b/build-logic/convention/src/main/kotlin/samplecomposeandroid/AndroidCompose.kt index 16b5ab2..5302aec 100644 --- a/build-logic/convention/src/main/kotlin/samplecomposeandroid/AndroidCompose.kt +++ b/build-logic/convention/src/main/kotlin/samplecomposeandroid/AndroidCompose.kt @@ -31,6 +31,9 @@ internal fun Project.configureAndroidCompose( val bom = libs.findLibrary("compose-bom").get() add("implementation", platform(bom)) add("androidTestImplementation", platform(bom)) + // Add ComponentActivity to debug manifest + add("debugImplementation", libs.findLibrary("androidx.compose.ui.testManifest").get()) + } } @@ -44,9 +47,12 @@ internal fun Project.configureAndroidCompose( private fun Project.buildComposeMetricsParameters(): List { val metricParameters = mutableListOf() val enableMetricsProvider = project.providers.gradleProperty("enableComposeCompilerMetrics") + val relativePath = projectDir.relativeTo(rootDir) + val buildDir = layout.buildDirectory.get().asFile + val enableMetrics = (enableMetricsProvider.orNull == "true") if (enableMetrics) { - val metricsFolder = File(project.buildDir, "compose-metrics") + val metricsFolder = buildDir.resolve("compose-metrics").resolve(relativePath) metricParameters.add("-P") metricParameters.add( "plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=" + metricsFolder.absolutePath @@ -56,7 +62,7 @@ private fun Project.buildComposeMetricsParameters(): List { val enableReportsProvider = project.providers.gradleProperty("enableComposeCompilerReports") val enableReports = (enableReportsProvider.orNull == "true") if (enableReports) { - val reportsFolder = File(project.buildDir, "compose-reports") + val reportsFolder = File(project.buildDir, "compose-reports").resolve(relativePath) metricParameters.add("-P") metricParameters.add( "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=" + reportsFolder.absolutePath diff --git a/core/ui/src/main/java/com/loodos/ui/JankStatsExtensions.kt b/core/ui/src/main/java/com/loodos/ui/JankStatsExtensions.kt new file mode 100644 index 0000000..647a64d --- /dev/null +++ b/core/ui/src/main/java/com/loodos/ui/JankStatsExtensions.kt @@ -0,0 +1,80 @@ +package com.loodos.ui + +import androidx.compose.foundation.gestures.ScrollableState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.DisposableEffectResult +import androidx.compose.runtime.DisposableEffectScope +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.platform.LocalView +import androidx.metrics.performance.PerformanceMetricsState +import kotlinx.coroutines.CoroutineScope + +/** + * Retrieves [PerformanceMetricsState.Holder] from current [LocalView] and + * remembers it until the View changes. + * @see PerformanceMetricsState.getHolderForHierarchy + */ + +@Composable +fun rememberMetricsStateHolder(): PerformanceMetricsState.Holder { + val localView = LocalView.current + + return remember(localView) { + PerformanceMetricsState.getHolderForHierarchy(localView) + } +} + +/** + * Convenience function to work with [PerformanceMetricsState] state. The side effect is + * re-launched if any of the [keys] value is not equal to the previous composition. + * @see TrackDisposableJank if you need to work with DisposableEffect to cleanup added state. + */ + +@Composable +fun TrackJank( + vararg keys: Any?, + reportMetric: suspend CoroutineScope.(state: PerformanceMetricsState.Holder) -> Unit, +) { + val metrics = rememberMetricsStateHolder() + LaunchedEffect(metrics, *keys) { + reportMetric(metrics) + } +} + +/** + * Convenience function to work with [PerformanceMetricsState] state that needs to be cleaned up. + * The side effect is re-launched if any of the [keys] value is not equal to the previous composition. + */ +@Composable +fun TrackDisposableJank( + vararg keys: Any?, + reportMetric: DisposableEffectScope.(state: PerformanceMetricsState.Holder) -> DisposableEffectResult, +) { + val metrics = rememberMetricsStateHolder() + DisposableEffect(metrics, *keys) { + reportMetric(this, metrics) + } +} + + + +/** + * Track jank while scrolling anything that's scrollable. + */ +@Composable +fun TrackScrollJank(scrollableState: ScrollableState, stateName: String) { + TrackJank(scrollableState) { metricsHolder -> + snapshotFlow { scrollableState.isScrollInProgress }.collect { isScrollInProgress -> + metricsHolder.state?.apply { + if (isScrollInProgress) { + putState(stateName, "Scrolling=true") + } else { + removeState(stateName) + } + } + } + } +} diff --git a/feature/home/src/main/java/com/merttoptas/home/HomeScreen.kt b/feature/home/src/main/java/com/merttoptas/home/HomeScreen.kt index 5f43ecb..a4e8c59 100644 --- a/feature/home/src/main/java/com/merttoptas/home/HomeScreen.kt +++ b/feature/home/src/main/java/com/merttoptas/home/HomeScreen.kt @@ -16,6 +16,8 @@ import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.CircularProgressIndicator @@ -32,6 +34,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.loodos.samplecomposeandroid.feature.home.R +import com.loodos.ui.TrackScrollJank import de.palm.composestateevents.EventEffect /** @@ -89,6 +92,9 @@ fun Content( onProductClick: (ProductItem) -> Unit, modifier: Modifier = Modifier, ) { + val scrollableState = rememberLazyListState() + TrackScrollJank(scrollableState = scrollableState, stateName = "home:LazyList") + Column( modifier = modifier .fillMaxSize() @@ -96,6 +102,7 @@ fun Content( horizontalAlignment = Alignment.CenterHorizontally, ) { LazyColumn( + state = scrollableState, modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp), contentPadding = PaddingValues(vertical = 16.dp),