如何進一步提升flutter內存表現

做者:閒魚技術-福居java

前言

性能穩定性是App的生命,Flutter帶了不少創新與機遇,然而團隊在享受Flutter帶來的收益同時也迎接了不少新事物帶來的挑戰。git

本文就內存優化過程當中一些實踐經驗跟你們作一個分享。github

Flutter 上線以後

閒魚使用一套混合棧管理的方案將Flutter嵌入到現有的App中。在產品體驗上咱們取得了優於Native的體驗。主要得益於Flutter的在跨平臺渲染方面的優點,部分緣由則是由於咱們用Dart語言從新實現的頁面拋棄了不少歷史的包袱輕裝上陣。算法

上線以後各方面技術指標,都達到甚至超出了部分預期。而咱們最爲擔憂的一些穩定性指標,好比crash也在穩定的範圍以內。可是在一段時間後咱們發現因爲內存太高而被系統殺死的abort率數據有比較明顯的異常。性能穩定性問題是很是關鍵的,因而咱們火速開展了問題排查。api

問題定位與排查

顯然問題出在了過大的內存消耗上。內存消耗在App中構成比較複雜,如何在複雜的業務中去定位到罪魁禍首呢?稍加觀察,咱們肯定Flutter問題相對比價明顯。工欲善其事必先利其器,須要更好地定位內存的問題,善用已經的工具是很是有幫助的。好在咱們在Native層和Dart層都有足夠多的性能分析工具進行使用。緩存

工具分析

這裏簡單介紹咱們如何使用的工具去觀察手機數據以便於分析問題。須要注意的是,本文的重點不是工具的使用方法介紹,因此只是簡單列舉部分使用到的常見工具。bash

Xcode Instruments

Instruments是iOS內存排查的利器,能夠比較便捷地觀察實時內存使用狀況,天然沒必要多說。閉包

Xcode MemGraph + VMMap

XCode 8以後推出的MEMGraph是Xcode的內存調試利器,能夠看到實時的可視化的內存。更爲方便的是,你能夠將MemGraph導出,配合命令行工具更好的獲得結構化的信息。異步

Dart Observatory

這是Dart語言官方的調試工具,裏面也包含了相似於Xcode的Instruments的工具。在Debug模式下Dart VM啓動之後會在特定的端口接受調試請求。官方文檔async

觀察結果

在整個過程當中我進行了大量的觀察,這裏分享一部分典型的數據表現。

經過Xcode Instruments排查的話,咱們觀察到CG Raster Data這個數據有些高。這個Raster Data呢實際上是圖片光柵化的時候的內存消耗。

咱們將App內存異常的場景的MemGraph導出來,對其執行VMMap指令得出的結果:

vmmap --summary Runner[40957].memgraph

vmmap Runner[40957].memgraph | grep 'IOKit'
複製代碼

vmmap Summary

vmmap address

咱們主要關注resident和dirty的內存。發現IOKit佔用了大量的內存。

結合Xcode Raster Data還有IOKit的大量內存消耗,咱們開始懷疑問題是圖內存泄漏致使的。通過進一步經過Dart Observatory觀察Dart Image對象的內存狀況。

Dart image instance
觀察結果顯示,在內存較高的場景下在Dart層的確同時存在了較多Image(如圖中270)的對象。如今基本能夠肯定內存問題跟Dart層的圖片有很大的關係。

這個結果,我估計不少人都已經想到了,App有明顯的內存問題頗有可能就是跟多媒體資源有關係。經過工具得出的準確數據線索,咱們獲得一個大體的方向去深刻研究。

詭異的Dart圖片數量爆炸

圖片對象泄漏?

前面咱們用工具觀察到Dart層的Image對象數量過多直接致使了很是大的內存壓力,咱們起初懷疑存在圖片的內存泄漏。可是咱們在通過進一步確認之後發現圖片其實並無真正的泄漏。

Dart語言採用垃圾回收機制(Garbage Collection 下面開始簡稱GC)來管理分配的內存,VM層面的垃圾回收應該大多數狀況下是可信的。可是從實際觀察來看,圖片數量的爆炸形成的較大的內存峯值直觀感受上GC來得有些不及時。在Debug模式下咱們使用Dart Observatory手動觸發GC,最終這些圖片對象在沒有引用的狀況下最終仍是會被回收。

