Flutter 流暢度優化實踐總結

作者介紹:

張雲龍(雲從),閒魚客戶端專家。先後在網易、字節、阿里任職移動端研發。目前在阿里巴巴閒魚技術部,目前負責閒魚 app 包大小、流暢度、啓動等端體驗內容。

“圍繞 Flutter 流暢度體感優化,分享了挑戰、線上線下監控工具建設、優化手段在組件容器沉澱,最後給出了優化建議。"

大綱

本次分享圍繞 flutter 流暢度,分別講述:1.Flutter 流暢度優化挑戰;2. 列表容器和 FlutterDx 組件優化;3. 性能衡量和 devtool 擴展;4.Flutter 滑動曲線優化;5. 性能優化建議。

Flutter 流暢度優化挑戰

▐****業務複雜度挑戰

Flutter 一直以高性能被大家所認知,Flutter Gallery(左圖所示)展示的列表控件,也確實非常流暢。但實際業務場景(右圖所示)比 Gallery 列表 demo 複雜的多:

  1. 相同的卡片,有更多和複雜(如圓角)的視圖控件;

  2. 列表滾動時,有更多的視圖邏輯,如滾動控制其他控件漸顯和消失;

  3. 卡片控件,也有更多的業務邏輯,如基於後臺數據控制不同的標籤、活動價等,也有埋點等常見業務邏輯;

  4. 因爲閒魚是電商 App,所以我們需要有一定的動態能力應對頻繁多變的活動。這裏我們使用阿里自研的 Flutter DynamicX 組件實現我們的動態能力。

▐****框架實現的挑戰

我們再來看列表滾動的整體流程,這裏只關注手指放開後的自由滾動階段。

  1. 手指鬆開時,基於 ScrollDragController.end 計算初始速度;

  2. UI Thread 向 Platform Thread 請求 requestFrame,在 Platform Thread 收到 Vsync 信息,則向 UI Thread 調用 beginFrame;

  3. UI Thread Animate 階段觸發列表滑動一點距離,同時向 Platform Thread 註冊下一幀回調;

  4. UI Thread Build Widget,再通過 Flutter 三棵樹 Diff 算法生成 / 更新 RenderObject 樹;

  5. UI Thread RenderObject 樹 Layout、Paint 生成 Scene 對象,最後傳遞給 Raster Thread 進行繪製上屏;

上述流程,必須要 16.6 ms 內完成,才能保證不掉幀。大部分情況,不需要構建新的卡片,但當新卡片進入列表區域時,整個計算量就會變得巨大,尤其是在複雜的業務場景下,如何保證在一幀 16.6ms 內完成全部計算,是一個不小的挑戰。

上圖是一次滑動 devtool 樣例,卡頓階段都是新卡片上屏時發生,其他階段均很流暢,因爲滾動速度在衰減,所以卡頓間隔也在變大。因爲大部分時候都很流暢,所以平均 FPS 不低。但新卡片構建時的產生畫面停頓,給我們的卡頓體感卻很明顯。

▐****動態能力的挑戰 - Flutter DynamicX

閒魚 App 卡片使用自研 Flutter DynamicX 來支持我們的動態能力。基本原理:在線編輯佈局 DSL,生成 dx 文件並下發。端側通過解析 dx 文件,並結合後臺卡片數據,生成 DXComponentWidget,最後生成 Widget Tree。Flutter DynamicX 技術給閒魚帶來動態更新的能力,統一監控能力(如在 DXComponentWidget 監控卡片創建),良好研發體感(在線 DSL 和 Android Layout 基本一致,對 Android 開發優化),在線編輯能力;

但在性能上,我們也付出了一定的代價:DX 卡片相比增加了模板裝載和數據綁定開銷,Widget 要通過 WidgetNode 遞歸遍歷動態創建,視圖嵌套層級會更得更深(後續講述)。

說明:Flutter DynamicX 參考阿里集團 DSL 規則實現

▐****用戶體感的挑戰

前面已經講述過,相同 FPS 下,Flutter 列表的卡頓體感更明顯;

在 Android RecycleView 發生小卡頓(16.6*2ms)時,體感並不明顯,而 Flutter 列表在發生卡頓時,不僅時間上停頓,滑動 Offset 上也發生了跳變,爲此小卡頓的體感也變得明顯了;

