手淘 Android 幀率採集與監控詳解

語言: CN / TW / HK

作者:黎磊(千諾)

APM 提供幀率的相關資料,即 FPS(Frames Per Second) 資料。FPS 在一定程度上反映了頁面流暢程度,但 APM 提供的 FPS 並不是很準確。恰逢手淘低端機效能優化專案開啟,亟需相關指標來衡量對滑動體驗的優化,幀率資料探索實踐就此拉開。

在探索實踐中,我們遇到了許多問題:

  • 高刷手機佔比相對不低,影響整體 FPS 資料
  • 非人為滑動資料參雜在 FPS 中,不能直接體現使用者操作體驗
  • 計算平均資料時,卡頓資料被淹沒在海量正常資料中,一次卡頓是否隻影響一個 FPS 值還是一次使用者操作體驗?

經過一段時間的探索,我們沉澱下來了一些指標,其中包括:滑動幀率、凍幀佔比、scrollHitchRate、卡頓幀率。除了相關幀率指標之外,為了更好的指導效能優化,APM 還提供了幀率主因分析,同時為了更好的定位卡頓問題,也提供了卡頓堆疊。

下面是 APM 基於平臺的特性,對幀率相關探索實踐的詳細介紹,希望本文可以給大家帶來一些幫助。

系統渲染機制

在介紹指標的實現之前,首先需要了解系統是如何做渲染的,只有知曉系統渲染機制,才能幫助我們更好的進行幀率資料計算處理。

渲染機制是 Android 中重要的一部分,其中又牽扯甚廣,包括我們常說的 measure/layout/draw 原理、卡頓、過度繪製等,都與其相關。在這裡我們主要是對渲染流程進行整體瞭解,知曉後續需要計算哪幾部分、通過系統 API 得到了哪幾部分,以便計算出目標資料。

渲染流程

我們都知道,當觸發渲染後,會走到 ViewRootImpl 的 scheduleTraversals。這時,scheduleTraversals 方法主要是向 Choreographer 註冊下一個 VSync 的回撥。當下一個 VSync 來臨時,Choreographer 首先切到主執行緒(傳 VSync 上來的 native 程式碼不執行在主執行緒),當然它並不是直接給 Looper sendMessage,而是 msg.setAsynchronous(true) ,提高了 UI 的響應速率。

當切到主執行緒後,Choreographer 開始執行所有註冊了這個 VSync 的回撥,回撥型別分為以下四種:

  1. CALLBACK_INPUT,輸入事件
  2. CALLBACK_ANIMATION,動畫處理
  3. CALLBACK_TRAVERSAL,UI 分發
  4. CALLBACK_COMMIT

Choreographer 會將所有的回撥按型別分類,用連結串列來組織,表頭存在一個大小固定的陣列中(因為只支援這四種回撥)。在 VSync 傳送到主執行緒的訊息中,就會一條連結串列一條連結串列的取出順序執行並清空。

而在 scheduleTraversals 註冊的就是 CALLBACK_TRAVERSAL 型別的 callback,這個 callback 中執行的就是我們最為熟悉的 ViewRootImpl#doTraversal() 方法,doTraversal 方法中呼叫了 performTraversals 方法,performTraversals 方法中最重要的就是呼叫了耳熟能詳的 performMeasure、performLayout、performDraw 方法。

詳細程式碼可以翻看: android.view.Choreographer 和 android.view.ViewRootImpl

從這裡我們可以看到,想要上屏一幀資料,至少包括:VSync 切到主執行緒的耗時、處理輸入事件的耗時、處理動畫的耗時、處理 UI 分發(measure、layout、draw)的耗時。

然而,當 draw 流程結束,只是 CPU 計算部分結束,接下來會把資料交給 RenderThread 來完成 GPU 部分工作。

螢幕重新整理

Android 4.1 引入了 VSync 和三緩衝機制,VSync 給予開始 CPU 計算的時機,以及 GPU 和 Display 交換的緩衝區的時機,這樣有利於充分利用時間來處理資料和減少 jank。

