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 的回調,回調類型分為以下四種: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#drawprivate 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 呢?不需要,沒有意義,是可以過濾掉的。基於這個理念,我們對滑動幀率的計算進行了優化。需要減少非必要的幀回調與註冊,就需要明確幾個問題:起點(什麼時候開始 postFrameCallback):在第一次收到 scroll 事件的時候(onSrollChanged)
終點(什麼時候不再 postFrameCallback):在計算完一個手指滑動 FPS 後,如果下一幀不再滑動,那麼就停止註冊下一幀的回調。
如果細心的話,就會發現,這裡的起點可以認為是手指帶來的滑動的渲染起點,這裡的終點可以認為是手指帶來的滑動的渲染終點(包括了 Fling),這個數據很重要,我們相當於識別了一次手指滑動,並且能夠提供每次手指滑動的耗時等數據。這樣進行優化是否就完美無缺呢?其實不是的,仔細看上圖的計算開始時間點,就會發現:損失了開始滑動的第一幀數據。因為我們計算的是兩次 doFrame 回調的差值,即使知道當前這一幀是需要計算的幀,但是沒有上一幀的時間戳,也就無法計算出開始滑動的這一幀真正的耗時。凍幀是 Google 官方定義的一種幀:
Frozen frames are UI frames that take longer than 700ms to render.
凍幀作為一種特殊的幀,不是被強烈建議不要出現的幀,在華為等文檔中也被提及過。一旦出現此類幀,頁面也就像凍住似的。所以,在 APM 中,也將這一類特殊的幀納入監控範圍,計算出凍幀占比:
凍幀占比 = 滑動過程中的凍幀數量 / 滑動產生的幀數
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。這個過程是怎麼計算的?按照100(99.6ms,6幀的時間)毫秒左右的時間細化卡頓區間;從時間間隔大於33.3毫秒的幀開始記錄,作為區間起點;結束點是從起點開始的幀耗時相加,達到99.6ms並且後面的一幀耗時小於17毫秒(或者到達最後一幀),否則會繼續尋找結束點;可以看到有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 平台搭配使用之外,已經與全鏈路排查數據、輿情數據、版本發布性能關口相打通,借用後台聚類、告警、自動化郵件報告等數據手段透出,專有數據平台進行承接。對於數據的態度,我們不僅是要有,而且要全面而強大。
在一輪又一輪的技術迭代下,手淘的高可用體現也不斷完善與重構,希望在未來,手淘客戶端高可用相關數據能夠更好的助力研發各個環節,預防用戶體驗腐化,幫助不斷提升用戶體驗。
淘寶Android體驗技術團隊,以打造極致的移動用戶體驗為願景,立志於研發體驗相關技術、中間件,以及提供產品化解決方案,一站式為手淘及其他移動應用核心場景體驗賦能。團隊在應用級優化有豐富的經驗,並深耕於系統級能力,目前已有多個成熟技術方案服務於大促會場,應用啟動,外鏈拉端等,已建立全鏈路的線上性能監控體系,探索非確定性性能問題的監控及排查能力,我們長期招聘志同道合的夥伴,歡迎有志人士加入。簡歷投遞郵箱:difei.zdf@alibaba-inc.com。