假設列表內容足夠簡單,滾動不會發生卡頓,我們也發現 Flutter 列表和 Android RecycleView 也不太一樣:

  1. 使用 ClampingScrollPhysics,在列表快停止的時候,會感受到類似磁鐵吸住的感覺。

  2. 使用 BouncingScrollPhysics,列表滾動開始時,速度衰減的更快;

在 90hz 機器上,早期 Flutter 列表並不流暢,原因是部分機器上,觸控採樣率是 120hz,屏幕刷新率是 90hz,導致部分畫面是 2 次觸控事件,部分是 1 次觸控事件,最後導致滾動 offset 發生跳變。在 Flutter 1.22 版本時,可以使用 resamplingEnabled 對觸控事件進行重採樣。

列表容器和 FlutterDx 組件優化

講述了 Flutter 流暢度優化的挑戰,現在來分享閒魚如何優化流暢度,並沉澱進 PowerScrollView 和 Flutter Dynamic 組件。

▐  Power****ScrollView 設計和性能優化

PowerScrollView 是閒魚團隊自研 Flutter 列表組件,在 Sliver 協議上有了更好的封裝和補充:數據增刪改方面,補充了局部刷新;佈局方面,補充了瀑布流;事件方面,補充了卡片上屏、離屏、滾動事件;控制方面,補充了滾動到 index 的能力。

在性能方面,補充了瀑布流佈局優化、局部刷新優化、卡片分幀優化和滑動曲線優化。

▐  Powe****rScrollView 瀑布流佈局

PowerScrollView 瀑布流佈局提供了縱向佈局、橫向佈局、混排佈局(橫向卡片和普通卡片混排)。現在閒魚大部分列表頁面均採用 PowerScrollView 的瀑布流佈局,如首頁同城頁、搜索結果頁等。

▐  PowerS****crollView 瀑布流佈局優化

首先通過常規的緩存優化,緩存每個卡片左上角 x 值和屬於哪一列。

相比 SliverGrid 卡片是並排進入列表區域,而瀑布流佈局,我們需要定義 Page,卡片入場創建和離場銷燬需要以 Page 爲單位。優化前,Page 以屏幕可視區域爲單位計算卡片,同時爲了確定 Page 的起點 Y 值,一次佈局需要計算 Page N 和 N+1 二頁,所以參與佈局計算的卡片量較多,性能變低。優化後,使用全部卡片高度平均值的近似值計算 Page,極大減少參與佈局卡片的數量,同時 Page 離場銷燬的卡片數量也變少。

經過列緩存和分頁優化,使用閒魚自研 benchmark 工具(後續介紹)對比瀑布流和 GridView,查看丟幀數和最差幀耗時,能發現性能表現基本一致。

▐  PowerScrollVi****ew 局部刷新優化

閒魚產品期望用戶瀏覽商品更流暢,不會被 loadmore 加載打斷,所以列表在滾動過程中就需要觸發 loadmore。Flutter SliverList 在 loadmore 補充卡片數據時,會對 List 控件標髒,而標髒後 SliverList build 會銷燬全部卡片並重新創建,此刻性能數據能想象非常的差。PowerScrollView 提供了佈局刷新優化:緩存屏幕上的全部卡片,不再重新創建,UI Thread 耗時從原來的 34ms 優化至 6ms(見左下圖),右圖查看 Timeline,視圖構建的深度和複雜度均有明顯優化。

**▐  **PowerScrollView 卡片分幀優化

左圖 2 個卡片是閒魚早期搜索結果頁,當時還不是瀑布流。查看卡片創建時的 Timeline 圖(補充了 Dx Widget 創建 和 PerformLayout 開銷),可以發現一次卡片創建的複雜度極大,在普通中端機器上,UI Thread 耗時機已經超出 30ms,要優化至 16.6ms 以內,用常規的優化手段就很困難了。爲此想象 2 個卡片能否拆解掉,各自使用 1 幀的時間去渲染。

