DEV Community

Cover image for Android Vitals - First draw time 👩‍🎨
Py ⚔
Py ⚔

Posted on • Updated on

Android Vitals - First draw time 👩‍🎨

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:

First draw

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()
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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)
      )
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Notice the nice extension function:

view.onNextDraw { 
  // report first draw
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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)
      )
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
        }
      }
    })
  }
}
Enter fullscreen mode Exit fullscreen mode

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
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
          }
        }
      }
    })
  }
}
Enter fullscreen mode Exit fullscreen mode

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()
            }
          }
        }
      }
    })
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

I hope you enjoyed these deep dives, stay tuned for more!

Top comments (4)

Collapse
 
penkzhou profile image
Peng 👨‍💻

I really enjoy these deep dives, can I translate these posts into Chinese?

Collapse
 
sewerk profile image
Kamil Seweryn

what if (view.viewTreeObserver.isAlive) is false and we won't viewTreeObserver.removeOnDrawListener(this)? Next onDraw callback will return just after firing forever?

Collapse
 
pyricau profile image
Py ⚔

The assumption is that if the view tree observer stops being alive then the views are detached from the window (e.g. activity destroyed) so the view tree observer will be gced and this won't fire again ever.

Collapse
 
jubincn profile image
Bin

How about get first draw time from "onActivityStarted" instead of "onActivityCreated" since when activity started the docer view is already installed.