位元組跳動業務在 Flutter 輕量級引擎上的實踐與優化

語言: CN / TW / HK

 

本文介紹了位元組跳動業務在 Flutter 輕量級引擎上的實踐歷程,介紹了在此過程中遇到的各種各樣的問題以及最終使用的解決方案。

作者:位元組跳動終端技術——候華勇

一、背景

Flutter 在2.0版本之前混合工程開發對檢視級別的開發支援非常有限,在使用過程 Flutter 如果要實現顯示卡片檢視有兩種方式,一種是單引擎模型,通過在不同的 Native 介面裡共享同一個容器,iOS上是 FlutterViewController,Android 上為 FlutterActivity,頁面在移除和新增容器的同時,在Flutter側和 Native 側維護一個展現關係用來保證頁面的展示和恢復;另外一種方式是建立一個獨立引擎負責新頁面展示。方案一的優點是不會產生額外的記憶體消耗,缺點是會增加維護成本,在不同的使用場景下很容易造成黑屏、白屏、頁面無響應等疑難雜症;方案二的優點是簡單,缺點是記憶體增量大、啟動慢。官方2.0版本的輕量級引擎為 Flutter 在 View 級別使用上開拓出了一個新的方向,輕量級引擎集合上述兩種方案的優點,新建立頁面的時候 spawn 一個新的 Engine,新的 Engine 與原 Engine 共享執行緒和一些C++資源以達到新增引擎但不過多增加記憶體的目的。

img

二、方案實現

EngineGroup

img

輕量引擎推出了 FlutterEngineGroup 的概念,讓同一個 EngineGroup 中的 Engine 物件能夠最大程度的共享資源。使用起來也比較簡單,建立一個 EngineGroup 然後通過 makeEngine 的方式不斷的spawn出來新的輕量級引擎,然後可以使用這些引擎進行頁面展現。

記憶體佔用

對於開發者來說輕量級引擎帶給大家最大的改善就是記憶體的增量,我們也對使用輕量級引擎的具體記憶體增量進行了一些調研測試。

官方資料:雙端新增Engine都僅需180KB.https://flutter.dev/docs/development/add-to-app/multiple-flutters

實測Android新增一個Flutter卡片,記憶體增量0.8M;iOS新增一個FlutterVC,記憶體增量3.8-4.8M

FlutterView數量 2 3 4
Android記憶體值 68.8 69.6 70.5
iOS記憶體值 39.3 42.7 47.6

img   img   img

imgimgimg

跟官方的統計會有一些差別,官方應該是隻對引擎建立時候的記憶體增量進行了統計,並未計算額外的記憶體消耗,而iOS上的記憶體增量差異之所以與官方統計有如此之大,是因為官方並未統計具體將 Flutter 頁面進行展示時候建立是 iOSurface,而在單引擎中這部分記憶體在新建立頁面的時候是不會額外分配的,可以通過在頁面不可見的時候回收來降低記憶體佔用(需要注意的是這部分記憶體的消耗與具體裝置解析度有關)。

啟動速度

Android:速度提升~2.63倍

iOS: 速度提升~9倍

以 FlutterFragment 形式新增卡片,統計為 Engine 開始建立到 onFlutterUiDisplayed

  普通方式建立 EngineGroup建立
Android 耗時 140~150ms 50~60ms
iOS 耗時 280~300ms 30~40ms

因為輕量級引擎是從 EngineGroup 中 spawn 出來的,此時很大一部分共享的內容已經完成了建立,所以在輕量級引擎在建立的時候的耗時是很短的,啟動速度也會相應的得到提升,但是需要注意的是 EngineGroup 中首個 Engine 的建立跟在此之前的引擎建立形式是沒有區別的,我們暫且稱之為主引擎,其他的輕量級引擎從主引擎中 spawn 而來,主引擎的建立耗時可以通過預載入的方式提前載入。

三、業務落地

