1.前言
flutter_deer這個項目開源也近一年了,目前收穫了3100+的star,這無疑是對這個項目的最大認可。雖然從功能和UI看來和一年前的沒什麼區別。不過這期間我不斷在優化它,希望它的性能和體驗越來越好。這篇集中整理了deer在UI流暢上的優化細節,以實踐爲主,源碼爲輔。分享出來,希望對你有所啓發和幫助。
既然要優化,那麼首先就要掌握定位問題、分析性能問題的方法,這樣纔可以對比優化前後的效果。具體方法這裏我就不詳細介紹了,可以參考官方文檔,或是看這個視頻:Flutter 的性能測試和理論。
[Google Flutter 團隊出品] Flutter 的性能測試和理論 (GDD China ’18)
在官方文檔中,性能分析需要確保使用真機並在profile
模式下運行。不過我們可以使用debug
模式來尋找卡頓,因爲我覺得它可以放大你的“問題”。
下面正式進入正題。(爲了顯得口語化一點,我會將Flutter的構建(build
)用“刷新”表示。本篇源碼基於Flutter SDK版本 1.17.0
)
2.控制刷新範圍
我們使用setState
方法就可以輕鬆刷新頁面,但是要盡力控制刷新範圍。我舉一個例子:
在註冊賬戶時,通常需要獲取驗證碼。這時會有一個倒計時功能,那麼我們就需要每隔一秒刷新一下這個倒計時數字並顯示出來。
如果這個倒計時的邏輯處理你放在了註冊頁面,那麼每當setState
時都是一整個頁面的刷新。而這整頁刷新顯然是不必要的。而它並不會讓你感知到卡頓,所以也不易發現,
解決方法就是將這個倒計時的按鈕單獨封裝到一個StatefulWidget
,在這個StatefulWidget
中使用setState
刷新,控制刷新範圍。
同樣的,你也可以使用provider等狀態管理框架來實現局部刷新。精準控制你的刷新範圍,千萬不要setState
刷新一把梭。
3.控制刷新次數
比起控制刷新範圍,控制刷新次數(避免無效刷新)甚至更加重要。這部分我整理了四點,下面逐一說明一下。
需求控制
還是上面的註冊場景,這裏需要我們輸入的內容滿足條件纔可以點擊註冊
按鈕。
那麼我們的做法就是監聽TextField
的文字輸入,每次輸入時判斷是否滿足條件,更新按鈕是否可點擊的狀態。代碼大致如下:
bool _clickable = false;
void _verify() {
String phone = _phoneController.text;
String vCode = _vCodeController.text;
String password = _passwordController.text;
_clickable = true;
if (phone.isEmpty || phone.length < 11) {
_clickable = false;
}
if (vCode.isEmpty || vCode.length < 6) {
_clickable = false;
}
if (password.isEmpty || password.length < 6) {
_clickable = false;
}
setState(() {
});
}
MyButton(
onPressed: _clickable ? _register : null,
text: '註冊',
)
其實這裏可以優化一下。因爲現在的每次輸入都必定刷新,我們可以在_clickable
參數有變化時再刷新,避免無效的刷新。優化的代碼如下:
void _verify() {
String phone = _phoneController.text;
String vCode = _vCodeController.text;
String password = _passwordController.text;
bool clickable = true;
if (phone.isEmpty || phone.length < 11) {
clickable = false;
}
if (vCode.isEmpty || vCode.length < 6) {
clickable = false;
}
if (password.isEmpty || password.length < 6) {
clickable = false;
}
/// 狀態不一致時刷新
if (clickable != _clickable) {
setState(() {
_clickable = clickable;
});
}
}
就這樣一個簡單的處理,試想一下可以減少多少次的刷新。
類似的,在CustomPainter
中有個shouldRepaint
的重寫方法,我們可以根據需求控制CustomPainter
是否進行重繪。
預構建Widget
動畫的使用在實際開發中很常見,但是一旦使用不當也會造成不必要的刷新,甚至會帶來卡頓。
舉一個deer中的例子,商品列表頁中有一個商品操作菜單的呼入呼出動畫(這裏就不談具體的實現效果了,有興趣的可以去看源碼)。一開始的寫法如下:
AnimatedBuilder(
animation: animation,
builder:(_, __) {
return MenuReveal(
revealPercent: animation.value,
child: _buildGoodsMenu(context),
);
}
)
效果如下:
這個動畫看起來還是比較流暢的。頂部的性能圖表(Performance Overlay
)中,UI花費的時間平均在7.2ms/frame。比起16ms的安全標準來說已經非常好了。
但是我們來看看構建次數(呼入呼出各一次):
這裏仔細看就有點問題,動畫執行時我們只希望可變的部分刷新(MenuReveal),但實際上連菜單中的按鈕也一起刷新構建了。
那麼優化的方法就是預構建菜單中的按鈕,將_buildGoodsMenu(context)
方法放在AnimatedBuilder
之前執行再傳入或是放在AnimatedBuilder
的child
中。
AnimatedBuilder(
animation: animation,
child: _buildGoodsMenuContent(context), // <-----放在這裏
builder:(_, child) {
return MenuReveal(
revealPercent: animation.value,
child: child // <----這裏使用
);
}
)
效果如下:
可以看到UI線程花費的時間在6ms/frame左右。這個提升還是比較大的(16%左右),雖然對於用戶來說是無感知的。
再次看一下構建次數:
那麼提升的原因也就找到了,因爲避免了不必要的構建。所以針對這類不依賴於動畫的子Widget,預構建它可以顯著提高性能。
類似這種builder/child的模式還有不少,你可以多多留意一下。
複用
- 儘量使用
const
來定義一些不變的Widget,這相當於緩存一個Widget並複用它。
我之前看到過一篇博客,作者測試一個頁面上構建1000個重複圖標,結果使用const
構造函數的,FPS大約高8.4%,內存使用量降低約20%。
當然作者也說了,實際一個頁面上有1000個Widget也不現實。其實說這個點的原因也是希望大家能養成一個好習慣。
- 添加
GlobalKey
也能複用widget。這個使用場景相對較少,可以瞭解一下。相關內容鏈接:說說Flutter中的Key
RepaintBoundary
這個我之前有詳細介紹過,可以直接查看:說說Flutter中的RepaintBoundary,這裏我就不重複說了。合理的使用RepaintBoundary
可以減少不必要的刷新提升性能。
4.加載策略
按需加載
推薦使用ListView.builder
來動態實現列表,而不是直接使用ListView
靜態創建。注意這裏在使用ListView.builder
的itemBuilder
來構建item時,可不要預構建Widget了。類似的Widget還有PageView.builder
和 GridView.builder
。
PS:按需加載是一種策略,並不是僅僅依靠這幾個類型的Widget。比如之前阿里AliFlutter的分享中,就有提到列表中加載圖片的優化。通過判斷圖片的在屏和離屏,來合理回收圖片,這樣減小了內存的波動,同樣也可以帶來性能的提升。
錯峯加載
錯峯加載的目的是爲了避免因同一時間的大量構建,而產生卡頓現象。這裏我舉一個例子:
在使用PageView.builder
這個Widget時,我發現在左右滑動切換頁面時會有卡頓的現象。使用timeline
來分析發現兩個問題,一是切換的頁面比較複雜,比較耗時。二是頁面構建的時間點在滑動中。
頁面複雜的問題我進行了一定的優化,雖然有效果,但還是有卡頓發生。那麼只能針對第二點再進行優化,我們先看一下PageView.
相關源碼:
return NotificationListener<ScrollNotification>(
onNotification: (ScrollNotification notification) {
if (notification.depth == 0 && widget.onPageChanged != null && notification is ScrollUpdateNotification) {
final PageMetrics metrics = notification.metrics;
final int currentPage = metrics.page.round();
if (currentPage != _lastReportedPage) {
_lastReportedPage = currentPage;
widget.onPageChanged(currentPage);
}
}
return false;
},
child: Scrollable(),
);
代碼很簡單,如果我們設置了onPageChanged
的監聽,那麼在滑動中(ScrollUpdateNotification
)計算當前頁的頁碼並返回(round方法,四捨五入)。所以在滑動到一半的時候,onPageChanged
就會回調結果,我因爲在這裏觸發了頁面的刷新代碼,導致了卡頓的發生。
其實在我熟知的安卓中,默認行爲都是在滑動結束後纔去加載頁面數據。所以按照這個思路處理,調整一下加載策略。
修改代碼如下:
NotificationListener<ScrollNotification>(
onNotification: (ScrollNotification notification) {
if (notification.depth == 0 && notification is ScrollEndNotification) {
final PageMetrics metrics = notification.metrics;
final int currentPage = metrics.page.round();
if (currentPage != _lastReportedPage) {
_lastReportedPage = currentPage;
_onPageChange(currentPage);
}
}
return false;
},
child: PageView.builder(),
)
我們在PageView.builder
上添加一個NotificationListener
,同時修改ScrollUpdateNotification
爲ScrollEndNotification
。這樣就自定義了滑動監聽事件,通過錯峯加載保證了UI的流暢。
PS:在Flutter 1.17的重要改動中就有一條:在高速滾動時推遲圖像解碼。這也是用了錯峯加載的思路。
5.耗時計算
避免將一些耗時計算放在UI線程,我們可以把耗時計算放到Isolate
去執行(多線程)。
舉一個Flutter源碼中的例子:
Future<String> loadString(String key, { bool cache = true }) async {
final ByteData data = await load(key);
if (data == null)
throw FlutterError('Unable to load asset: $key');
if (data.lengthInBytes < 10 * 1024) {
// 10KB takes about 3ms to parse on a Pixel 2 XL.
// See: https://github.com/dart-lang/sdk/issues/31954
return utf8.decode(data.buffer.asUint8List());
}
return compute(_utf8decode, data, debugLabel: 'UTF8 decode for "$key"');
}
static String _utf8decode(ByteData data) {
return utf8.decode(data.buffer.asUint8List());
}
因爲utf8.decode
方法處理10KB數據大約需要3ms的時間(手機Pixel 2 XL),所以在超過10KB的數據就使用了compute
方法將耗時計算放到Isolate
。這裏根據數據大小選擇不同的方式,是因爲Isolate
的創建使用也是有空間和時間上的消耗,所以Isolate雖好,可不要濫用哦!
同樣的,我們項目中的json解析操作也可以這樣處理,以保證在一些性能較差的機子上可以不造成UI的卡頓。具體實現可以看:在後臺處理 JSON 數據解析
這裏我簡單說明一下原因:Flutter應用中的Dart
代碼執行在UI Runner
中,而Dart
是單線程的,我們平時使用的異步任務Future
都是在這個單線程的Event Queue
之中,通過Event Loop
來按順序執行。(這個單線程模型和js是一樣的)
也就是說即使我們是異步執行這段計算代碼,但由於這段代碼耗時過長,那麼這段時間內線程沒有空閒(可以理解爲任務代碼都是插空執行?),也就是線程過載了。導致期間Widget的layout等計算遲遲無法執行,那麼時間越長,卡頓的現象就越明顯。
因此使用Isolate
來處理耗時計算,利用多線程來做到代碼的並行執行。
可能這裏你會有疑問,那我網絡請求也是Dart代碼而且有時也挺耗時的,怎麼不見頁面卡頓?其實這是因爲網絡請求在io線程,不會佔用ui線程。且實際的網絡請求也並不是在Dart層做的,Dart代碼部分只是一層封裝,真正的請求是由底層的操作系統去實現的。
6.GPU
上面的幾點大都是關於UI線程的優化。其實在觀察Performance Overlay
時,我們發現有時UI很流暢,但是GPU卻會很耗時。這裏主要是繪製上的壓力比較大(GPU Runner
)導致的,可能包括對Skia
的saveLayer
、clipPath
等耗時函數調用。
saveLayer
會在GPU中分配一塊新的繪圖緩衝區(離屏渲染),切換繪圖目標,這些操作是在GPU中非常的耗時,尤其在比較老的設備上。
使用
clipPath
會影響接下來每一個繪圖指令。尤其這個Path比較複雜的時候都需要和這個複雜的Path做相交操作,而且把Path之外的部分剔除掉。
在Flutter源碼中搜索canvas.saveLayer
可以發現一些需要注意的:
-
當
Text
的overflow
屬性爲TextOverflow.fade
,且文字超出範圍時,會調用saveLayer
。 -
使用
Clip.antiAliasWithSaveLayer
作爲剪切行爲時,會調用saveLayer
(據說早期Flutter版本中大都使用這一方式)。建議優先使用Clip.hardEdge
和Clip.antiAlias
。這部分屬性一般在ClipRect
、ClipOval
和ClipPath
等裁剪功能Widget中用到。 -
修改
RawChip
的isEnabled
屬性,觸發enableAnimation
動畫時,會調用saveLayer
。
而對於clipPath
,相對沒有saveLayer
耗時。但需要注意對於裁剪行爲。優先考慮使用BoxDecoration
的borderRadius
屬性來解決。比如Inkwell
的borderRadius
屬性就可以裁剪它的外形,如果borderRadius
實在不能滿足,可以使用customBorder
屬性(使用clipPath
)。
到這裏你可能會很慶幸,你說的這些我都沒有用到。其實。。。
除過上面所說的顯式調用耗時方法,還存在部分隱式調用的(Opacity
、ShaderMask
、ColorFilter
、PhysicalModel
、BackdropFilter
等)。
比如在Opacity
的文檔註釋中有以下描述:
該類將其子組件繪製到中間緩衝區中,然後將子組件混合回透明的場景中。 對於0和1以外的不透明度值,該類相對昂貴,因爲它需要將子組件繪製到中間緩衝區中。對於
opacity
爲0,根本不繪製子組件。對於opacity
爲1.0,將立即繪製子組件,而不使用中間緩衝區。
所以使用Opacity
且opacity
屬性不爲0和1時,需要注意。如果真的需要使用它,可以先看能否使用替換方案:
-
如果有透明度變化需求,可以使用
AnimatedOpacity
實現。 -
對於透明圖像,可以修改
color
屬性實現,而不是包裹一層Opacity
。例如:
Image.network(
'https://xxxx.jpeg',
color: Color.fromRGBO(255, 255, 255, 0.5),
colorBlendMode: BlendMode.modulate
)
PS:雖然看似許多Widget存在一定性能問題,但是具體場景具體對待。這裏只是提醒大家使用前三思,儘量尋找替代方案,並不是完全不讓使用。就比如用BackdropFilter
實現高斯模糊效果,CupertinoAlertDialog
和CupertinoActionSheet
就用到了它,我們不可能因此就不使用了。
雖然有了上述的經驗,但是監測發現問題的手段還是需要掌握,下面簡單說明一下,詳細的可以看深入瞭解 Flutter 的高性能圖形渲染。
在MaterialApp
中添加 checkerboardOffscreenLayers: true
來檢查是否使用了 saveLayer
(包含顯式或隱式調用),如果使用了會有一個"棋盤網格"覆蓋在上方。不過很遺憾,目前我只發現對於BackdropFilter
的使用可以通過這個直接檢查到。下圖是使用CupertinoActionSheet
的效果:
既然checkerboardOffscreenLayers
受限,那麼可以使用timeline
查看Flutter
對 Skia
的調用。這裏以CupertinoActionSheet
的彈出過程舉例。
首先profile
模式運行:
flutter run --profile --trace-skia
安裝成功後會有“觀測臺”的鏈接:
timeline
表現如下:
圖中的Sk
開頭就是Skia
的函數, 可以看到調用了saveLayer
方法。不過這樣看起來並不直觀,顯得也很複雜。所以可以通過捕捉 SKPicture
來分析每一條繪圖指令。
繼續運行以下命令:
flutter screenshot --type=skia --observatory-uri=uri
這個uri就是“觀測臺”的鏈接。
這裏會生成一個skp
格式的文件在你的項目根目錄,然後上傳文件到 https://debugger.skia.org/
(需fq)進行分析。
這個分析工具包含播放暫停逐條的繪圖指令、查看Clip區域、指令調用次數統計等強大的功能。
圖中可以看到調用了saveLayer
方法以及調用次數。利用這個分析工具,可以詳細瞭解頁面的繪圖過程,便於我們去除不必要的繪製部分,提升性能。
7.其他
-
注意
FlatButton
等複雜Widget的使用。
舉例:deer中的訂單列表Item中有三個按鈕,所以一開始就用FlatButton
實現了,結果發現頁面滑動時有點卡頓。就用timeline
檢測了一下:
發現最多的時候一個FlatButton
就用了1.5ms,平均一個1ms。但是因爲一屏一般顯示3個Item,這累積起來不卡頓纔怪。原因呢也是FlatButton
這個Widget功能過多,層級複雜,導致了Widget build耗時。那麼就用
GestureDetector
+Container
+Text
自己去實現一個這樣的按鈕去替換。再次看下效果:
修改後,build所用時間大大的減少了(平均0.3ms)。可以看到層級也簡單了很多。所以使用FlatButton
沒有問題,但是要注意它的複雜度,合理使用。 -
優先使用
StatelessWidget
,而不是用StatefulWidget
。 -
儘量給Widget指定大小,避免不必要的
Layout
計算。比如ListView
的itemExtent
使用。 -
儘量避免更改子樹的深度或更改子樹中Widget的類型。因爲這一操作會重新構建、佈局和繪製整個子樹。
如果需要更改深度,可以考慮給子樹的公共部分添加
GlobalKey
。如果需要修改Widget的類型,比如顯示隱藏的需求,可以使用
Visibility
。順便想一下下面這三種方式的區別:Column( children: <Widget>[ if (_visible) const Text('1'), _visible ? const Text('2') : const SizedBox.shrink(), Visibility( visible: _visible, child: const Text('3'), ), ], )
-
可以使用一些
Curves
曲線動畫(先快後慢)。這樣在相同的時間內,視覺上會比線性動畫顯得快,讓人覺得流暢。
前幾天Flutter 1.17.0穩定版也發佈了,這其中也看到了大量的性能優化,甚至Container
的一個color
實現都包含在內,相信未來Flutter體驗會更上一個臺階。
這篇斷斷續續寫了一週😭😭😭,暫時就整理和想到了這麼多,後面有補充也會更新在這裏。如果你也有好的優化實踐,歡迎討論!
最後,可以點贊收藏支持一波!同時也多多支持一下我的Flutter開源項目flutter_deer。好了,下個月見~