至此,咱們基本能夠確認,圖片對象不存在泄漏。那是什麼致使了GC的反應遲鈍呢,難道是Dart語言自己的問題嗎?

Garbage Collection 不及時?

爲此我須要瞭解一下Dart內存管理機制垃圾回收的實現,關於詳細的內存問題我團隊的 @匠修 同窗已經發過一篇相關文章能夠參考:內存文章

我這裏不詳細討論Dart垃圾回收實現細節,只聊一聊Flutter與Dart相關的一些內容。

關於Flutter我須要首先明確幾個概念:

  1. Framework(Dart)(跟iOS平臺鏈接的庫Flutter.framework要區別開)特指由Dart編寫的Flutter相關代碼。

  2. Dart VM執行Dart代碼的Dart語言相關庫,它是以C實現的Dart SDk形式提供的。對外主要暴露了C接口Dart Api。裏面主要包含了Dart的編譯器,運行時等等。

  3. FLutter Engine C++實現的Flutter驅動引擎。他主要負責跨平臺的繪製實現,包含Skia渲染引擎的接入;Dart語言的集成;以及跟Native層的適配和Embeder相關的一些代碼。簡單理解,iOS平臺上面Flutter.framework, Android平臺上的Flutter.jar即是引擎代碼構建後的產物。

在Dart代碼裏面對於GC是沒有感知的。

對於Dart SDK也就是Dart語言咱們能夠作的頗有限,由於Dart語言自己是一種標準,若是Dart真的有問題咱們須要和Dart維護團隊協做推動問題的解決。Dart語言設計的時候初衷也是但願GC對於使用者是透明的,咱們不該該依賴GC實現的具體算法和策略。不過咱們仍是須要經過Dart SDK的源碼去理解GC的大體狀況。

既然咱們前面已經確認並不是內存泄漏,因此咱們在對GC延遲的問題的調查主要放在Flutter Engine以及Dart CG入口上。

Flutter與Dart Garbage Collection

既然感受GC不及時,先撇開消耗,咱們至少能夠嘗試多觸發幾回GC來減輕內存峯值壓力。可是我在仔細查閱dart_api.h(/src/third_party/dart/runtime/include/dart_api.h )接口文件後,可是並無找到顯式提供觸發GC的接口。

可是找到了以下這個方法Dart_NotifyIdle

/** * Notifies the VM that the embedder expects to be idle until |deadline|. The VM * may use this time to perform garbage collection or other tasks to avoid * delays during execution of Dart code in the future. * * |deadline| is measured in microseconds against the system's monotonic time. * This clock can be accessed via Dart_TimelineGetMicros(). * * Requires there to be a current isolate. */
DART_EXPORT void Dart_NotifyIdle(int64_t deadline);
複製代碼

這個接口意思是咱們能夠在空閒的時候顯式地通知Dart,你接下來能夠利用這些時間(dealine以前)去作GC。注意,這裏的GC不保證會立刻執行,能夠理解咱們請求Dart去作GC,具體作不作仍是取決於Dart自己的策略。

另外,我還找到一個方法叫作Dart_NotifyLowMemory:

/** * Notifies the VM that the system is running low on memory. * * Does not require a current isolate. Only valid after calling Dart_Initialize. */
DART_EXPORT void Dart_NotifyLowMemory();
複製代碼

不過這個Dart_NotifyLowMemory方法其實跟GC沒有太大關係,它實際上是在低內存的狀況下把多餘的isolate去終止掉。你能夠簡單理解,把一些不是必須的線程給清理掉。

在研究Flutter Engine代碼後你會發現,Flutter Engine其實就是經過Dart_NotifyIdle去跟Dart層進行GC方面的協做的。咱們能夠在Flutter Engine源碼animator.cc看到如下代碼:

//Animator負責刷新和通知幀的繪製
  if (!frame_scheduled_) {
    // We don't have another frame pending, so we're waiting on user input
    // or I/O. Allow the Dart VM 100 ms.
    delegate_.OnAnimatorNotifyIdle(*this, dart_frame_deadline_ + 100000);
  }
  
  
  //delegate 最終會調用到這裏
  bool RuntimeController::NotifyIdle(int64_t deadline) {
  if (!root_isolate_) {
    return false;
  }

  tonic::DartState::Scope scope(root_isolate_.get());
  //Dart api接口
  Dart_NotifyIdle(deadline);
  return true;
}

