這是JsonChao的第57期分享

Flutter 作為目前最火爆的移動端跨平台框架,能夠幫助開發者通過一套代碼庫高效地構建多平台的精美應用,並支持移動、Web、桌面和嵌入式平台。對於 Android 來說,Flutter 能夠創作媲美原生的高性能應用,但是,在較為複雜的 App 中,使用 Flutter 開發也很難避免產生各種各樣的性能問題。在這篇文章中,我將和你一起全方位地深入探索 Flutter 性能優化的疆域。
一、檢測手段準備以 profile 模式啟動應用,如果是混合 Flutter 應用,在 flutter/packages/flutter_tools/gradle/flutter.gradle 的 buildModeFor 方法中將 debug 模式改為 profile即可。
為什麼要在分析模式下來調試應用性能?分析模式在發布模式的基礎之上,為分析工具提供了少量必要的應用追蹤信息。
那,為什麼要在發布模式的基礎上來調試應用性能?與調試代碼可以在調試模式下檢測 Bug 不同,性能問題需要在發布模式下使用真機進行檢測。這是因為,相比發布模式而言,調試模式增加了很多額外的檢查(比如斷言),這些檢查可能會耗費很多資源,而更重要的是,調試模式使用 JIT 模式運行應用,代碼執行效率較低。這就使得調試模式運行的應用,無法真實反映出它的性能問題。
而另一方面,模擬器使用的指令集為 x86,而真機使用的指令集是 ARM。這兩種方式的二進制代碼執行行為完全不同,因此,模擬器與真機的性能差異較大,例如,針對一些 x86 指令集擅長的操作,模擬器會比真機快,而另一些操作則會比真機慢。這也同時意味着,你無法使用模擬器來評估真機才能出現的性能問題。
1、Flutter InspectorFlutter Inspector有很多功能,但你應該把注意力花在更有用的功能學習上,例如:「Select Widget Mode」 和 「Repaint Rainbow」。
Select Widget Mode點擊 「Select Widget Mode」 圖標,可以在手機上查看當前頁面的布局框架與容器類型。

快速查看陌生頁面的布局實現方式。
Repaint Rainbow點擊 「Repaint Rainbow」 圖標,它會為所有 RenderBox 繪製一層外框,並在它們重繪時會改變顏色。

幫你找到 App 中頻繁重繪導致性能消耗過大的部分。
例如:一個小動畫可能會導致整個頁面重繪,這個時候使用 RepaintBoundary Widget 包裹它,可以將重繪範圍縮小至本身所占用的區域,這樣就可以減少繪製消耗。
使用場景例如頁面的進度條動畫刷新時會導致整個布局頻繁重繪。
缺點使用 RepaintBoundary Widget 會創建額外的繪製畫布,這將會增加一定的內存消耗。
2、性能圖層性能圖層會在當前應用的最上層,以 Flutter 引擎自繪的方式展示 Raster 與 UI 線程的執行圖表,而其中每一張圖表都代表當前線程最近 300 幀的表現,如果 UI 產生了卡頓(跳幀),這些圖表可以幫助你分析並找到原因。
藍色垂直的線條表示已執行的正常幀,綠色的線條代表的是當前幀,如果其中有一幀處理時間過長,就會導致界面卡頓,圖表中就會展示出一個紅色豎條。
如果紅色豎條出現在 GPU 線程圖表,意味着渲染的圖形太複雜,導致無法快速渲染;而如果是出現在了 UI 線程圖表,則表示 Dart 代碼消耗了大量資源,需要優化代碼的執行時間。如下圖所示:

它定位的是渲染引擎底層渲染的異常。
解決方案是把需要靜態緩存的圖像加入到 RepaintBoundary。而 RepaintBoundary 可以確定 Widget 樹的重繪邊界,如果圖像足夠複雜,Flutter 引擎會自動將其緩存,避免重複刷新。當然,因為緩存資源有限,如果引擎認為圖像不夠複雜,也可能會忽略 RepaintBoundary。
4、UI 線程問題定位問題場景在視圖構建時,在 build 方法中使用了一些複雜的運算,或是在主 Isolate 中進行了同步的 I/O 操作。
使用 Performance 進行檢測點擊 Android Studio 底部工具欄中的 「Open DevTools」 按鈕,然後在打開的 Dart DevTools 網頁中將頂部的 tab 切換到 Performance。
與性能圖層能夠自動記錄應用執行的情況不同,使用 Performance 來分析代碼執行軌跡,你需要手動點擊 「Record」 按鈕去主動觸發,在完成信息的抽樣採集後,點擊 「Stop」 按鈕結束錄製。這時,你就可以得到在這期間應用的執行情況了。
使用 Performance 記錄應用的執行情況,即 CPU 幀圖,又被稱為火焰圖。火焰圖是基於記錄代碼執行結果所產生的圖片,用來展示 CPU 的調用棧,表示的是 CPU 的繁忙程度。
其中:
所以,我們要檢測 CPU 耗時問題,皆可以查看火焰圖底部的哪個函數占據的寬度最大。只要有 「平頂」,就表示該函數可能存在性能問題。如下圖所示:

