Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Extract frame draw detection to its own component #1793

Open
wants to merge 1 commit into
base: hho/improve-ui-load-tests
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,11 @@ package io.embrace.android.embracesdk.internal.capture.startup

import android.app.Activity
import android.app.Application
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.View
import android.view.ViewTreeObserver
import android.view.Window
import io.embrace.android.embracesdk.annotation.StartupActivity
import io.embrace.android.embracesdk.internal.logging.EmbLogger
import io.embrace.android.embracesdk.internal.logging.InternalErrorType
import io.embrace.android.embracesdk.internal.capture.activity.traceInstanceId
import io.embrace.android.embracesdk.internal.session.lifecycle.ActivityLifecycleListener
import io.embrace.android.embracesdk.internal.utils.VersionChecker
import io.embrace.android.embracesdk.internal.ui.DrawEventEmitter

/**
* Component that captures various timestamps throughout the startup process and uses that information to log spans that approximates to
Expand All @@ -29,22 +22,16 @@ import io.embrace.android.embracesdk.internal.utils.VersionChecker
*
* For approximating the first frame being completely drawn:
*
* - Android 10 onwards, we use a [ViewTreeObserver.OnDrawListener] callback to detect that the first frame from the first activity load
* has been fully rendered and queued for display.
* - Android 10 onwards, [FirstDrawDetector] to detect that an activity's first frame was been rendered.
*
* - Older Android versions that are supported, we just use when the first Activity was resumed. We will iterate on this in the future.
*
* Note that this implementation has benefited from the work of Pierre-Yves Ricau and his blog post about Android application launch time
* that can be found here: https://blog.p-y.wtf/tracking-android-app-launch-in-production. PY's code was adapted and tweaked for use here.
*/
class StartupTracker(
private val appStartupDataCollector: AppStartupDataCollector,
private val activityLoadEventEmitter: ActivityLifecycleListener?,
private val logger: EmbLogger,
private val versionChecker: VersionChecker,
private val drawEventEmitter: DrawEventEmitter?,
) : Application.ActivityLifecycleCallbacks {
private var isFirstDraw = false
private var nullWindowCallbackErrorLogged = false

private var startupActivityId: Int? = null
private var startupDataCollectionComplete = false

Expand All @@ -56,39 +43,15 @@ class StartupTracker(

override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
if (activity.useAsStartupActivity()) {
val activityName = activity.localClassName
val application = activity.application
appStartupDataCollector.startupActivityInitStart()
if (versionChecker.isAtLeast(Build.VERSION_CODES.Q)) {
if (!isFirstDraw) {
val window = activity.window
if (window.callback != null) {
window.onDecorViewReady {
val decorView = window.decorView
decorView.onNextDraw {
if (!isFirstDraw) {
isFirstDraw = true
val callback = {
appStartupDataCollector.firstFrameRendered(
activityName = activityName,
collectionCompleteCallback = { startupComplete(application) }
)
}
decorView.viewTreeObserver.registerFrameCommitCallback(callback)
}
}
}
} else if (!nullWindowCallbackErrorLogged) {
logger.trackInternalError(
type = InternalErrorType.APP_LAUNCH_TRACE_FAIL,
throwable = IllegalStateException(
"Fail to attach frame rendering callback because the callback on Window was null"
)
)
nullWindowCallbackErrorLogged = true
}
}
val application = activity.application
val callback = {
appStartupDataCollector.firstFrameRendered(
activityName = activity.localClassName,
collectionCompleteCallback = { startupComplete(application) }
)
}
drawEventEmitter?.registerFirstDrawCallback(activity, callback)
}
}

Expand Down Expand Up @@ -125,8 +88,8 @@ class StartupTracker(
private fun startupComplete(application: Application) {
if (!startupDataCollectionComplete) {
application.unregisterActivityLifecycleCallbacks(this)
activityLoadEventEmitter?.apply {
application.registerActivityLifecycleCallbacks(this)
if (activityLoadEventEmitter != null) {
application.registerActivityLifecycleCallbacks(activityLoadEventEmitter)
}
startupDataCollectionComplete = true
}
Expand All @@ -138,7 +101,7 @@ class StartupTracker(
*/
private fun Activity.isStartupActivity(): Boolean {
return if (observeForStartup()) {
startupActivityId == hashCode()
startupActivityId == traceInstanceId(this)
} else {
false
}
Expand All @@ -154,78 +117,13 @@ class StartupTracker(
}

if (observeForStartup()) {
startupActivityId = hashCode()
startupActivityId = traceInstanceId(this)
}

return isStartupActivity()
}

private companion object {
private class PyNextDrawListener(
val view: View,
val onDrawCallback: () -> Unit,
) : ViewTreeObserver.OnDrawListener {
val handler = Handler(Looper.getMainLooper())
var invoked = false

override fun onDraw() {
if (!invoked) {
invoked = true
onDrawCallback()
handler.post {
if (view.viewTreeObserver.isAlive) {
view.viewTreeObserver.removeOnDrawListener(this)
}
}
}
}
}

private class PyWindowDelegateCallback(
private val delegate: Window.Callback,
) : Window.Callback by delegate {

val onContentChangedCallbacks = mutableListOf<() -> Boolean>()

override fun onContentChanged() {
onContentChangedCallbacks.removeAll { callback ->
!callback()
}
delegate.onContentChanged()
}
}

fun Activity.observeForStartup(): Boolean = !javaClass.isAnnotationPresent(StartupActivity::class.java)

fun View.onNextDraw(onDrawCallback: () -> Unit) {
viewTreeObserver.addOnDrawListener(
PyNextDrawListener(this, onDrawCallback)
)
}

fun Window.onDecorViewReady(onDecorViewReady: () -> Unit) {
if (callback != null) {
if (peekDecorView() == null) {
onContentChanged {
onDecorViewReady()
return@onContentChanged false
}
} else {
onDecorViewReady()
}
}
}

private fun Window.onContentChanged(onDrawCallbackInvocation: () -> Boolean) {
val currentCallback = callback
val callback = if (currentCallback is PyWindowDelegateCallback) {
currentCallback
} else {
val newCallback = PyWindowDelegateCallback(currentCallback)
callback = newCallback
newCallback
}
callback.onContentChangedCallbacks += onDrawCallbackInvocation
}
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package io.embrace.android.embracesdk.internal.injection

import android.os.Build
import io.embrace.android.embracesdk.internal.Systrace
import io.embrace.android.embracesdk.internal.capture.activity.UiLoadEventListener
import io.embrace.android.embracesdk.internal.capture.activity.UiLoadTraceEmitter
Expand All @@ -15,6 +16,7 @@ import io.embrace.android.embracesdk.internal.capture.webview.EmbraceWebViewServ
import io.embrace.android.embracesdk.internal.capture.webview.WebViewService
import io.embrace.android.embracesdk.internal.config.ConfigService
import io.embrace.android.embracesdk.internal.session.lifecycle.ActivityLifecycleListener
import io.embrace.android.embracesdk.internal.ui.FirstDrawDetector
import io.embrace.android.embracesdk.internal.utils.BuildVersionChecker
import io.embrace.android.embracesdk.internal.utils.VersionChecker
import io.embrace.android.embracesdk.internal.worker.Worker
Expand Down Expand Up @@ -67,8 +69,11 @@ internal class DataCaptureServiceModuleImpl @JvmOverloads constructor(
StartupTracker(
appStartupDataCollector = appStartupDataCollector,
activityLoadEventEmitter = activityLoadEventEmitter,
logger = initModule.logger,
versionChecker = versionChecker,
drawEventEmitter = if (versionChecker.isAtLeast(Build.VERSION_CODES.Q)) {
FirstDrawDetector(initModule.logger)
} else {
null
}
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package io.embrace.android.embracesdk.internal.ui

import android.app.Activity

/**
* Interface that allows callbacks to be registered and invoked when UI draw events happen
*/
interface DrawEventEmitter {
fun registerFirstDrawCallback(activity: Activity, completionCallback: () -> Unit)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package io.embrace.android.embracesdk.internal.ui

import android.app.Activity
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.view.View
import android.view.ViewTreeObserver
import android.view.Window
import androidx.annotation.RequiresApi
import io.embrace.android.embracesdk.internal.logging.EmbLogger
import io.embrace.android.embracesdk.internal.logging.InternalErrorType

/**
* Component that uses the [ViewTreeObserver.OnDrawListener] callback to detect that the first frame of a registered
* [Activity] has been fully rendered and queued for display.
*
* This implementation has benefited from the work of Pierre-Yves Ricau and his blog post about Android application launch time
* that can be found here: https://blog.p-y.wtf/tracking-android-app-launch-in-production. PY's code was adapted and tweaked for use here.
*/
@RequiresApi(Build.VERSION_CODES.Q)
internal class FirstDrawDetector(
private val logger: EmbLogger,
) : DrawEventEmitter {
private var isFirstDraw: Boolean = false
private var nullWindowCallbackErrorLogged = false

override fun registerFirstDrawCallback(activity: Activity, completionCallback: () -> Unit) {
if (!isFirstDraw) {
val window = activity.window
if (window.callback != null) {
window.onDecorViewReady {
val decorView = window.decorView
decorView.onNextDraw {

Check warning on line 34 in embrace-android-features/src/main/kotlin/io/embrace/android/embracesdk/internal/ui/FirstDrawDetector.kt

View check run for this annotation

Codecov / codecov/patch

embrace-android-features/src/main/kotlin/io/embrace/android/embracesdk/internal/ui/FirstDrawDetector.kt#L33-L34

Added lines #L33 - L34 were not covered by tests
if (!isFirstDraw) {
isFirstDraw = true
decorView.viewTreeObserver.registerFrameCommitCallback(completionCallback)

Check warning on line 37 in embrace-android-features/src/main/kotlin/io/embrace/android/embracesdk/internal/ui/FirstDrawDetector.kt

View check run for this annotation

Codecov / codecov/patch

embrace-android-features/src/main/kotlin/io/embrace/android/embracesdk/internal/ui/FirstDrawDetector.kt#L36-L37

Added lines #L36 - L37 were not covered by tests
}
}
}
} else if (!nullWindowCallbackErrorLogged) {
logger.trackInternalError(
type = InternalErrorType.UI_CALLBACK_FAIL,
throwable = IllegalStateException(
"Fail to attach frame rendering callback because the callback on Window was null"

Check warning on line 45 in embrace-android-features/src/main/kotlin/io/embrace/android/embracesdk/internal/ui/FirstDrawDetector.kt

View check run for this annotation

Codecov / codecov/patch

embrace-android-features/src/main/kotlin/io/embrace/android/embracesdk/internal/ui/FirstDrawDetector.kt#L42-L45

Added lines #L42 - L45 were not covered by tests
)
)
nullWindowCallbackErrorLogged = true

Check warning on line 48 in embrace-android-features/src/main/kotlin/io/embrace/android/embracesdk/internal/ui/FirstDrawDetector.kt

View check run for this annotation

Codecov / codecov/patch

embrace-android-features/src/main/kotlin/io/embrace/android/embracesdk/internal/ui/FirstDrawDetector.kt#L48

Added line #L48 was not covered by tests
}
}
}

private fun View.onNextDraw(onDrawCallback: () -> Unit) {
viewTreeObserver.addOnDrawListener(
PyNextDrawListener(this, onDrawCallback)

Check warning on line 55 in embrace-android-features/src/main/kotlin/io/embrace/android/embracesdk/internal/ui/FirstDrawDetector.kt

View check run for this annotation

Codecov / codecov/patch

embrace-android-features/src/main/kotlin/io/embrace/android/embracesdk/internal/ui/FirstDrawDetector.kt#L54-L55

Added lines #L54 - L55 were not covered by tests
)
}

private fun Window.onDecorViewReady(onDecorViewReady: () -> Unit) {
if (callback != null) {
if (peekDecorView() == null) {
onContentChanged {
onDecorViewReady()
return@onContentChanged false

Check warning on line 64 in embrace-android-features/src/main/kotlin/io/embrace/android/embracesdk/internal/ui/FirstDrawDetector.kt

View check run for this annotation

Codecov / codecov/patch

embrace-android-features/src/main/kotlin/io/embrace/android/embracesdk/internal/ui/FirstDrawDetector.kt#L63-L64

Added lines #L63 - L64 were not covered by tests
}
} else {
onDecorViewReady()

Check warning on line 67 in embrace-android-features/src/main/kotlin/io/embrace/android/embracesdk/internal/ui/FirstDrawDetector.kt

View check run for this annotation

Codecov / codecov/patch

embrace-android-features/src/main/kotlin/io/embrace/android/embracesdk/internal/ui/FirstDrawDetector.kt#L67

Added line #L67 was not covered by tests
}
}
}

private fun Window.onContentChanged(onDrawCallbackInvocation: () -> Boolean) {
val currentCallback = callback
val callback = if (currentCallback is PyWindowDelegateCallback) {
currentCallback

Check warning on line 75 in embrace-android-features/src/main/kotlin/io/embrace/android/embracesdk/internal/ui/FirstDrawDetector.kt

View check run for this annotation

Codecov / codecov/patch

embrace-android-features/src/main/kotlin/io/embrace/android/embracesdk/internal/ui/FirstDrawDetector.kt#L75

Added line #L75 was not covered by tests
} else {
val newCallback = PyWindowDelegateCallback(currentCallback)
callback = newCallback
newCallback
}
callback.onContentChangedCallbacks += onDrawCallbackInvocation
}

private class PyNextDrawListener(
val view: View,
val onDrawCallback: () -> Unit,

Check warning on line 86 in embrace-android-features/src/main/kotlin/io/embrace/android/embracesdk/internal/ui/FirstDrawDetector.kt

View check run for this annotation

Codecov / codecov/patch

embrace-android-features/src/main/kotlin/io/embrace/android/embracesdk/internal/ui/FirstDrawDetector.kt#L84-L86

Added lines #L84 - L86 were not covered by tests
) : ViewTreeObserver.OnDrawListener {
val handler = Handler(Looper.getMainLooper())
var invoked = false

Check warning on line 89 in embrace-android-features/src/main/kotlin/io/embrace/android/embracesdk/internal/ui/FirstDrawDetector.kt

View check run for this annotation

Codecov / codecov/patch

embrace-android-features/src/main/kotlin/io/embrace/android/embracesdk/internal/ui/FirstDrawDetector.kt#L88-L89

Added lines #L88 - L89 were not covered by tests

override fun onDraw() {
if (!invoked) {
invoked = true
onDrawCallback()
handler.post {

Check warning on line 95 in embrace-android-features/src/main/kotlin/io/embrace/android/embracesdk/internal/ui/FirstDrawDetector.kt

View check run for this annotation

Codecov / codecov/patch

embrace-android-features/src/main/kotlin/io/embrace/android/embracesdk/internal/ui/FirstDrawDetector.kt#L93-L95

Added lines #L93 - L95 were not covered by tests
if (view.viewTreeObserver.isAlive) {
view.viewTreeObserver.removeOnDrawListener(this)

Check warning on line 97 in embrace-android-features/src/main/kotlin/io/embrace/android/embracesdk/internal/ui/FirstDrawDetector.kt

View check run for this annotation

Codecov / codecov/patch

embrace-android-features/src/main/kotlin/io/embrace/android/embracesdk/internal/ui/FirstDrawDetector.kt#L97

Added line #L97 was not covered by tests
}
}
}
}
}

private class PyWindowDelegateCallback(
private val delegate: Window.Callback,
) : Window.Callback by delegate {

val onContentChangedCallbacks = mutableListOf<() -> Boolean>()

override fun onContentChanged() {
onContentChangedCallbacks.removeAll { callback ->
!callback()

Check warning on line 112 in embrace-android-features/src/main/kotlin/io/embrace/android/embracesdk/internal/ui/FirstDrawDetector.kt

View check run for this annotation

Codecov / codecov/patch

embrace-android-features/src/main/kotlin/io/embrace/android/embracesdk/internal/ui/FirstDrawDetector.kt#L111-L112

Added lines #L111 - L112 were not covered by tests
}
delegate.onContentChanged()

Check warning on line 114 in embrace-android-features/src/main/kotlin/io/embrace/android/embracesdk/internal/ui/FirstDrawDetector.kt

View check run for this annotation

Codecov / codecov/patch

embrace-android-features/src/main/kotlin/io/embrace/android/embracesdk/internal/ui/FirstDrawDetector.kt#L114

Added line #L114 was not covered by tests
}
}
}
Loading
Loading