字节跳动业务在Flutter 轻量级引擎上的实践与优化

Posted 字节跳动终端技术

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了字节跳动业务在Flutter 轻量级引擎上的实践与优化相关的知识,希望对你有一定的参考价值。

本文介绍了字节业务在 Flutter 轻量级引擎上的实践历程,介绍了在此过程中遇到的各种各样的问题以及最终使用的解决方案。

作者:字节跳动终端技术——候华勇

一、背景

Flutter 在2.0版本之前混合工程开发对视图级别的开发支持非常有限,在使用过程 Flutter 如果要实现显示卡片视图有两种方式,一种是单引擎模型,通过在不同的 Native 界面中共享同一个容器,ios上是 FlutterViewController,android 上为 FlutterActivity,页面在移除和添加容器的同时,在Flutter侧和 Native 侧维护一个展现关系用来保证页面的展示和恢复;另外一种方式是创建一个独立引擎负责新页面展示。方案一的优点是不会产生额外的内存消耗,缺点是会增加维护成本,在不同的使用场景下很容易造成黑屏、白屏、页面无响应等疑难杂症;方案二的优点是简单,缺点是内存增量大、启动慢。官方2.0版本的轻量级引擎为 Flutter 在 View 级别使用上开拓出了一个新的方向,轻量级引擎集合上述两种方案的优点,新创建页面的时候 spawn 一个新的 Engine,新的 Engine 与原 Engine 共享线程和一些C++资源以达到新增引擎但不过多增加内存的目的。

二、方案实现

EngineGroup

轻量引擎推出了 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数量234
Android内存值68.869.670.5
iOS内存值39.342.747.6

跟官方的统计会有一些差别,官方应该是只对引擎创建时候的内存增量进行了统计,并未计算额外的内存消耗,而iOS上的内存增量差异之所以与官方统计有如此之大,是因为官方并未统计具体将 Flutter 页面进行展示时候创建是 iOSurface,而在单引擎中这部分内存在新创建页面的时候是不会额外分配的,可以通过在页面不可见的时候回收来降低内存占用(需要注意的是这部分内存的消耗与具体设备分辨率有关)。

启动速度

Android:速度提升~2.63倍

iOS: 速度提升~9倍

以 FlutterFragment 形式新增卡片,统计为 Engine 开始创建到 onFlutterUiDisplayed

普通方式创建EngineGroup创建
Android 耗时140~150ms50~60ms
iOS 耗时280~300ms30~40ms

因为轻量级引擎是从 EngineGroup 中 spawn 出来的,此时很大一部分共享的内容已经完成了创建,所以在轻量级引擎在创建的时候的耗时是很短的,启动速度也会相应的得到提升,但是需要注意的是 EngineGroup 中首个 Engine 的创建跟在此之前的引擎创建形式是没有区别的,我们暂且称之为主引擎,其他的轻量级引擎从主引擎中 spawn 而来,主引擎的创建耗时可以通过预加载的方式提前加载。

三、业务落地

2.0 之前,诸多业务方对 Flutter 轻量级引擎保持着关注,该功能推出后 Flutter Infra 团队和业务方中也进行了合作共建,并且在一些业务中进行场景落地。在这里我们也特别感谢大力家长、小荷、幸福里等团队对 Flutter 多引擎方案的支持,下面展示典型的业务场景。

在大力家长端中我们进行了两期的落地实践,第一期是将拍照提示弹窗改造为轻量级引擎,在一期上线之后再次进行更深层次的使用,将首页Tab 切换为轻量引擎实现。

在使用了轻量级引擎之后的页面首帧时间如下:

使用轻量级引擎之后首帧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 需要把数据序列化成字符串,接收方再反序列化,使用成本高,性能不太高;

为了解决上述的问题,我们设计了如下方案:

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 的复用达到如下状态

对 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: https://github.com/flutter/engine/pull/29915)),同时除了图片的缓存之外还存在一些其他的缓存元素,我们也在尝试降低这些缓存的占用。

页面不可见释放 iOSSurface

前文也提到过官方对外说明额外创建一个卡片引擎内存增量~180K,在实测的过程中 iOS 每多创建一个引擎的内存增量在4-5M。而在安卓机器上的增量约800K,双端创建 Engine 的流程本质上是一致,为什么会产生这么大的差异呢。