直接看源碼,基本思想是:對卡片 Widget 進行標記,在左邊卡片真實創建的時候,右邊卡片先 _buildPlaceholderCell 構建佔位 Widget(空的 Container),並註冊監聽下一幀。在下一幀,右邊卡片進行修改 needShowRealCell 爲 true,並自我標髒,此後構建真實內容。

延遲構建卡片真實內容,是否會對顯示內容產生影響?因爲 Flutter 列表在可視區域上下還有 CacheExtends 區域,這部分區域用戶不可見。爲此在大部分場景下,用戶並不會看到空白卡片的場景。

同樣使用 Flutter BenchMark 工具進行性能測試,能看到卡片分幀前後 90 分位,99 分位幀耗時都有明顯的降級,丟幀數也從 39 降低至 27

這裏注意,監聽下一幀的時候,需要 WidgetsBinding.instance.scheduleFrame() 觸發 requestFrame。因爲在列表首屏顯示的時候,有可能因爲沒有下一幀的回調,導致延遲顯示隊列的任務沒有執行,最終使得首屏內容顯示不正確。

▐  延遲****分幀優化思路和使用建議

對比 Flutter 和 H5 設計比較接近:

  1. dart 和 js 都是單線程模型,跨線程通信需要走序列化和反序列化;

  2. Flutter Widget 和 H5 vDom 類似,都有一個 Diff 過程。

早期 FaceBook 在 React 優化時,提出了 Fiber 架構:基於 vDom tree 的父節點→子節點→兄弟節點→子節點的方式,將 vDom tree 轉化爲 fiber 數據結構(鏈式結構),進而實現 reconcile 階段的可中斷可恢復;基於 fiber 數據結構,控制部分 fiber 節點在下一幀繼續操作。

基於 React Fiber 思路,我們提出了自己的延遲分幀優化,不只是左右卡片粒度,更進一步,將渲染內容拆解爲當前幀任務、高優延遲任務和低優延遲任務,上屏優先級依次變低。其中當前幀任務,是左右 2 個空白 Container;高優延遲任務獨佔一幀,其中圖片部分也使用 Container 佔位;在閒魚場景,我們把全部的 DX Image Widget 從卡片內拆解出來,作爲低優延遲任務,並設置在一幀消費不超過 10 個。

通過將 1 幀顯示任務拆解到 4 幀時間,高端機上最高 UI 耗時從 18ms 優化至 8ms。

說明 1:不同業務場景下,高優任務和低優任務設置要有所不同 說明 2:在低端機(如 vivo Y67)上快速列表滑動,分幀方案會讓用戶看到列表變白和內容上屏的過程

▐  Flu****tter-DynamicX 組件優化 - 原理詳解

在線編輯 “類 Android Layout DSL”,編譯生成二進制 dx 文件。端側通過文件下載、加載和解析,生成 WidgetNode Tree,見右圖。

之後結合後臺下發的業務數據,通過遞歸遍歷 WidgetNode Tree 動態生成 Widget Tree,最後顯示上屏。

說明:Flutter DynamicX 參考阿里集團 DSL 規則實現

▐  Flutt****er-DynamicX 組件優化 - 緩存優化

知道了原理,就容易發現上圖紅色框中的流程:二進制(模板)文件解析裝載、數據綁定、Widget 動態創建都有一定的開銷。爲避免反覆開銷,我們對 DxWidgetNode 和 DxWidget 均進行了緩存,藍色選中代碼展示了 Widget 緩存。

▐  Flutte****r-DynamicX 組件優化 - 獨立 isolate 優化

此外,將上述邏輯放置到獨立 isolate 中,最大限度的將開銷降低至最低。經過線上技術灰度 AB 實驗,平均卡頓壞幀比例從 2.21% 降低至 1.79%。

▐  Flut****ter-DynamicX 組件優化 - 層級優化

Flutter DynamicX 提供了類 Android Layout DSL,爲實現每個控件 padding、margin、corner 等屬性,增加了 Decoration 層;爲實現類 Android FrameLayout、LinearLayout 佈局能力,增加了 DXContainerRender 層。每一層都有自己的清晰職責,代碼層次清晰。但也因爲增加 2 層導致 Widget Tree 層級變深,3 棵樹的 Diff 邏輯變得複雜,性能變低。爲此,我們將 Decoration 層和 DXContainerRender 層進行了合併,查看中間 Timeline 圖,可以發現優化後的燃焰圖層級和複雜度都變低。經過線上技術灰度 AB 實驗,平均卡頓壞幀比例從 2.11% 降低至 1.93%。

