Flutter 記憶體機制初探
Dart RunTime簡介
Flutter Framework使用Dart語言開發,所以App程序中需要一個Dart執行環境(VM),和Android Art一樣,Flutter也對Dart原始碼做了AOT編譯,直接將Dart原始碼編譯成了本地位元組碼,沒有了解釋執行的過程,提升執行效能。這裡重點關注Dart VM記憶體分配(Allocate)和回收(GC)相關的部分。
和Java顯著不同的是Dart的"執行緒"(Isolate)是不共享記憶體的,各自的堆(Heap)和棧(Stack)都是隔離的,並且是各自獨立GC的,彼此之間通過訊息通道來通訊。Dart天然不存在資料競爭和變數狀態同步的問題,整個Flutter Framework Widget的渲染過程都執行在一個isolate中。

Dart VM將記憶體管理分為新生代(New Generation)和老年代(Old Generation)。
新生代(New Generation): 通常初次分配的物件都位於新生代中,該區域主要是存放記憶體較小並且生命週期較短的物件,比如區域性變數。新生代會頻繁執行記憶體回收(GC),回收採用“複製-清除”演算法,將記憶體分為兩塊(圖中的from 和 to),執行時每次只使用其中的一塊(圖中的from),另一塊備用(圖中的to)。當發生GC時,將當前使用的記憶體塊中存活的物件拷貝到備用記憶體塊中,然後清除當前使用記憶體塊,最後,交換兩塊記憶體的角色。

老年代(Old Generation): 在新生代的GC中“倖存”下來的物件,它們會被轉移到老年代中。老年代存放生命力週期較長,記憶體較大的物件。老年代通常比新生代要大很多。老年代的GC回收採用“標記-清除”演算法,分成標記和清除兩個階段。在標記階段會觸發停頓(stop the world),多執行緒併發的完成對垃圾物件的標記,降低標記階段耗時。在清理階段,由GC執行緒負責清理回收物件,和應用執行緒同時執行,不影響應用執行。

可以看到,Dart VM借鑑了很多JVM的思路,Dart中產生記憶體洩露的方式也和Java類似,Java中很多排查記憶體洩露的思路和防止記憶體洩露的程式設計方法應該也可以借鑑過來。
Image記憶體初探
對圖片的合理使用和優化是UI程式設計的重要部分,Flutter提供了Image Widget,我們可以方便地使用:

我們知道Android將記憶體分為Java虛擬機器記憶體和Native記憶體,各大廠商都對Java虛擬機器記憶體有一個上限限制,到達上限就會觸發OOM異常,而對Native記憶體的使用沒有太嚴格的限制,現在的手機記憶體都很大,一般有較大的Native記憶體富餘。那麼Android中ImageView使用的是Java虛擬機器記憶體還是Native記憶體呢?
我們可以來做一個測試:在一個介面上,每點選一次,就在上面堆加一張圖片。為了防止後面的圖片完全覆蓋前面的圖片而出現優化的情況,每次都縮小几個畫素,這樣就不會出現完全覆蓋。

開啟Android Profiler,一張一張新增圖片,觀察記憶體資料。分別測試了Android的6.0,7.0和8.0系統,結果如下:
Android 6.0(Google Nextus5)

Android 7.0(Meizu pro5)

Android 8.0(Google pixel)

在測試中,隨著圖片一張張增加,Android 6.0 和 7.0都是Java部分的記憶體在增長,而Android 8.0則是Native部分的記憶體在增長。由此有結論,Android原生的ImageView在6.0和7.0版本中使用的Java虛擬機器記憶體,而在Android 8.0中則使用的Native記憶體。
而Flutter Image Widget使用的是哪部分記憶體呢?我們用Flutter介面來做相同的測試。Flutter Engine的Debug版本和Release版本存在很大的效能差異,所以我們測試最好使用Release版本,但是,Release版本的Apk又不能使用Android profiler來觀察記憶體,所以我們需要在Debug版本的Apk中打包一個Release版本的Flutter Engine, 可以修改flutter tool中的flutter.gradle來實現:

相同地,我們向Flutter介面中新增圖片並用Android Profiler來觀察記憶體,測試使用的dart程式碼:

得到的結果是:
Android 6.0
Android 8.0