2.0 之前,諸多業務方對 Flutter 輕量級引擎保持著關注,該功能推出後 Flutter Infra 團隊和業務方中也進行了合作共建,並且在一些業務中進行場景落地。在這裡我們也特別感謝大力家長、小荷、幸福裡等團隊對 Flutter 多引擎方案的支援,下面展示典型的業務場景。

在大力家長端中我們進行了兩期的落地實踐,第一期是將拍照提示彈窗改造為輕量級引擎,在一期上線之後再次進行更深層次的使用,將首頁Tab 切換為輕量引擎實現。

imgimgimg

在使用了輕量級引擎之後的頁面首幀時間如下:

img

使用輕量級引擎之後首幀50分位時間由96ms降低到了78ms,在單引擎使用預載入功能且輕量級引擎每次開啟頁面都需要建立新的引擎的背景下,效果是符合預期的。

Flutter 輕量引擎在業務落地的過程中確實遇到了一些問題,但是最終在落地效果、資料反饋得到了業務方的認可。由於官方在輕量引擎方向目前只是提供了一套實現機理,缺乏一定的配套設施,在真正業務需要落地的時候仍然有比較多的事情要做,例如外掛的註冊管理、引擎的銷燬策略,Flutter 入口全域性配置,以及引擎之間變數的同步管理等。在實踐的過程中業務反饋給我們很多問題,在共建的過程中也解決了很多問題,再一次感謝我們合作的業務方。我們也對一些遺漏的功能點進行了完善,力圖打造一套更完善的Flutter輕量級引擎解決方案。

四、功能優化

配置頁面入口引數

在單引擎模型之中 Flutter 以 main 函式作為引擎入口,但是在建立輕量級引擎的過程中需要指定對應引擎的入口函式,在Flutter側需要使用 @pragma('vm:entry-point') 對特定方法進行指定,引擎啟動之後進入該方法執行。相較於其他語言的main函式,Flutter 中的 main 函式是缺少入參,而業務在使用輕量級引擎的時候從 Native 側往 Flutter 傳遞一些引數是非常有必要的,也便於業務方進行後續的邏輯處理。

void main() {
  runApp(HomePage());
}

@pragma('vm:entry-point')
void home() {
  runApp(HomePage());
}

我們修改引擎層的 Settings,添加了 entryPointsArgsJson,使其能夠在 Flutter 側從 Settings 中獲取我們設定的入口引數,Flutter 側的使用則變更為以下方式。

@pragma('vm:entry-point')
void home(List<String> args) {
  runApp(HomePage(extras: args));
}

給 main 函式新增引數還有一個好處在於可以省略部分重複程式碼,因為在不同的輕量引擎中執行的程式碼是相互隔離的,通常我們在頁面構建之前會有一些初始化程式碼或者一些全域性的初始化設定,如果我們開闢多個 EntryPoint 的話這些重複的程式碼都必須在每個 EntryFunction 中寫一遍。這樣的話,我們可以只需要定義一個 EntryPoint,然後通過在 EntryFunction 的引數中特定的引數值來判斷具體的執行路徑,而不用去定義多個 @pragma('vm:entry-point'),而在 Native 側也只需要知道這個唯一定義的 EntryPoint 就可以,避免因為指定入口函式名稱帶來的硬編碼。

多引擎資料通訊

輕量級引擎的基本原理是利用 Dart 的 IsolateGroup,相比之前沒有 IsolateGroup 的情況,記憶體和啟動速度上都有很大的提升。然而多個引擎雖然在同一個 IsolateGroup中,並且使用的是同一個 Heap,但是 Isolate 的本質特性並不會有變化,即 Isolate 之間的資料是不共享的。

int count = 10;

@pragma('vm:entry-point')
void topMain(){
  count++;
  print("topMain:${count}");
}

@pragma('vm:entry-point')
void centerMain(){
  count++;
  print("centerMain:${count}");
}

