Header image: Alone Together by Romain Guy.
Android users expect apps to respond to their actions within a short time window.
💡 Did you know?
UX research teaches us that a response time shorter than 100ms feels immediate, and a response time beyond 1s makes users lose focus. When the response time gets closer to 10 seconds, users simply abandon their task (source).
👉📱
Measuring user action response times is critical to ensure a good user experience. Taps are the most common action apps must respond to. Can we measure Tap Response Time?
🎓 Tap Response Time
The Tap Response Time is the time from when the user is done pressing a button to when the app has visibly reacted to the tap.
More precisely, it's the time from when the finger leaves the touch screen to when the display has rendered a frame with a visible reaction to that tap (e.g. the start of a navigation animation). The Tap Response Time does not include any animation time.
Naive Tap Response Time
I opened the Navigation Advanced Sample project and added a call to measureTimeMillis() to measure the Tap Response Time when tapping on the about button.
aboutButton.setOnClickListener {
val tapResponseTimeMs = measureTimeMillis {
findNavController().navigate(R.id.action_title_to_about)
}
PerfAnalytics.logTapResponseTime(tapResponseTimeMs)
}
Simple enough! However this approach presents several drawbacks:
- ⌛️ It can return a negative time.
- 📈 It doesn't scale with the codebase size.
- 👉 It doesn't account for the time from when the finger leaves the touch screen to when the click listener is called.
- 📱 It doesn't account for the time from when we're done calling NavController.navigate() to when the display has rendered a frame with the new screen visible.
⌛️ Negative time
measureTimeMillis() calls System.currentTimeMillis()
whihch can be set by the user or the phone network, so the time may jump backwards or forwards unpredictably. Elapsed time measurements should not use System.currentTimeMillis()
(learn more: Android Vitals - What time is it?
).
📈 Large codebases
Adding measuring code to every single meaningful click listener is a daunting task. We need a solution that scales with the codebase size, which means we need central hooks to detect when meaningful actions are triggered by taps.
👉 Touch pipeline
When a finger leaves the touch screen, the following happens:
- The
system_server
process receives the information from the touch screen and determines which window should receive aMotionEvent.UP
touch event.
💡 Did you know?
Every window is associated with an input event socket pair: the first socket is owned by
system_server
to send input events. That first socket is paired with a second socket owned by the app that created the window, to receive input events.
- The
system_server
process sends the touch event to the input event socket for the targeted window. - The app receives the touch event on its listening socket, stores it in a queue (ViewRootImpl.QueuedInputEvent) and schedules a Choreographer frame to consume input events.
💡 Did you know?
The
system_server
process detects when an input event stays in the queue for more than 5 seconds, and that's when it knows it should show an Application Not Responding (ANR) dialog.
- When the Choreographer frame triggers, the touch event is dispatched to the root view of a window, which then dispatches it through its view hierarchy.
- The tapped view receives the
MotionEvent.UP
touch event and posts a click listener callback (source). This lets other visual state of the view update before click actions start. - Then finally when the main thread runs that posted callback the view click listener is called.
As you can see, quite a lot happens from when the finger leaves the touch screen to when the click listener is called. Every motion event includes the time at which the event occurred (MotionEvent.getEventTime()). If we could get access to that MotionEvent.UP
event leading to a click we could measure the true start of the Tap Response Time.
📱 Traversal and rendering
What does this do?
findNavController().navigate(R.id.action_title_to_about)
- In most apps, the above code starts a fragment transaction. That transaction may be immediate (
commitNow()
) or posted (commit()
). - When the transaction execute, the view hierarchy is updated and a layout traversal is scheduled.
- When the layout traversal executes, a new frame is drawn into a surface.
- It is then composited with frames from other windows and sent to the display. To learn more, watch this talk from Romain Guy and Chet Haase: Drawn out: How Android renders.
Ideally we'd like to know exactly when a change to the view hierarchy becomes actually visible on the display. Unfortunately, as far as I know there's no Java API for that, so we'll have to get creative.
🤔 What now?
We've seen that the naive approach to measuring Tap Response Time isn't accurate and doesn't scale. We need to build something better!
The good news is, I've implemented exactly that, on top of Jetpack Navigation. The bad news is, you'll have to wait for me to publish Part 2!. The even better news is, you can read about it in Part 2: Tap Response Time: Jetpack Navigation 🗺.
⚖️ This work is licensed under a Creative Commons Attribution 4.0 International License.
Top comments (1)
Thanks, Py! I was always curious to learn about motion events/listeners internals