Flutter 动态化在最右 App 中的实践
1、写在前面
Flutter自诞生便备受关注,其高效的自渲染技术注定要在性能和体验上优于在这之前的跨端方案,美中不足的是目前Flutter不具备像Hybrid、RN、Weex等拥有的动态更新能力,官方在2019年的Roadmap里面原本有支持动态化的想法,但后来又出于性能、安全等方面的考量而放弃了。
最右去做Flutter动态化的目的是为技术选型提供多样的选择,在一些使用H5但交互性强的场景,或者使用原生但非核心的独立场景,提供更优的方案。今天主要跟大家分享最右App在实现Flutter动态化的过程中的一些经验教训。下面列出本篇文章的大纲和思路,以便于大家更好地理解:
2、先说Android
2.1 实现思路
实现动态化,就是实现指定路径的代码和资源的加载,并确保代码能正常执行。 要达成这个目标,修改Engine是必然的。我们先将Engine的源码Clone下来,配置好Engine的编译环境,具体可参考官方Wiki[1]。 同时我们必须先了解Flutter本身的启动流程,了解系统本身到底是如何工作的,不清楚的同学可以查看Gityuan的博客——深入理解Flutter引擎启动[2]。这篇文章的第3.3.1小节可以获知系统的资源路径信息保存在Settings结构体中,其实可执行代码路径也是保存在这个结构体中,具体可以看下图。
理解系统的工作流程之后,我们要实现这个目的,必须找到恰当的时机,将Settings中保存的代码路径和资源路径修改成指定的路径。然后编译Engine,生成flutter.jar。Engine的编译可参考官方Wiki[3]。
2.2 实现原理
在实现思路中,我们要解决这个问题的核心就在于找到这个恰当的时机。我们选择的是在platform_view_android_jni.cc的AttachJNI,AndroidShellHolder创建之前的时机。
在重新编译Engine之后,我们将生成的flutter.jar预置到主工程中去,这样主工程就有了动态加载Flutter代码和资源的能力。
2.3 工作流程
2.3.1 编译阶段
Android端走正常的flutter build即可,将编译产物libapp.so和flutter_assets打包成一个资源包,上传CDN。
2.3.2 加载阶段
在加载阶段之前,还需要有一个资源包下载和安全校验的环节,在此不做讨论。当我们拿到完整的资源包之后,将其路径传递到Flutter Engine层。 Android端是从Flutter.createView开始向下透传路径,大致的经过是:Flutter.createView -> FlutterNativeView构造方法 -> FlutterJNI.attachToNative -> FlutterJNI.nativeAttach -> AttachJNI,在Engine层platform_view_android_jni.cc的AttachJNI处修改Flutter默认的Settings,将Settings的application_library_path指向自定义路径下的libapp.so, 将assets_path指向自定义路径下的flutter_assets。至此,Android侧便能加载到自定义的代码和资源了。
3、再说iOS
3.1 实现思路
按道理iOS上也可以采取跟Android同样的思路,但是由于苹果开发者协议的规定,不允许动态更新、运行可执行代码;所以在Flutter资源的处理上,我们可以采用同Android一样的思路,但是对代码的处理,我们需要寻找新的方案。回顾之前的这些跨端方案,我们可以参照RN的实现,只不过N不再是Native了,而是Flutter。RN是通过JS控制Native渲染,我们要实现的是通过JS控制Flutter渲染。
开发者一定要用JS去开发吗?腾讯TGIF-iMatrix开源的MXFlutter[4]便是一个基于JS的Flutter动态化框架。它用极类似Dart的开发方式,通过编写JavaScript代码,来开发Flutter应用。
能不能对Flutter开发者透明? 最右探索了另外一条路,Flutter提供了一个强大的工具dart2js,借助这个工具我们可以实现编译阶段将Dart代码编译成JS。 为此,我们还需要研发一套框架,支持动态下发的JS控制Flutter渲染,并让Flutter回传事件,JS实现所有的业务逻辑,来实现动态化。
3.2 实现原理
我们有两方面的事情需要完成,一方面是修改Engine实现自定义资源加载,这部分的思路同Android端是一致的,唯一的区别是在iOS侧只需要支持自定义资源的加载,这部分就不再赘述了。另一方面就是实现一套类RN的框架,这部分相对比较复杂,后面会详细介绍。
我们确定了这套框架大致的工作流程,JS侧承载所有的业务逻辑,通过构建与业务逻辑匹配的Widget虚拟树,将其数据化传递给Flutter,Flutter解析这个UI描述,构建出真实的Widget Tree;
这个框架必须有三部分:由Flutter SDK的同名镜像类和业务代码一起编译生成app.js,我们称之为Client部分;在Flutter侧用于UI渲染和事件接收,我们称之为Host部分;还有一部分是连接两端的桥梁,不仅需要辅助实现JS和Flutter的双向通信,还为JS侧提供一些必要的机制,我们称之为Native部分。 我们将修改过的Engine编译出Flutter.framework,以及框架Host部分的代码(App.framework),预置到主工程中,这相当于在主App中给Flutter App提供了环境支撑。
把app.js和flutter_assets打包成一个资源包下发到端上,当用户启动某个Flutter App时,主App会将资源包的路径传递给Flutter Engine,并且启动了预置的App.framework,而且主App还会加载app.js,在JS与Flutter建立通信之后,实现Flutter App的运转。
3.3、iOS端动态化框架——JS2Flutter
3.3.1 Client部分
3.3.1.1 Flutter SDK Widget组件镜像
Widget组件主要是提供Flutter各种组件的镜像,为了便于Widget虚拟树的构建,每个Widget都有数据化成Json的能力,以MaterialButton为例,toJson时将splashColor、height等信息存入Json,Host侧在收到onPressed事件时会将事件传递给Client侧的镜像,由镜像的MaterialButton通知业务onPressed事件被触发。
class MaterialButton extends MapChildWidget {
const MaterialButton({
Key key,
this.onPressed,
this.onHighlightChanged,
this.textColor,
...
this.minWidth,
this.height,
this.child,
}) : super(key: key);
final VoidCallback onPressed;
final ValueChanged<bool> onHighlightChanged;
final Color textColor;
...
final double minWidth;
final double height;
@override
Map<String, Widget> children() {
return {'child': child};
}
@override
bool shouldGenerateId() {
return onPressed != null || onHighlightChanged != null;
}
@override
void handleEvent(String action, dynamic data, int callbackId) {
if (action == 'onPressed') {
onPressed();
} else if (action == 'onHighlightChanged') {
onHighlightChanged(data);
}
}
@override
Map<String, dynamic> toJson() {
Map<String, dynamic> json = super.toJson();
if (onPressed != null) {
json['onPressed'] = true;
}
if (onHighlightChanged != null) {
json['onHighlightChanged'] = true;
}
if (textColor != null) {
json['textColor'] = textColor.toJson();
}
...
if (minWidth != null) {
json['minWidth'] = minWidth.toString();
}
if (height != null) {
json['height'] = height.toString();
}
return json;
}
}
3.3.1.2 UI数据化
UI数据化的过程指的是在JS侧构建出虚拟树,然后将这棵树通过Json数据化之后传递给Flutter。
为什么要UI数据化? 我们的逻辑都是在JS侧控制的,但是真实的绘制能力是在Flutter侧,我们要想完成JS控制Flutter去渲染,就必须告诉Flutter我们想要渲染的是什么,这个过程就需要用Json来描述了。
怎样实现UI数据化? 每个节点都有数据化自己和子树的能力,就能从根节点完成数据化。对于树的构建,我们参照了Flutter的实现,根据不同的场景,提供了一些基础的Widget,如StatelessWidget、StatefulWidget、SingleChildWidget、NoChildWidget、DeferChildWidget、MultiChildWidget和MapChildWidget等。
3.3.1.3 通信机制
通信机制是整个框架的基石,Client部分主要是跟Native双向通信,要在JS侧建立异步、同步、Vsync等机制,当然这部分需要Native部分的配合。
大部分消息可能是无需关注返回结果的,比如通知Flutter侧刷新UI数据,但有部分场景也需要返回结果,这时候就需要异步、同步机制了。举个使用场景,我需要showTimePicker,然后获取到选择的时间,系统的返回值也是一个Future,这种场景我们就需要异步机制。同步其实是相对于JS侧的,针对于JSWorkThread,这是一个与UI绘制无关的线程,所以它的阻塞并不影响渲染和事件接收,所以这个同步也仅仅是JS侧的同步,有些方法是必须需要同步机制才能保证正确性的,比如你要通过TextPainter来测量文字的高度。Vsync的机制主要是用来支持CustomPainter和小游戏能力的,它们出现在那些直接通过Canvas自绘的场景。
除开这三大部分外,还有一些机制的支持,比如WidgetBinding、MethodChannel和EventChannel的支持,它们的实现思路跟Widget组件都一样,在Client侧都是镜像,真身还是在Host部分。Client部分的代码最终会被依赖编译到app.js文件中去。
3.3.2 Native部分
Native部分的XCJSRuntime创建了独立的JSWorkThread,并通过RunLoop建立消息循环,在这个线程完成了app.js的加载和执行。通过CADisplayLink,给JS侧提供了Vsync机制,Client部分的SchedulerBinding便是基于此Vsync机制去实现的。同时也实现setTimeout和setInterval等,这个主要是为了解决dart2js之后,Timer在js侧没有setTimeout和setInterval的问题。
3.3.3 Host部分
这部分主要是解析Client传递过来的数据,构建出真实的Widget Tree,当接受到用户事件之后,将事件传递给Client对应的镜像。
3.3.3.1 数据解析
数据解析包括两类,一类是包含Widget信息的数据,主要是根据携带过来的类型解析成对应的Widget,比如当识别到传过来的是MaterialButton之后,在Host会构造真实的MaterialButton,并填充其splashColor、height等。一类是通过Canvas直接绘制的指令,这类数据根据自己定义的协议解析出对应的Canvas命令即可,如save、restore、translate、rotate、drawImageRect等。
MaterialButton materialButtonCreator(Map<dynamic, dynamic> data) {
int widgetId = data['widgetId'];
VoidCallback onPressed;
if (data['onPressed'] ?? false) {
onPressed = () {
Flutter2JSChannel.instance.sendWidgetEvent('onPressed', widgetId);
};
}
ValueChanged<bool> onHighlightChanged;
if (data['onHighlightChanged'] ?? false) {
onHighlightChanged = (value) {
Flutter2JSChannel.instance
.sendWidgetEvent('onHighlightChanged', widgetId, value);
};
}
return MaterialButton(
key: newInstance(data['key']),
child: newInstance(data['child']),
onPressed: onPressed,
onHighlightChanged: onHighlightChanged,
textColor: newInstance(data['textColor']),
...
minWidth: numToDouble(data['minWidth']),
height: numToDouble(data['height']),
);
}
3.3.3.2 通信机制
Host侧的通信机制主要是通过MethodChannel跟Native双向通信,从而实现JS到Flutter,Flutter到JS的双向通信。
3.4 工作流程
3.4.1 编译阶段
借助dart2js这个强大的工具,将业务代码和框架的Flutter SDK镜像代码编译成app.js,跟flutter_assets打包成一个资源包,上传CDN。
3.4.2 加载阶段
加载分为两部分:一部分是flutter_assets资源的加载,一部分是app.js的加载;我们给FlutterDartProject添加了一个setAssetsPath方法,其作用就是指定Settings的assets_path,在FlutterEngine的initWithName构造方法中指定这个FlutterDartProject即可。在启动FlutterEngine之后,上层通过XCJSRuntime开始加载app.js。
3.4.3 运行阶段
XCJSRuntime在JSWorkThread线程中加载app.js,并在加载完成之后将Widget Tree数据发送到Native侧,然后经过MethodChannel传递给框架Host部分,框架先对数据进行解析,还原成对应的Widget,从而构建出真实的Widget Tree,至此便完成了页面的展示,当Flutter接收到事件之后,会回传给JS侧,由JS侧处理事件的响应。
4、爬过的坑
在实现JS2Flutter框架过程中遇到了很多大大小小的坑,挑几个印象比较深刻的坑跟大家分享。
4.1 Widget Tree状态同步
这个主要是针对StatefulWidget,StatefulWidget的应用非常广,而且经常出现在一些较为复杂的场景,由于最原始的UI描述数据来自于JS侧,当StatefulWidget对应的State触发刷新时,JS侧会重新构建子树,传递给Flutter,在Host侧解析新的数据,并重新渲染。由于整棵树的数据化结构是一个大的Map,从根节点开始进行遍历创建节点,StatefulWidget的数据其实依赖于父节点传给他的数据,问题就出现在这里。
试想一下如果有两个StatefulWidget嵌套,子StatefulWidget先触发了自身State的刷新,它的数据在JS侧已经变了,如果这个时候外层的StatefulWidget触发一次build(不由JS主动触发,比如进去一个新的页面,系统会触发一次build),子StatefulWidget的状态会被刷新成原始状态,因为子StatefulWidget本身的数据刷新,并没有将这部分数据同步到整个树结构中去,这是框架初期犯的比较大的一个逻辑错误。
4.2 延迟构造的Widget嵌套StatefulWidget
延迟构造子树的Widget很多,比如Builder、LayoutBuilder等,在框架Client端我们称它们为DeferChildWidget,这类Widget的实现基本上都是在Host侧预先用一个StatefulWidget占坑,然后在占坑的StatefulWidget的State的initState时机,向JS侧请求子树的数据,JS侧构建好子树数据后,再回传给占位的StatefulWidget,刷新其State,这时候才开始触发真实子树的构建。
而前期对于StatefulWidget的实现,虽然在Host侧有与之对应的StatefulWidgetHost(继承自真实的StatefulWidget)来实现自定义的StatefulWidget,但并没有用Host侧真实的时机同步给JS侧。框架Client侧回调给StatefulWidget的State的initState和dispose都是在JS侧根据虚拟树构建、销毁时回调的。
所以当DeferChildWidget嵌套一个StatefulWidget的时候,这里面就有一个时序问题,假如initState中有一个异步获取数据(如:从SharedPreference获取一个状态),拿到数据后更新状态的操作,而这些都先于DeferChildWidget在Host侧预占坑的StatefulWidget触发其真实子树的构建的时候,问题就暴露了。
其实,实现这样一个框架还遇到了很多有挑战的问题,例如:小游戏如何高效绘制?如何提升通信效率等等?在此就不展开讨论了。
5、结束语
Flutter的流行已经势不可挡,相信有很多开发者已经在Flutter的动态化方向上做尝试,本文分享了最右App在实现Flutter动态化的过程中的一些经验教训,希望对大家有所帮助。最右App所采用的Flutter版本是1.9.1+hotfix.6,本文所讨论的技术都是基于此版本,Gityuan的文章——深入理解Flutter引擎启动[2]是基于Flutter1.5的源码进行分析,源码细节略微有些差异,启动过程是一致的。
6、参考文献
[1]:Engine编译环境构建 https://github.com/flutter/flutter/wiki/Setting-up-the-Engine-development-environment
[2]:深入理解Flutter引擎启动 http://gityuan.com/2019/06/22/flutter_booting/
[3]:Engine编译 https://github.com/flutter/flutter/wiki/Compiling-the-engine
[4]:MXFlutter https://github.com/mxflutter/mxflutter
作者:刘剑_最右
来源:掘金