上述示例中 topMain 和 centerMain 是兩個不同的輕量級引擎入口,對應兩個位於IsolateGroup 的 Isolate,有個全域性變數 count,在兩個入口都進行了 +1 操作並列印,結果顯示兩處都列印為 11,資料不共享。

在實際使用場景中,我們會有很多輕量級引擎之間共享資料的場景,比如使用者的登入資訊或者例如上面的 count,我們更加希望 topMain 的修改會被同步到 centerMain。

因為在Isolate之間資料無法直接進行共享,那麼一個很直觀的想法就是將具體資料放在 Native 側,然後在 Flutter 通過 Channel 與 Native 進行資料互動。官方的思路是通過 pigeon 生成程式碼,提供多端同步訪問的能力,不過官方方案目前因為各方面的原因暫時還沒有進展。

我們也對 Channel 的方式實現資料通訊進行了一些探索,在此過程中發現了有一些問題:

  • 多端(Android,iOS等)都需要寫相應的 Native 實現,開發成本高,對人員結構有要求;
  • MethodChannel 需要把資料序列化成字串,接收方再反序列化,使用成本高,效能不太高;

為了解決上述的問題,我們設計瞭如下方案:

img

Dart 的 Isolate 雖然彼此之間不能共享資料但是可以通過 Port 的方式進行通訊,我們可以藉助這項機制來實現多個Isolate之間的資料同步。

  • 將需要共享資料收斂到一個 ServiceIsolate 中,這樣共享資料還在 Dart 層,不再需要考慮多端的問題;
  • 當其他的 Isolate對資料進行更新的時候,可以通過傳送一條更新的訊息到 ServiceIsolate 中,此時 ServiceIsolate 將更新的訊息廣播到其他的 Isolate 中;
  • 當Isolate 需要獲取最新資料的時候,向 ServiceIsolate 傳送一條請求訊息,ServiceIsolate 在收到訊息之後將資料再發送回來;
  • 通過 FFI 進行 Isolate 間 Port 的繫結,可以直接在不同的 Isolate 之間傳遞 Dart 物件,不需要序列化,效能要更好,使用也簡單。

資料更新廣播

針對每個單獨的需要共享的資料進行監聽,當發生改變之後執行對應的操作。

當需要對資料進行更新的時候呼叫 channel.postUpdateMessage(content),其他的地方只需要對該訊息進行監聽即可。

當有廣播的需求的時候可以直接呼叫channel.postNotification(content)這樣可以在多個引擎之間傳送廣播訊息而不影響內建的同步資料,content 內容可以自定義。

監聽資料更新

當使用BroadcastChannel channel = BroadcastChannel(channelName)的時候即可加入對應的頻道,該頻道下的內容更新和訊息通知都可以收到

BroadcastChannel channel = BroadcastChannel('countService');
channel.onDataUpdated = (dynamic content) {
  setState(() {
    int counter = content as int;
    _counter = counter;
  });
  print('this will be invoked after data has been changed');
};

監聽通知訊息程式碼如下

BroadcastChannel channel = BroadcastChannel('countService');
channel.onReceiveNoti = (dynamic content) {
  print('this will be invoked after receive notification');
};

獲取最新資料

當用戶進行資料初始化的時候可能會需要進行資料獲取,則可以直接請求共享資料。

channel.request().then((value){
  setState(() {
    int counter = value as int;
    _counter = counter;
  });
  print('this will be invoked after data has received!');
});

ImageCache 共享

快取記憶體問題

在使用輕量級引擎的時候還需要注意的一個問題是引擎中的快取,因為額外建立了引擎就會導致快取會成倍的增加,如果不處理這部分問題就可能導致輕量引擎帶來的記憶體優勢蕩然無存。而在Flutter快取中,圖片佔用一 直都是絕對的大比例,圖片快取在使用輕量引擎會導致如下問題:

  • 圖片記憶體不共享,同一張圖片在多個 Engine 中顯示需要重複解碼,重複佔用記憶體

  • 每個 Engine 預設有 100M 的 ImageCache,如果不共享,可能出現不同引擎利用率差異大的問題,比如有的引擎圖片少 Cache 利用率不高,有的引擎圖片多導致 Cache 不夠用。