使用 Instrument 获取内存增长详情的过程中,从官方的 Demo 中不断的 Push 进入新的轻量引擎界面,可以很清楚的看到里面内存占用比例最高的部分是在进行渲染过程中产生的缓冲区,这个所需内存块的大小取决于屏幕分辨率以及创建出 FlutterView 的ViewportMetrics

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能够释放掉。

从上图的持有关系中可以看到,对ios_surface的持有主要有两个地方,RasterizerPlatformView,除此之外当然还有一个最直接的引用关系就是FlutterView的layer,因为ios_surface本身就依赖layer而生。在这个关系中,Shell的创建和销毁消耗是非常大的,持有关系也非常的复杂,基本上等同于重新将Flutter上下文创建和销毁,这里就不考虑直接将Shell重新销毁&创建,分别将PlatformViewRasterizer进行处理就好。

针对platfomview中对ios_surface的持有,由于在 FlutterViewController 在viewDidDisappear中会触发surfaceUpdated 而执行 PlatformView 的NotifyDestroyed 方法,那么我们可以在这个地方更改,保证移除对ios_surface的引用。

在完成上述的逻辑之后,使用 Instrument 进行多次 Push 之后的内存占用情况如上图,在下一次 Push 的时候上一个页面内存占用大大降低,使用此方案之后除去当前展示页面中的 Surface 占用,每新增一个页面的内存增量由原来的5M,减小到500K。由于前页面对 Sureface 进行销毁,新页面创建新的 Sureface 会导致内存有一个短暂的峰值,如果不进行销毁&创建,直接复用上一个可能效果会更好。

FlutterView 内容自适应

轻量级引擎使用方案使 Flutter 可以更方便应用到列表 Item、Banner 等场景中,但是在使用 FlutterView 过程中由于父布局的限制,Flutter 内容只能充满父布局,无法根据具体的内容进行自适应的布局,这使得该方案在一些常规场景中有一些问题。

因为移动设备的尺寸的多样性导致该弹窗在展示的时候需要具备自适应能力,在不进行任何改动之前该弹窗的尺寸只能按照固定尺寸来展示,这也导致了其中图片元素会存在展示不及预期的情况。

解决方案

  • 在获取整个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:https://github.com/flutter/engine/pull/27662 ), ThreadMerge 中的死锁问题 (PR:https://github.com/flutter/engine/pull/28224 )等。

Flutter的视图级别的使用的需求由来已久,在现在存量App的时代,让Flutter更好的服务现有的业务的重要性不言而喻。在跨平台的方案中视图级别的使用现在也是一项基础功能,Flutter中的这项功能在官方的努力之下姗姗来迟,所以我们更应该让它跑的更快、落地更广,切实解决业务的问题,拓展业务的边界。从目前落地效果来看该方案还有需要完善的地方,官方和社区也在持续优化,字节也会继续结合实际业务场景持续完善多引擎方案,并将相关成果贡献给社区。

参考链接

相关文档

  1. https://flutter.dev/docs/development/add-to-app/multiple-flutters
  2. https://mp.weixin.qq.com/s/6aW9vbithMWTOJ_PGniQTA

PR 链接

  1. https://github.com/flutter/engine/pull/29915

  2. https://github.com/flutter/engine/pull/27662

  3. https://github.com/flutter/engine/pull/28224

# 关于字节终端技术团队

字节跳动终端技术团队(Client Infrastructure)是大前端基础技术的全球化研发团队(分别在北京、上海、杭州、深圳、广州、新加坡和美国山景城设有研发团队),负责整个字节跳动的大前端基础设施建设,提升公司全产品线的性能、稳定性和工程效率;支持的产品包括但不限于抖音、今日头条、西瓜视频、飞书、瓜瓜龙等,在移动端、Web、Desktop等各终端都有深入研究。

就是现在!客户端/前端/服务端/端智能算法/测试开发 面向全球范围招聘!一起来用技术改变世界,感兴趣请联系 flutter-infra@bytedance.com。邮件主题 简历-姓名-求职意向-期望城市-电话

以上是关于字节跳动业务在Flutter 轻量级引擎上的实践与优化的主要内容,如果未能解决你的问题,请参考以下文章

字节跳动数据湖技术选型的思考与落地实践

字节跳动 EB 级 HDFS 实践

字节跳动Flutter架构实践

深度介绍Flink在字节跳动数据流的实践

字节跳动埋点数据流建设与治理实践

字节跳动云原生:机器学习平台建设实践