上圖中 A、B、C 分別代表著三個緩衝區。我們可以看到 CPU、GPU、顯示器都能儘快拿到 buffer,減少不必要的等待。如果顯示器和 GPU 現在都使用著一個 buffer,如果下一次渲染開始了,因為還有一個 buffer 可以用於 CPU 資料的寫入,所以可以馬上開始下一幀資料的渲染,例如圖中第一個 VSync。

是不是引入三緩衝機制就沒有任何問題呢,當我們仔細看上圖可發現,資料 A 在第三個 VSync 來臨時就已經準備好,隨時可以重新整理到螢幕上,到真正刷到螢幕卻是第四個 VSync 來臨。由此可知,三緩衝雖然有效利用了等待 VSync 的時間,減少了 jank,但是帶來了延遲

這裡只是簡單帶大家回顧了這塊的知識,建議大家翻下發展的歷史,知其然亦要知其所以然。

對幀資料資訊的挖掘

當我們知道了整個系統渲染的流程後,我們需要監控什麼,怎麼監控,這是一個問題。

業界方案

APM 原始方案:

當收到 Touch 事件後,APM 會採集頁面 1s 內 draw 的次數。這個方案的優點是效能損耗低,但是存在致命缺陷。如果頁面渲染總時長不足 1s 就停止重新整理,會導致資料人為偏低。其次,觸碰螢幕不一定會帶來重新整理,重新整理也不一定是 Touch 事件帶來的。而以上情況計算出來的都是髒資料。

但是,Android 在 ViewRootImpl 實現了一個Debug 的 FPS 方案,原理與上訴方案類似,都是在 draw 時累積時長到 1s,所以,如果是想要一個低成本效能無損的線下測試 FPS,這不失為一個方案。

感興趣可以看 ViewRootImpl 的 trackFPS 方法。

Matrix:

在幀率這部分,Matrix 創新性的 hook 了 Choreographer 的 CallbackQueue,同時還通過反射呼叫 addCallbackLocked 在每一個回撥佇列的頭部添加了自定義的 FrameCallback。如果回調了這個 Callback,那麼這一幀的渲染也就開始了,當前在 Looper 中正在執行的訊息就是渲染的訊息。這樣除了監控幀率外,還能監控到當前幀的各個階段耗時資料。

除此之外,幀率回撥和 Looper 的 Printer 結合使用,能夠在出現卡頓幀的時候去 dump 主執行緒資訊,便於業務方解決卡頓,但是頻繁拼接字串會帶來一定的效能開銷(println 方法呼叫時有字串拼接)。

常規:

使用 Choreographer.FrameCallback 的 doFrame(frameTimeNanos: Long) 方法,在每一次的回撥裡計算兩幀之差,通過計算可以得到 FPS。

滑動幀率

FPS 是業界簡單而又通用的一個指標,是 Frames Per Second 的簡寫,即每秒渲染幀數,通俗來講就是每秒渲染的畫面數。

計算出 FPS 並不是我們的目標,我們一直希望計算出的是滑動幀率,針對 FPS,我們更為關注的是使用者在互動過程中的幀率,監控這一類幀率才能更好反映使用者體驗。

首先,面對之前的採集方案,根本不能採集出符合定義的 FPS,所以原始的方案就必須要進行捨棄,需要進行重新設計。當看到 Matrix 的方案時,覺得想法很棒,但是太過 hack,我們更傾向於維護成本更低、穩定性高的系統開放 API。

所以,在選擇上,我們還是決定使用最普通的 Choreographer.FrameCallback 進行實現。當然,它不是最完美的,但是可以儘量在設計上去避免這種缺陷。

那我們怎麼計算出一個 FPS 值呢?

Choreographer.FrameCallback 被回撥時,doFrame 方法都帶上了一個時間戳,計算與上一次回撥的差值,就可以將之視之為一幀的時間。當累加超過 1s 後,就可以計算出一個 FPS 值。