圖片現狀梳理

先簡單回顧一下 Flutter 載入圖片的流程:

  • 通過 Image 的 Key 獲取快取內容,命中則直接使用,否則新建 ImageStreamCompleter;

  • ImageStreamCompleter 內部建立 Codec,Codec 觸發解碼邏輯;

  • 引擎內部 MultiFrameCodec & SingleFrameCodec 完成解碼得到 CanvasImage,與 Flutter 側Image 繫結;

  • Flutter 側獲取到 Image 後用於顯示

方案核心目標

解決上述問題的核心點在於 C++ 層完成 CanvasImage 和 Codec 的複用達到如下狀態

img

對 CavasImage 和 Codec 增加代理機制,第一個觸發圖片載入的 Engine 會真正觸發 CanvasImage與 Codec 的建立並做快取,後續 Engine 觸發圖片載入時,則是基於 CavasImage 與 Codec 的類建立代理,該代理相當於一個空殼,僅起轉發作用,所有操作轉發到真正的 CavasImage 和 Codec 來執行。

具體實現方案

  • C++側增加 Map 用於快取建立的 CanvasImage,Codec,代理類建立時增加對快取的引用,代理類銷燬時解除對快取的引用;

  • 增加 ImageCacheKey 的列表記錄,用於完成 LRU 邏輯,Dart 側訪問圖片時通知到該列表,列表將相應Key遷移,空間不足時通知各Engine Dart 側釋放相應 Key 的圖片;為避免新增 count 邏輯,各個Engine進行釋放時不會通知到列表變動,列表進行相關計算前會先向各 Engine 請求正在使用的圖片資訊,以清除在自己記錄內但完全沒有 Engine 在用的圖片,清除完成後才會進行相關計算與變動;

  • 新增 ImageCacheKey 介面,由當前被充做 Key 的各個 Object 來實現,根據 Object 內的一些特徵值來返回一個 String,使用 String 作為 C++ 側的 ImageCacheKey 來進行圖片相等性判斷;

在解決圖片快取問題的過程中也發現了其他方面的一些,例如兩個 Engine 同時顯示一張 Gif,主Engine 銷燬之後,後建立的 Engine 隨之崩潰,這個問題的原因是兩個 Engine 使用同一個IOManager,當主引擎銷燬之後 IOManager 銷燬,當第二引擎再使用的時候會丟擲異常,這個問題最終通過多引擎直接共享 IOManager 解決,問題的 PR 我們已經 Merge 到了官方 (PR: github.com/flutter/eng…) , 同時除了圖片的快取之外還存在一些其他的快取元素,我們也在嘗試降低這些快取的佔用。

頁面不可見釋放 iOSSurface

前文也提到過官方對外說明額外建立一個卡片引擎記憶體增量~180K,在實測的過程中 iOS 每多建立一個引擎的記憶體增量在4-5M。而在安卓機器上的增量約800K,雙端建立 Engine 的流程本質上是一致,為什麼會產生這麼大的差異呢。

使用 Instrument 獲取記憶體增長詳情的過程中,從官方的 Demo 中不斷的 Push 進入新的輕量引擎介面,可以很清楚的看到裡面記憶體佔用比例最高的部分是在進行渲染過程中產生的緩衝區,這個所需記憶體塊的大小取決於螢幕解析度以及創建出 FlutterView 的ViewportMetrics

img