性能衡量和 devtool 擴展

講述了優化手段,這裏講述我們的流暢度性能如何做衡量,以及工具的構建 / 擴展。

▐  線****下場景 - flutter benchmark

檢測 Flutter 每幀耗時,需要統計 UI Thread 和 Raster Thread 上的計算耗時。所以 Flutter 優化前後比較,使用 SchedulerBinding.instance.addTimingsCallback 獲取每一幀的 UI Thread 和 Raster Thread 的耗時數據。

此外,流暢度性能數值受操作手勢、滾動速度影響,所以基於人工操作的測量結果會存在誤差。這裏使用 WidgetController 控制列表控件 fling。

工具提供設置滾動速度、滾動次數、滾動之間的間隔時間等。滾動測試完成後,顯示 UI 和 Raster Thread 丟幀數,50 分位、90 分位、99 分位的幀耗時等數據,從多種維度給出了性能數據。

▐  線****下場景 - 基於錄屏的流暢度檢測

flutter benchmark 在 flutter 頁面給出了多維度的測量數據,但有時候我們需要橫向比較競品 App,所以我們需要有工具橫向比較不同技術棧的頁面流暢度。閒魚在 Android 端自研了基於錄屏數據的流暢度檢測。將手機界面想象成多個畫面,通過向系統錄屏服務 MediaProjection 註冊獲取 VirtualDisplay,間隔 16.6 ms 讀取其中的畫面數據(字節數組),這裏使用字節數組的 hash 值代表當前畫面,當前後 2 次讀取的 hash 值不變,則認爲發生了卡頓。

爲了保證流暢度檢測工具 app 自身不發生卡頓,這裏讀取的是壓縮畫面數據,低端機上壓縮比例要更高

通過工具無侵入的檢測,可以檢測到一次滾動測試,平均 FPS 值(圖中 57),幀分佈均方差(7.28),1s 時間發生的大卡頓次數平均值(0.306),大卡頓累計時間(27.919)。中間數組展示幀分佈情況:371 代表正常幀數量,6 代表 16.62ms 的小卡頓數量,1 代表 16.63ms 的卡頓數量。

這裏大卡頓的定義是:大於 16.6*2 ms 的卡頓

▐  線下****場景 - 基於 devtool 的性能檢測

此外,閒魚線下場景也擴展了 devtool。在一次 Timeline 圖擴展了每個階段的耗時,大於 16.6ms 紅色高亮顯示,便捷了開發使用。

▐  線下場****景 -Flutter 高可用檢測 FPS 實現原理

在線上場景,閒魚自研了 Flutter 高可用。基本原理是基於 2 個事件:

這裏我們在 handleBeginFrame 處理之前,記錄一幀開始事件,在 handleDrawFrame 之後記錄一幀的結束。這裏每一幀都需要計算列表控件 offset 值,具體代碼實現見右圖。在整個累計超過 1s 時,執行一次計算,使用 offset 過濾掉沒有發生滾動的場景,使用每一幀的時間計算 fps 值。

▐  線上****場景 - FlutterBlockCanary 線上卡頓堆棧檢測

使用 Flutter 高可用計算得到線上 FPS 數值後,如何定位卡頓問題,需要收集堆棧信息。閒魚使用自研的 FlutterBlockCanary 收集卡頓堆棧。基本原理是,在 C 層輪詢發送信號,比如 5ms 一次,每次信號接收觸發 dart UI Thread 堆棧採集,對得到的一系列堆棧進行聚合,連續多次相同堆棧就認爲是發生了卡頓,這時這個堆棧就是我們想要的卡頓堆棧。

上圖是 FlutterBlockCanary 採集的堆棧信息,中間 FrameFpsRecorder.getScrollOffset 就是發生卡頓的調用。

**▐  線****上場景 -****FlutterBlockCanary 檢測過度渲染
**

