原生長列表內嵌 Flutter 卡片性能調研


作者:易旭昕

原文鏈接:https://zhuanlan.zhihu.com/p/354631257

本文由作者授權發佈。

寫作費時,敬請點贊,關注,收藏三連。

這篇文章主要是對在原生長列表中嵌入多個 Flutter 卡片,每個卡片都對應一個獨立的 FlutterView/Engine 這種使用場景進行調研,分析該場景下的性能和內存使用等指標。通過調研,我們希望瞭解這種使用場景下 Flutter 的性能表現如何,在實際的業務中是否可行。

主要調研的指標包括三方面:

  1. 原生長列表的滾動流暢度,是否存在一些 Flutter 相關的調用會長時間阻塞主線程,也就是 Flutter.platform 線程,導致掉幀;
  2. Flutter 卡片的空白延遲幀數,我們知道 Flutter 的佈局是在 Flutter.ui 線程,光柵化是在 Flutter.raster 線程,它們跟原生 UI 的繪製是異步的,如果在 FlutterView 可見之後才觸發卡片的佈局和光柵化,卡片必然存在一定時間的空白,我們希望知道這個空白持續的幀數和對視覺的影響;
  3. 內存佔用,Flutter 本身會帶來一定的內存增量,那多個 FlutterView/Engine 同時共存並顯示是不是會進一步增大內存的壓力,圖片紋理緩存管理在該場景下表現如何,是否還有進一步優化的空間;

心急的同學可以直接跳到最後結論的部分。

Flutter Card Demo 說明

img

爲了進行調研,我們編寫了一個 Android Demo,Demo 在 Android Native 端使用了 androidx 提供的 RecyclerView 實現長列表。RecyclerView 會自動創建多個卡片並循環使用,在 Demo 中,每個卡片都是一個 FlutterCard 對象,其中包含一個獨立 FlutterView 和 FlutterEngine,卡片的內容由 Flutter 呈現。

  1. 在上圖 "#5 at 11" 的文本中,5 代表這個卡片的 ID,對應創建的 FlutterView/FlutterEngine 的序號,11 代表這個卡片在 RecyclerView 顯示的位置,從這段文本我們可以很清楚地看到創建的 FlutterCard 卡片對象是不斷被 RecyclerView 循環使用的;
  2. 長列表包含了 200 張卡片,在實際的運行中 RecyclerView 創建了約 9 個 FlutterCard 對象,也就是 9 對 FlutterView/FlutterEngine(實際個數跟 RecyclerView 的高度和卡片的高度有關);
  3. 爲了模擬真實的場景,我們會在 RecyclerView 重用 FlutterCard 對象時,會重新隨機產生一個新的卡片高度,並通過 MessageChannel 通知 FlutterEngine 更新內容,觸發該卡片的 Widget 樹的更新和重佈局,每個卡片顯示一張圖片和兩段文本;
  4. FlutterView 使用 TextureView 作爲輸出的 Surface,當 FlutterView 被 RecyclerView 回收時,TextureView 會觸發 Surface Destroy,當 FlutterView 被 RecyclerView 重用並重新參與繪製時,TextureView 會觸發 Surface Available(Create);

性能表現分析

測試手機使用了 Google Pixel,在現在來說算是性能比較差了,可以更好地反映實際的狀況。

滾動流暢度

FlutterCard

可能是因爲壓縮的原因,視頻顯示不如實際表現流暢

除了初始滾動時,可能因爲集中創建和初始化 FlutterEngine 導致主線略微阻塞,會有輕微掉幀的現象外,整個滾動過程都非常流暢。在慣性滾動中,卡片會不斷地被回收和重用,所以 Surface 的 Destroy 和 Create 會頻繁地被觸發,在應用主線程,也就是 Flutter.platform 線程觸發 Surface Destroy 和 Create,主線程需要阻塞等待 Flutter 完成清理或者初始化的操作,如果它造成明顯阻塞就很容易導致掉幀。

在 Android 平臺上,PlatformViewAndroid::NotifyDestroyed 主要工作:

  1. 通知 Flutter.ui 線程停止 Animator;
  2. 通知 Flutter.raster 線程的光柵化器釋放資源,如 RasterCache,GrResourceCache,LayerTree,GrContext 等;
  3. 通知 http://Flutter.io 線程釋放已經處於等待釋放狀態的 GPU 資源;
  4. 通知 Flutter.raster 線程釋放 Window Surface;

PlatformViewAndroid::NotifyCreated 主要工作:

  1. 通知 Flutter.raster 線程設置 Window Surface;
  2. 通知 Flutter.raster 線程創建 GrContext;
  3. 通知 http://Flutter.io 線程設置紋理上傳使用的 GrContext;
  4. 通知 Flutter.ui 線程啓動 Animator,開始調度渲染 ScheduleFrame;
  5. 通知 Flutter.raster 設置光柵化器;

通過分析發現,在對比開啓和關閉我們的引擎優化的情況下:

  1. Surface Destroy 過程耗時 1 ~ 2ms,開啓和關閉引擎優化無明顯影響;
  2. Surface Create 過程沒有開啓引擎優化耗時 4 ~ 7ms,開啓引擎優化後降到了 2 ~ 4ms,引擎優化降低了 3ms 左右的耗時;

可以看到,在開啓引擎優化後,Surface Destroy 和 Create 的耗時都很少,絕大部分情況下都不會導致掉幀。

卡片空白幀數

在 Demo 的場景中,RecyclerView 在慣性滾動時,將新的卡片從不可見區域移進可見區域,觸發了 TextureView 的繪製,而 TextureView 的 Surface Available(Create)是在它第一次被繪製的時候觸發。

RecyclerView 會提前一些將卡片加入 View 樹參與佈局