在這個過程中,有個點要大家知曉,doFrame 在什麼時機回撥:

首先,我們每一次回撥後,都需要對 Choreographer 進行 postFrameCallback 呼叫,而呼叫 postFrameCallback 就是在下一幀 CALLBACK_ANIMATION 型別的連結串列上進行新增一個節點。所以,doFrame 回撥時機並不是這一幀開始計算,也不是這一幀上屏,而是 CPU 處理動畫過程中的一個 callback

當計算出一個 FPS 值後,就需要在上面疊加以下狀態了:

View 滑動幀率

在最開始實現時,View 只要滑動就監控幀率,一直幀率產出到不滑動為止。根據需求,我們的幀率採集就變成了如下這樣:

那怎麼監控 View 是否有滑動呢?那就需要介紹一下這個 ViewTreeObserver.OnScrollChangedListener。畢竟只有瞭解實現原理,才能決定是否可用。

// ViewRootImpl#draw private void draw(boolean fullRedrawNeeded) { // ... if (mAttachInfo.mViewScrollChanged) { mAttachInfo.mViewScrollChanged = false; mAttachInfo.mTreeObserver.dispatchOnScrollChanged(); } // ... mAttachInfo.mTreeObserver.dispatchOnDraw(); // ... }

我們可以看到,在 ViewRootImpl#draw 中,判斷了 mAttachInfo 資訊中 View 是否產生了滑動,如果產生滑動就分發出來。那麼什麼時候設定的 View 位置變化(產生滑動)的呢?在 View 的 onScrollChanged 被呼叫的時候:

// View#onScrollChanged protected void onScrollChanged(int l, int t, int oldl, int oldt) { // ... final AttachInfo ai = mAttachInfo; if (ai != null) { ai.mViewScrollChanged = true; } // ... }

onScrollChanged 就直接連線著 View#scrollTo 和 View#scrollBy,在大多數場景下,已經足夠通用。

根據我們之前講解的渲染流程:我們可以看到 ViewTreeObserver.OnScrollChangedListener 的回撥是在 ViewRootImpl#draw 中,那麼 Choreographer.FrameCallback 的回撥先於 ViewTreeObserver.OnScrollChangedListener 的。

對於單幀,就可以如下表示:

這樣,每一幀都帶上了是否滑動的狀態,當某一幀是滑動的幀,就可以開始計數,一直累積時間到 1s,一個滑動幀率資料計算出來就出來了。

手指滑動幀率

View 滑動幀率,線上下驗證時,與測試平臺出的資料一致,並且能夠符合基本需求,驗收通過。上線後,也開始了執行,並能夠承擔起幀率相關工作。

但是,View 滾動並不代表著是使用者操作導致,資料始終不全是使用者體驗的結果。所以,我們開始實現手指的滑動幀率。

手指滑動幀率,首先我們需要能夠接收到手指的 Touch 行為。由於 APM 中已有對 Callback 的 dispatchTouchEvent 介面的 hook,所以決定直接使用此介面識別手指滑動。

這個時候,我們需要知道幾個時機問題:

  • 有 dispatchTouchEvent 不會立馬產生 doFrame
  • 通過 dispatchTouchEvent 計算移動時間/距離超過 TapTimeout/ScaledTouchSlop,不一定立馬產生 doFrame

所以,通過 dispatchTouchEvent 計算移動時間/距離超過 TapTimeout/ScaledTouchSlop 時,只會給一個 flag,通知後面的 ViewTreeObserver.OnScrollChangedListener 的 doFrame 可以開始計算成手指滑動幀率。

效能優化/滑動次數識別

我們在收到每一幀的 doFrame 回撥後,都需要重新 postFrameCallback。每一次 postFrameCallback 都會註冊 VSync(如果沒有被註冊),當 Vsync 來臨後,會給主執行緒拋一個訊息,這勢必會給主執行緒帶來一定的壓力。

