Flutter | 性能優化——如何避免應用 jank

前言

流暢的用戶體驗一直是每一位開發者的不斷追求,爲了讓本身的應用是否能給用戶帶來持續的高幀率渲染體驗,咱們天然想要極力避免發生 jank(卡頓,不流暢)。html

本文將會解釋爲何即便在 Flutter 高性能的渲染能力下,應用仍是可能會出現 jank,以及咱們應該如何處理這些狀況。這是 Flutter 性能分析系列的第一篇文章,後續將會持續剖析 Flutter 中渲染流程以及性能優化。數據庫

何時會產生 jank?

我見過許多開發者在剛上手了 Flutter 以後,嘗試開發了一些應用,然而並無取得比較好的性能表現。例如在長列表加載的時候可能會出現明顯卡頓的狀況(固然這並不常見)。當你對這種狀況沒有頭緒的時候,可能會誤覺得是 Flutter 的渲染還不夠高效,然而大機率是你的 姿式不對。咱們來看一個小例子。json

在屏幕中心有一個一直旋轉的 FlutterLogo,當咱們點擊按鈕後,開始計算 0 + 1 + ... +1000000000。這裏能夠很明顯的感覺到明顯的卡頓。爲何會出現這種狀況呢?api

Flutter Rendering Pipeline

Flutter 由 GPU 的 vsync 信號驅動,每一次信號都會走一個完整的 pipeline(咱們如今並不須要關心整個流程的具體細節),而一般咱們開發者會接觸到的部分就是使用 dart 代碼,通過 build -> layout -> paint 最後生成一個 layer,整個過程都在一個 UI 線程中完成。Flutter 須要在每秒60次,也就是 16.67 ms 經過 vsync 進行一次 pipline。性能優化

在 Android 中咱們是不能在 主線程(UI線程)中進行耗時操做的,若是作一些比較繁重的操做,好比網絡請求、數據庫操做等相關操做,就會致使 UI 線程卡住,觸發 ANR。因此咱們須要把這些操做放在子線程去作,經過 handler/looper/message queue 三板斧把結果傳給主線程。而 dart 天生是單線程模式,爲何咱們可以輕鬆的作這些任務,而不須要另開一個線程呢?網絡

熟悉 dart 的同窗確定瞭解 event loop 機制了,經過異步處理咱們能夠把一個方法在執行過程當中暫停,首先保證咱們的同步方法可以按時執行(這也是爲何 setState 中只能進行同步操做的緣故)。而整個 pipline 是一次同步的任務,因此異步任務就會暫停,等待 pipline 執行結束,這樣就不會由於進行耗時操做卡住 UI。多線程

可是單線程畢竟也有它的侷限,可是當咱們有一些比較重的同步處理任務,例如解析大量 json(這是一個同步操做),或是處理圖片這樣的操做,極可能處理時間會超過一個 vsync 時間,這樣 Flutter 就不能及時將 layer 送到 GPU 線程,致使應用 jank。負載均衡

在上面這個例子中,咱們經過計算 0 + 1 + ... +1000000000 來模擬一個耗時的 json 解析操做,因爲它是一個同步的行爲,因此它的計算不會被暫停。咱們這個複雜的計算任務耗時超過了一次 sync 時間,因此產生了明顯的 jank。異步

int doSomeHeavyWork() {
    int res = 0;
    for (int i = 0; i <= 1000000000; i++) {
      res += i;
    }
    return res;
  }
複製代碼

如何解決

既然 dart 單線程沒法解決這樣的問題,咱們很容易就會想到使用多線程解決這個問題。在 dart 中,它的線程概念被稱爲 isolate。async

它與咱們以前理解的 Thread 概念有所不一樣,各個 isolate 之間是沒法共享內存空間,isolate 之間有本身的 event loop。咱們只能經過 Port 傳遞消息,而後在另外一個 isolate 中處理而後將結果傳遞回來,這樣咱們的 UI 線程就有更多餘力處理 pipeline,而不會被卡住。更多概念性的描述請參考 isolate API文檔

建立一個 isolate

咱們能夠經過 Isolate.spawn 建立一個 isolate。

static Future<Isolate> spawn<T>(void entryPoint(T message),T message);
複製代碼

當咱們調用 Isolate.spawn 的時候,它將會返回一個對 isolate 的引用的 Future。咱們能夠經過這個 isolate 來控制建立出的 Isolate,例如 pause、resume、kill 等等。

  • entryPoint:這裏傳入咱們想要在其餘 isolate 中執行的方法,入參是一個任意類型的 message。entryPoint 只能是頂層方法或靜態方法,且返回值爲 void。
  • message:建立 Isolate 第一個調用方法的入參,能夠是任意值。

