loading...
Cover image for Android Vitals - First draw time ๐Ÿ‘ฉโ€๐ŸŽจ

Android Vitals - First draw time ๐Ÿ‘ฉโ€๐ŸŽจ

pyricau profile image Py โš” ใƒป7 min read

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

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

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!

Posted on by:

pyricau profile

Py โš”

@pyricau

Author of LeakCanary ๐Ÿท๐Ÿฅ–โ›ท๐Ÿ‡ซ๐Ÿ‡ท

Discussion

pic
Editor guide
 

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