Skip to content

Commit

Permalink
Extract frame draw detection to its own component
Browse files Browse the repository at this point in the history
  • Loading branch information
bidetofevil committed Jan 7, 2025
1 parent 716ea97 commit 83ae031
Show file tree
Hide file tree
Showing 9 changed files with 257 additions and 133 deletions.
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 {
if (!isFirstDraw) {
isFirstDraw = true
decorView.viewTreeObserver.registerFrameCommitCallback(completionCallback)
}
}
}
} 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"
)
)
nullWindowCallbackErrorLogged = true
}
}
}

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

private 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
}

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()
}
}
}
Loading

0 comments on commit 83ae031

Please sign in to comment.