sk_sp<SkSurface> SkSurface::MakeFromCAMetalLayer(GrRecordingContext* rContext,
                         GrMTLHandle layer,
                         GrSurfaceOrigin origin,
                         int sampleCnt,
                         SkColorType colorType,
                         sk_sp<SkColorSpace> colorSpace,
                         const SkSurfaceProps* surfaceProps,
                         GrMTLHandle* drawable)

這裡想到的是既然之前的頁面沒有進行展示,那麼佔用的記憶體被釋放也沒有什麼影響,理論上來說只需要在頁面重新展示的時候進行恢復就可以。我們這邊需要做的事情就是找到ios_surface的持有關係,保證在 FlutterViewController 消失的時候ios_surface能夠釋放掉。

img

從上圖的持有關係中可以看到,對ios_surface的持有主要有兩個地方,RasterizerPlatformView,除此之外當然還有一個最直接的引用關係就是FlutterView的layer,因為ios_surface本身就依賴layer而生。在這個關係中,Shell的建立和銷燬消耗是非常大的,持有關係也非常的複雜,基本上等同於重新將Flutter上下文建立和銷燬,這裡就不考慮直接將Shell重新銷燬&建立,分別將PlatformViewRasterizer進行處理就好。

針對platfomview中對ios_surface的持有,由於在 FlutterViewController 在viewDidDisappear中會觸發surfaceUpdated 而執行 PlatformView 的NotifyDestroyed 方法,那麼我們可以在這個地方更改,保證移除對ios_surface的引用。

img

在完成上述的邏輯之後,使用 Instrument 進行多次 Push 之後的記憶體佔用情況如上圖,在下一次 Push 的時候上一個頁面記憶體佔用大幅降低,使用此方案之後除去當前展示頁面中的 Surface 佔用,每新增一個頁面的記憶體增量由原來的5M,減小到500K。由於前頁面對 Sureface 進行銷燬,新頁面建立新的 Sureface 會導致記憶體有一個短暫的峰值,如果不進行銷燬&建立,直接複用上一個可能效果會更好。

FlutterView 內容自適應

輕量級引擎使用方案使 Flutter 可以更方便應用到列表 Item、Banner 等場景中,但是在使用 FlutterView 過程中由於父佈局的限制,Flutter 內容只能充滿父佈局,無法根據具體的內容進行自適應的佈局,這使得該方案在一些常規場景中有一些問題。

img

因為移動裝置的尺寸的多樣性導致該彈窗在展示的時候需要具備自適應能力,在不進行任何改動之前該彈窗的尺寸只能按照固定尺寸來展示,這也導致了其中圖片元素會存在展示不及預期的情況。

解決方案

  • 在獲取整個Flutter佈局的時候我們需要修改 FlutterView 尺寸變更的通知流程,先給 Dart側 一個足夠大的Size,保證 Dart 在佈局的時候能夠測量出正確的結果;

  • 然後在監聽 Dart 側的佈局,獲取寬高通知給 Native;

這裡採用的方法是封裝 RootWrapContentWidget 用於 Widget 最外層,通過自定義的 RenderObject 監聽 Layout 過程,同時給自己新增 IntrinsicWidth 或者 IntrinsicHeight 的父 Widget,使頁面整體採用包裹布局。

class RootWrapContentWidget extends StatelessWidget {
  /// constructor
  const RootWrapContentWidget(
      {Key? key,
      required this.child,
      this.wrapWidth = false,
      this.wrapHeight = false})
      : assert(child != null),
        assert(wrapWidth || wrapHeight),
        super(key: key);

  final Widget child;
  final bool wrapWidth;
  final bool wrapHeight;

  @override
  Widget build(BuildContext context) {
    Widget result = _RootSizeChangeListener(
      child: child,
    );
    if (wrapWidth) {
      result = IntrinsicWidth(child: result);
    }
    if (wrapHeight) {
      result = IntrinsicHeight(child: result);
    }
    return result;
  }
}

圖片尺寸問題

