Android 动态加载 JS — Flutter Kraken / Open WebF

2,822 阅读8分钟

一、背景

  在基于 GSL 的跨端 UI 框架中,如果我们只依赖事先制定的已有组件来支撑业务,从业务可变性和开发成本来说,都会存在不可规避的瓶颈问题。试想:如果 Web 端完成一个复杂的日历组件开发,并希望其能在设备端上渲染。

  在没有动态性的支撑下,设备端一是无法支持(无法识别日历组件),二是需要复刻这个日历组件开发一遍,发布软件版本,并祈祷所有线上用户都能升级到最新软件。

  基于这样的背景,端的动态渲染便有其存在的必要和使命。

二、方案

  本文标题便已点明我们探究的是哪种解决方案,即阿里开源的、基于 Flutter 的 Kraken 渲染引擎来实现对 JS 的动态加载。

  相信搞过 Flutter 开发的同学都了解 Flutter 导入业务有两种模式:一种是基于纯 Flutter 的开发,另一种是 Flutter 与原生平台的融合开发。而第二种方式中 Flutter 在 Native 平台上又有三种 UI 渲染载体,分别为:

  • FlutterActivity
  • FlutterFragment
  • FlutterView

结合我们自研的跨平台 UI 框架对动态性渲染的诉求:

  • 动态渲染的 JS 组件作为子节点融合进原生 View 树
  • 渲染组件支持与外部作双向通信

那么我们基本敲定思路:在 Android 中通过动态创建 FlutterView 连接 Flutter,并与之做数据的传输;而 Flutter 便借助 Kraken 专注于接收 JS 数据并执行渲染,同时支持其组件交互事件的跨平台响应。

三、实现

Step 1 - Android 引入 Flutter module

  这部分已经很成熟了,官文 诚不欺我,自行在掘金上搜搜也能找到其他指南:即如何在原生项目中,引入 Flutter 模块


Step 2 - Android 使用 FlutterView 渲染 Dart UI

  正如 官文 所提及的 “Integrating via a FlutterView requires a bit more work than via FlutterActivity and FlutterFragment previously described.” 集成 FlutterView 需要更多的人为操作,没有 FlutterActivity 那么方便。

  Github add_to_app 是官方提供的 Demo,基本阐述了一个 FlutterView 在 Android 平台中的接入过程,即:

  1. 创建 FlutterEngine(一个 FlutterView 对应一个 FlutterEngine)
  2. 创建 FlutterView 并添加进 Android View 容器
  3. 确保 FlutterEngine 在恰当的时机调用FlutterEngine.lifecycleChannel.appIsResumed()
  4. FlutterEngine 执行自定义或默认的 Dart EntryPoint
  5. FlutterView 与 FlutterEngine 完成连接(attach)

  上面步骤都需要我们在 Android Native 侧完成,具体代码见 Demo 即可,这里便不赘述。

  需要特别指出的是:FlutterView 默认的 renderSurface 是 SurfaceView,这并不支持透明背景,且无法融入原生 View 树的,所以在构建 FlutterView 的时候应该使用 TextureView 作为其 renderSurface,即:

val flutterTextureView = FlutterTextureView(this)
flutterTextureView.isOpaque = false
val view = FlutterView(this, flutterTextureView)

Step 3 - Flutter 基于 Kraken 渲染 JS UI

  根据 Kraken 官网,将 Kraken 在 Flutter 中跑起来是非常清楚且简单的事情,建议把 官方文档 大体过一遍,对 Kraken 会有个较为全面的认识。

虽然问题不大,但难免还是踩了一些小坑:

  • 本地 Bundle 的加载与官方描述不符
// 官方描述:
Kraken kraken = Kraken(
    bundle: KrakenBundle.fromUrl('assets://assets/bundle.js')
);

// 实际有效:
Kraken kraken = Kraken(
    bundle: KrakenBundle.fromUrl('assets:///assets/bundle.js')
);

