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

Vadaski發表於2019-10-21

前言

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

本文將會解釋為什麼即使在 Flutter 高效能的渲染能力下,應用還是可能會出現 jank,以及我們應該如何處理這些情況。這是 Flutter 效能分析系列的第一篇文章,後續將會持續剖析 Flutter 中渲染流程以及效能優化。

什麼時候會產生 jank?

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

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

在螢幕中心有一個一直旋轉的 FlutterLogo,當我們點選按鈕後,開始計算 0 + 1 + ... +1000000000。這裡可以很明顯的感受到明顯的卡頓。為什麼會出現這種情況呢?

Flutter Rendering Pipeline

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

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。

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

但是單執行緒畢竟也有它的侷限,但是當我們有一些比較重的同步處理任務,例如解析大量 json(這是一個同步操作),或是處理圖片這樣的操作,很可能處理時間會超過一個 vsync 時間,這樣 Flutter 就不能及時將 layer 送到 GPU 執行緒,導致應用 jank。

Flutter | 效能優化——如何避免應用 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。

它與我們之前理解的 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 的時候初始化執行緒池。

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

當應用開啟後,即使我們在頂層函式中呼叫了 LoadBalancer.create,但是還是隻會有一個 Isolate。

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

當我們呼叫 run 方法時,才真正建立出了實際的 isolate。

寫在最後

寫這篇文章的緣故其實是前兩天法空大佬在做圖片處理的時候剛好遇到了這個問題,他最後還是呼叫原生的庫解決的,不過我還是寫一篇,給之後遇到這個問題的同學一種參考方案。

當然 Flutter 中效能調優遠不止這一種情況,build / layout / paint 每一個過程其實都有很多能夠優化的細節,這個會在之後效能優化系列跟大家慢慢分享。

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

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

相關文章