眾所周知,系統在頁面靜止的時候是不會進行渲染的,也就不會有 VSync 被註冊。那麼在沒有渲染的時候,是否也需要 post 呢?不需要,沒有意義,是可以過濾掉的。基於這個理念,我們對滑動幀率的計算進行了優化。

需要減少非必要的幀回撥與註冊,就需要明確幾個問題:

  1. 起點(什麼時候開始 postFrameCallback):在第一次收到 scroll 事件的時候(onSrollChanged)
  2. 終點(什麼時候不再 postFrameCallback):在計算完一個手指滑動 FPS 後,如果下一幀不再滑動,那麼就停止註冊下一幀的回撥。

如果細心的話,就會發現,這裡的起點可以認為是手指帶來的滑動的渲染起點,這裡的終點可以認為是手指帶來的滑動的渲染終點(包括了 Fling),這個資料很重要,我們相當於識別了一次手指滑動,並且能夠提供每次手指滑動的耗時等資料。

這樣進行優化是否就完美無缺呢?其實不是的,仔細看上圖的計算開始時間點,就會發現:損失了開始滑動的第一幀資料。因為我們計算的是兩次 doFrame 回撥的差值,即使知道當前這一幀是需要計算的幀,但是沒有上一幀的時間戳,也就無法計算出開始滑動的這一幀真正的耗時。

凍幀佔比

凍幀是 Google 官方定義的一種幀:

Frozen frames are UI frames that take longer than 700ms to render.

凍幀作為一種特殊的幀,不是被強烈建議不要出現的幀,在華為等文件中也被提及過。一旦出現此類幀,頁面也就像凍住似的。所以,在 APM 中,也將這一類特殊的幀納入監控範圍,計算出凍幀佔比:

凍幀佔比 = 滑動過程中的凍幀數量 / 滑動產生的幀數

scrollHitchRate**

scrollHitchRate 概念來自於 iOS,主要是用於描述滑動過程中,hitch 時長的佔比。什麼叫 hitch?可以簡單理解為單個幀耗時超過了渲染標準耗時的部分就是 hitch。

計算公式如圖所示:

這裡的分子是指整個滑動過程中,hitch 的累加值,這裡的分母就是整個滑動耗時(包含 Fling)。

大家可能會問: 那為什麼不用FPS? 不是可以用 fps 來檢測滑動卡頓情況麼,為什麼還要有一個 Hitch rate ?

這是因為 FPS 並不適用於所有的情況。比如當一個動畫中有停頓時間, FPS 就無法反應該動畫的流暢程度,而且並不是所有的應用都以達到 60 fps/120 fps 為目標,比如有些遊戲只想以 30 fps 執行。而對於 Hitch rate 而言,我們的目標永遠是讓它達到 0。

引入 scrollHitchRate 單純為了解決高刷手機的資料不一致問題嗎?不是的。我們在採集到一個 scrollHitchRate 資料,還隱式的帶上了滑動次數。例如,在手淘場景下,首頁同學諮詢過一個問題,會不會頁面越往下刷,卡得越嚴重?當採集到這個資料後,就可以進行回答了。

幀率主因分析

無論是滑動幀率,還是凍幀,更多的還是偏向於監控資料,如果想要在資料上分析出當前幀率低的主要原因還是沒有辦法入手的。

在之前渲染流程中,就講到渲染流程主要分成哪幾步,如果能夠將渲染流程的每一步都進行監控,那麼我們就可以認為:當某一個異常幀出現後,主要問題出現在哪一個階段了,但是我們還是希望不要像 Matrix 那樣侵入系統程式碼。基於這個思路,我們發現系統提供了滿足我們需求的 API:Window.OnFrameMetricsAvailableListener。Google Firebase 也同樣在使用這個 API 進行幀資料監控,也不太會有後續的相容性問題。

FrameMetrics,開發文件見 https://developer.android.com/reference/android/view/FrameMetrics

