背景
一定規模的App開發如要引入Flutter開發體系,因某些原因如底層二、三方Native庫或頁面呼叫,不可避免需要混合開發的能力,但Flutter本身是個單容器的應用,純粹引入SDK會遇到頁面在Flutter和Native跳轉無法流暢切換,沒有統一的路由管理等問題。我們釋出的FlutterBoost1.0能很好的解決這些問題(文件參考這裡)。同時,我們也持續關注到以下痛點:
Flutter在不斷的演進升級,其演進會給上層應用來到更多可能;同時,在我們的業務應用中,FlutterBoost1.0在線上使用的過程中遇到一些如黑屏和白屏的反饋,這些歷史遺留問題我們希望解決掉;最後,社群的關注及需求是推動我們前進的動力,我們也希望藉此將FlutterBoost的開源做的更完善,比如增加更多測試用例,更多文件等等。這篇文章介紹了FlutterBoost2.0(統稱1.0之後的所有適配版本,下同)針對上述問題的架構升級,並且重點介紹iOS端在升級的過程中遇到的問題和解決方式。
問題分析
背景容器的升級:渲染和引擎的解耦
大家知道FlutterBoost1.0是單頁面模式,即不管你開啟多少Flutter頁面,其實呈現頁面的FlutterViewController或者FlutterView其實僅有一個,這其實是有歷史原因的。讓我們從Flutter底層的架構說起。Flutter發展到現在,在Plugin, ViewController(FlutterView),Flutter Engine這三個核心物件的管理上一直在演進。1.5版本是個分水嶺,終於對這三個物件做了較好的解耦。如flutter0.x版本,這三個物件的關係及我們使用API的方式是這樣的(根本看不到Engine物件):
作為使用方,我們看不到Engine物件,因為Engine已經內建在FlutterViewController中,沒有暴露出來。Flutter1.0做了解耦和改進,如下圖:
我們可以看到雖然全域性還是僅有一個FlutterViewController例項,但FlutterEngine被單獨剝離出來。不過三者關係還是沒理清。如,Plugin似乎應該註冊到Engine上更合理,怎麼會和負責渲染的FlutterViewController發生關係;Flutter Engine雖然已經暴露出來給使用者直接使用,但和VC之間還是1對1的關係,按理說負責引擎和資料的Engine全域性唯一,渲染層FlutterView應該可以多次切換啊。終於在1.5之後,我們看到Flutter團隊努力的結果:
Engine終於完全剝離,Plugin也終於“嫁”對了人。FlutterViewController和Engine不再是同生共死的組合關係,而僅是個通過VC表現層呈現及事件獲取的依賴。我們可以猜到Flutter嘗試往多引擎方向發展的努力!相應的,我們也給FlutterBoost提出了適配Flutter新架構的要求。
頁面白屏或黑屏
FlutterBoost1.0受限於Flutter的架構,出於節省記憶體的考慮,全域性只有一個FlutterViewController。同時在混合頁面滑動切換的時候,為了快速顯示上一個頁面並實現原生頁面切換的效果,採用CPU截圖的方式為每個頁面儲存了打底圖。打底圖通過檔案和記憶體二級快取的方式避免持有多張圖片的記憶體問題。但這也帶來了一些問題:在切換的過程中因需要對老頁面截圖及載入之前截圖圖片等耗時工作,會偶爾出現白屏或者黑屏問題——截圖和載入都在CPU執行緒上進行,會影響主執行緒渲染;而且在頁面切換的時候,截圖和載入圖片操作雖然處於降低記憶體的目的,但會帶來短暫的記憶體飆漲(見下圖),雖然持續的時間很短,但帶來了OOM的abort的風險。
生命週期管理
Flutter目前的架構是單例項的,這意味著一個FlutterViewController的生命週期和整個App的生命週期AppLifecycleState是一致的。頁面完全隱藏或者app切後臺都會發送pause訊息告知監聽者告知app要暫停。但這在有多個flutter頁面和原生頁面共存的混合棧情形下顯然不合理。針對單個Flutter Container頁面,也需要有自己可見與不可見的事件通知。FlutterBoost1.0中沒有解決這個問題,在閒魚內部也導致一些小問題,比如二次開啟視訊播放頁面後,老頁面通過app在WidgetBinding中監聽了pause事件就將播放器stop,但同時也將新頁面本自動播放的視訊也停止了。如果有單獨的頁面事件來分別精準控制而非依賴於整個App的事件就能解決這個問題。我們曾就這個VC頁面和應用生命週期的設計和Flutter的同學討論過,他們也覺得是個問題,但受限於當初的設計,暫沒有具體的解決時間點。
開源建設
升級之前,我們在github上的issue較多,同時文件不足,升級計劃也不明確,單元測試並不全面。這些短板需要我們在升級後解決。
解決方案
正是基於上述的問題,我們對FlutterBoost做了升級,主要有以下幾個方面。頁面管理方案升級
新版不再維護單一的FlutterViewController(或FlutterView),而是和原生一樣每次有新頁面請求時就直接開啟新的ViewController或者FlutterView,和管理原生的頁面一毛一樣。如此,自然也不需要實現截圖功能,小夥伴們再也不用擔心通過CPU截圖導致白屏或者黑屏的困擾啦。我們看如下頁面結構前後的對比。
如上圖,升級前全域性只有一個FlutterViewAdapter(其實是FlutterViewController),負責FlutterView渲染子View並將其內嵌在每個FLBFlutterViewContainer(繼承自UIViewController)中,每次新的FLBFlutterViewContainer拉起時就需要複雜的detach和attach操作來轉移唯一的FlutterView,同時進行截圖快取。升級後,不再需要內嵌的FlutterViewAdapter和Screenshot快取列表:
其底層實現也變的更加簡單,不再需要在detach頁面的時候截圖,下圖是前後兩個方案在頁面pop和push過程中的對比。
記憶體及穩定性治理
在頁面管理方案升級之後,白屏和黑屏問題解決了,世界就應該安靜了。但我們繼續做了深入的記憶體及穩定性治理,發現新版本在iOS下每個新的VC開啟的時候雖然沒有了記憶體飆漲的peak,但每個新頁面會帶來約10M記憶體的增量。拿FlutterBoost的官方demo做了測試,1.54這個版本就是升級後的原始版本:
這個記憶體增量是因為什麼導致的?我們升級前後記憶體變化做了分析,如下圖(左邊是升級前,右邊是升級後):
發現記憶體的增量主要來自於Anonymous VM和IOSSurface。Android版本這類問題並不明顯,暫略不表。
IOSurface的增量
什麼是IOSurface?從apple的文件裡了解到:
The IOSurface framework provides a framebuffer object suitable for sharing across process boundaries.
記得哪一年蘋果的開發者大會上重點提過這個,主要是和iOS上OpenGL的GPU記憶體和CPU記憶體管理創新有關,如CVOpenGLESTextureCacheCreateTextureFromImage就是基於IOSurface的能力直接從影象對映為紋理,而不像OpenGL官方的glTexImage2D需要將影象從CPU傳輸到GPU再對映,從而提高了效能。
在Flutter裡,Rasterrizer的setup和teardown會使底層系統建立和銷燬IOSurface。FlutterViewController的surfaceUpdated會用於刪除或建立Rasterrizer。我們review了升級後對於VC的surface的控制,的確有許多不合理之處——多次重複呼叫surfaceUpdated。
導致多次呼叫的根本原因脫離不了FlutterViewController單引擎單頁面的設計。FlutterVC設計並不考慮混合棧的情形,它設想的場景是全域性一個engine,只需管理其唯一持有的FlutterVC的生命週期。如此,其surfaceUpdated函式固化在view的appear和disappear事件中,並沒有暴露出來。這導致混合棧在處理多FlutterVC頁面切換的時候,無法重寫頁面事件處理函式而靈活處理何時應該建立和銷燬surface,最終不可避免的重複建立surface或者銷燬surface。
同時,我們發現頁面present和push在iOS13下將被覆蓋的頁面的生命週期和新彈出的頁面生命週期順序還不一樣。比如present在新頁面view appear之後才會disppear老頁面,並不像push的時候先disppear老頁面然後再appear新頁面。這也給我們處理混合棧頁面的surfaceUpdate帶來了困難。
為了相容這個頁面邏輯,並且儘量避免多次建立或銷燬surface,我們重新梳理了頁面的生命週期,只在viewDidAppear的時候重新建立surface,而在viewDisappear的時候僅對非前臺VC進行刪除surface。但儘管如此,受限於FlutterVC固化了surfaceUpdate的呼叫,這裡還是難以避免會多重複一次建立和銷燬surface。不過,記憶體略有改觀,線上穩定性得到不少提升。見下圖記憶體比較。
VM的記憶體增長從記憶體分析的Anonymous VM上看不出VM的記憶體增長的原因,但從升級後有多個VC這個角度看,有許多可解釋這個增長的地方。每個新的VC開啟後,其會通過CAGLLayer渲染內容,CAGLLayer會持有後臺的content。正是基於這個content,iOS系統內部對VC進行截圖,在頁面切換的時候才有半開半閉的動態效果。相對於FlutterBoost1.0的CPU截圖,系統截圖自然有許多效能的優化,但帶來記憶體的增長難以避免。為了驗證這個問題,我寫了個類似Flutter的用OpenGL渲染UIView的demo。果然,每次開啟新頁面,記憶體肯定增長10M左右。這個增量似乎難以優化,只能設法避免。目前我們推薦兩種方式:
通過頁面棧裡頁面個數限制來避免過多頁面導致OOM。如閒魚這邊限制了頁面多次push後,僅保留最近3個頁面。避免從Flutter頁面開啟Flutter頁面就新建FlutterVC,可重用上次的FlutterVC。FlutterBoost提供了這種能力,BoostContainer繼承了Navigator,原生支援Navigator的能力。但這裡需要使用者自己判斷何時應該使用Navigator的push,何時用FlutterBoost的open來開啟頁面。後期我們會增加一些這樣的demo。穩定性治理
每次底層庫的升級都會帶來穩定性問題。為了保障穩定性,我們通過收集線上crash日誌的方式,定位到幾個Engine層面的bug。這些bug或提交了issue給google,或在我們engine內部版本做了規避。如無障礙模式下FlutterSemanticObject洩漏導致crash,參考https://github.com/flutter/engine/pull/14155。在Flutter1.12下,多FlutterVC情形會觸發surface空指標呼叫而crash,參考https://github.com/flutter/flutter/issues/52455。其他還有一些bug,我們在內部版本做了規避,並和google做了溝通,但因復現難度等原因,還未取得一致的結論。整體上,FlutterBoost2.0在閒魚內部場景升級後,經多輪灰度及線上驗證,穩定性不錯,效果較好。
頁面生命週期管理及其他
FlutterBoost完善了之前的ContainerLifeCycle,在Dart層能較好的支援頁面的appear和disappear事件,同時能監聽app切到Background或者Foreground事件。如果涉及到頁面的生命週期管理,建議您使用FlutterBoost.singleton.addBoostContainerLifeCycleObserver()來註冊相關事件監聽程式。社群同學也給了不少建議,比如幫忙優化了hero動畫的能力等。其他功能提升及使用上的建議。為了整個框架更穩定和易於迴歸驗證,我們也補了一些單元測試。目前主要是Dart層面的用例(覆蓋率達70%左右),後續會增加混合頁面跳轉方面的用例。同時定義了升級計劃和release清單(見首頁)。在這輪升級後,我們總結了目前FlutterBoost的能力對比表,供參考:
總結
綜上所述,經過此次升級,flutterboost解決掉了頁面切換時白屏或者閃爍等問題,同時代碼也更簡潔易懂。同時我們對記憶體及穩定性做了分析。對於記憶體上的問題,給出了規避的方式,穩定性上我們主要通過頁面的detach和attach時序優化來解決。後續我們會繼續優化程式碼,增加更多的測試用例,尤其是支援混合測試的用例。同時在Flutter頁面跳Flutter頁面上也在考慮不影響上層業務的基礎上支援一致的API。路漫漫其修遠兮,FlutterBoost會一直與時俱進,進化為更加完美的框架。也歡迎大家多參與到這個框架的開發中,一起討論並改進他。最後,我們也開發了一個使用flutterboost的腳手架:flutter-boot。歡迎使用並送小星星,地址在:https://github.com/alibaba-flutter/flutter-boot