如果在頁面中存在圖片,由於 Dart 側需要多次 Layout 才能獲取到準確的寬高值,而在獲取到最終的寬高之前,不能修改父佈局的尺寸,否則父佈局的尺寸變動會同步到 Dart 側然後影響到 Dart 側的佈局。這裡要麼監聽所有圖片的載入過程,使用所有圖片都載入完畢後的 Layout 的測量值作為FlutterView 的 Size,要麼想辦法在首次 Layout的時候就能夠獲取到準確的寬高。監聽所有圖片的載入過程程式碼改動比較大,我們最終決定在方案二上進行研究。

Size _sizeForConstraints(BoxConstraints constraints) {
  // Folds the given |width| and |height| into |constraints| so they can all
  // be treated uniformly.
  constraints = BoxConstraints.tightFor(
    width: _width,
    height: _height,
  ).enforce(constraints);

  if (_image == null)
    return constraints.smallest;

  return constraints.constrainSizeAndAttemptToPreserveAspectRatio(Size(
    _image!.width.toDouble() / _scale,
    _image!.height.toDouble() / _scale,
  ));
}

獲取到解碼 _image 資訊之前,測量的邏輯是 ImageWidget 有設定寬高就使用設定的寬高,沒有設定寬高就是使用最小值。似乎要求業務方在自適應佈局的場景中指定圖片的寬高就可以了,但是真正在程式碼編寫的時候,這個是比較難做到的,而在一些佈局中圖片的寬度和高度是沒法獲取的。

最終我們採用要求業務方書寫寬高比的方式,結合圖片寬高比和 BoxConstraints 中父佈局給的限制,可以在沒有設定寬高,沒有解碼資料的情況推測出 ImageWidget 應該佔用的佔用大小。

五、總結展望

除上述介紹的優化方案之外,我們還解決其他輕量引擎相關問題,如 PlatformView 使用中的 ThreadMerge (PR:github.com/flutter/eng… ), ThreadMerge 中的死鎖問題 (PR:github.com/flutter/eng… )等。

Flutter的檢視級別的使用的需求由來已久,在現在存量App的時代,讓Flutter更好的服務現有的業務的重要性不言而喻。在跨平臺的方案中檢視級別的使用現在也是一項基礎功能,Flutter中的這項功能在官方的努力之下姍姍來遲,所以我們更應該讓它跑的更快、落地更廣,切實解決業務的問題,拓展業務的邊界。從目前落地效果來看該方案還有需要完善的地方,官方和社群也在持續優化,位元組也會繼續結合實際業務場景持續完善多引擎方案,並將相關成果貢獻給社群。

參考連結

  1. 相關文件

flutter.dev/docs/develo…

mp.weixin.qq.com/s/6aW9vbith…

  1. PR 連結

github.com/flutter/eng…

github.com/flutter/eng…

github.com/flutter/eng…

# 關於位元組跳動終端技術團隊

位元組跳動終端技術團隊(Client Infrastructure)是大前端基礎技術的全球化研發團隊(分別在北京、上海、杭州、深圳、廣州、新加坡和美國山景城設有研發團隊),負責整個位元組跳動的大前端基礎設施建設,提升公司全產品線的效能、穩定性和工程效率;支援的產品包括但不限於抖音、今日頭條、西瓜影片、飛書、瓜瓜龍等,在移動端、Web、Desktop等各終端都有深入研究。

就是現在!客戶端/前端/服務端/端智慧演算法/測試開發 面向全球範圍招聘!一起來用技術改變世界,感興趣請聯絡 [email protected]。郵件主題 簡歷-姓名-求職意向-期望城市-電話


# 關於🔥 火山引擎MARS

火山引擎應用開發套件MARS,面向移動研發、前端開發、QA、 運維、產品經理、專案經理以及運營角色,提供一站式整體研發解決方案,助力企業研發模式升級,降低企業研發綜合成本。目前產品正在免費公測階段,歡迎掃描下方二維碼進行試用!