可是在此以前咱們必需要建立兩個 isolate 之間溝通的橋樑。

ReceivePort / SendPort

在兩個 isolate 之間,咱們必須經過 port 來傳遞 message。ReceivePort 與 SendPort 就像是一部單向通訊電話。ReceivePort 自帶一部 SendPort,當咱們建立 isolate 的時候,就把 ReceivePort 的 SendPort 丟給建立出來的 isolate。當新的 isolate 完成了計算任務時,經過這個 sendPort 去 send message。

static void _methodRunAnotherIsolate(dynamic message) {
    if (message is SendPort) {
      message.send('Isolate Created!');
    }
  }
複製代碼

這裏假設先有一個須要在其餘 isolate 中執行的方法,入參是一個 SendPort。須要注意的是,這裏的方法只能是頂層方法或靜態方法,因此咱們這裏使用了 static 修飾,並讓其變成一個私有方法("_")。它的返回值也只能是 void,你可能會問,那咱們如何得到結果呢?

還記得咱們剛纔建立的 ReceivePort 嗎。是的,如今咱們就須要監聽這個 ReceivePort 來得到 sendPort 傳遞的 message。

createIsolate() async {
    ReceivePort receivePort = ReceivePort();
    try {
    // create isolate
      isolate =
          await Isolate.spawn(_methodRunAnotherIsolate, receivePort.sendPort);
          
    // listen message from another isolate 
      receivePort.listen((dynamic message) {
          print(message.toString());
      });
    } catch (e) {
      print(e.toString());
    } finally {
      isolate.addOnExitListener(receivePort.sendPort,
          response: "isolate has been killed");
    }
    isolate?.kill();
  }
複製代碼

咱們先建立出 ReceivePort,而後在 Isolate.spawn 的時候將 receivePort.sendPort 做爲 message 傳入新的 isolate。

而後監聽 receivePort,並打印收聽到的 message。這裏須要注意的是,咱們須要手動調用 isolate?.kill() 來關閉這個 isolate。

輸出結果:

flutter: Isolate Created!

flutter: isolate has been killed

實際上這裏不寫 isolate?.kill() 也會在 gc 時自動銷燬 isolate。

這時候你可能會問,咱們的 entryPoint 只容許有一個入參,若是咱們想要執行的方法須要傳入其餘參數怎麼辦呢。

定義協議

其實很簡單,咱們定義一個協議就好了。好比像下面這樣咱們定義一個 SpawnMessageProtocol 做爲 message。

class SpawnMessageProtocol{
  final SendPort sendPort;
  final String url;
  SpawnMessageProtocol(this.sendPort, this.url);
}
複製代碼

協議中包含 SendPort 便可。

更方便的 Compute

剛纔咱們使用的 Isolate.spawn 建立 Isolate 天然會以爲太過複雜,有沒有一種更好的方式呢。實際上 Flutter 已經爲咱們封裝了一些實用方法,讓咱們可以更加天然地使用多線程進行處理。這裏咱們先建立一個須要在其餘 isolate 中運行的方法。

static int _doSomething(int i) {
    return i + 1;
  }
複製代碼

而後使用 compute 在另外一個 isolate 中執行該方法,並返回結果。

runComputeIsolate() async{
      int i = await compute(_doSomething, 8);
      print(i);
  }
複製代碼

僅僅一行代碼咱們就可以讓 _doSomething 運行在另外一個 isolate 中,並返回結果。這種方式對使用者來講幾乎沒有負擔,基本上和寫異步代碼是同樣的。

代價是什麼

對於咱們來講,實際上是把多線程當作一種計算資源來使用的。咱們能夠經過建立新的 isolate 計算 heavy work,從而減輕 UI 線程的負擔。可是這樣作的代價是什麼呢?

時間

一般來講,當咱們使用多線程計算的時候,整個計算的時間會比單線程要多,額外的耗時是什麼呢?

  • 建立 Isolate
  • Copy Message

當咱們按照上面的代碼執行一段多線程代碼時,經歷了 isolate 的建立以及銷燬過程。下面是一種咱們在解析 json 中這樣編寫代碼可能的方式。

static BSModel toBSModel(String json){}
  
  parsingModelList(List<String> jsonList) async{
    for(var model in jsonList){
      BSModel m = await compute(toBSModel, model);
    }
  }
