Android Vitals - First draw time 👩🎨
Py ⚔
Posted on August 29, 2020
Header image: Light Field by Romain Guy.
This blog series is focused on stability and performance monitoring of Android apps in production. Last week, I wrote about how to best determine the app start time.
Today, we focus on determining the time at which cold start ends.
According to the Play Console documentation:
Startup times are tracked when the app's first frame completely loads.
We learn a bit more from the App startup cold time documentation:
Once the app process has completed the first draw, the system process swaps out the currently displayed background window, replacing it with the main activity. At this point, the user can start using the app.
In Android Vitals - Rising to the first drawn surface 🤽♂️, we learnt that:
- ActivityThread.handleResumeActivity() schedules the first frame.
- On the first frame Choreographer.doFrame() calls ViewRootImpl.doTraversal() which performs a measure pass, a layout pass, and finally the first draw pass on the view hierarchy.
First frame
Since API level 16, Android provides a simple API to schedule a callback when the next frame happens: Choreographer.postFrameCallback().
class MyApp : Application() {
var firstFrameDoneMs: Long = 0
override fun onCreate() {
super.onCreate()
Choreographer.getInstance().postFrameCallback {
firstFrameDoneMs = SystemClock.uptimeMillis()
}
}
}
Unfortunately, calling Choreographer.postFrameCallback()
has the side effect of scheduling a frame that runs before the first traversal is scheduled. So the time reported here is before the time of the frame that runs the first draw. I was able to reproduce this on API 25 but also noticed it doesn't happen in API 30, so this bug was probably fixed.
First draw
ViewTreeObserver
On Android, each view hierarchy has a ViewTreeObserver which can hold callbacks for global events such as layout or draw.
ViewTreeObserver.addOnDrawListener()
We can call ViewTreeObserver.addOnDrawListener() to register a draw listener:
view.viewTreeObserver.addOnDrawListener {
// report first draw
}
ViewTreeObserver.removeOnDrawListener()
We only care about the first draw, so we need to remove the OnDrawListener as soon as we've received a callback. Unfortunately, ViewTreeObserver.removeOnDrawListener() cannot be called from the onDraw()
callback:
public final class ViewTreeObserver {
public void removeOnDrawListener(OnDrawListener victim) {
checkIsAlive();
if (mInDispatchOnDraw) {
throw new IllegalStateException(
"Cannot call removeOnDrawListener inside of onDraw");
}
mOnDrawListeners.remove(victim);
}
}
So we have to do the removal in a post:
class NextDrawListener(
val view: View,
val onDrawCallback: () -> Unit
) : OnDrawListener {
val handler = Handler(Looper.getMainLooper())
var invoked = false
override fun onDraw() {
if (invoked) return
invoked = true
onDrawCallback()
handler.post {
if (view.viewTreeObserver.isAlive) {
viewTreeObserver.removeOnDrawListener(this)
}
}
}
companion object {
fun View.onNextDraw(onDrawCallback: () -> Unit) {
viewTreeObserver.addOnDrawListener(
NextDrawListener(this, onDrawCallback)
)
}
}
}
Notice the nice extension function:
view.onNextDraw {
// report first draw
}
FloatingTreeObserver
If we call View.getViewTreeObserver() before the view hierarchy is attached, there is no real ViewTreeObserver already available so the view will create a fake one to store the callbacks:
public class View {
public ViewTreeObserver getViewTreeObserver() {
if (mAttachInfo != null) {
return mAttachInfo.mTreeObserver;
}
if (mFloatingTreeObserver == null) {
mFloatingTreeObserver = new ViewTreeObserver(mContext);
}
return mFloatingTreeObserver;
}
}
Then when the view is attached the callbacks are merged back into the real ViewTreeObserver.
That's nice, except there was a bug fixed in API 26: the draw listeners were not merged back into the real view tree observer.
We work around that by waiting until the view is attached before registering our draw listeners:
class NextDrawListener(
val view: View,
val onDrawCallback: () -> Unit
) : OnDrawListener {
val handler = Handler(Looper.getMainLooper())
var invoked = false
override fun onDraw() {
if (invoked) return
invoked = true
onDrawCallback()
handler.post {
if (view.viewTreeObserver.isAlive) {
viewTreeObserver.removeOnDrawListener(this)
}
}
}
companion object {
fun View.onNextDraw(onDrawCallback: () -> Unit) {
if (viewTreeObserver.isAlive && isAttachedToWindow) {
addNextDrawListener(onDrawCallback)
} else {
// Wait until attached
addOnAttachStateChangeListener(
object : OnAttachStateChangeListener {
override fun onViewAttachedToWindow(v: View) {
addNextDrawListener(onDrawCallback)
removeOnAttachStateChangeListener(this)
}
override fun onViewDetachedFromWindow(v: View) = Unit
})
}
}
private fun View.addNextDrawListener(callback: () -> Unit) {
viewTreeObserver.addOnDrawListener(
NextDrawListener(this, callback)
)
}
}
}
DecorView
Now that we have a nice utility to listen to the next draw, we can use it when an activity is created. Note that the first created activity may not draw: it's fairly common for apps to have a trampoline activity as launcher activity which immediately starts another activity and finishes itself. We register our draw listener on the activity window DecorView.
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
var firstDraw = false
registerActivityLifecycleCallbacks(
object : ActivityLifecycleCallbacks {
override fun onActivityCreated(
activity: Activity,
savedInstanceState: Bundle?
) {
if (firstDraw) return
activity.window.decorView.onNextDraw {
if (firstDraw) return
firstDraw = true
// report first draw
}
}
})
}
}
Locking window characteristics
According to the documentation for Window.getDecorView():
Note that calling this function for the first time "locks in" various window characteristics as described in setContentView().
Unfortunately, we're calling Window.getDecorView()
from ActivityLifecycleCallbacks.onActivityCreated() which is called by Activity.onCreate(). In a typical activity, setContentView()
is called after super.onCreate()
so we're calling Window.getDecorView()
before setContentView()
is called, which has unexpected side effects.
We need to wait for setContentView()
to be called before we retrieve the decor view.
Window.Callback.onContentChanged()
We can use Window.peekDecorView() to determine if we already have a decor view. If not, we can register a callback on our window, which provides the hook we need, Window.Callback.onContentChanged():
This hook is called whenever the content view of the screen changes (due to a call to Window#setContentView() or Window#addContentView()).
However, a window can only have one callback, and the activity already sets itself as the window callback. So we'll need to replace that callback and delegate to it.
Here's a utility class which does that and adds a Window.onDecorViewReady()
extension function:
class WindowDelegateCallback constructor(
private val delegate: Window.Callback
) : Window.Callback by delegate {
val onContentChangedCallbacks = mutableListOf<() -> Boolean>()
override fun onContentChanged() {
onContentChangedCallbacks.removeAll { callback ->
!callback()
}
delegate.onContentChanged()
}
companion object {
fun Window.onDecorViewReady(callback: () -> Unit) {
if (peekDecorView() == null) {
onContentChanged {
callback()
return@onContentChanged false
}
} else {
callback()
}
}
fun Window.onContentChanged(block: () -> Boolean) {
val callback = wrapCallback()
callback.onContentChangedCallbacks += block
}
private fun Window.wrapCallback(): WindowDelegateCallback {
val currentCallback = callback
return if (currentCallback is WindowDelegateCallback) {
currentCallback
} else {
val newCallback = WindowDelegateCallback(currentCallback)
callback = newCallback
newCallback
}
}
}
}
Leveraging Window.onDecorViewReady()
Let's put it all together:
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
var firstDraw = false
registerActivityLifecycleCallbacks(
object : ActivityLifecycleCallbacks {
override fun onActivityCreated(
activity: Activity,
savedInstanceState: Bundle?
) {
if (firstDraw) return
val window = activity.window
window.onDecorViewReady {
window.decorView.onNextDraw {
if (firstDraw) return
firstDraw = true
// report first draw
}
}
}
})
}
}
Not quite there yet
Let's look at the OnDrawListener.onDraw() documentation:
Callback method to be invoked when the view tree is about to be drawn.
Drawing can still take a while. We want to know when the drawing is done, not when it starts. Unfortunately, there is no ViewTreeObserver.OnPostDrawListener
API.
In Android Vitals - Rising to the first drawn surface 🤽♂️, we learnt that the first frame and traversal all happen in just one MSG_DO_FRAME
message. If we could determine when that message ends, we would know when we're done drawing.
Handler.postAtFrontOfQueue()
Instead of determining when the MSG_DO_FRAME
message ends, we can detect when the next message starts by posting to the front of the message queue with Handler.postAtFrontOfQueue():
class MyApp : Application() {
var firstDrawMs: Long = 0
override fun onCreate() {
super.onCreate()
var firstDraw = false
val handler = Handler()
registerActivityLifecycleCallbacks(
object : ActivityLifecycleCallbacks {
override fun onActivityCreated(
activity: Activity,
savedInstanceState: Bundle?
) {
if (firstDraw) return
val window = activity.window
window.onDecorViewReady {
window.decorView.onNextDraw {
if (firstDraw) return
firstDraw = true
handler.postAtFrontOfQueue {
firstDrawMs = SystemClock.uptimeMillis()
}
}
}
}
})
}
}
Edit: I measured the time difference between the first onNextDraw()
and the following postAtFrontOfQueue()
in production on a large number of devices, here are the results:
- 10th percentile: 25ms
- 25th percentile: 37ms
- 50th percentile: 61ms
- 75th percentile: 109ms
- 90th percentile: 194ms
That interval is significant enough to not be left out.
Conclusion
We now have everything we need to monitor cold start times in production:
- In Why did my process start? 🌄 we learnt how to detect a cold start.
- In When did my app start? ⏱ we learnt how to determine the app start time.
- In this blog we learnt how to determine the first draw time.
I hope you enjoyed these deep dives, stay tuned for more!
Posted on August 29, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.