// 这两者的差别仅在于 assets: 后是两个 '/' 还是三个 '/',着实是坑 🙂
  • JS 组件打包

  根据公司内部主流前端技术栈,我们通过 React Kraken 来完成 JS 组件的开发,按照 Kraken 官方提供的 React Demo 中关于 webpack.config.js 的修改,将其同步至自己的 React 项目,并通过 Kraken Cli 完成项目的 Debug。

注意:一定要确保 webpack 的配置跟 Kraken 的配置 同步,不然通过 npm run build 打出来的 bundle.js 在 Android 端是无法使用的,具体详见 kraken-react-demo

  • JS 组件适配(尺寸)

  首先我们必须知道,一个 JS 组件的渲染尺寸,是由多个平台决定的:

1)Android FlutterView(LayoutParams)
(2)Flutter Dart UI
(3)Kraken viewPort size
(4)React JS widget size

  注意:Android add FlutterView 的时候如果不指定大小,FlutterView 默认撑满父布局

  过多的因素无疑会造成复杂性的陡增,所以结合我们自己的业务特点,进行范围的收窄。 由此规定

  1. Android 添加 FlutterView 的时候明确指定 JS 组件能显示的最大范围(GSL 框架协议指定);
  2. Flutter Dart UI 不干涉尺寸设定,默认撑满外部容器
  3. Kraken 不指定 ViewPort size,默认撑满外部容器
  4. React JS 即可指定具体尺寸,亦可设置自适应尺寸

  假设按一倍图的标准进行尺寸的设定,比如一个长宽是 200px 的方块,JS 的尺寸设定为:

// React JS
const styles = {
    width: '200px',
    height: '200px'
}

Android add FlutterView 时的尺寸则为:

// Android
addView(flutterView,
    DisplayUtil.dip2px(this, 200f),
    DisplayUtil.dip2px(this, 200f)
)

Step 4 - Multi Flutter Kraken View

  Kraken 方案是否可用的关键因素之一在于是否支持添加多个视图,而多 FlutterView 的关键是多 FlutterEngine。

  Flutter 2.0 之后优化了 FlutterEngine 多实例的资源占用,使得我们在同一进程,同一界面同时处理多个 Flutter Kraken View 具备了可行性。为此实验 Demo 做了三种 FlutterEngine 构造和使用方式进行验证,分别是:

  1. new FlutterEngine()

最原始的方法,将一个 FlutterEngine 所需要的所有资源都构建出来,占用内存最大。单个构建成本:瞬时暴增 124.2MB(89.1->213.3),稳定一段时间后,经历两段释放降为 192.1MB 至 165.5MB

new FlutterEngine memory.jpg

再次添加 FlutterView,构建 FlutterEngine,每次增量约 64MB

image.png

  1. FlutterEngineGroup.createAndRunEngine()

借助 FlutterEngineGroup 生成的 FlutterEngine 具有常用共享资源(例如 GPU 上下文、字体度量和隔离线程的快照)的性能优势,能加快首次渲染的速度、降低延迟并降低内存占用,但每个 FlutterEngine 又保持其独立性,各自维护路由栈、UI 和应用状态。其构建成本:首个 FlutterEngine 128.4MB(89.7->218.1),后续衍生构建的 FlutterEngine,官方称 180KB,实测 197KB 基本吻合(如下方图 2 所示)

image.png

  1. FlutterEngineCache.put() / get()

尝试构建一个指向某个 EntryPoint 的 FlutterEngine 后,存入 FlutterEngineCache,要用时再取出复用是否可行?

  官方给出了明确提示:一个 FlutterView 应该对应一个 FlutterEngine,并且同一个 FlutterEngine 不允许执行多次 executeDartEntrypoint(DartEntrypoint point)。如果强行将某个已经 attach FlutterView 的 FlutterEngine 绑到另一个 FlutterView 又会有什么反应呢?请见下图:

原本 attach 在第 1 个 FlutterView 的 FlutterEngine,不断地更换 attach 对象到第 2 个、第 3 个

Record_2022-09-19-14-59-19.gif

