flutter_deer這個項目開源也近一年了,目前收穫了3100+的star,這無疑是對這個項目的最大承認。雖然從功能和UI看來和一年前的沒什麼區別。不過這期間我不斷在優化它,但願它的性能和體驗愈來愈好。這篇集中整理了deer在UI流暢上的優化細節,以實踐爲主,源碼爲輔。分享出來,但願對你有所啓發和幫助。html
既然要優化,那麼首先就要掌握定位問題、分析性能問題的方法,這樣才能夠對比優化先後的效果。具體方法這裏我就不詳細介紹了,能夠參考官方文檔,或是看這個視頻:Flutter 的性能測試和理論。java
在官方文檔中,性能分析須要確保使用真機並在profile
模式下運行。不過咱們可使用debug
模式來尋找卡頓,由於我以爲它能夠放大你的「問題」。git
下面正式進入正題。(爲了顯得口語化一點,我會將Flutter的構建(build
)用「刷新」表示。本篇源碼基於Flutter SDK版本 1.17.0
)github
咱們使用setState
方法就能夠輕鬆刷新頁面,可是要盡力控制刷新範圍。我舉一個例子:json
在註冊帳戶時,一般須要獲取驗證碼。這時會有一個倒計時功能,那麼咱們就須要每隔一秒刷新一下這個倒計時數字並顯示出來。canvas
若是這個倒計時的邏輯處理你放在了註冊頁面,那麼每當setState
時都是一整個頁面的刷新。而這整頁刷新顯然是沒必要要的。而它並不會讓你感知到卡頓,因此也不易發現,api
解決方法就是將這個倒計時的按鈕單獨封裝到一個StatefulWidget
,在這個StatefulWidget
中使用setState
刷新,控制刷新範圍。緩存
一樣的,你也可使用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的使用。**舉例**:deer中的訂單列表Item中有三個按鈕,因此一開始就用`FlatButton`實現了,結果發現頁面滑動時有點卡頓。就用`timeline`檢測了一下:
發現最多的時候一個FlatButton
就用了1.5ms,平均一個1ms。可是由於一屏通常顯示3個Item,這累積起來不卡頓纔怪。緣由呢也是FlatButton
這個Widget功能過多,層級複雜,致使了Widget build耗時。
那麼就用GestureDetector
+ Container
+ Text
本身去實現一個這樣的按鈕去替換。再次看下效果:
修改後,build所用時間大大的減小了(平均0.3ms)。能夠看到層級也簡單了不少。因此使用`FlatButton`沒有問題,可是**要注意它的複雜度,合理使用**。
StatelessWidget
,而不是用StatefulWidget
。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。好了,下個月見~