發表日期:2018-10 文章編輯:小燈 瀏覽次數:7247
性能穩定性是App的生命,Flutter帶了很多創新與機遇,然而團隊在享受Flutter帶來的收益同時也迎接了很多新事物帶來的挑戰。
本文就內存優化過程中一些實踐經驗跟大家做一個分享。
閑魚使用一套混合棧管理的方案將Flutter嵌入到現有的App中。在產品體驗上我們取得了優于Native的體驗。主要得益于Flutter的在跨平臺渲染方面的優勢,部分原因則是因為我們用Dart語言重新實現的頁面拋棄了很多歷史的包袱輕裝上陣。
上線之后各方面技術指標,都達到甚至超出了部分預期。而我們最為擔心的一些穩定性指標,比如crash也在穩定的范圍之內。但是在一段時間后我們發現由于內存過高而被系統殺死的abort率數據有比較明顯的異常。性能穩定性問題是非常關鍵的,于是我們火速開展了問題排查。
顯然問題出在了過大的內存消耗上。內存消耗在App中構成比較復雜,如何在復雜的業務中去定位到罪魁禍首呢?稍加觀察,我們確定Flutter問題相對比價明顯。工欲善其事必先利其器,需要更好地定位內存的問題,善用已經的工具是非常有幫助的。好在我們在Native層和Dart層都有足夠多的性能分析工具進行使用。
這里簡單介紹我們如何使用的工具去觀察手機數據以便于分析問題。需要注意的是,本文的重點不是工具的使用方法介紹,所以只是簡單列舉部分使用到的常見工具。
Instruments是iOS內存排查的利器,可以比較便捷地觀察實時內存使用情況,自然不必多說。
XCode 8之后推出的MEMGraph是Xcode的內存調試利器,可以看到實時的可視化的內存。更為方便的是,你可以將MemGraph導出,配合命令行工具更好的得到結構化的信息。
這是Dart語言官方的調試工具,里面也包含了類似于Xcode的Instruments的工具。在Debug模式下Dart VM啟動以后會在特定的端口接受調試請求。官方文檔
在整個過程中我進行了大量的觀察,這里分享一部分典型的數據表現。
通過Xcode Instruments排查的話,我們觀察到CG Raster Data這個數據有些高。這個Raster Data呢其實是圖片光柵化的時候的內存消耗。
我們將App內存異常的場景的MemGraph導出來,對其執行VMMap指令得出的結果:
vmmap --summary Runner[40957].memgraphvmmap Runner[40957].memgraph | grep 'IOKit'
vmmap Summary vmmap address我們主要關注resident和dirty的內存。發現IOKit占用了大量的內存。
結合Xcode Raster Data還有IOKit的大量內存消耗,我們開始懷疑問題是圖內存泄漏導致的。經過進一步通過Dart Observatory觀察Dart Image對象的內存情況。
觀察結果顯示,在內存較高的場景下在Dart層的確同時存在了較多Image(如圖中270)的對象。現在基本可以確定內存問題跟Dart層的圖片有很大的關系。
這個結果,我估計很多人都已經想到了,App有明顯的內存問題很有可能就是跟多媒體資源有關系。通過工具得出的準確數據線索,我們得到一個大致的方向去深入研究。
前面我們用工具觀察到Dart層的Image對象數量過多直接導致了非常大的內存壓力,我們起初懷疑存在圖片的內存泄漏。但是我們在經過進一步確認以后發現圖片其實并沒有真正的泄漏。
Dart語言采用垃圾回收機制(Garbage Collection 下面開始簡稱GC)來管理分配的內存,VM層面的垃圾回收應該大多數情況下是可信的。但是從實際觀察來看,圖片數量的爆炸造成的較大的內存峰值直觀感覺上GC來得有些不及時。在Debug模式下我們使用Dart Observatory手動觸發GC,最終這些圖片對象在沒有引用的情況下最終還是會被回收。
至此,我們基本可以確認,圖片對象不存在泄漏。那是什么導致了GC的反應遲鈍呢,難道是Dart語言本身的問題嗎?
為此我需要了解一下Dart內存管理機制垃圾回收的實現,關于詳細的內存問題我團隊的 @匠修 同學已經發過一篇相關文章可以參考:內存文章
我這里不詳細討論Dart垃圾回收實現細節,只聊一聊Flutter與Dart相關的一些內容。
[圖片上傳失敗...(image-8cfc73-1539229685285)]
關于Flutter我需要首先明確幾個概念:
Framework(Dart)(跟iOS平臺連接的庫Flutter.framework要區別開)特指由Dart編寫的Flutter相關代碼。
Dart VM執行Dart代碼的Dart語言相關庫,它是以C實現的Dart SDk形式提供的。對外主要暴露了C接口Dart Api。里面主要包含了Dart的編譯器,運行時等等。
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入口上。
既然感覺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; }
大致的邏輯
Flutter ImageCache最初的版本其實非常簡單,用Map實現的基于LRU算法緩存。這個算法和實現沒有什么問題,但是要注意的是ImageCache緩存的是ImageStream對象,也就是緩存的是一個異步加載的圖片的對象。而且緩存沒有對占用內存總量做限制,而是采用默認最大限制1000個對象(Flutter在0.5.6 beta中加入了對內存大小限制的邏輯)。緩存異步加載對象的一個問題是,在圖片加載解碼完成之前,無法知道到底將要消耗多少內存,至少在Flutter這個Cache實現中沒有處理這個問題。具體的實現感興趣的朋友可以閱讀ImageCache.dart源碼。
其實Flutter本身提供了定制化Cache的能力,所以優化ImageCache的第一步就是要根據機型的物理內存去做緩存大小的適配,設置ImageCache的合理限制。關于ImageCache的問題,可以參考官方文檔和這個issue,我這里不展開去聊了。
回到我們的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)。
這個優化進一步節省了不必要的內存開銷。
對于電商類App存在一個普遍的問題,用戶會不斷的push頁面到棧里面,我們不能阻止用戶這種行為。我們當然可以把老頁面干掉,每次回退的時候重新加載,但是這種用戶體驗跟Web頁一樣,是用戶不可接受的。我們要維持頁面的狀態以保證用戶體驗。這必然會導致內存的線性增長,最終肯定難免要被殺。我們優化的目的是提高用戶能夠push的極限頁面數量。
對于Flutter頁面優化,除了在優化每一個頁面消耗的內存之外,我們做了降級兜底策略去保證App的可用性:在極端情況下將老頁面進行銷毀,在需要的時候重新創建。這的確降低了用戶體驗,在極端情況下,降級體驗還是比Crash要好一些。
image另外我想討論的一個話題是關于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內存方面其實還有比較多方面可以去研究。我這里列舉幾個目前觀察到的問題。
我在內存分析的時候發現Flutter底層使用的boring ssl庫有可以確定的內存泄漏。雖然這個泄漏比較緩慢,但是對于App長期運行還是有影響的。我在GitHub上面提了個issue跟進,目前已有相關的人員進行跟進。SSL leak issue
關于圖片渲染,目前Flutter還是有優化空間的,特別是圖片的按需剪裁。大多數情況下是沒有不要將整一個bitmap解壓到內存中的,我們可以針對顯示的區域大小和屏幕的分辨率對圖片進行合理的縮放以取得最好的性能消耗。
在分析Flutter內存的MemGraph的時候,我發現Skia引擎當中對于TextLayout消耗了大量的內存.目前我沒有找到具體的原因,可能存在優化的空間。
在這篇文章里,我簡要的聊了一下目前團隊在Flutter應用內存方面做出的嘗試和探索。短短一篇文章無法包含所有內容,只能推出了幾個典型的案例來作分析,希望可以跟大家一起探討研究。歡迎感興趣的朋友一起研究,如有更好的想法方案,我非常樂意看到你的分享。
歡迎加入閑魚,一起探索Flutter更多可能。
簡歷投遞:guicai.gxy@alibaba-inc.com
日期:2018-10 瀏覽次數:7246
日期:2018-12 瀏覽次數:4319
日期:2018-07 瀏覽次數:4868
日期:2018-12 瀏覽次數:4168
日期:2018-09 瀏覽次數:5490
日期:2018-12 瀏覽次數:9915
日期:2018-11 瀏覽次數:4797
日期:2018-07 瀏覽次數:4573
日期:2018-05 瀏覽次數:4851
日期:2018-12 瀏覽次數:4315
日期:2018-10 瀏覽次數:5132
日期:2018-12 瀏覽次數:6206
日期:2018-11 瀏覽次數:4453
日期:2018-08 瀏覽次數:4585
日期:2018-11 瀏覽次數:12622
日期:2018-09 瀏覽次數:5570
日期:2018-12 瀏覽次數:4823
日期:2018-10 瀏覽次數:4178
日期:2018-11 瀏覽次數:4522
日期:2018-12 瀏覽次數:6057
日期:2018-06 瀏覽次數:4002
日期:2018-08 瀏覽次數:5427
日期:2018-10 瀏覽次數:4452
日期:2018-12 瀏覽次數:4516
日期:2018-07 瀏覽次數:4355
日期:2018-12 瀏覽次數:4493
日期:2018-06 瀏覽次數:4375
日期:2018-11 瀏覽次數:4369
日期:2018-12 瀏覽次數:4242
日期:2018-12 瀏覽次數:5275
Copyright ? 2013-2018 Tadeng NetWork Technology Co., LTD. All Rights Reserved.