Rendering performance monitoring on Android

Artem Grishin
Booking.com Engineering
4 min readNov 10, 2021

As developers, we always want our apps to offer the best user experience. When it comes to performance, we know that an “ideal” rendering performance for a regular application is 60 frames per second, or 60 fps.

This gif illustrates the difference between ideal and not-so-ideal frame rendering:

To have a solid 60fps, each frame needs to be rendered by the app within a 16.6ms window (1 sec = 1000ms, 1000ms / 60 = 16.6ms). Whenever a frame takes more time to render, the next frames are skipped, which ultimately results in hangs and glitches. The Android Vitals section in Google Play has an overview for such “delayed” frames, defining them as ‘slow frames’ and ‘frozen frames’.

A slow frame is one rendered between 17ms and 700ms. A frozen frame is rendered in 700ms+.

While the Android Vitals rendering performance reports are available in the Google Play Developer Console, they come with a 2 day delay and only show data aggregated per app. If you want to have a more detailed overview, let’s say per screen, then you will need to implement your own monitoring. Let’s see how this can be done in 3 simple phases.

FrameMetricsAggregator

Let’s get started with Android Jetpack. It provides us with a class which can help us called FrameMetricsAggregator (Note: it uses FramesMetrics API which is available starting from Android 7). According to documentation, “this class can be used to record and return data about per-frame durations”.

Sample usage:

//passing TOTAL_DURATION just for example.
// In real life, the default constructor uses TOTAL_DURATION by default.
val frameAggregator = FrameMetricsAggregator(FrameMetricsAggregator.TOTAL_DURATION)
frameAggregator.add(activity)

val metrics = frameAggregator.getMetrics()

frameAggregator.remove(activity)

In order to use this class, we first need to create an instance of FrameMetricsAggregator. The constructor receives a parameter of metricTypeFlags. These flags represent certain timings of a whole frame rendering process (i.e. you can measure a total frame duration, like in example above, or you can opt to use one of many configuration options like only animation duration time or draw duration).

The next step is to add a target Activity. The FrameMetricsAggregator starts recording the frames when the activity is added, so there is no special “start” API method. Then, once an observation is done, we can get recorded data by calling frameAggregator.getMetrics. Finally, to stop observation, call frameAggregator.remove(activity).

At this stage we have recorded our metrics. Now the next question is, how can we collect data from these results?

Parsing results

The return type of getMetrics() call is Array<SparseIntArray>. This array holds sub-arrays, each of which represents a certain slice of frame-related data. Remember the flags that can be passed to the FrameMetricsAggregator constructor? For each type of flag, there is matching INDEX constant, that can be used to access metrics:

val totalDuration: SparseIntArray = metrics[FrameMetricsAggregator.TOTAL_INDEX]

Unfortunately, this convenient array-like access example won’t work in real life — the return type of frameMetricsAggregator.getMetrics() is nullable, and the bucket with TOTAL_INDEX might be missing in it. Let’s use Kotlin features to help with nullability:

val totalDuration = frameMetricsAggregator.metrics?.getOrNull(FrameMetricsAggregator.TOTAL_INDEX)

Now, what does this totalDuration array consist of? It holds data on the frame timings, in a format (frameTime | number of frames rendered). So, the data

(5, 2), (12, 1), (15, 3)

would mean that there were 2 frames rendered in 5ms, 1 frame rendered in 12ms and 3 frames rendered in 15ms.

Using this data, we can finally identify slow and frozen frames:

val totalDuration = metrics[FrameMetricsAggregator.TOTAL_INDEX]
var slow = 0
var frozen = 0
totalDuration.forEach { frameTime, frameCount ->
if (frameTime >= 17) {
slow += frameCount
}
if (frameTime >= 700) {
frozen += frameCount
}
}

Tying to a lifecycle

Let’s implement a simple monitoring class to see how it all works together. Ideally, we would like to tie our monitoring to the activity lifecycle, so that it can be started and stopped in the appropriate time. To get access to all activities used in the app, we can use ActivityLifecycleCallbacks .

class RenderMonitor(
val reporter: RenderMonitorReporter
) : Application.ActivityLifecycleCallbacks {
private val frameAggregator = FrameMetricsAggregator()

override fun onActivityResumed(activity: Activity) {
frameAggregator.add(activity)
}
override fun onActivityPaused(activity: Activity) {
reporter(activity, parseResults(frameAggregator.metrics))
frameAggregator.remove(activity)
frameAggregator.reset()
}
private fun parseResults(rawData: Array<out SparseIntArray>?) : FramesData {
val totalDuration = rawData?
.getOrNull(FrameMetricsAggregator.TOTAL_INDEX)
?: return FramesData(0, 0)
var slow:Long = 0
var frozen:Long = 0
totalDuration.forEach { frameTime, frameCount ->
if (frameTime >= 17) {
slow += frameCount
}
if (frameTime >= 700) {
frozen += frameCount
}
}
return FramesData(slow, frozen)
}
}

Let’s dive into what’s going on here.

There are two methods that interest us most for our case: onActivityResumed (screen becomes active) and onActivityPaused (screen becomes inactive). You might want to extend the observation to cover a longer period of a lifetime (i.e. onStart-onStop), and if this is the case, just move the code to the appropriate callbacks.

In onActivityResumed, we’re adding the activity to the frameAggregator, thus starting the monitoring.

While the screen is active, the frameAggregator silently collects data.

Finally, when the screen becomes inactive, we need to get the results. First, the collected metrics are passed to the parseResults method which goes through each pair of frame aggregations and counts slow and frozen frames. Then, the activity is removed from the frameAggregator, thus monitoring is stopped. And finally, to clear the state of the aggregator, we need to call reset.

Congratulations! You’ve built your own slow and frozen frames monitoring. You can use it to find UX issues, or build a real-time monitoring to see how your app is perceived by users.

--

--

Artem Grishin
Booking.com Engineering

I'm an Engineering manager with a solid Android development background. I write about mobile development, people management, team processes setup, etc.