Android 點選響應時間

語言: CN / TW / HK

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 方法跟蹤。

  1. MotionEvent.ACTION_UP 事件被排程,一個點選被髮送到主執行緒。

  2. 釋出的點選執行,點選偵聽器呼叫 NavController.navigate() 並將片段事務釋出到主執行緒。

  3. 片段事務執行,檢視層次結構更新,並在主執行緒上為下一幀安排檢視遍歷。

  4. 檢視遍歷執行,檢視層次結構被測量、佈局和繪製。

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 傳送到顯示器,因此我沒有包含該部分。