Flutter动态化在最右App中的实践

2020 年 1 月 07 日

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 事件被触发。



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 等。



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 编译环境构建 Wiki https://github.com/flutter/flutter/wiki/Setting-up-the-Engine-development-environment


[2]:深入理解 Flutter 引擎启动 http://gityuan.com/2019/06/22/flutter_booting/


[3]:Engine 编译 Wiki https://github.com/flutter/flutter/wiki/Compiling-the-engine


[4]:MXFlutter https://github.com/TGIF-iMatrix/MXFlutter


作者介绍: 刘剑,最右 App Android 工程师,近 1 年主要从事 Flutter 相关领域的技术探索,负责 Flutter 的动态化在最右 App 落地及成功实践。


2020 年 1 月 07 日 10:313040

评论 3 条评论

发布
用户头像
有相应的Demo参考下吗?谢谢。
2020 年 07 月 30 日 10:39
回复
用户头像
有相应的Demo参考下吗?谢谢。
2020 年 05 月 19 日 16:20
回复
用户头像
可以,写得很好👌
2020 年 03 月 11 日 22:43
回复
没有更多评论了
发现更多内容

训练营第二周作业

大脸猫

极客大学架构师训练营

区块链云算力挖矿平台开发公司,云算力软件开发价格

135深圳3055源中瑞8032

C 语言实现一个简单的 web 服务器

C语言与CPP编程

c c++ socket C语言 HTTP

“绝影”机器狗如何利用ModelArts强化学习算法更改导航轨迹

华为云开发者社区

人工智能 机器狗 导航

DDIA 读书笔记(3)数据编码与演化

莫黎

cglib 入门前篇

Rayjun

Java cglib

第六周 Doris临时故障时序图

Geek_fabd84

架构师训练营第 2 周学习总结

菜青虫

极客大学架构师训练营

作为打工人,如何正确的选择自动化测试工具(完全指南)

软测小生

软件测试 测试 软件自动化测试 测试工具 测试开发

技术实践丨PostgreSQL插件之pg_dirtyread "闪回查询"

华为云开发者社区

数据库 后端

前端组件化

韩向民

新手初学Java性能之 垃圾收集器

Java架构师迁哥

区块链将与人工智能、物联网、云计算技术形成互补

CECBC区块链专委会

人工智能 物联网

承兑商支付平台开发搭建,USDT支付系统解决方案

135深圳3055源中瑞8032

训练营第二周总结

大脸猫

极客大学架构师训练营

2020中国系统架构师大会活动回顾:ZEGO实时音视频服务架构实践

ZEGO即构

架构设计 RTC

架构训练营 - 第6周课后作业 - 学习总结

Pudding

架构师训练营第 1 期 week6

张建亮

极客大学架构师训练营

架构师训练营第 1 期第 6 周作业

du tiezheng

极客大学架构师训练营

二、ood原则

Geek_28b526

人少钱少需求多的新项目该怎么带?看到这篇我心里有底了!

华为云开发者社区

技术 运维 后端

区块链交易所源码开发,币币场外交易系统搭建

135深圳3055源中瑞8032

架构师训练营 -week06-总结

大刘

极客大学架构师训练营

区块链钱包开发技术公司,数字钱包APP系统搭建

135深圳3055源中瑞8032

第2周 框架设计-作业

SuGeek

一周信创舆情观察(10.19~10.25)

统小信uos

架构师训练营第二周作业

李日盛

架构

打破内卷化,AppGallery Connect的全生命周期赋能方案

脑极体

Mac常见问题解决方案与使用技巧

jiangling500

Mac

第二周-作业

Geek_0b0f83

极客大学架构师训练营

架构师训练营 - 第 6 周课后作业(1 期)

Pudding

Flutter动态化在最右App中的实践-InfoQ