经过实验,直接给结论:

  • FlutterEngine 会将数据同步给后面 attach 的 FlutterView,也就是新创建的 FlutterView 跟 FlutterEngine 上一个 attach 的 View 长得一模一样

  • 被 FlutterEngine attach 的 FlutterView 能够响应 FlutterEngine 中的数据变化,而“失去” FlutterEngine 的 FlutterView 将不再响应数据变化,更新 UI

  • 失去 FlutterEngine 的 FlutterView 被点击后,引起的数据更新依然能同步至被 FlutterEngine attach 的新的 FlutterView

所以,此方式并不是处理多 FlutterView 的良药,同时缓存 FlutterEngine 本身并不能减少 Multi FlutterEngine 的内存占用,它的意义在于利用空间换时间,减少 FlutterEngine 构造并执行 DartEntrypoint 所占用的时间。

最后,有个值得你注意的地方:当你在 Flutter Dart 中构建 Kraken 对象时,如果传入了 ChromeDevToolsService 那么你的 FlutterEngine 的内存就无法释放,导致内存泄露。

var kraken = Kraken(
    background: Colors.green,
    bundle: WebFBundle.fromUrl('assets:///jss/bundle.js'),
    javaScriptChannel: javaScriptChannel,
    // 🙂 坑呐!开启 DevToolService 会导致内存泄露
    devToolsService: ChromeDevToolsService(),
);


Step 5 - Kraken 的跨平台通信

Kraken 的跨平台通信无非要打通两条路,即:

  • Native —— Flutter
  • Flutter —— Kraken

打通两条路其实也很简单,在官方文档都有教程,在 Kraken React 部分跟大家稍微提个醒,当 JS 项目通过 Kraken cli 运行后,Kraken 会在其 JS window 对象挂上 kraken 对象,如果你用的是 Kraken 的 Fork 项目 OpenWebF,那 window 上挂载的对象则是 webf。具体通信代码如下(Demo 节选,后续会抽成 Bridge Library):

// React JS Demo
const krakenObj = window.kraken;
    // Kraken invoked Flutter method
    krakenObj.methodChannel.invokeMethod('onJSCall', new Date().getTime().toString(), ['Param Two'], {
    value: 'Param Three',
})
.then(result => {
    console.log('Received reply from Kraken', result);
    setNativeReply(result);
})
.catch(err => {
    console.log('Some error occured', err);
});

// Flutter invode Kraken method
krakenObj.methodChannel.clearMethodCallHandler();
krakenObj.methodChannel.addMethodCallHandler((method, args) => {
    var request = method + ' method invoked' + '\n' + 'Its params is : ' + args;
    console.log('Received request from Kraken : ' + request);
    setNativeRequest(request);
});

四、渲染性能

image.png

如上图所示,两对图片分别展示了渲染 FlutterView Dart UI 和 Kraken UI 各个环节所占用的时间。而每对照片左右两张对比的则是:首次加载和复用 Engine 的二次加载

对其「关键性能数据」做一番记录可得:

渲染场景初始化 FlutterView创建 FlutterEngine连接 View to Engine渲染 Dart UI渲染 JS UI总耗时
首次渲染 FlutterView3ms75ms11ms744ms\833ms
二次渲染 FlutterView0ms2ms7ms142ms\151ms
首次渲染 Kraken FlutterView2ms72ms16ms869ms133ms1092ms
二次渲染 Kraken FlutterView0ms1ms7ms170ms78ms256ms

当然上面的数据只是参考,跟机型、跟代码的统计方式都有关系,但总的来说,在初始化一个 FlutterEngine 后对其复用,所达到的总耗时是可接受的。

五、写在最后

网传 Kraken 不在维护了?

事实确实如此,原有的团队已不再维护 Kraken,但另起炉灶,基于 Kraken fork 了 OpenWebF,目前支持 Flutter 3.0,其用法除了 API 的名字要从 Kraken 改成 WebF 外,其他使用方式与 Kraken 并没有什么不同。注意前端项目在 Debug 的时候应换成 OpenWebF cli 运行。

如何看待阿里北海 Kraken 项目即将弃坑?

Kraken 是个好东西,思路好,具有学习价值,也能实际解决我们的业务痛点,业内一些大厂的轮子也有一些是基于 Kraken,Kraken 在跨端渲染也算是一个里程碑了。

Demo Github