一般的耗時問題,我們通常可以使用 Isolate(或 compute)將這些耗時的操作挪到並發主 Isolate 之外去完成。
dart 的單線程執行異步任務是怎麼實現的?
網絡調用的執行是由操作系統提供的另外的底層線程做的,而在 event queue 里只會放一個網絡調用的最終執行結果(成功或失敗)和響應執行結果的處理回調。
5、使用 checkerboardOffscreenLayers 檢查多視圖疊加的視圖渲染只要在 MaterialApp 的初始化方法中,將 checkerboardOffscreenLayers 開關設置為 true,分析工具就會自動幫你檢測多視圖疊加的情況。
這時,使用了 saveLayer 的 Widget 會自動顯示為棋盤格式,並隨着頁面刷新而閃爍。
而 saveLayer 一般會通過一些功能性 Widget,在涉及需要剪切或半透明蒙層的場景中間接地使用。
6、使用 checkerboardRasterCacheImages 檢查緩存的圖像它也是用來檢測在界面重繪時頻繁閃爍的圖像(即沒有靜態緩存)。解決方案是把需要靜態緩存的圖像加入到 RepaintBoundary。
二、關鍵優化指標1、頁面異常率頁面異常率,即 頁面渲染過程中出現異常的概率。
它度量的是頁面維度下功能不可用的情況,其統計公式為:
頁面異常率 = 異常發生次數 / 整體頁面 PV 數。
統計異常發生次數利用 Zone 與 FlutterError 這兩個方法,然後在異常攔截的方法中,去累計異常的發生次數。
統計整體頁面 PV 數繼承自 NavigatorObserver 的觀察者,並在其 didPush 方法中,去累加頁面的打開次數。
2、頁面幀率Flutter 在全局 Window 對象上提供了幀回調機制。我們可以在 Window 對象上註冊 onReportTimings 方法,將最近繪製幀耗費的時間(即 FrameTiming),以回調的形式告訴我們。
有了每一幀的繪製時間後,我們就可以計算 FPS 了。
為了讓 FPS 的計算更加平滑,我們需要保留最近 25 個 FrameTiming 用於求和計算。
由於幀的渲染是依靠 VSync 信號驅動的,如果幀繪製的時間沒有超過 16.67 ms,我們也需要把它當成 16.67 ms 來算,因為繪製完成的幀必須要等到下一次 VSync 信號來了之後才能渲染。而如果幀繪製時間超過了 16.67 ms,則會占用後續 VSync 的信號周期,從而打亂後續的繪製次序,產生卡頓現象。
那麼,頁面幀率的統計公式就是:
FPS = 60 * 實際渲染的幀數 / 本來應該在這個時間內渲染完成的幀數。
首先,定義一個容量為 25 的列表,用於存儲最近的幀繪製耗時 FrameTiming。
然後,在 FPS 的計算函數中,你再將列表中每幀繪製時間與 VSync 周期 frameInterval 進行比較,得出本來應該繪製的幀數。
最後,兩者相除就得到了 FPS 指標。
3、頁面加載時長頁面加載時長 = 頁面可見的時間 - 頁面創建的時間(包括網絡加載時長)
統計頁面可見的時間WidgetsBinding 提供了單次 Frame 回調的 addPostFrameCallback 方法,它會在當前 Frame 繪製完成之後進行回調,並且只會回調一次。一旦監聽到 Frame 繪製完成回調後,我們就可以確認頁面已經被渲染出來了,因此我們可以藉助這個方法去獲取頁面的渲染完成時間 endTime。
統計頁面創建的時間獲取頁面創建的時間比較容易,我們只需要在頁面的初始化函數 initState() 里記錄頁面的創建時間 startTime。
最後,再將這兩個時間做減法,你就能得到頁面的加載時長。
需要注意的是,正常的頁面加載時長一般都不應該超過2秒。如果超過了,則意味着有嚴重的性能問題。
三、布局加載優化Flutter 為什麼要使用聲明書 UI 的編寫方式?
為了減輕開發人員的負擔,無需編寫如何在不同的 UI 狀態之間進行切換的代碼,Flutter 使用了聲明式的 UI 編寫方式,而不是 Android 和 iOS 中的命令式編寫方式。
這樣的話,當用戶界面發生變化時,Flutter 不會修改舊的 Widget 實例,而是會構造新的 Widget 實例。
Fluuter 框架使用 RenderObjects 管理傳統 UI 對象的職責(比如維護布局的狀態)。RenderObjects 在幀之間保持不變, Flutter 的輕量級 Widget 通知框架在狀態之間修改 RenderObjects, 而 Flutter Framework 則負責處理其餘部分。
1、常規優化常規優化即針對 build() 進行優化,build() 方法中的性能問題一般有兩種:耗時操作和 Widget 堆疊。
1)、在 build() 方法中執行了耗時操作我們應該儘量避免在 build() 中執行耗時操作,因為 build() 會被頻繁地調用,尤其是當 Widget 重建的時候。
此外,我們不要在代碼中進行阻塞式操作,可以將文件讀取、數據庫操作、網絡請求等通過 Future 來轉換成異步方式來完成。
最後,對於 CPU 計算頻繁的操作,例如圖片壓縮,可以使用 isolate 來充分利用多核心 CPU。
isolate 作為 Flutter 中的多線程實現方式,之所以被稱之為 isolate(隔離),是因為每一個 isolate 都有一份單獨的內存。
Flutter 會運行一個事件循環,它會從事件隊列中取得最舊的事件,處理它,然後再返回下一個事件進行處理,依此類推,直到事件隊列清空為止。每當動作中斷時,線程就會等待下一個事件。
實質上,不僅僅是 isolate,所有的高級 API 都能夠應用於異步編程,例如 Futures、Streams、async 和 await,它們全部都是構建在這個簡單的事件循環之上。
而,async 和 await 實際上只是使用 futures 和 streams 的替代語法,它將代碼編寫形式從異步變為同步,主要用來幫助你編寫更清晰、簡潔的代碼。
此外,async 和 await 也能使用 try on catch finally 來進行異常處理,這能夠幫助你處理一些數據解析方面的異常。
2)、build() 方法中堆砌了大量的 Widget這將會導致三個問題:
所以,你需要控制 build 方法耗時,將 Widget 拆小,避免直接返回一個巨大的 Widget,這樣 Widget 會享有更細粒度的重建和復用。
3)、使用 Widget 而不是函數如果一個函數可以做同樣的事情,Flutter 就不會有 StatelessWidget ,使用 StatelessWidget 的最大好處在於:能儘量避免不必要的重建。總的來說,它的優勢有:
如果某一個實例已經用 const 定義好了,那麼其它地方再次使用 const 定義時,則會直接從常量池裡取,這樣便能夠節省 RAM。
5)、儘可能地使用 const 構造器當構建你自己的 Widget 或者使用 Flutter 的 Widget 時,這將會幫助 Flutter 僅僅去 rebuild 那些應當被更新的 Widget。
因此,你應該儘量多用 const 組件,這樣即使父組件更新了,子組件也不會重新進行 rebuild 操作。特別是針對一些長期不修改的組件,例如通用報錯組件和通用 loading 組件等。
6)、使用 nil 去替代 Container() 和 SizedBox()首先,你需要明白nil 僅僅是一個基礎的 Widget 元素 ,它的構建成本幾乎沒有。
在某些情況下,如果你不想顯示任何內容,且不能返回 null 的時候,你可能會返回類似 const SizedBox/Container 的 Widget,但是 SizedBox 會創建 RenderObject,而渲染樹中的 RenderObject 會帶來多餘的生命周期控制和額外的計算消耗,即便你沒有給 SizedBox 指定任何的參數。
下面,是我平時使用 nil 的一套方式:
//BESTtext!=null?Text(text):nilorif(text!=null)Text(text)text!=null?Text(text):constContainer()/SizedBox()7)、列表優化在構建大型網格或列表的時候,我們要儘量避免使用 ListView(children: [],) 或 GridView(children: [],)。
因為,在這種場景下,不管列表內容是否可見,會導致列表中所有的數據都會被一次性繪製出來,這種用法類似於 Android 的 ScrollView。
如果我們列表數據比較大的時候,建議使用 ListView 和 GridView 的 builder 方法,它們只會繪製可見的列表內容,類似於 Android 的 RecyclerView。
其實,本質上,就是對列表採用了懶加載而不是直接一次性創建所有的子 Widget,這樣視圖的初始化時間就減少了。
8)、針對於長列表,記得在 ListView 中使用 itemExtent。有時候當我們有一個很長的列表,想要用滾動條來大跳時,使用 itemExtent 就很重要了,它會幫助 Flutter 去計算 ListView 的滾動位置而不是計算每一個 Widget 的高度,與此同時,它能夠使滾動動畫有更好的性能。
9)、減少可摺疊 ListView 的構建時間針對於可摺疊的 ListView,未展開狀態時,設置其 itemCount 為 0,這樣 item 只會在展開狀態下才進行構建,以減少頁面第一次的打開構建時間。
10)、儘量不要為 Widget 設置半透明效果考慮用圖片的形式代替,這樣被遮擋的部分 Widget 區域就不需要繪製了。
除此之外,還有網絡請求預加載優化、抽取文本 Theme 等常規的優化方式就不贅述了。
2、深入優化1)、優化光柵線程所有的 Flutter 應用至少都會運行在兩個並行的線程上:UI 線程和 Raster 線程。
**UI 線程是你構建 Widgets 和運行應用邏輯的地方。**Raster 線程是 Flutter 用來柵格化你的應用的。它從 UI 線程獲取指令並將它們轉換為可以發送到圖形卡的內容。
在光柵線程中,會獲取圖片的字節,調整圖像的大小,應用透明度、混合模式、模糊等等,直到產生最後的圖形像素。然後,光柵線程會將其發送到圖形卡,繼而發送到屏幕上顯示。
使用 Flutter DevTools-Performance 進行檢測,步驟如下:
一個 element 是由 Widget 內部創建的,它的主要目的是,知道對應的 Widget 在 Widget 樹中所處的位置。但是元素的創建是非常昂貴的,通過 Keys(ValueKeys 和 GlobalKeys),我們可以去重複使用它們。
GlobalKey 與 ValueKey 的區別?
GlobalKey 是全局使用的 key,在跨小部件的場景時,你就可以使用它去刷新其它小部件。但,它是很昂貴的,如果你不需要訪問 BuildContext、Element 和 State,應該儘量使用 LocalKey。
而 ValueKey 和 ObjectKey、UniqueKey 一樣都歸屬於局部使用的 LocalKey,無法跨容器使用,ValueKey 比較的是 Widget 的值,而 ObjectKey 比較的是對象的 key,UniqueKey 則每次都會生成一個不同的值。
元素的生命周期為了去改善性能,你需要去儘可能讓 Widget 使用 Activie 和 Update 操作,並且儘量避免讓 Widget觸發 UnMount 和 Mount。而使用 GlobayKeys 和 ValueKey 則能做到這一點:
///1、給MaterialApp指定GlobalKeysMaterialApp(key:global,home:child,);///2、通過把ValueKey分配到正在被卸載的根Widget,你就能夠///減少 Widget 的平均構建時間。Widgetbuild(BuildContextcontext){returnColumn(children:[value?constSizedBox(key:ValueKey('SizedBox')):constPlaceholder(key:ValueKey('Placeholder')),GestureDetector(key:ValueKey('GestureDetector'),onTap:(){setState((){value=!value;});},child:Container(width:100,height:100,color:Colors.red,),),!value?constSizedBox(key:ValueKey('SizedBox')):constPlaceholder(key:ValueKey('Placeholder')),],);}如何知道哪些 Widget 會被 Update,哪些 Widget會被 UnMount?
只有 build 直接 return 的那個根 Widget 會自動更新,其它都有可能被 UnMount,因此都需要給其分配 ValueKey。
為什麼沒有給 Container 分配 ValueKey?
因為 Container 是 GestureDetector 的一個子 Widget,所以當給 GestureDetector 使用 ValueKey 去實現復用更新時,Container 也能被自動更新。
優化效果優化前:

優化後:

可以看到,平均構建時間由 5.5ms 減少到 1.6ms,優化效果還是很明顯的。
優勢大幅度減少 Widget的平均構建時間。
缺點注意📢:在大部分場景下,Flutter 的性能都是足夠的,不需要這麼細緻的優化,只有當產生了視覺上的問題,例如卡頓時才需要去分析優化。
四、啟動速度優化1、Flutter 引擎預加載使用它可以達到頁面秒開的一個效果,具體實現為:
在 HIFlutterCacheManager 類中定義一個 preLoad 方法,使用 Looper.myQueue().addIdleHandler 添加一個 idelHandler,當 CPU 空閒時會回調 queueIdle 方法,在這個方法裡,你就可以去初始化 FlutterEngine,並把它緩存到集合中。
預加載完成之後,你就可以通過 HIFlutterCacheManager 類的 getCachedFlutterEngine 方法從集合中獲取到緩存好的引擎。
2、Dart VM 預熱對於 Native + Flutter 的混合場景,如果不想使用引擎預加載的方式,那麼要提升 Flutter 的啟動速度也可以通 過Dart VM 預熱來完成,這種方式會提升一定的 Flutter 引擎加載速度,但整體對啟動速度的提升沒有預加載引擎提升的那麼多。

無論是引擎預加載還是 Dart VM 預熱都是有一定的內存成本的,如果 App 內存壓力不大,並且預判用戶接下來會訪問 Flutter 業務,那麼使用這個優化就能帶來很好的價值;反之,則可能造成資源浪費,意義不大。
五、內存優化1、const 實例化優勢const 對象只會創建一個編譯時的常量值。在代碼被加載進 Dart Vm 時,在編譯時會存儲在一個特殊的查詢表里,由於 flutter 採用了 AoT 編譯,const + values 的方式會提供一些小的性能優勢。例如:const Color() 僅僅只分配一次內存給當前實例。
應用場景Color()、GlobayKey() 等等。
2、識別出消耗多餘內存的圖片Flutter Inspector:點擊 「Invert Oversized Images」,它會識別出那些解碼大小超過展示大小的圖片,並且系統會將其倒置,這些你就能更容易在 App 頁面中找到它。