此外,FlutterBlockCanary 也集成了過度渲染檢測的能力。通過複寫 WidgetsFlutterBinding 的 buildOwner 方法替換 BuildOwner 對象,進而重寫 scheduleBuildFor 方法,實現攔截髒 element。基於髒 element 節點,提取出髒節點的深度、直接子節點的數量、全部子節點的數量。

基於全部子節點數量,在閒魚詳情頁,我們定位到 “快速提問視圖” 在滾動過程中,頻繁被標髒和全部子節點數量過大。查看代碼,定位該視圖層級過高,通過將視圖下沉到葉子節點,一次標髒 build 節點數量從 255 優化至 43。

Flutter 滑動曲線優化

前面講述了卡頓優化手段和衡量工具和標準,主要還是圍繞着 FPS。但從用戶體感出發,我們發現 Flutter 也有很多可優化點。

▐****Flutter 列表滑動曲線和原生曲線

分別對比 offset/time 的滾動曲線,可以發現 Flutter BouncingScrollSimulation 和 iOS 滾動曲線接近,ClampingScrollSimulation 和 RecyclerView 接近。查看 Flutter 源碼註釋,也確實是如此。

因爲 BouncingScrollSimulation 具有回彈能力,所以很多下拉刷新和加載更多功能,都是基於 BouncingScrollSimulation 封裝實現,這也就造成 Flutter 頁面滑動時,體感和原生 Android 頁面不一致的原因。

▐****Flutter 列表在快速滑動下的表現和優化

雖然 ClampingScrollSimulation 滑動曲線和 Android RecyclerView 接近,但在快速滑動場景下,可以發現 Flutter 列表滾動快停止的時候會像磁鐵吸住一般,快速滑動一下停止。究其原因,可以看到滑動曲線快停止的瞬間,速度並不是下降,而會加快,最後到達終點,快速停止。基於源碼公式,繪製曲線,可以發現,Flutter ClampingScrollSimulation 是通過公式擬合方式,去逼近 Android RecyclerView 曲線(BSpline)。在快速滑動的情況下,公式曲線的重點並不是 1 對應的值,而是右圖虛線位置,速度會變快。

可以理解 Flutter 的公式擬合結果並不理想,爲此近期也有 PR 提出使用 dart 實現了 RecyclerView 曲線。

▐****Flutter 列表在卡頓情況下的表現和優化

第一章提過相同 FPS 情況下,如 FPS 55,原生列表感受流暢,而 Flutter 列表的卡頓體感更明顯。這裏一個原因是原生列表通常有多線程操作,出現大卡頓的概率更低;另一個原因是,相同小卡頓的體感,Flutter 有明顯的卡頓感,而原生列表幾乎感受不出來。那這是爲什麼呢?

我們在構建卡片的時候,故意製造小卡頓,在前後對比 Flutter 列表和 RecyclerView,可以發現 RecyclerView offset 並不會發生跳變,而 Flutter 曲線有很多毛刺,因爲 Flutter 滾動是基於 d/t 曲線計算,當發生卡頓的時候,△t 發生翻倍,offset 也發生跳變。也正是因爲時間停頓和 Offset 跳變,讓用戶明顯感受到 Flutter 列表在小卡頓的不流暢感。

通過修改 y=d(t) 公式,在卡頓情況下,將△t-16.6ms,保證小卡頓情況下,offset 不發生跳變。而在大卡頓情況下,就沒有必要將 △t 重置爲 16.6ms 了,因爲在停頓時長上,已經明顯讓用戶給感受到卡頓了,offset 不發生跳變只會讓列表滾動距離變短。

性能優化建議

最後分享一些性能優化的建議。

  1. 在優化時,我們更應該關注用戶體感,而不是隻看性能數值。右上圖可見,即便 FPS 值一樣,但 offset 發生跳變,體感就會有明顯的不同;右下 2 個遊戲錄屏,左邊平均 40 FPS,右邊平均 30 FPS,但體感上卻是右邊的更順暢。

  2. 不僅要關注 UI Thread 的性能,也要關注 Raster Thread 的開銷,如視圖圓角、save layer 等特性 / 操作,也可能導致卡頓

  3. 在工具方面,建議在不同場景下使用不同的工具。需要注意的是,工具檢測的問題,是穩定復現問題還是數據抖動產生的偶現問題。此外,也要考慮工具自身的性能開銷,工具自身的 CPU 佔用和主線程佔用都需要儘可能降低。

  4. 在優化思路方面,我們要擴寬方向,Flutter 大部分優化思路都是優化計算任務;而多線程方向也並不是不可以,參考前面 Flutter DynamicX 的獨立 isolate 優化;此外,一幀時間難以消化的任務,是否有可能拆解到多個幀時間,儘量讓每幀時間不發生卡頓,優先響應用戶。

  5. 最後,推薦關注 Flutter 社區。Flutter 社區持續有各種優化合入,定期升級 Flutter 或維度自己的版本,cherry-pick 優化提交,都是不錯的選擇。