按照原生的邏輯,Flutter 需要在 Surface Create 時才觸發 ScheduleFrame。如果當前幀是第 N 幀,在第 N 幀的 Draw 的過程中觸發了 TextureView 的 Surface Available(Create),同時觸發了 Flutter 的 ScheduleFrame,Flutter 要等到 N + 1 幀的 VSync 回調時才觸發 BeginFrame 開始繪製,如果 Flutter 首幀的佈局 + 光柵化耗時少於一個 VSync 週期,那 Flutter 的首幀可以在 Native UI 第 N + 2 幀輸出。

也就是說即使卡片的 Widget 樹很簡單,或者設備的性能非常高,Flutter 卡片最少也有兩幀的空白時間,實際空白持續的幀數跟設備的性能,Widget 樹的複雜程度都有關係。從 Demo 在 Pixel 上運行的情況來看,因爲卡片比較簡單,大部分情況下都是兩幀空白。

如果僅僅只是兩幀的空白,考慮到卡片本身只是一部分可見,設置卡片的 Flutter Widget 背景色跟原生 View 保持一致,或者乾脆 Flutter Widget 不繪製背景,完全透明(需要使用 TextureView),這樣一般情況下也不太容易察覺。

另外,因爲 Flutter 的圖片是異步加載和解碼,所以圖片如果太大,圖片的繪製相比其它 Widget 可能會有更明顯的延遲。

相關的 Android 渲染流水線幀調度的分析,可以參考我的文章TextureView 的血與淚

內存佔用分析

爲了排除圖片解碼緩存內存管理的干擾,我們專門測試了無圖和有圖兩種情況,並且增加了開啓引擎優化和關閉引擎優化的對比。我們加入了只有一個 FlutterView/Engine 的無圖簡單 Demo 作爲對比參考(使用 SurfaceView,大小隻有窗口的一半),另外也加入了一個純原生無圖的長列表 Demo 作爲對比參考(卡片內容不完全一致,僅供參考)。

內存佔用通過 meminfo 查看,主要看 PSS,PSS 雖然不能完全代表真實的物理內存佔用,不過用於對比增量還是有一定參考價值的。實際操作中會滾動到底部之後再滾動回頭部,長列表設置顯示 200 張卡片,在這個過程中 RecyclerView 一共創建了 9 個 FlutterCard 對象,也就是 9 對 FlutterView/Engine 循環使用。

img

我們首先對比單引擎的簡單 Demo 和完全原生的應用,主要增加的部分在:

  1. .so mmap:額外的 so 庫;
  2. EGL mtrack:額外的 Surface buffer,考慮到 Demo 的 FlutterView 只有一半窗口大小,如果是整個窗口大小,應該增加 24m 左右(Android 的 Surface 是 triple buffer);
  3. Unknown:主要是 Flutter Engine 分配的內存,包括 Skia 的內存分配,Dart VM 的內存分配;

所以一個單引擎全屏簡單的 Flutter App 對比純原生也會帶來 40 ~ 50m 左右的額外開銷

再對比多引擎同時運行多個 Flutter App 的情況:

  1. Native Heap 小幅增加,猜測主要是額外線程的堆棧;
  2. EGL mtrack 因爲多引擎 Demo 使用的是 TextureView,TextureView 分配的 buffer 在 meminfo 中 存在重複計數的問題,改成 SurfaceView 之後兩者應該是差不多的,括號裏面的 46 是改成使用 SurfaceView 時的佔用, 實際上這一項的增量只取決於當前可見的 FlutterView 的總面積,在我們的卡片場景,全部可見的卡片總面積也只是略大於當前窗口的面積,在 1080p 的手機上,20 ~ 30m 的增量是一個典型值;
  3. Unknown 增加的比較多,猜測主要來源至多個 Flutter App 運行在多個 Dart Isolate,Dart VM 分配的內存;

從上面的對比,如果在可見的 FlutterView 面積一樣的情況下,並且開啓引擎優化,9 個引擎運行 9 個比較簡單的 Flutter App 對比只有一個引擎運行一個 Flutter App 大約增加了 40 ~ 50m 左右的額外開銷。如果沒有開啓引擎優化,我們會看到大量額外的線程和 GL 上下文會導致 Native Heap 和 GL mtrack 大幅增加,總共增加了 68m。

開啓有圖之後,我們可以看到 Gfx Dev 大幅增加 348m,主要來自於圖片解碼後上傳的紋理。Unknown 部分也有一定幅度增加,猜測主要來自於圖片原始數據的內存緩存。這裏面最主要的問題是 Engine 在循環使用的過程中,會一直累積圖片紋理緩存不會主動釋放,並且每個 Engine 獨立管理紋理緩存,缺少全局管控。

結論

  1. 慣性滾動十分流暢,Surface Destroy 和 Create 在開啓引擎優化後基本不會導致掉幀;
  2. 原生的邏輯導致最少兩幀的卡片空白,實際的空白幀數取決於設備的性能和 Widget 樹的複雜程度,測試 Demo 在 Pixel 上大部分情況都是兩幀;
  3. 內存佔用的問題比較明顯,雖然我們的引擎優化已經大幅減少了額外的內存佔用,但是每個獨立的 Flutter App 運行在獨立的 Dart Isolate 仍然有一定的內存增量(簡單的卡片大概 4m 左右),我們仍然需要限制一定數量的引擎分配,不過最嚴重的還是圖片的紋理內存佔用,這是我們需要進一步優化的;


你可能還喜歡


關注「老孟Flutter」
讓你每天進步一點點


本文分享自微信公衆號 - 老孟Flutter(lao_meng_qd)。
如有侵權,請聯繫 [email protected] 刪除。
本文參與“OSC源創計劃”,歡迎正在閱讀的你也加入,一起分享。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章