Flutter性能優化實踐 —— UI篇

奔跑的鹿

1.前言

flutter_deer這個項目開源也近一年了,目前收穫了3100+的star,這無疑是對這個項目的最大承認。雖然從功能和UI看來和一年前的沒什麼區別。不過這期間我不斷在優化它,但願它的性能和體驗愈來愈好。這篇集中整理了deer在UI流暢上的優化細節,以實踐爲主,源碼爲輔。分享出來,但願對你有所啓發和幫助。html

既然要優化,那麼首先就要掌握定位問題、分析性能問題的方法,這樣才能夠對比優化先後的效果。具體方法這裏我就不詳細介紹了,能夠參考官方文檔,或是看這個視頻:Flutter 的性能測試和理論java

在官方文檔中,性能分析須要確保使用真機並在profile模式下運行。不過咱們可使用debug模式來尋找卡頓,由於我以爲它能夠放大你的「問題」。git

下面正式進入正題。(爲了顯得口語化一點,我會將Flutter的構建(build)用「刷新」表示。本篇源碼基於Flutter SDK版本 1.17.0github

2.控制刷新範圍

咱們使用setState方法就能夠輕鬆刷新頁面,可是要盡力控制刷新範圍。我舉一個例子:json

註冊帳戶頁
在註冊帳戶時,一般須要獲取驗證碼。這時會有一個倒計時功能,那麼咱們就須要每隔一秒刷新一下這個倒計時數字並顯示出來。

若是這個倒計時的邏輯處理你放在了註冊頁面,那麼每當setState時都是一整個頁面的刷新。而這整頁刷新顯然是沒必要要的。而它並不會讓你感知到卡頓,因此也不易發現,canvas

解決方法就是將這個倒計時的按鈕單獨封裝到一個StatefulWidget,在這個StatefulWidget中使用setState刷新,控制刷新範圍。api

一樣的,你也可使用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以前執行再傳入或是放在AnimatedBuilderchild中。

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.builderitemBuilder來構建item時,可不要預構建Widget了。相似的Widget還有PageView.builderGridView.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,同時修改ScrollUpdateNotificationScrollEndNotification。這樣就自定義了咱們的滑動監聽事件,經過錯峯加載保證了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)致使的,可能包括對SkiasaveLayerclipPath等耗時函數調用。

saveLayer會在GPU中分配一塊新的繪圖緩衝區(離屏渲染),切換繪圖目標,這些操做是在GPU中很是的耗時,尤爲在比較老的設備上。

使用clipPath會影響接下來每個繪圖指令。尤爲這個Path比較複雜的時候都須要和這個複雜的Path作相交操做,並且把Path以外的部分剔除掉。

在Flutter源碼中搜索canvas.saveLayer能夠發現一些須要注意的:

  • Textoverflow屬性爲TextOverflow.fade,且文字超出範圍時,會調用saveLayer

  • 使用Clip.antiAliasWithSaveLayer做爲剪切行爲時,會調用saveLayer(聽說早期Flutter版本中大都使用這一方式)。建議優先使用Clip.hardEdgeClip.antiAlias。這部分屬性通常在ClipRectClipOvalClipPath等裁剪功能Widget中用到。

  • 修改RawChipisEnabled屬性,觸發enableAnimation動畫時,會調用saveLayer

而對於clipPath,相對沒有saveLayer耗時。但須要注意對於裁剪行爲。優先考慮使用BoxDecorationborderRadius屬性來解決。好比InkwellborderRadius屬性就能夠裁剪它的外形,若是borderRadius實在不能知足,可使用customBorder屬性(使用clipPath)。

到這裏你可能會很慶幸,你說的這些我都沒有用到。其實。。。

發現事情不簡單
除過上面所說的顯式調用耗時方法,還存在部分隱式調用的( OpacityShaderMaskColorFilterPhysicalModelBackdropFilter等)。

好比在Opacity的文檔註釋中有如下描述:

該類將其子組件繪製到中間緩衝區中,而後將子組件混合回透明的場景中。 對於0和1之外的不透明度值,該類相對昂貴,由於它須要將子組件繪製到中間緩衝區中。對於opacity爲0,根本不繪製子組件。對於opacity爲1.0,將當即繪製子組件,而不使用中間緩衝區。

因此使用Opacityopacity屬性不爲0和1時,須要注意。若是真的須要使用它,能夠先看可否使用替換方案:

  • 若是有透明度變化需求,可使用AnimatedOpacity實現。

  • 對於透明圖像,能夠修改color屬性實現,而不是包裹一層Opacity。例如:

Image.network(
  'https://xxxx.jpeg',
  color: Color.fromRGBO(255, 255, 255, 0.5),
  colorBlendMode: BlendMode.modulate
)
複製代碼

PS:雖然看似許多Widget存在必定性能問題,可是具體場景具體對待。這裏只是提醒你們使用前三思,儘可能尋找替代方案,並非徹底不讓使用。就好比用BackdropFilter實現高斯模糊效果,CupertinoAlertDialogCupertinoActionSheet就用到了它,咱們不可能所以就不使用了。

雖然有了上述的經驗,可是監測發現問題的手段仍是須要掌握,下面簡單說明一下,詳細的能夠看深刻了解 Flutter 的高性能圖形渲染

MaterialApp中添加 checkerboardOffscreenLayers: true 來檢查是否使用了 saveLayer(包含顯式或隱式調用),若是使用了會有一個"棋盤網格"覆蓋在上方。不過很遺憾,目前我只發現對於BackdropFilter的使用能夠經過這個直接檢查到。下圖是使用CupertinoActionSheet的效果:

CupertinoActionSheet
既然 checkerboardOffscreenLayers受限,那麼可使用 timeline查看 FlutterSkia 的調用。這裏以 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的使用。

    訂單列表Item
    舉例: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計算。好比ListViewitemExtent使用。

  • 儘可能避免更改子樹的深度或更改子樹中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。好了,下個月見~

8.參考

相關文章
相關標籤/搜索