複製代碼

這裏的邏輯比較直觀:若是當前沒有幀渲染的任務時候就經過NotifyIdle告訴Dart層能夠進行GC操做了。注意,這裏並非說只有在這種狀況下Dart纔回去作GC,Flutter只是經過這種方式儘量利用空閒去作GC,配合Dart以更合理的時間去作GC。

看到這裏,咱們有足夠的理由去嘗試一下這個接口,因而咱們在一些內存壓力比較大的場景進行了手動請求GC的操做。線上的Abort雖然有明顯好轉,可是內存峯值並無所以獲得改善。咱們須要進一步找到根本緣由。

圖片數量爆炸的真相

爲了肯定圖片大量囤積釋放不及時的問題,咱們須要跟蹤Flutter圖片從初始化到銷燬的整個流程。

咱們從Dart層開始去追尋Image對象的生命週期,咱們能夠看到Flutter裏面因此的圖片都是通過ImageProvider來獲取的,ImageProvider在獲取圖片的時候會調用一個Resolve接口,而這個接口會首先查詢ImageCache去讀取圖片,若是不存在緩存就new Image的實例出來。

關鍵代碼:

ImageStream resolve(ImageConfiguration configuration) {
    assert(configuration != null);
    final ImageStream stream = new ImageStream();
    T obtainedKey;
    obtainKey(configuration).then<void>((T key) {
      obtainedKey = key;
      stream.setCompleter(PaintingBinding.instance.imageCache.putIfAbsent(key, () => load(key)));
    }).catchError(
      (dynamic exception, StackTrace stack) async {
        FlutterError.reportError(new FlutterErrorDetails(
          exception: exception,
          stack: stack,
          library: 'services library',
          context: 'while resolving an image',
          silent: true, // could be a network error or whatnot
          informationCollector: (StringBuffer information) {
            information.writeln('Image provider: $this');
            information.writeln('Image configuration: $configuration');
            if (obtainedKey != null)
              information.writeln('Image key: $obtainedKey');
          }
        ));
        return null;
      }
    );
    return stream;
  }
複製代碼

大體的邏輯

  1. Resolve 請求獲取圖片.
  2. 查詢是否存在於ImageCache.Yes->3 NO->4
  3. 返回已經存在的圖片對象
  4. 生成新的Image對象並開始加載 看起來沒有特別複雜的邏輯,不過這裏我要提一下Flutter ImageCache的實現。

Flutter ImageCache

Flutter ImageCache最初的版本其實很是簡單,用Map實現的基於LRU算法緩存。這個算法和實現沒有什麼問題,可是要注意的是ImageCache緩存的是ImageStream對象,也就是緩存的是一個異步加載的圖片的對象。並且緩存沒有對佔用內存總量作限制,而是採用默認最大限制1000個對象(Flutter在0.5.6 beta中加入了對內存大小限制的邏輯)。緩存異步加載對象的一個問題是,在圖片加載解碼完成以前,沒法知道到底將要消耗多少內存,至少在Flutter這個Cache實現中沒有處理這個問題。具體的實現感興趣的朋友能夠閱讀ImageCache.dart源碼。

其實Flutter自己提供了定製化Cache的能力,因此優化ImageCache的第一步就是要根據機型的物理內存去作緩存大小的適配,設置ImageCache的合理限制。關於ImageCache的問題,能夠參考官方文檔和這個issue,我這裏不展開去聊了。

Flutter Image生命週期

回到咱們的Image對象跟蹤,很明顯,在緩存沒有命中的狀況下會有新的Image產生。繼續深刻代碼會發現Image對象是由這段代碼產生的:

Future<Codec> instantiateImageCodec(Uint8List list) {
  return _futurize(
    (_Callback<Codec> callback) => _instantiateImageCodec(list, callback, null)
  );
}

String _instantiateImageCodec(Uint8List list, _Callback<Codec> callback, _ImageInfo imageInfo) native 'instantiateImageCodec';
複製代碼