針對這些圖片,你可以指定 cacheWidth 和 cacheHeight 為展示大小,這樣可以讓 flutter 引擎以指定大小解析圖片,減少內存消耗。
3、針對 ListView item 中有 image 的情況來優化內存ListView 不能夠殺死那些在屏幕可視範圍之外的那些 item,如果 item 使用了高分辨率的圖片,那麼它將會消耗非常多的內存。
換言之,ListView 在默認情況下會在整個滑動/不滑動的過程中讓子 Widget 保持活動狀態,這一點是通過 AutomaticKeepAlive 來保證,在默認情況下,每個子 Widget 都會被這個 Widget 包裹,以使被包裹的子 Widget 保持活躍。
其次,如果用戶向後滾動,則不會再次重新繪製子 Widget,這一點是通過 RepaintBoundaries 來保證,在默認情況下,每個子 Widget 都會被這個 Widget 包裹,它會讓被包裹的子 Widget 僅僅繪製一次,以此獲得更高的性能。
但,這樣的問題在於,如果加載大量的圖片,則會消耗大量的內存,最終可能使 App 崩潰。
解決方案通過將這兩個選項置為 false 來禁用它們,這樣不可見的子元素就會被自動處理和 GC。
ListView.builder(...addAutomaticKeepAlives:false(truebydefault)addRepaintBoundaries:false(truebydefault));由於重新繪製子元素和管理狀態等操作會占用更多的 CPU 和 GPU 資源,但是它能夠解決你 App 的內存問題,並且會得到一個高性能的視圖列表。
六、包體積優化1、圖片優化對圖片壓縮或使用在線的網絡圖片。
2、移除冗餘的二三庫隨着業務的增加,項目中會引入越來越多的二三方庫,其中有不少是功能重複的,甚至是已經不再使用的。移除不再使用的和將相同功能的庫進行合併可以進一步減少包體積。
3、啟用代碼縮減和資源縮減打開 minifyEnabled 和 shrinkResources,構建出來的 release 包會減少 10% 左右的大小,甚至更多。
4、構建單 ABI 架構的包目前手機市場上,x86 / x86_64/armeabi/mips / mips6 的占有量很少,arm64-v8a 作為最新一代架構,是目前的主流,而 armeabi-v7a 只存在少部分的老舊手機中。
所以,為了進一步優化包大小,你可以構建出單一架構的安裝包,在 Flutter 中可以通過以下方式來構建出單一架構的安裝包:
cd<flutter應用的android目錄>flutterbuildapk--split-per-abi如果想進一步壓縮包體積可將 so 進行動態下發,將 so 放在遠端進行動態加載,不僅能進一步減少包體積也可以實現代碼的熱修復和動態加載。
七、總結在本篇文章中,我主要從以下 六個方面 講解了 Flutter 性能優化相關的知識:
在近一年實踐 Flutter 的過程中,越發發現一個人真正應該具備的核心能力應該是你的思考能力。
思考能力,包括結構化思考/系統性思考/遷移思考/層級思考/逆向思考/多元思考等,使用這些思考能力分析問題時能快速地把握住問題的本質,在本質上做功夫,才是王道,才是真的 yyds。
END
參考鏈接:
14、Splitting widgets to methods is an antipattern
16、How to improve the performance of your Flutter app
17、nil: ^1.1.1