阿里、蚂蚁、晟腾、中科加禾精彩分享 AI 基础设施洞见,现购票可享受 9 折优惠 |AICon 了解详情
写点什么

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

  • 2020-01-07
  • 本文字数:5404 字

    阅读完需:约 18 分钟

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-01-07 10:316153

评论 3 条评论

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

花两个半月吃透这份Java手打面经,成功从外包上岸到京东

Java迁哥

Java 学习 腾讯 面试 资料

区块链交易所开发源码,数字货币交易所app开发

13530558032

week12学习总结

burner

程序员如何获取一份高薪工作?阿里P8大牛给你一些中肯的建议

Java迁哥

Java 华为 程序员 面试 资料

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

fujin

鲲鹏迁移第一批吃螃蟹的人,践行技术国际化

华为云开发者联盟

鲲鹏920 服务器 华为云 ARM芯片 X86

LeetCode题解:84. 柱状图中最大的矩形,双循环暴力,JavaScript,详细注释

Lee Chen

大前端 LeetCode

35K成功上岸华为商城事业部,只因学透了这几个开源的商城项目

Java迁哥

Java 华为 源码 资料 商城项目

9块钱,构建个私有网盘,关键不限速

华为云开发者联盟

网站 OBS 在线网盘 华为云 云存储

隐私计算会成为“金融”向“数科”转型的一剂猛药?

hellompc

【API进阶之路】破圈,用一个API代替10人内容团队

华为云开发者联盟

内容 编辑 API 华为云 文本摘要

高效程序员的45个习惯:敏捷开发修炼之道(7)

石云升

敏捷开发 晨会

架构设计复杂度来源

escray

学习 从零开始学架构 架构师预科班

第 0 期架构师训练营第 8 周作业2-总结

fujin

Java中强、软、弱、虚四种引用详解

奈学教育

Java

week12 homework

burner

数字货币钱包系统定制开发,区块链钱包源码

13530558032

【运维探讨】RPA落地实践,提升IT运维工作效能!

嘉为蓝鲸

RPA 运维自动化 标准化 系统运维 流程

合约跟单系统开发,数字货币合约跟单软件搭建

13530558032

迭代技术方案设计文档规范

程序员架构进阶

技术方案

突破传统 区块链如何实现病历永存

CECBC

区块链 电子病历 信息共享

区块链技术成为金融业务应用热点

CECBC

区块链 人工智能 金融

Java中强、软、弱、虚四种引用详解

古月木易

Java

为什么阿里巴巴的程序员成长速度这么快,看完他们的内部资料我明白了

Java迁哥

Java 阿里巴巴 程序员 成长 笔记

区块链支付系统源码开发,USDT承兑支付平台

13530558032

2019年我最喜欢的三款数码产品。

徐说科技

手机 苹果

浅析LR.Net工作流引擎

Learun

.net 敏捷开发 工作流

基于Prometheus的微服务应用监控

易观大数据

Docker 网络模式详解及容器间网络通信

哈喽沃德先生

Docker 容器 微服务

分析HiveQL 生成的MapReduce执行程序

任小龙

ArCall远比你想象的要强大的多

anyRTC开发者

WebRTC 在线教育 直播 RTC 安卓

Flutter动态化在最右App中的实践_文化 & 方法_刘剑_InfoQ精选文章