diff --git a/app/build.gradle b/app/build.gradle index 1b8baff7599..84013fe2e70 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -283,6 +283,9 @@ dependencies { androidTestImplementation(composeBom) debugImplementation(libs.compose.debug.test) debugImplementation(composeBom) + + implementation libs.androidx.metrics.performance + } private setSigningConfigKey(config, Properties props) { diff --git a/app/src/main/java/org/wikipedia/JankStatsAggregator.kt b/app/src/main/java/org/wikipedia/JankStatsAggregator.kt new file mode 100644 index 00000000000..e6d49ba551e --- /dev/null +++ b/app/src/main/java/org/wikipedia/JankStatsAggregator.kt @@ -0,0 +1,87 @@ +package org.wikipedia + +import android.view.Window +import androidx.metrics.performance.FrameData +import androidx.metrics.performance.JankStats + +/** + * This utility class can be used to provide a simple data aggregation mechanism for JankStats. + * Instead of receiving a callback on every frame and caching that data, JankStats users can + * create JankStats indirectly through this Aggregator class, which will compile the data + * and issue it upon request. + * + * @param window The Window for which stats will be tracked. A JankStatsAggregator + * instance is specific to each window in an application, since the timing metrics are + * tracked on a per-window basis internally. + * @param onJankReportListener This listener will be called whenever there is a call to + * [issueJankReport]. + * @throws IllegalStateException This function will throw an exception if `window` has + * a null DecorView. + */ +class JankStatsAggregator( + window: Window, + private val onJankReportListener: OnJankReportListener +) { + + private val listener = JankStats.OnFrameListener { frameData -> + ++numFrames + if (frameData.isJank) { + jankReport.add(frameData.copy()) + if (jankReport.size >= REPORT_BUFFER_LIMIT) { + issueJankReport("Max buffer size reached") + } + } + } + + val jankStats = JankStats.createAndTrack(window, listener) + + private var jankReport = ArrayList() + + private var numFrames: Int = 0 + + /** + * Issue a report on current jank data. The data includes FrameData for every frame + * experiencing jank since the listener was set, or since the last time a report + * was issued for this JankStats object. Calling this function will cause the jankData + * to be reset and cleared. Note that this function may be called externally, from application + * code, but it may also be called internally for various reasons (to reduce memory size + * by clearing the buffer, or because there was an important lifecycle event). The + * [reason] parameter explains why the report was issued if it was not called externally. + * + * @param reason An optional parameter specifying the reason that the report was issued. + * This parameter may be used if JankStats issues a report for some internal reason. + */ + fun issueJankReport(reason: String = "") { + val jankReportCopy = jankReport + val numFramesCopy = numFrames + onJankReportListener.onJankReport(reason, numFramesCopy, jankReportCopy) + jankReport = ArrayList() + numFrames = 0 + } + + /** + * This listener is called whenever there is a call to [issueJankReport]. + */ + fun interface OnJankReportListener { + /** + * The implementation of this method will be called whenever there is a call + * to [issueJankReport]. + * + * @param reason Optional reason that this report was issued + * @param totalFrames The total number of frames (jank and not) since collection + * began (or since the last time the report was issued and reset) + * @param jankFrameData The FrameData for every frame experiencing jank during + * the collection period + */ + fun onJankReport(reason: String, totalFrames: Int, jankFrameData: List) + } + + companion object { + /** + * The number of frames for which data can be accumulated is limited to avoid + * memory problems. When the limit is reached, a report is automatically issued + * and the buffer is cleared. + */ + private const val REPORT_BUFFER_LIMIT = 1000 + } +} diff --git a/app/src/main/java/org/wikipedia/feed/FeedFragment.kt b/app/src/main/java/org/wikipedia/feed/FeedFragment.kt index 7b35416fd90..76e56289fee 100644 --- a/app/src/main/java/org/wikipedia/feed/FeedFragment.kt +++ b/app/src/main/java/org/wikipedia/feed/FeedFragment.kt @@ -9,6 +9,7 @@ import android.view.ViewGroup import androidx.activity.result.contract.ActivityResultContracts import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope +import androidx.metrics.performance.PerformanceMetricsState import androidx.recyclerview.widget.RecyclerView import com.google.android.material.dialog.MaterialAlertDialogBuilder import org.wikipedia.BackPressedHandler @@ -132,11 +133,15 @@ class FeedFragment : Fragment(), BackPressedHandler { return binding.root } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + val metricsStateHolder = PerformanceMetricsState.getHolderForHierarchy(requireActivity().window.decorView) + metricsStateHolder.state?.putState("screen", this.javaClass.simpleName) + } override fun onResume() { super.onResume() maybeShowRegionalLanguageVariantDialog() OnThisDayGameOnboardingFragment.maybeShowOnThisDayGameDialog(requireActivity(), InvokeSource.FEED) - // Explicitly invalidate the feed adapter, since it occasionally crashes the StaggeredGridLayout // on certain devices. // https://issuetracker.google.com/issues/188096921 diff --git a/app/src/main/java/org/wikipedia/main/MainActivity.kt b/app/src/main/java/org/wikipedia/main/MainActivity.kt index a3c96f90160..1da073cca3d 100644 --- a/app/src/main/java/org/wikipedia/main/MainActivity.kt +++ b/app/src/main/java/org/wikipedia/main/MainActivity.kt @@ -9,7 +9,9 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.view.ActionMode import androidx.appcompat.widget.Toolbar import androidx.fragment.app.Fragment +import androidx.metrics.performance.PerformanceMetricsState import org.wikipedia.Constants +import org.wikipedia.JankStatsAggregator import org.wikipedia.R import org.wikipedia.activity.SingleFragmentActivity import org.wikipedia.analytics.eventplatform.ImageRecommendationsEvent @@ -38,6 +40,15 @@ class MainActivity : SingleFragmentActivity(), MainFragment.Callba } } + private lateinit var jankStatsAggregator: JankStatsAggregator + private val jankReportListener = JankStatsAggregator.OnJankReportListener { reason, totalFrames, jankFrameData -> + println("JankStats: " + + "reason $reason" + + "totalFrames = $totalFrames" + + "jankFrames = ${jankFrameData.size}") + println("JankStats --> $jankFrameData") + } + override fun inflateAndSetContentView() { binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) @@ -48,6 +59,10 @@ class MainActivity : SingleFragmentActivity(), MainFragment.Callba if (!DeviceUtil.assertAppContext(this)) { return } + val metricsStateHolder = PerformanceMetricsState.getHolderForHierarchy(binding.root) + jankStatsAggregator = JankStatsAggregator(window, jankReportListener) + metricsStateHolder.state?.putState("screen", javaClass.simpleName) + metricsStateHolder.state?.putState("device", android.os.Build.MODEL) setImageZoomHelper() if (Prefs.isInitialOnboardingEnabled && savedInstanceState == null && @@ -67,9 +82,16 @@ class MainActivity : SingleFragmentActivity(), MainFragment.Callba override fun onResume() { super.onResume() + jankStatsAggregator.jankStats.isTrackingEnabled = true invalidateOptionsMenu() } + override fun onPause() { + super.onPause() + jankStatsAggregator.issueJankReport("Activity paused") + jankStatsAggregator.jankStats.isTrackingEnabled = false + } + override fun createFragment(): MainFragment { return MainFragment.newInstance() } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f03a69cf67b..cdd63c21b6e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -28,6 +28,7 @@ kotlinxSerializationJson = "1.8.1" kspPlugin = "2.1.21-2.0.1" leakCanaryVersion = "2.14" material = "1.12.0" +metricsPerformance = "1.0.0-beta02" metricsVersion = "2.9" mlKitVersion = "17.0.6" mockitoVersion = "5.2.0" @@ -55,6 +56,7 @@ android-sdk = { module = "org.maplibre.gl:android-sdk", version.ref = "androidSd android-plugin-annotation-v9 = { module = "org.maplibre.gl:android-plugin-annotation-v9", version.ref = "androidPluginAnnotationV9" } androidx-espresso-intents = { module = "androidx.test.espresso:espresso-intents", version.ref = "espressoVersion" } androidx-junit = { module = "androidx.test.ext:junit", version.ref = "junitVersion" } +androidx-metrics-performance = { module = "androidx.metrics:metrics-performance", version.ref = "metricsPerformance" } androidx-orchestrator = { module = "androidx.test:orchestrator", version.ref = "orchestrator" } androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "roomVersion" } androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "roomVersion" }