Flutter性能優化實踐 —— UI篇

奔跑的鹿

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之前執行再傳入或是放在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體驗會更上一個臺階。

這篇斷斷續續寫了一週😭😭😭,暫時就整理和想到了這麼多,後面有補充也會更新在這裏。如果你也有好的優化實踐,歡迎討論!

最後,可以點贊收藏支持一波!同時也多多支持一下我的Flutter開源項目flutter_deer。好了,下個月見~

8.參考

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章