字節跳動業務在 Flutter 輕量級引擎上的實踐與優化

 

本文介紹了字節跳動業務在 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、 運維、產品經理、項目經理以及運營角色,提供一站式整體研發解決方案,助力企業研發模式升級,降低企業研發綜合成本。目前產品正在免費公測階段,歡迎掃描下方二維碼進行試用!
 
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章