複製代碼

在解析 json 的時候,咱們可能經過 compute 把解析任務放在新的 isolate 中完成,而後把值傳過來。這時候咱們會發現,整個解析會變得異常的慢。這是因爲咱們每次建立 BSModel 的時候都經歷了一次 isolate 的建立以及銷燬過程。這將會耗費約 50-150ms 的時間。

在這之中,咱們傳遞 data 也經歷了 Network -> Main Isolate -> New Isolate (result) -> Main Isolate,多出來兩次 copy 的操做。若是咱們是在 Main 線程以外的 isolate 下載的數據,那麼就能夠直接在該線程進行解析,最後只須要傳回 Main Isolate 便可,省下了一次 copy 操做。(Network -> New Isolate (result)-> Main Isolate)

空間

Isolate 其實是比較重的,每當咱們建立出來一個新的 Isolate 至少須要 2mb 左右的空間甚至更多,取決於咱們具體 isolate 的用途。

OOM 風險

咱們可能會使用 message 傳遞 data 或 file。而實際上咱們傳遞的 message 是經歷了一次 copy 過程的,這其實就可能存在着 OOM 的風險。

若是說咱們想要返回一個 2GB 的 data,在 iPhone X(3GB ram)上,咱們是沒法完成 message 的傳遞操做的。

Tips

上面已經介紹了使用 isolate 進行多線程操做會有一些額外的 cost,那麼是否能夠經過一些手段減小這些消耗呢。我我的建議從兩個方向上入手。

  • 減小 isolate 建立所帶來的消耗。
  • 減小 message copy 次數,以及大小。

使用 LoadBalancer

如何減小 isolate 建立所帶來的消耗呢。天然一個想法就是可否建立一個線程池,初始化到那裏。當咱們須要使用的時候再拿來用就行了。

實際上 dart team 已經爲咱們寫好一個很是實用的 package,其中就包括 LoadBalancer

咱們如今 pubspec.yaml 中添加 isolate 的依賴。

isolate: ^2.0.2
複製代碼

而後咱們能夠經過 LoadBalancer 建立出指定個數的 isolate。

Future<LoadBalancer> loadBalancer = LoadBalancer.create(2, IsolateRunner.spawn);
複製代碼

這段代碼將會建立出一個 isolate 線程池,並自動實現了負載均衡。

因爲 dart 天生支持頂層函數,咱們能夠在 dart 文件中直接建立這個 LoadBalancer。下面咱們再來看看應該如何使用 LoadBalancer 中的 isolate。

int useLoadBalancer() async {
    final lb = await loadBalancer;
    int res = await lb.run<int, int>(_doSomething, 1);
    return res;
  }
複製代碼

咱們關注的只有 Future<R> run<R, P>(FutureOr<R> function(P argument), argument, 方法。咱們仍是須要傳入一個 function 在某個 isolate 中運行,並傳入其參數 argument。run 方法將會返回咱們執行方法的返回值。

總體和 compute 使用感受上差很少,可是當咱們屢次使用額外的 isolate 的時候,再也不須要重複建立了。

而且 LoadBalancer 還支持 runMultiple,可讓一個方法在多線程中執行。具體使用請查看 api。

LoadBalancer 通過測試,它會在第一次使用其 isolate 的時候初始化線程池。

當應用打開後,即便咱們在頂層函數中調用了 LoadBalancer.create,可是仍是隻會有一個 Isolate。

當咱們調用 run 方法時,才真正建立出了實際的 isolate。

寫在最後

寫這篇文章的緣故實際上是前兩天法空大佬在作圖片處理的時候恰好遇到了這個問題,他最後仍是調用原生的庫解決的,不過我仍是寫一篇,給以後遇到這個問題的同窗一種參考方案。

固然 Flutter 中性能調優遠不止這一種狀況,build / layout / paint 每個過程其實都有不少可以優化的細節,這個會在以後性能優化系列跟你們慢慢分享。

最近很長一段時間其實在學習混合棧相關的知識,以後會從官方混合接入方案開始到閒魚 Flutter Boost 進行介紹,下一篇文章就會是混合開發的第一篇,但願我能不要拖更🤣

此次的內容就是這樣了,若是您對本文還有任何疑問或者文章的建議,歡迎在下方評論區以及個人郵箱1652219550a@gmail.com與我聯繫,我會及時回覆!

相關文章
相關標籤/搜索