首頁>科技>

前言

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

本文就記憶體優化過程中一些實踐經驗跟大家做一個分享。

Flutter 上線之後

閒魚使用一套混合棧管理的方案將Flutter嵌入到現有的App中。在產品體驗上我們取得了優於Native的體驗。主要得益於Flutter的在跨平臺渲染方面的優勢,部分原因則是因為我們用Dart語言重新實現的頁面拋棄了很多歷史的包袱輕裝上陣。

上線之後各方面技術指標,都達到甚至超出了部分預期。而我們最為擔心的一些穩定性指標,比如crash也在穩定的範圍之內。但是在一段時間後我們發現由於記憶體過高而被系統殺死的abort率資料有比較明顯的異常。效能穩定性問題是非常關鍵的,於是我們火速開展了問題排查。

問題定位與排查

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

工具分析

這裡簡單介紹我們如何使用的工具去觀察手機資料以便於分析問題。需要注意的是,本文的重點不是工具的使用方法介紹,所以只是簡單列舉部分使用到的常見工具。

Xcode Instruments

Instruments是iOS記憶體排查的利器,可以比較便捷地觀察實時記憶體使用情況,自然不必多說。

Xcode MemGraph + VMMap

XCode 8之後推出的MEMGraph是Xcode的記憶體除錯利器,可以看到實時的視覺化的記憶體。更為方便的是,你可以將MemGraph匯出,配合命令列工具更好的得到結構化的資訊。

Dart Observatory

這是Dart語言官方的除錯工具,裡面也包含了類似於Xcode的Instruments的工具。在Debug模式下Dart VM啟動以後會在特定的埠接受除錯請求。可以參看官方文件。

觀察結果

在整個過程中我進行了大量的觀察,這裡分享一部分典型的資料表現。

通過Xcode Instruments排查的話,我們觀察到CG Raster Data這個資料有些高。這個Raster Data呢其實是圖片光柵化的時候的記憶體消耗。

我們將App記憶體異常的場景的MemGraph匯出來,對其執行VMMap指令得出的結果:

我們主要關注resident和dirty的記憶體。發現IOKit佔用了大量的記憶體。

結合Xcode Raster Data還有IOKit的大量記憶體消耗,我們開始懷疑問題是圖記憶體洩漏導致的。經過進一步通過Dart Observatory觀察Dart Image物件的記憶體情況。

觀察結果顯示,在記憶體較高的場景下在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; }

大致的邏輯

Resolve 請求獲取圖片.查詢是否存在於ImageCache.Yes->3 NO->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的問題,可以參考官方文件,我這裡不展開去聊了。

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任務觸發呢?上述邏輯雖然可能會有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的時候也無法釋放。

我在經過一段時間的嘗試後,算是把迴圈引用解除了。這些迴圈引用主要集中在FlutterChannel這一塊。在解除之後我順利的釋放了FlutterViewController,可以明顯看到常駐記憶體得到了釋放。但是我發現釋放FlutterViewController的時候會導致一部分Skia Image物件洩漏,因為Skia Objects必須在它建立的執行緒進行釋放(詳情請參考skiagpuobject.cc原始碼),執行緒同步的問題。關於這個問題我在GitHub上面有一個issue大家可以參考——“Unable to release FlutterViewController even when there is nothing referencing it. #21347”

目前,這個優化我們已經反饋給Flutter團隊,期待他們官方支援。希望大家可以一起探索研究。

進一步探討

除此之外,Flutter記憶體方面其實還有比較多方面可以去研究。我這裡列舉幾個目前觀察到的問題。

1.我在記憶體分析的時候發現Flutter底層使用的boring ssl庫有可以確定的記憶體洩漏。雖然這個洩漏比較緩慢,但是對於App長期執行還是有影響的。我在GitHub上面提了個issue跟進,目前已有相關的人員進行跟進——“Flutter SSL Memory Leaks #20409”

2.關於圖片渲染,目前Flutter還是有優化空間的,特別是圖片的按需剪裁。大多數情況下是沒有不要將整一個bitmap解壓到記憶體中的,我們可以針對顯示的區域大小和螢幕的解析度對圖片進行合理的縮放以取得最好的效能消耗。

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

結語

在這篇文章裡,我簡要的聊了一下目前團隊在Flutter應用記憶體方面做出的嘗試和探索。短短一篇文章無法包含所有內容,只能推出了幾個典型的案例來作分析,希望可以跟大家一起探討研究。歡迎感興趣的朋友一起研究,如有更好的想法方案,我非常樂意看到你的分享。

原文出處:閒魚技術

179

Dart

Xcode

最新評論
  • 整治雙十一購物亂象,國家再次出手!該跟這些套路說再見了
  • 從10級風口,到神祕人攜巨資進場,獨立站註定在今年逆勢爆發?