這裏有個native關鍵字,這是Dart調用C代碼的能力,咱們查看具體的源碼能夠發現這個最終初始化的是一個C++的codec對象。具體的代碼在Flutter Engine codec.cc。它大體的過程就是先在IO線程中啓動了一個解碼任務,在IO完成以後再把最終的圖片對象發回UI線程。關於Flutter線程的詳細介紹,我在另一篇文章中已經有介紹,這裏附上連接給有興趣的朋友。深刻理解Flutter Engine線程模型。通過來這些代碼和線程分析,咱們獲得大體的流程圖:

圖片爆炸流程圖

也就是說,解碼任務在IO線程進行,IO任務隊列裏面都是C++ lambda表達式,持有了實際的解碼對象,也就持有了內存資源。當IO線程任務過多的時候,會有不少IO任務在等待執行,這些內存資源也被閉包所持有而等待釋放。這就是爲何直觀上會有內存釋放不及時而形成內存峯值的問題。這也解釋了爲何以前拿到的vmmap虛擬內存數據裏面IOKit是大頭。

這樣咱們找到了關鍵的線索,在緩存不命中的狀況下,大量初始化Image對象,致使IO線程任務繁重,而IO又持有大量的圖片解碼所用的內存資源。帶這個推論,我在Flutter Engine的Task Runner加入了任務數量和C++ image對象的監控代碼,證明了的確存在IO任務線程過載的狀況,峯值在極端狀況下瞬時達到了100+IO操做。

IO Runner監控

到這裏問題彷佛愈來愈明瞭了,可是爲何會有這麼IO任務觸發呢?上述邏輯雖然可能會有IO線程過載的狀況下佔用大量內存的狀況。上層要求生成新的圖片對象,這種請求是沒有錯誤的,設計就是如此。就比如主線程阻塞大量的任務,必然會致使界面卡頓,但者卻不是主線程自己的問題。咱們須要從源頭找到致使新對象建立暴漲真正致使IO線程過載的緣由。

大量請求的根源

在前面的線索之下,咱們繼續尋找問題的根源。咱們在實際App操做的過程中發現,頁面Push的越多,圖片生成的速度愈來愈快。也就是說頁面越多請求越快,看起來沒有什麼大問題。可是可見的圖片其實老是在必定數量範圍以內的,不該該隨着頁面增多而加快對象建立的頻率。咱們下意識的開始懷疑是否存在不可見的Image Widget也在不斷請求圖片的狀況。最終致使了Cache沒法命中而大量生成新的圖片的場景。

我開始調查每一個頁面的圖片加載請求,咱們知道Flutter裏面萬物皆Widget,頁面都是是Widget,由Navigator管理。我在Widget的生命週期方法(詳細見Flutter官方文檔)中加入監控代碼,如我所料,在Navigator棧底下不可見的頁面也還在不停的Resolve Image,直接致使了image對象暴漲而致使IO線程過載,致使了內存峯值。

看起來,咱們終於找到了根本緣由。解決方案並不難。在頁面不可見的時候不必發出多餘的圖片加載請求,峯值也就隨之降下來了。再通過一番代碼優化和測試之後問題獲得了根本上的解決。優化上線之後,咱們看到了數據發生了質的好轉。 有朋友可能想問,爲何不可見的Widget也會被調用到相關的生命週期方法。這裏我推薦閱讀Flutter官方文檔關於Widget相關的介紹,篇幅有限我這裏不展開介紹了。widgets

至此,咱們已經解決了一個較爲嚴重的內存問題。內存優化狀況複雜,能夠點也比較多,接下來我繼續簡要分享在其它一些方面的優化方案。

截圖緩存優化

文件緩存+預加載策略

咱們是採用嵌入式Flutter並使用一套混合棧模式管理Native和Flutter頁面相互跳轉的邏輯。因爲FlutterView在App中是單例形式存在的,咱們爲了更好的用戶體驗,在頁面切換的過程當中使用的截圖的方式來進行過渡。

你們都知道,圖片是很是佔用內存的對象,咱們如何在不下降用戶體驗的同時得到最小的內存消耗呢?假如咱們每push一個頁面都保存一張截圖,那麼內存是以線性複雜度增加的,這顯然不夠好。

內存和空間在大多數狀況下是一個互相轉換的關係,優化不少時候實際上是找一個合理的折中點。 最終我採用了預加載+緩存的策略,在頁面最多隻在內存中同時存在兩個截圖,其它的存文件,在須要的時候提早進行預加載。 簡要流程圖:

簡要流程圖

這樣的話就作到了不影響用戶體驗的前提下,將空間複雜度從O(n)下降到了O(1)。 這個優化進一步節省了沒必要要的內存開銷。

截圖額外的優化

  • 針對當前設備的內存狀況,自適應調整截圖的分辨率,爭取最小的內存消耗。
  • 在極端的內存狀況下,把全部截圖都從內存中移除存(存文件可恢復),採用PlaceHolder的形式。極端狀況下避免被殺,保證可用性的體驗降級策略。

頁面兜底策略

對於電商類App存在一個廣泛的問題,用戶會不斷的push頁面到棧裏面,咱們不能阻止用戶這種行爲。咱們固然能夠把老頁面幹掉,每次回退的時候從新加載,可是這種用戶體驗跟Web頁同樣,是用戶不可接受的。咱們要維持頁面的狀態以保證用戶體驗。這必然會致使內存的線性增加,最終確定不免要被殺。咱們優化的目的是提升用戶可以push的極限頁面數量。

對於Flutter頁面優化,除了在優化每個頁面消耗的內存以外,咱們作了降級兜底策略去保證App的可用性:在極端狀況下將老頁面進行銷燬,在須要的時候從新建立。這的確下降了用戶體驗,在極端狀況下,降級體驗仍是比Crash要好一些。

FlutterViewController 單例析構

另外我想討論的一個話題是關於FlutterViewController的。目前Flutter的設計是按照單例模式去運行的,這對於徹底用Flutterc從新開發的App沒有太大的問題。可是對於混合型App,多出來的常駐內存確實是一個問題。

實際上,Flutter Engine底層實現是考慮到了析構這個問題,有相關的接口。可是在Embeder這一層(具體FlutterViewController Message Channels這一層),在實現過程當中存在一些循環引用,致使在Native層就算沒有引用FlutterViewController的時候也沒法釋放.

FlutterViewController引用圖

我在通過一段時間的嘗試後,算是把循環引用解除了。這些循環引用主要集中在FlutterChannel這一塊。在解除以後我順利的釋放了FlutterViewController,能夠明顯看到常駐內存獲得了釋放。可是我發現釋放FlutterViewController的時候會致使一部分Skia Image對象泄漏,由於Skia Objects必須在它建立的線程進行釋放(詳情請參考skia_gpu_object.cc源碼),線程同步的問題。關於這個問題我在GitHub上面有一個issue你們能夠參考。FlutterViewController釋放issue

目前,這個優化咱們已經反饋給Flutter團隊,期待他們官方支持。但願你們能夠一塊兒探索研究。

進一步探討

除此以外,Flutter內存方面其實還有比較多方面能夠去研究。我這裏列舉幾個目前觀察到的問題。

  1. 我在內存分析的時候發現Flutter底層使用的boring ssl庫有能夠肯定的內存泄漏。雖然這個泄漏比較緩慢,可是對於App長期運行仍是有影響的。我在GitHub上面提了個issue跟進,目前已有相關的人員進行跟進。SSL leak issue

  2. 關於圖片渲染,目前Flutter仍是有優化空間的,特別是圖片的按需剪裁。大多數狀況下是沒有不要將整一個bitmap解壓到內存中的,咱們能夠針對顯示的區域大小和屏幕的分辨率對圖片進行合理的縮放以取得最好的性能消耗。

  3. 在分析Flutter內存的MemGraph的時候,我發現Skia引擎當中對於TextLayout消耗了大量的內存.目前我沒有找到具體的緣由,可能存在優化的空間。

結語

在這篇文章裏,我簡要的聊了一下目前團隊在Flutter應用內存方面作出的嘗試和探索。短短一篇文章沒法包含全部內容,只能推出了幾個典型的案例來做分析,但願能夠跟你們一塊兒探討研究。歡迎感興趣的朋友一塊兒研究,若有更好的想法方案,我很是樂意看到你的分享。

閒魚期待你的加入

歡迎加入閒魚,一塊兒探索Flutter更多可能。 簡歷投遞: guicai.gxy@alibaba-inc.com

參考資料

相關文章
相關標籤/搜索