可以看到,Flutter Image使用的記憶體既不屬於Java虛擬機器記憶體也不屬於Native記憶體,而是Graphics記憶體(在Meizu pro5裝置上也不屬於Graphics,事實上Meizu pro5裝置不能歸類Flutter Image所使用的記憶體),官方對Graphics記憶體的解釋是:

那麼至少Flutter Image所使用的記憶體不會是Java虛擬機器記憶體,這對不少Android裝置都是一個好訊息,這意味著使用Flutter Image沒有OOM的風險,能夠較好的利用Native記憶體。
使用Image的時候,建立一個記憶體快取池是個好習慣,Flutter Framework提供了一個ImageCache來快取載入的圖片,但它不同於Android Lru Cache,不能精確的使用記憶體大小來設定快取池容量,而是隻能粗略的指定最大快取圖片張數。
FlutterView記憶體初探
Flutter設計之初是想統一Android和IOS的介面程式設計,所以理想的基於Flutter的apk只需要提供一個MainActivity做入口即可,後面所有的頁面跳轉都在FlutterView中管理。但是,如果是一個已有規模的app接入Flutter開發,我們不可能將已有的Activity頁面都用Flutter重新實現一遍,這時候就需要考慮本地頁面和Flutter頁面之間的跳轉互動了。iOS可以方便的管理頁面棧,但是Android就很複雜(Android有任務棧機制,低記憶體Activity回收機制等),所以通常我們還是使用Activity作為頁面容器來展示flutter頁面。這時有兩種選擇,可以每次啟動一個Activity就啟動一個新的FlutterView,也可以啟動Activity的時候複用已有的FlutterView。
不復用FlutterView

複用FlutterView

Flutter Framework中FlutterView是繫結Activity使用的,要複用FlutterView就必須能夠把FlutterView單獨拎出來使用。所幸現在FlutterView和Activity耦合程度並不很深,最關鍵的地方是FlutterNativeView必須attach一個Activity:

初始化FlutterView時必須傳入一個Activity,當其他Activity複用FlutterView時再呼叫該Attach方法即可。這裡有個問題,就是FlutterView中必須儲存一個Activity引用,這個一個記憶體洩露隱患,我們可以在FluterView detach時候將MainActivity傳入,因為通常整個App互動過程中MainActivity都是一直存在的,可以避免其他Activity洩露。
為了更好的權衡兩種方法的利弊,我們先用空頁面來測試一下當頁面增加時記憶體的變化:
不復用FlutterView時,頁面增加時記憶體變化

複用FlutterView時,頁面增加時記憶體變化

不復用FlutterView時平均開啟一個頁面(空頁面),Java記憶體增長0.02M,Native記憶體增長0.73M。複用FlutterView時平均開啟一個頁面(空頁面),Java記憶體增長0.019M,Native記憶體增長0.65M。可見覆用FlutterView在記憶體使用上是有優勢的,但主要複用的還是Native部分的記憶體。複用FlutterView必然帶來額外的一些複雜邏輯,有時候為了邏輯簡單,後期維護上的方便,犧牲一些相對不太珍貴的Native記憶體也是值得的。
複用單個FlutterView有時會有些“意外”,比如當Activity切換時,就不得不將當前FlutterView detach掉給後面新建的Activity使用,當前介面就會空白閃動,有個想法是可以將當前介面截圖下來遮擋住後面的介面變化,這種方式有時會帶來額外的適配問題。
FlutterView複用與否不是絕對的,有時候可以使用一些綜合性折中方案,比如,我們可以建立一個FlutterViewProvider,裡面維護N個可複用的FlutterView,如圖:

這樣的好處是,可以存在一定程度上的複用,又可以避免只有一個FlutterView出現的一些尷尬問題。
FlutterView的首幀渲染耗時較高,在Debug版本有明顯感受,大概會黑屏2秒,release版本會好很多。但我們觀察Cpu曲線,發現還是一個較為耗時的過程。有一種體驗優化的思路是,我們可以預先讓將要使用的FlutterView載入好首幀,這樣,在真正使用的時候就很快了,可以先建立一個只有1個畫素的視窗,在這個窗口裡面完成FlutterView首幀渲染,程式碼如下:

下面提供一套flutter電子書(可以免費領取)

flutter電子書
flutter電子書免費領取:
加群 4112676,驗證: flutter,即可