flutter_deer這個項目開源也近一年了,目前收穫了3100+的star,這無疑是對這個項目的最大承認。雖然從功能和UI看來和一年前的沒什麼區別。不過這期間我不斷在優化它,但願它的性能和體驗愈來愈好。這篇集中整理了deer在UI流暢上的優化細節,以實踐爲主,源碼爲輔。分享出來,但願對你有所啓發和幫助。html
既然要優化,那麼首先就要掌握定位問題、分析性能問題的方法,這樣才能夠對比優化先後的效果。具體方法這裏我就不詳細介紹了,能夠參考官方文檔,或是看這個視頻:Flutter 的性能測試和理論。java
在官方文檔中,性能分析須要確保使用真機並在profile
模式下運行。不過咱們可使用debug
模式來尋找卡頓,由於我以爲它能夠放大你的「問題」。git
下面正式進入正題。(爲了顯得口語化一點,我會將Flutter的構建(build
)用「刷新」表示。本篇源碼基於Flutter SDK版本 1.17.0
)github
咱們使用setState
方法就能夠輕鬆刷新頁面,可是要盡力控制刷新範圍。我舉一個例子:json
若是這個倒計時的邏輯處理你放在了註冊頁面,那麼每當setState
時都是一整個頁面的刷新。而這整頁刷新顯然是沒必要要的。而它並不會讓你感知到卡頓,因此也不易發現,canvas
解決方法就是將這個倒計時的按鈕單獨封裝到一個StatefulWidget
,在這個StatefulWidget
中使用setState
刷新,控制刷新範圍。api
一樣的,你也可使用provider等狀態管理框架來實現局部刷新。精準控制你的刷新範圍,千萬不要setState
刷新一把梭。緩存
比起控制刷新範圍,控制刷新次數(避免無效刷新)甚至更加劇要。這部分我整理了四點,下面逐一說明一下。安全
仍是上面的註冊場景,這裏須要咱們輸入的內容知足條件才能夠點擊註冊
按鈕。性能優化
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
是否進行重繪。
動畫的使用在實際開發中很常見,可是一旦使用不當也會形成沒必要要的刷新,甚至會帶來卡頓。
舉一個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這個我以前有詳細介紹過,能夠直接查看:說說Flutter中的RepaintBoundary,這裏我就不重複說了。合理的使用RepaintBoundary
能夠減小沒必要要的刷新提高性能。
推薦使用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的重要改動中就有一條:在高速滾動時推遲圖像解碼。這也是運用了錯峯加載的策略。
避免將一些耗時計算放在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代碼部分只是一層封裝,真正的請求是由底層的操做系統去實現的。
上面的幾點大都是關於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
方法以及調用次數。利用這個分析工具,能夠詳細瞭解頁面的繪圖過程,便於咱們去除沒必要要的繪製部分,提高性能。
注意FlatButton
等複雜Widget的使用。
FlatButton
實現了,結果發現頁面滑動時有點卡頓。就用timeline
檢測了一下:
發現最多的時候一個FlatButton
就用了1.5ms,平均一個1ms。可是由於一屏通常顯示3個Item,這累積起來不卡頓纔怪。緣由呢也是FlatButton
這個Widget功能過多,層級複雜,致使了Widget build耗時。 那麼就用GestureDetector
+ Container
+ Text
本身去實現一個這樣的按鈕去替換。再次看下效果:
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體驗會更上一個臺階。
這篇斷斷續續寫了一週:sob::sob::sob:,暫時就整理和想到了這麼多,後面有補充也會更新在這裏。若是你也有好的優化實踐,歡迎討論!
最後,能夠點贊收藏支持一波!同時也多多支持一下個人Flutter開源項目flutter_deer。好了,下個月見~