Android 点击响应时间
Android 用户希望应用能够在短时间内响应他们的操作。
UX 研究告诉我们,响应时间短于 100 毫秒会让人感觉立竿见影,而超过 1 秒的响应时间会让用户失去注意力。 当响应时间接近 10 秒时,用户只需放弃他们的任务。
测量用户操作响应时间对于确保良好的用户体验至关重要。 点击是应用程序必须响应的最常见的操作。 我们可以测量 Tap 响应时间吗?
Tap Response Time 是从用户按下按钮到应用程序对点击做出明显反应的时间。
更准确地说,它是从手指离开触摸屏到显示器呈现出对该点击具有可见反应的帧(例如导航动画的开始)的时间。 Tap Response Time 不包括任何动画时间。
Naive Tap 响应时间
我打开了 Navigation Advanced Sample 项目并添加了一个对 measureTimeMillis() 的调用来测量点击 about 按钮时的 Tap Response Time。
aboutButton.setOnClickListener {
val tapResponseTimeMs = measureTimeMillis {
findNavController().navigate(R.id.action_title_to_about)
}
PerfAnalytics.logTapResponseTime(tapResponseTimeMs)
}
这种方法存在几个缺点:
它可以返回负时间。
它不会随着代码库的大小而扩展。
没有考虑从手指离开触摸屏到点击监听器被调用的时间。
它没有考虑从我们完成调用 NavController.navigate() 到显示渲染一个新屏幕可见的帧的时间。
负时间
measureTimeMillis() 调用 System.currentTimeMillis() 可以由用户或电话网络设置,因此时间可能会不可预测地向后或向前跳跃。 经过的时间测量不应使用 System.currentTimeMillis()
大型代码库
为每一个有意义的点击监听器添加测量代码是一项艰巨的任务。 我们需要一个可随代码库大小扩展的解决方案,这意味着我们需要中央钩子来检测何时触发了有意义的操作。
触摸流水线
当手指离开触摸屏时,会发生以下情况:
-
system_server 进程接收来自触摸屏的信息并确定哪个窗口应该接收 MotionEvent.UP 触摸事件。(每个窗口都与一个输入事件套接字对相关联:第一个套接字由 system_server 拥有以发送输入事件。 第一个套接字与创建窗口的应用程序拥有的第二个套接字配对,以接收输入事件。)
-
system_server 进程将触摸事件发送到目标窗口的输入事件套接字。
-
该应用程序在其侦听套接字上接收触摸事件,将其存储在一个队列 (ViewRootImpl.QueuedInputEvent) 中,并安排一个 Choreographer 框架来使用输入事件。(system_server 进程检测输入事件何时在队列中停留超过 5 秒,此时它知道它应该显示应用程序无响应 (ANR) 对话框。)
-
当 Choreographer 框架触发时,触摸事件被分派到窗口的根视图,然后通过其视图层次结构分派它。
-
被点击的视图接收 MotionEvent.UP 触摸事件并发布一个单击侦听器回调。 这允许在单击操作开始之前更新视图的其他视觉状态。
-
最后,当主线程运行发布回调时,将调用视图单击侦听器。
从手指离开触摸屏到调用单击侦听器时发生了很多事情。 每个运动事件都包括事件发生的时间 (MotionEvent.getEventTime())。 如果我们可以访问导致点击的 MotionEvent.UP 事件,我们就可以测量 Tap Response Time 的真正开始时间。
遍历和渲染
findNavController().navigate(R.id.action_title_to_about)
-
在大多数应用程序中,上述代码启动片段事务。 该事务可能是立即的(commitNow())或发布的(commit())。
-
当事务执行时,视图层次结构会更新并安排布局遍历。
-
当布局遍历执行时,一个新的帧被绘制到一个表面上。
-
然后它与来自其他窗口的帧合成并发送到显示器。
理想情况下,我们希望确切知道视图层次结构的更改何时在显示器上真正可见。 不幸的是,据我所知,没有 Java API,所以我们必须要有创意。
从点击到渲染
Main thread tracing
为了弄清楚这一点,我们在单击按钮时启用 Java 方法跟踪。
-
MotionEvent.ACTION_UP 事件被调度,一个点击被发送到主线程。
-
发布的点击运行,点击侦听器调用 NavController.navigate() 并将片段事务发布到主线程。
-
片段事务运行,视图层次结构更新,并在主线程上为下一帧安排视图遍历。
-
视图遍历运行,视图层次结构被测量、布局和绘制。
Systrace
在步骤 4 中,视图遍历绘制通道生成绘制命令列表(称为显示列表)并将该绘制命令列表发送到渲染线程。
第 5 步:渲染线程优化显示列表,添加波纹等效果,然后利用 GPU 运行绘图命令并绘制到缓冲区(OpenGL 表面)。 完成后,渲染线程告诉表面抛掷器(位于单独的进程中)交换缓冲区并将其放在显示器上。
第6步(在systrace截图中不可见):所有可见窗口的表面由surface flinger和hardware composer合成,并将结果发送到显示器。
点击响应时间
我们之前将 Tap Response Time 定义为从用户按下按钮到应用对点击做出明显反应的时间。 换句话说,我们需要测量经过步骤 1 到 6 的总持续时间。
第 1 步:向上调度
我们定义了 TapTracker,一个触摸事件拦截器。 TapTracker 存储上次 MotionEvent.ACTION_UP 触摸事件的时间。 当发布的点击监听器触发时,我们通过调用 TapTracker.currentTap 来检索触发它的 up 事件的时间: ``` object TapTracker : TouchEventInterceptor {
var currentTap: TapResponseTime.Builder? = null private set
private val handler = Handler(Looper.getMainLooper())
override fun intercept( motionEvent: MotionEvent, dispatch: (MotionEvent) -> DispatchState ): DispatchState { val isActionUp = motionEvent.action == MotionEvent.ACTION_UP if (isActionUp) { val tapUptimeMillis = motionEvent.eventTime // Set currentTap right before the click listener fires handler.post { TapTracker.currentTap = TapResponseTime.Builder( tapUptimeMillis = tapUptimeMillis ) } } // Dispatching posts the click listener. val dispatchState = dispatch(motionEvent)
if (isActionUp) {
// Clear currentTap right after the click listener fires
handler.post {
currentTap = null
}
}
return dispatchState
} } ```
然后我们将 TapTracker 拦截器添加到每个新窗口: ``` class ExampleApplication : Application() {
override fun onCreate() { super.onCreate()
Curtains.onRootViewsChangedListeners +=
OnRootViewAddedListener { view ->
view.phoneWindow?.let { window ->
if (view.windowAttachCount == 0) {
window.touchEventInterceptors += TapTracker
}
}
}
} } ```
第 2 步:单击侦听器和导航
让我们定义一个 ActionTracker,当发布的点击监听器触发时,它会被调用:
object ActionTracker {
fun reportTapAction(actionName: String) {
val currentTap = TapTracker.currentTap
if (currentTap != null) {
// to be continued...
}
}
}
以下是我们如何利用它:
aboutButton.setOnClickListener {
findNavController().navigate(R.id.action_title_to_about)
ActionTracker.reportTapAction("About")
}
但是,我们不想将该代码添加到每个点击侦听器中。 相反,我们可以向 NavController 添加目标侦听器:
navController.addOnDestinationChangedListener { _, dest, _ ->
ActionTracker.reportTapAction(dest.label.toString())
}
我们可以为每个选项卡添加一个目标侦听器。 或者我们可以利用生命周期回调向每个新的 NavHostFragment 实例添加目标侦听器: ``` class GlobalNavHostDestinationChangedListener : ActivityLifecycleCallbacks {
override fun onActivityCreated( activity: Activity, savedInstanceState: Bundle? ) { if (activity is FragmentActivity) { registerFragmentCreation(activity) } }
private fun registerFragmentCreation(activity: FragmentActivity) { val fm = activity.supportFragmentManager fm.registerFragmentLifecycleCallbacks( object : FragmentLifecycleCallbacks() { override fun onFragmentCreated( fm: FragmentManager, fragment: Fragment, savedInstanceState: Bundle? ) { if (fragment is NavHostFragment) { registerDestinationChange(fragment) } } }, true ) }
private fun registerDestinationChange(fragment: NavHostFragment) { val navController = fragment.navController navController.addOnDestinationChangedListener { _, dest, _ -> val actionName = dest.label.toString() ActionTracker.reportTapAction(actionName) } } ```
第三步:片段执行
调用 NavController.navigate() 不会立即更新视图层次结构。 相反,一个片段事务被发布到主线程。 当片段事务执行时,将创建并附加目标片段的视图。 由于所有挂起的片段事务都是一次性执行的,因此我们添加了自己的自定义事务以利用 runOnCommit() 回调。 让我们首先构建一个实用程序 OnTxCommitFragmentViewUpdateRunner.runOnViewsUpdated():
class OnTxCommitFragmentViewUpdateRunner(
private val fragment: Fragment
) {
fun runOnViewsUpdated(block: (View) -> Unit) {
val fm = fragment.parentFragmentManager
val transaction = fm.beginTransaction()
transaction.runOnCommit {
block(fragment.view!!)
}.commit()
}
}
然后我们将一个实例传递给 ActionTracker.reportTapAction():
class GlobalNavHostDestinationChangedListener
...
val navController = fragment.navController
navController.addOnDestinationChangedListener { _, dest, _ ->
val actionName = dest.label.toString()
- ActionTracker.reportTapAction(actionName)
+ ActionTracker.reportTapAction(
+ actionName,
+ OnTxCommitFragmentViewUpdateRunner(fragment)
+ )
}
}
}
object ActionTracker {
- fun reportTapAction(actionName: String) {
+ fun reportTapAction(
+ actionName: String,
+ viewUpdateRunner: OnTxCommitFragmentViewUpdateRunner
+ ) {
val currentTap = TapTracker.currentTap
if (currentTap != null) {
- // to be continued...
+ viewUpdateRunner.runOnViewsUpdated { view ->
+ // to be continued...
+ }
}
}
}
第 4 步:帧和视图层次遍历
当片段事务执行时,会为下一帧安排一次视图遍历,我们使用 Choreographer.postFrameCallback() 将其挂钩:
object ActionTracker {
+
+ // Debounce multiple calls until the next frame
+ private var actionInFlight: Boolean = false
+
fun reportTapAction(
actionName: String,
viewUpdateRunner: OnTxCommitFragmentViewUpdateRunner
) {
val currentTap = TapTracker.currentTap
- if (currentTap != null) {
+ if (!actionInFlight & currentTap != null) {
+ actionInFlight = true
viewUpdateRunner.runOnViewsUpdated { view ->
- // to be continued...
+ val choreographer = Choreographer.getInstance()
+ choreographer.postFrameCallback { frameTimeNanos ->
+ actionInFlight = false
+ // to be continued...
+ }
}
}
}
}
第 5 步:渲染线程
视图遍历完成后,主线程将显示列表发送到渲染线程。 渲染线程执行额外的工作,然后告诉表面flinger交换缓冲区并将其放在显示器上。 我们注册一个 OnFrameMetricsAvailableListener 来获取总帧持续时间(包括在渲染线程上花费的时间):
object ActionTracker {
...
val choreographer = Choreographer.getInstance()
choreographer.postFrameCallback { frameTimeNanos ->
actionInFlight = false
- // to be continued...
+ val callback: (FrameMetrics) -> Unit = { frameMetrics ->
+ logTapResponseTime(currentTap, frameMetrics)
+ }
+ view.phoneWindow!!.addOnFrameMetricsAvailableListener(
+ CurrentFrameMetricsListener(frameTimeNanos, callback),
+ frameMetricsHandler
+ )
}
}
}
}
+
+ private fun logTapResponseTime(
+ currentTap: TapResponseTime.Builder,
+ fM: FrameMetrics
+ ) {
+ // to be continued...
+ }
一旦我们有了帧指标,我们就可以确定帧缓冲区何时被交换,因此是 Tap 响应时间,即从 MotionEvent.ACTION_UP 到缓冲区交换的时间:
object ActionTracker {
...
currentTap: TapResponseTime.Builder,
fM: FrameMetrics
) {
- // to be continued...
+ val tap = currentTap.tapUptimeMillis
+ val intendedVsync = fM.getMetric(INTENDED_VSYNC_TIMESTAMP)
+ // TOTAL_DURATION is the duration from the intended vsync
+ // time, not the actual vsync time.
+ val frameDuration = fM.getMetric(TOTAL_DURATION)
+ val bufferSwap = (intendedVsync + frameDuration) / 1_000_000
+ Log.d("TapResponseTime", "${bufferSwap-tap} ms")
}
}
SurfaceFlinger
没有 Java API 来确定合成帧何时最终由 SurfaceFlinger 发送到显示器,因此我没有包含该部分。