在非同步回撥給的 FrameMetrics 資料中,會告訴我們每一幀每一個階段的耗時,非常契合我們的監控訴求。但是依然有兩個問題值得重視:

  • FrameMetrics API 是在 Android 24 上提供的,檢視手淘使用者資料可以發現,能夠滿足基本需求;
  • 一幀資料處理不及時會有丟資料的風險,但可以通過介面知曉丟棄了幾幀資料。

下面我們就詳細檢視下 FrameMetrics 資料中定義了哪些渲染階段:

摘抄自 Android 26。除上訴提及的欄位此,還有幾個比較不錯的時間戳欄位,也可以探索出一些新奇的玩法,大家可以一起探索下。

大家有沒有發現,跟渲染流程一模一樣。在跟蹤了下相關原始碼後,註冊一個 listener,並沒有太多的效能損耗,FrameMetrics 內部記錄的時間戳即使不註冊也會進行採集,所以不會帶來額外的效能開銷。

首先我們定義了一個需要進行分析的幀耗時閾值,超過這個閾值就可以認為需要統計原因。我們定義:當一幀某一個階段耗時超過閾值一半即為主因,反之則主因不存在。

如此一來,針對某一個 Activity 就可以分析出是主執行緒卡頓導致幀率低,還是佈局問題導致 layout & measure 慢,亦或是 draw 有問題,在效能優化時,直接鎖定主因進行優化

卡頓幀率

首先我們再來回顧一下人眼的卡頓感知。原理上,高的幀率可以得到更流暢、更逼真的動畫,要生成平滑連貫的動畫效果,幀速不能小於8FPS;每秒鐘幀數越多,所顯示的動畫就會越流暢。一般來說人眼能繼續保留其影像1/24秒左右的影象,所以一般電影的幀速為24FPS。相對於遊戲而言,無論幀率有多高,60幀或120幀,最後一般人能分辨到的不會超過30幀。電影雖然只有24幀每秒,但由於每兩幀之間的間隔均為1/24秒,所以人眼不不會感覺到明顯的卡頓,遊戲或者我們介面的重新整理即使達到30幀每秒,但如果這一秒鐘內,30幀不是平均分配,就算是每秒60幀,其中59幀都非常流暢,而有一幀延時超過1/24秒,依然會讓我們感覺到明顯的卡頓。

這就是我們介面上大部分情況下都已經滑動的非常流暢,但是偶爾還是會察覺到卡頓的原因。按照1/24秒的話,幀時間在41.6ms,如果中間有超過41.6ms的話,我們是可以感覺到卡頓的,如果按照1/30的話,幀時間在33.3ms,如果某一幀的延遲時間超過了33.3ms,那麼人眼就容易察覺到這個過程,為了把這些卡頓的情況反映出來,我們需要在遇到這些幀的時候做一些記錄。但是如果我們只是去記錄過程中那些耗時超過33.3ms的幀,這種情況下,一方面會丟失掉時間的因素,很難去衡量卡頓的嚴重性(畢竟一段時間內不間斷的出現卡頓,比偶爾掉一幀要讓人明顯很多),另一方面,因為有多重緩衝區的影響,未必100%會掉幀,所以我們只是取這個超過某一時刻的幀未必是準確的。

基於以上的考慮,這裡使用了一個瞬時FPS的概念用於衡量卡頓,瞬時FPS就是在滑動過程中產生的一些耗時比較小的區間中計算的值。例如使用者滑動了500ms,這個過程可能會出現幾個使用者統計的瞬時FPS。這個過程是怎麼計算的?

  1. 滑動過程獲得每一幀的時間間隔;
  2. 按照100(99.6ms,6幀的時間)毫秒左右的時間細化卡頓區間;
  3. 從時間間隔大於33.3毫秒的幀開始記錄,作為區間起點;
  4. 結束點是從起點開始的幀耗時相加,達到99.6ms並且後面的一幀耗時小於17毫秒(或者到達最後一幀),否則會繼續尋找結束點;
  5. 這段時間內在統計幀率,是這裡要尋找的卡頓幀率。

