diff --git a/README.md b/README.md index d7503a2..c55213c 100644 --- a/README.md +++ b/README.md @@ -16,9 +16,13 @@ flexible with re-using the monitoring approaches already existing in your produc Library supports collecting following performance metrics: - App Cold Startup Time - Rendering performance per Activity +- Time to Interactive & Time to First Render per screen We recommend to read our blogpost ["Measuring mobile apps performance in production"](https://medium.com/booking-com-development/measuring-mobile-apps-performance-in-production-726e7e84072f) -first to get some idea on how performance metrics work and why those were chosen. +first to get some idea on what are these performance metrics, how they work and why those were chosen. + +> NOTE: You can also refer to the [SampleApp](sampleApp/src/main/java/com/booking/perfsuite/app) +> in this repo to see a simplified example of how the library can be used in the real app ### Dependency @@ -98,6 +102,55 @@ Then metrics will be represented as [`RenderingMetrics`](src/main/java/com/booki Even though we support collecting widely used slow & frozen frames we [strongly recommend relying on `totalFreezeTimeMs` as the main rendering metric](https://medium.com/booking-com-development/measuring-mobile-apps-performance-in-production-726e7e84072f#2d5d) +### Collecting Screen Time to Interactive (TTI) + +Implement the callbacks invoked every time when screen's +[Time To Interactive (TTI)](https://medium.com/booking-com-development/measuring-mobile-apps-performance-in-production-726e7e84072f#ad4d) & +[Time To First Render (TTFR)](https://medium.com/booking-com-development/measuring-mobile-apps-performance-in-production-726e7e84072f#f862) +metrics are collected: + +```kotlin +object MyTtiListener : BaseTtiTracker.Listener { + + override fun onScreenCreated(screen: String) {} + + override fun onFirstFrameIsDrawn(screen: String, duration: Long) { + // Log or report TTFR metrics for specific screen in a preferable way + } + override fun onFirstUsableFrameIsDrawn(screen: String, duration: Long) { + // Log or report TTI metrics for specific screen in a preferable way + } +} +``` + +Then instantiate TTI tracker in `Application#onCreate` before any activity is created and using this listener: + +```kotlin +// keep instances globally accessible or inject as singletons using any preferable DI framework +val ttiTracker = BaseTtiTracker(AppTtiListener) +val viewTtiTracker = ViewTtiTracker(ttiTracker) + +class MyApplication : Application() { + + override fun onCreate() { + super.onCreate() + ActivityTtfrHelper.register(this, viewTtiTracker) + } +} +``` + +That will enable automatic TTFR collection for every Activity in the app. +For TTI collection you'll need to call `viewTtiTracker.onScreenIsUsable(..)` manually from the Activity, +when the meaningful data is visible to the user e.g.: + +```kotlin +// call this e.g. when the data is received from the backend, +// progress bar stops spinning and screen is fully ready for the user +viewTtiTracker.onScreenIsUsable(activity.componentName, rootContentView) +``` + +See the [SampleApp](sampleApp/src/main/java/com/booking/perfsuite/app) for a full working example + ## Additional documentation - [Measuring mobile apps performance in production](https://medium.com/booking-com-development/measuring-mobile-apps-performance-in-production-726e7e84072f) - [App Startup Time documentation by Google](https://developer.android.com/topic/performance/vitals/launch-time) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9d0210e..9c9e29c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,6 +9,7 @@ androidx-appcompat = "1.6.1" [libraries] androidx-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-ktx" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidx-appcompat" } +androidx-fragment = { group = "androidx.fragment", name = "fragment", version.ref = "androidx-appcompat" } [plugins] kotlin = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } diff --git a/perfsuite/build.gradle.kts b/perfsuite/build.gradle.kts index cff0ba8..8abb59d 100644 --- a/perfsuite/build.gradle.kts +++ b/perfsuite/build.gradle.kts @@ -24,4 +24,5 @@ android { dependencies { implementation(libs.androidx.ktx) + implementation(libs.androidx.fragment) } diff --git a/perfsuite/src/main/java/com/booking/perfsuite/tti/BaseTtiTracker.kt b/perfsuite/src/main/java/com/booking/perfsuite/tti/BaseTtiTracker.kt new file mode 100644 index 0000000..29d5403 --- /dev/null +++ b/perfsuite/src/main/java/com/booking/perfsuite/tti/BaseTtiTracker.kt @@ -0,0 +1,116 @@ +package com.booking.perfsuite.tti + +import androidx.annotation.UiThread +import com.booking.perfsuite.internal.nowMillis + +/** + * The most basic TTFR/TTI tracking implementation. + * This class can be used with any possible screen implementation + * (Activities, Fragments, Views, Jetpack Compose and etc.). + * + * To work properly it requires that methods are called respectively to the screen lifecycle events: + * 1. Call [onScreenCreated] at the earliest possible moment of the screen instantiation + * 2. Then call [onScreenViewIsReady] when the first screen frame is shown to the user, + * that will indicate that TTFR metric is collected + * 3. Optionally call [onScreenIsUsable] when the usable content is shown to the user, + * that will indicate that TTI metric is collected + * + * For more details please refer to the documentation: + * https://github.com/bookingcom/perfsuite-android?tab=readme-ov-file#additional-documentation + * + * @param listener implementation is used to handle screen TTI\TTFR metrics when they are ready + */ +@UiThread +public class BaseTtiTracker( + private val listener: Listener +) { + + private val screenCreationTimestamp = HashMap() + + /** + * Call this method immediately on screen creation as early as possible + * + * @param screen - unique screen identifier + * @param timestamp - the time the screen was created at. + */ + public fun onScreenCreated(screen: String, timestamp: Long = nowMillis()) { + screenCreationTimestamp[screen] = timestamp + listener.onScreenCreated(screen) + } + + /** + * Call this method when screen is rendered for the first time + * + * @param screen - unique screen identifier + */ + public fun onScreenViewIsReady(screen: String) { + screenCreationTimestamp[screen]?.let { creationTimestamp -> + val duration = nowMillis() - creationTimestamp + listener.onFirstFrameIsDrawn(screen, duration) + } + } + + /** + * Call this method when the screen is ready for user interaction + * (e.g. all data is ready and meaningful content is shown). + * + * The method is optional, whenever it is not called TTI won't be measured + * + * @param screen - unique screen identifier + */ + public fun onScreenIsUsable(screen: String) { + screenCreationTimestamp[screen]?.let { creationTimestamp -> + val duration = nowMillis() - creationTimestamp + listener.onFirstUsableFrameIsDrawn(screen, duration) + screenCreationTimestamp.remove(screen) + } + } + + /** + * Call this when user leaves the screen. + * + * This prevent us from producing outliers and avoid tracking cheap screen transitions + * (e.g. back navigation, when the screen is already created in memory), + * so we're able to track only real screen creation performance + */ + public fun onScreenStopped(screen: String) { + screenCreationTimestamp.remove(screen) + } + + /** + * Returns true if the screen is still in the state of collecting metrics. + * When result is false,that means that both TTFR/TTI metrics were already collected or + * discarded for any reason + */ + public fun isScreenEnabledForTracking(screen: String): Boolean = + screenCreationTimestamp.containsKey(screen) + + /** + * Listener interface providing TTFR/TTI metrics when they're ready + */ + public interface Listener { + + /** + * Called as early as possible after the screen [screen] is created. + * + * @param screen - screen key + */ + public fun onScreenCreated(screen: String) + + /** + * Called when the very first screen frame is drawn + * + * @param screen - screen key + * @param duration - elapsed time since screen's creation till the first frame is drawn + */ + public fun onFirstFrameIsDrawn(screen: String, duration: Long) + + /** + * Called when the first usable/meaningful screen frame is drawn + * + * @param screen - screen key + * @param duration - elapsed time since screen's creation till the usable frame is drawn + */ + public fun onFirstUsableFrameIsDrawn(screen: String, duration: Long) + } +} \ No newline at end of file diff --git a/perfsuite/src/main/java/com/booking/perfsuite/tti/ViewTtiTracker.kt b/perfsuite/src/main/java/com/booking/perfsuite/tti/ViewTtiTracker.kt new file mode 100644 index 0000000..c83a74c --- /dev/null +++ b/perfsuite/src/main/java/com/booking/perfsuite/tti/ViewTtiTracker.kt @@ -0,0 +1,76 @@ +package com.booking.perfsuite.tti + +import android.view.View +import androidx.annotation.UiThread +import com.booking.perfsuite.internal.doOnNextDraw + +/** + * Android View-based implementation of TTI\TTFR tracking. This class should be used with screens + * which are rendered using Android [View] class (Activities, Fragments, Views). + * + * For Android Views we always should measure time until the actual draw happens + * and [View.onDraw] is called. + * That's why when [onScreenViewIsReady] or [onScreenIsUsable] are called, the tracker actually + * waits until the next frame draw before finish collecting TTFR/TTI metrics. + * + * Technically this is a wrapper around [BaseTtiTracker] which helps to collect metrics respectively to + * how [View] rendering works. + * Therefore, please use [BaseTtiTracker] directly in case of using canvas drawing, + * Jetpack Compose or any other approach which is not based on Views. + * + * See also [com.booking.perfsuite.tti.helpers.ActivityTtfrHelper] and + * [com.booking.perfsuite.tti.helpers.FragmentTtfrHelper] for automatic TTFR collection + * in Activities and Fragments. + */ +@UiThread +public class ViewTtiTracker(private val tracker: BaseTtiTracker) { + + /** + * Call this method immediately on screen creation as early as possible + * + * @param screen - unique screen identifier + */ + public fun onScreenCreated(screen: String) { + tracker.onScreenCreated(screen) + } + + /** + * Call this when screen View is ready but it is not drawn yet + * + * @param screen - unique screen identifier + * @param rootView - root view of the screen, metric is ready when this view is next drawn + */ + public fun onScreenViewIsReady(screen: String, rootView: View) { + if (tracker.isScreenEnabledForTracking(screen)) { + rootView.doOnNextDraw { tracker.onScreenViewIsReady(screen) } + } + } + + /** + * Call this when the screen View is ready for user interaction. + * Only the first call after screen creation is considered, repeat calls are ignored + * + * @see BaseTtiTracker.onScreenIsUsable + * + * @param screen - unique screen identifier + * @param rootView - root view of the screen, metric is ready when this view is next drawn + * + * + */ + public fun onScreenIsUsable(screen: String, rootView: View) { + if (tracker.isScreenEnabledForTracking(screen)) { + rootView.doOnNextDraw { tracker.onScreenIsUsable(screen) } + } + } + + /** + * Call this when user leaves the screen. + * + * This prevent us from tracking cheap screen transitions (e.g. back navigation, + * when the screen is already created in memory), so we're able to track + * only real screen creation performance, removing outliers + */ + public fun onScreenStopped(screen: String) { + tracker.onScreenStopped(screen) + } +} \ No newline at end of file diff --git a/perfsuite/src/main/java/com/booking/perfsuite/tti/helpers/ActivityTtfrHelper.kt b/perfsuite/src/main/java/com/booking/perfsuite/tti/helpers/ActivityTtfrHelper.kt new file mode 100644 index 0000000..f395fa9 --- /dev/null +++ b/perfsuite/src/main/java/com/booking/perfsuite/tti/helpers/ActivityTtfrHelper.kt @@ -0,0 +1,65 @@ +package com.booking.perfsuite.tti.helpers + +import android.app.Activity +import android.app.Application +import android.app.Application.ActivityLifecycleCallbacks +import android.os.Bundle +import com.booking.perfsuite.tti.ViewTtiTracker + +/** + * This class helps to automatically track TTFR metric for every activity by handling + * [android.app.Application.ActivityLifecycleCallbacks] + * + * @param tracker TTI tracker instance + * @param screenNameProvider function used to generate unique screen name/identifier for activity. + * If it returns null, then activity won't be tracked. + * By default it uses the implementation based on Activity's class name + */ +public class ActivityTtfrHelper( + private val tracker: ViewTtiTracker, + private val screenNameProvider: (Activity) -> String? = { it.javaClass.name } +) : ActivityLifecycleCallbacks { + + public companion object { + + /** + * Registers [ActivityTtfrHelper] instance with the app as + * [android.app.Application.ActivityLifecycleCallbacks] to collect TTFR metrics for + * every activity + * + * Call this method at the app startup, before the first activity is created + * + * @param application current [Application] instance + * @param tracker configured for the app [ViewTtiTracker] instance + */ + @JvmStatic + public fun register(application: Application, tracker: ViewTtiTracker) { + val activityHelper = ActivityTtfrHelper(tracker) + application.registerActivityLifecycleCallbacks(activityHelper) + } + } + + override fun onActivityPreCreated(activity: Activity, savedInstanceState: Bundle?) { + val screenKey = screenNameProvider(activity) ?: return + tracker.onScreenCreated(screenKey) + } + + override fun onActivityStarted(activity: Activity) { + val screenKey = screenNameProvider(activity) ?: return + val rootView = activity.window.decorView + tracker.onScreenViewIsReady(screenKey, rootView) + } + + override fun onActivityStopped(activity: Activity) { + val screenKey = screenNameProvider(activity) ?: return + tracker.onScreenStopped(screenKey) + } + + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { } + override fun onActivityResumed(activity: Activity) { } + override fun onActivityPaused(activity: Activity) { } + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) { } + override fun onActivityDestroyed(activity: Activity) { } + + +} diff --git a/perfsuite/src/main/java/com/booking/perfsuite/tti/helpers/FragmentTtfrHelper.kt b/perfsuite/src/main/java/com/booking/perfsuite/tti/helpers/FragmentTtfrHelper.kt new file mode 100644 index 0000000..03f39e4 --- /dev/null +++ b/perfsuite/src/main/java/com/booking/perfsuite/tti/helpers/FragmentTtfrHelper.kt @@ -0,0 +1,42 @@ +package com.booking.perfsuite.tti.helpers + +import android.os.Bundle +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import com.booking.perfsuite.tti.ViewTtiTracker + +/** + * This class helps to automatically track TTFR metric for every fragment + * within the particular activity or particular parent fragment by handling + * [androidx.fragment.app.FragmentManager.FragmentLifecycleCallbacks] + * + * @param tracker TTI tracker instance + * @param screenNameProvider function used to generate unique screen name/identifier for fragment. + * If it returns null, then fragment won't be tracked. + * By default it uses the implementation based on Fragment's class name + */ +public class FragmentTtfrHelper( + private val tracker: ViewTtiTracker, + private val screenNameProvider: (Fragment) -> String? = { it.javaClass.name } +) : FragmentManager.FragmentLifecycleCallbacks() { + + override fun onFragmentPreCreated( + fm: FragmentManager, + fragment: Fragment, + savedInstanceState: Bundle? + ) { + val screenKey = screenNameProvider(fragment) ?: return + tracker.onScreenCreated(screenKey) + } + + override fun onFragmentStarted(fm: FragmentManager, fragment: Fragment) { + val screenKey = screenNameProvider(fragment) ?: return + val rootView = fragment.view ?: return + tracker.onScreenViewIsReady(screenKey, rootView) + } + + override fun onFragmentStopped(fm: FragmentManager, fragment: Fragment) { + val screenKey = screenNameProvider(fragment) ?: return + tracker.onScreenStopped(screenKey) + } +} diff --git a/sampleApp/src/main/java/com/booking/perfsuite/app/MainActivity.kt b/sampleApp/src/main/java/com/booking/perfsuite/app/MainActivity.kt index f2b94c9..2c20a98 100644 --- a/sampleApp/src/main/java/com/booking/perfsuite/app/MainActivity.kt +++ b/sampleApp/src/main/java/com/booking/perfsuite/app/MainActivity.kt @@ -1,11 +1,23 @@ package com.booking.perfsuite.app import android.os.Bundle +import android.widget.TextView import androidx.appcompat.app.AppCompatActivity class MainActivity : AppCompatActivity() { + private lateinit var contentView: TextView + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + + contentView = TextView(this) + contentView.text = "Loading content..." + setContentView(contentView) + + contentView.postDelayed({ + contentView.text = "Screen is usable" + reportIsUsable() + }, 1000) } } diff --git a/sampleApp/src/main/java/com/booking/perfsuite/app/SampleApp.kt b/sampleApp/src/main/java/com/booking/perfsuite/app/SampleApp.kt index 1db35ba..8695188 100644 --- a/sampleApp/src/main/java/com/booking/perfsuite/app/SampleApp.kt +++ b/sampleApp/src/main/java/com/booking/perfsuite/app/SampleApp.kt @@ -1,10 +1,16 @@ package com.booking.perfsuite.app +import android.app.Activity import android.app.Application +import android.view.View import com.booking.perfsuite.app.monitoring.ActivityFrameMetricsListener import com.booking.perfsuite.app.monitoring.AppStartupTimeListener +import com.booking.perfsuite.app.monitoring.AppTtiListener import com.booking.perfsuite.rendering.ActivityFrameMetricsTracker import com.booking.perfsuite.startup.AppStartupTimeTracker +import com.booking.perfsuite.tti.BaseTtiTracker +import com.booking.perfsuite.tti.ViewTtiTracker +import com.booking.perfsuite.tti.helpers.ActivityTtfrHelper class SampleApp : Application() { @@ -13,7 +19,18 @@ class SampleApp : Application() { // setup startup time tracking AppStartupTimeTracker.register(this, AppStartupTimeListener) + // setup rendering performance tracking ActivityFrameMetricsTracker.register(this, ActivityFrameMetricsListener) + + // setup Activity TTI tracking + ActivityTtfrHelper.register(this, viewTtiTracker) } } + +val ttiTracker = BaseTtiTracker(AppTtiListener) +val viewTtiTracker = ViewTtiTracker(ttiTracker) + +fun Activity.reportIsUsable(contentView: View = this.window.decorView) { + viewTtiTracker.onScreenIsUsable(this.javaClass.name, contentView) +} diff --git a/sampleApp/src/main/java/com/booking/perfsuite/app/monitoring/AppTtiListener.kt b/sampleApp/src/main/java/com/booking/perfsuite/app/monitoring/AppTtiListener.kt new file mode 100644 index 0000000..5fac828 --- /dev/null +++ b/sampleApp/src/main/java/com/booking/perfsuite/app/monitoring/AppTtiListener.kt @@ -0,0 +1,17 @@ +package com.booking.perfsuite.app.monitoring + +import android.util.Log +import com.booking.perfsuite.tti.BaseTtiTracker + +object AppTtiListener : BaseTtiTracker.Listener { + + override fun onScreenCreated(screen: String) {} + + override fun onFirstFrameIsDrawn(screen: String, duration: Long) { + Log.d("PerfSuite", "$screen - TTFR = ${duration}ms") + } + + override fun onFirstUsableFrameIsDrawn(screen: String, duration: Long) { + Log.d("PerfSuite", "$screen - TTI = ${duration}ms") + } +} \ No newline at end of file