▐****性能分析工具使用建議

Flutter 工具方面,首推的就是官方的 DevTools 工具,裏面的 Timeline 和 CPU 燃焰圖能很好的協助我們發現問題;此外,Flutter 也提供了豐富的 Debug Flags 協助我們定位問題,熟悉每一個 debug 開關作用,相信對我們日常研發也會有不小的幫助;除了官方工具,性能日誌也是很好的輔助信息,如右下角所示,閒魚 fish-redux 組件輸出了滾動中的任務開銷時長,能方便的看出那一時刻發生了卡頓。

▐****性能分析工具自身開銷

性能檢測工具不可避免會有一定的開銷,但一定要控制在可接受範圍內,特別是線上使用。前面分享過 FlutterBlockCanary 檢測工具的一個案例,發現了 FrameFpsRecorder.getScrollOffset 有耗時情況,而這處邏輯正好是 Flutter 高可用計算滾動 Offset。見右圖的優化前源碼,每一幀都需要遞歸遍歷收集 RenderViewPortBase,是一個不小的開銷。最後,我們通過緩存優化的方式,避免了滾動過程中的反覆計算。

▐****卡頓優化建議

參考官方文檔和優秀的性能文章,在 UI 和 GPU 側都沉澱了很多常規優化手段,如刷新最小 Widget,使用 itemExtent,推薦使用 Selector 和 Consumer 等,避免了不必要的 Diff 計算、佈局計算等;如減少 saveLayer、使用圖片替換半透明效果等減輕了 Raster 線程的開銷。

因爲篇幅原因,這裏只列了一部分,更多的常見優化建議見官方文檔。

▐****使用最新 flutter engine

前面提過,Flutter 社區還在活躍,Framework 和 Engine 層持續的有優化 PR 合入,這些優化手段大部分可以讓業務層無感知,並且從底層視角更好的優化性能。

這裏舉一個典型的優化方案:現有 Flutter 方案:在每次 VSync 信號到來時,觸發 Build 操作,在 Build 結束時,開始註冊下一個 VSync 回調。在沒有發生卡頓的情況下,見圖 Normal。但在發生卡頓的情況下,見圖 Actual results,這裏2 Build耗時剛剛超過了 16.6ms,由於是註冊監聽下一個 VSync 回調時觸發下一次 Build,爲此中間空餘了大量的時間。明顯,我們所期望的是,2 Build結束時,立即執行3 Build,假設3 Build執行的足夠快,這個時候用戶看到的畫面還是流暢的。

如果團隊允許,建議定期升級 Flutter 版本;或者維護自己的 Flutter 獨立分支也是不錯的選擇,從社區 Cherry-Pick 優化提交,既能保證業務穩定也能享受社區貢獻。總之,推薦大家關注社區。

總結

綜上,分享了 Flutter 流暢度優化的挑戰、監控工具、優化手段和建議。性能優化要以人爲中心,從實際體感入手製定監控指標和優化點;流暢度優化並不是一蹴而就,以上分享也不是全部,還有很多優化手段可以關注:如何更好的複用 Element,如何避免 Platform Thread 繁忙導致 Vsync 信號缺失等都是可以關注的點,只有持續的技術熱情和匠心精神才能把 App 性能優化到極致;技術團隊也要和開源社區、其他團隊 / 公司建立連接,他山之石,可以攻玉。

作者 | 雲從

編輯 | 橙子君

出品 | 阿里巴巴新零售淘系技術

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/qkHwffqv2crA0mKOUWofjA