可以看到有3幀明顯超出比較多。按照以前的統計方法,幀耗時:1535ms, 幀數量是:83,那麼這個介面的FPS是54。我們可以看到幀率的FPS比較高,完全看不到卡頓了,即使前面有一些比較高的耗時幀,但是被後續耗時正常的幀給平均掉了。所以以前的統計方式已經不能反映出這些卡頓問題。

按照新的計算方式,應該是從第7幀開始統計第一個瞬時FPS區間,從這一幀開始,統計至少99.6ms的時間,那麼69+16+15,已經達到了100ms,3幀,所以FPS是30,因為低於50,所以這一次FPS會比記錄,其中最大的幀耗時是69ms。

第二次從17幀開始,5幀114ms,FPS為43ms,最大幀間隔是61ms。

第三次從26幀開始,98+10=108ms,但是後面幀的耗時時間為19ms,超過16.6ms,所以仍然會加入一起統計。3幀,127ms,FPS為23。最大幀間隔是98。

按照這次的統計,總共有3次卡頓FPS,分別是30,43,23,最大的幀耗時幀是98。

卡頓堆疊

如果使用主執行緒的 Looper Printer 來進行卡頓堆疊 dump,會因為大量的字串拼接而帶來效能損耗。在 Android 10 上,Looper 中新增 Observer,能夠效能無損的回撥,但由於是 hide 的 API,則無法使用。最終的辦法只能是不斷向主執行緒 post 訊息,可每隔一段時間就給主執行緒拋訊息又會給主執行緒帶來壓力。

是否有更好的方式呢?有的,通過 Choreographer postFrameCallback,本身就會 post 主執行緒訊息,利用兩次回撥之間的差值高於某一個閾值,就可以認為是卡頓。而且這個識別的卡頓,還是滑動過程中的卡頓。

知道什麼是卡頓,那什麼時候 dump 呢?我們使用了 watchdog 的機制 dump 出卡頓堆疊,即在子執行緒 post 一個 dump 主執行緒的訊息,如果單幀耗時超過閾值就進行 dump,如果在規定時間內完成當前幀,就取消 dump 的訊息。當我們採集上來堆疊後,我們會將卡頓的堆疊進行聚類,便於更好的決定主要矛盾、告警處理。

對幀資料使用的探索

AB 與 APM 結合使用

上文主要還是講解了我們怎麼計算出一個指標、怎麼去排查問題,可是對於一個大盤指標而言,重之又重的當然是需要用來衡量優化成果的,那怎麼去衡量優化呢?最好的手段是 AB。APM 指標資料與 AB 測試平臺打通,效能資料隨 APM 實驗產出。

這裡的AB平臺包含一休平臺、魔兔2平臺,一休平臺指標接入方式使用的是自定義指標,幀率只是作為指標之一接入,啟動、頁面等資料亦是其中之一。

一休是阿里集團一站式A/B實驗的服務平臺,向各個業務提供了視覺化的操作介面、科學的資料分析、自動化的實驗報告等一站式的實驗流程;通過科學的實驗方法和真實的使用者行為來驗證最佳解決方案,從而驅動業務增長。

我們在進行頁面效能優化時,能夠直接使用相關指標對基準桶與優化桶進行對比,直接而又明顯的顯示對頁面效能的優化。

寫在最後

對於手淘效能監控而言,幀率監控、卡頓監控只是效能監控其中的一小環,打磨好每一個細節也至關重要。相關資料除了與 AB 平臺搭配使用之外,已經與全鏈路排查資料、輿情資料、版本釋出效能關口相打通,借用後臺聚類、告警、自動化郵件報告等資料手段透出,專有資料平臺進行承接。對於資料的態度,我們不僅是要有,而且要全面而強大。

在一輪又一輪的技術迭代下,手淘的高可用體現也不斷完善與重構,希望在未來,手淘客戶端高可用相關資料能夠更好的助力研發各個環節,預防使用者體驗腐化,幫助不斷提升使用者體驗。

關注【阿里巴巴移動技術】微信公眾號,每週 3 篇移動技術實踐&乾貨給你思考!