查看原文
其他

【第2166期】满帮动态化Flutter框架 — Thresh开源啦~~

满帮 前端早读课 2021-03-08

前言

今日前端早读课文章由满帮@小小鲁班推荐投稿分享。

徐维顺:满帮移动端Android工程师,大前端业务中台移动端创新技术组负责人,负责满帮Flutter体系化建设和订单业务线。曾就职于苏宁易购。坚持探索新技术,并致力于提升端到端可用性和体验。

章伟:满帮移动端技术专家,大前端业务中台移动端负责人,负责移动端业务中台能力建设、跨平台开发和移动端持续效率提升。曾就职于腾讯微信团队,从0到1参与了多个平台微信客户端的开发和持续优化,对于移动端开发有深刻理解和丰富经验。

正文从这开始~~

移动端技术栈自诞生以来,其双端开发成本和发布效率一直广受诟病。为了解决这些问题,前端跨端技术一直在不断尝试,希望能一次开发、多端运行并且能做到快速发布。期间经历了多个技术发展阶段。

第一阶段:以H5为代表,基于webview渲染

只需一次开发即可运行在双端,解决了开发效率低下的问题。但是webview存在严重的性能问题,用户的交互体验相比Native渲染有明显差距。

第二阶段:以RN和Weex为代表,前端技术栈开发,Native渲染

这些方案使用前端技术开发,最终映射到Native组件渲染,用户体验相比H5方案有了巨大的提升。但是这一阶段的方案同样存在不足。由于框架的渲染最终还是依赖双端Native组件,存在双端体验不一致性和平台兼容问题,极端情况下开发成本甚至超过双端Native开发。

第三阶段:Flutter,自绘引擎渲染

Google基于Skia渲染引擎,推出了Flutter跨平台框架,支持了iOS、Android、Web等平台。基于一致的渲染引擎,Flutter抹平了各个平台的差异,真正做到了一处开发,多端运行。业内对于Flutter彻底解决跨端开发的问题也寄予厚望。但是Flutter也并不完美,其动态能力不足,无法像H5、RN等技术一样快速发布。

动态化更新能力无疑是研发效率提升最重要的一环,不仅研发效率能提高一倍,并且能大大缩短业务发版周期和降低发版成本,已经成为所有互联网大前端团队的关注的重点。

为了解决动态能力不足的问题,满帮大前端团队从2019年开始对Flutter动态化能力进行探索,开发并逐步完善我们的动态化Flutter框架。该框架在支撑上线了第一个页面后,线上数据指标表现达到预期,随后一边持续优化迭代,一边大规模上线10+业务场景,包括核心页面订单详情、货主货源详情等等。

接下来我们就详细聊一聊我们的自研Flutter动态化框架:Thresh。

没有套路,先上开源链接:

  • 主工程:https://github.com/ymm-tech/thresh

  • 模板工程:https://github.com/ymm-tech/thresh-template

  • 命令行工具:https://github.com/ymm-tech/thresh-cli

Flutter的动态化思考

核心目标

赋予Flutter动态化更新能力,并且利用其高效的渲染性能和多端一致性的特性 ,取代H5和React Native,作为新一代动态化跨平台开发框架。兼顾学习成本,让开发人员能快速学习并顺畅使用。

动态化常见思路
Flutter编译产物替换

Google原本打算在2019年推出Code Push方案,后来放弃了,主要两个原因:违反应用商店的规定和安全方面考虑;但目前android是可以通过产物替换来做到动态化,iOS端则无法做到。

组件化搭建

通过Dart来定义部分核心通用组件,在平台下发已有的组件列表拼装的页面JSON,端上再通过解析渲染成页面。这种方案能满足轻交互场景,但只能支持有限动态性。

自定义Dart转换+动态逻辑映射

通过自定义一套Dart规范以及通过转换器生成JSON来做到动态更新,性能损失小,但是逻辑动态性需要提前预埋,且前端开发同学需要一定的学习成本。

自定义DSL+依赖JS引擎的动态执行

类似于RN/Weex,通过自定义动态化UI描述 + JS引擎的解释运行转换思路,最终构建成页面和执行动态逻辑。这个方案对于前端开发非常友好,零学习成本,但是由于在JS引擎运行,会有一些性能损耗。

Thresh的选择

满帮的实际使用场景,业务快速迭代,需要Android和iOS都要支持动态性,所以产物替换的思路不能完全解决问题。随后又考虑使用组件化思路,拼接多个业务组件虽然能搭建出页面,但是弊端也很明显,复杂交互逻辑时无法实现。另外自定义Dart描述UI方案虽然满足了动态更新的要求,但是逻辑动态性依旧不强,而且Dart开发对于前端开发同学有一定的学习成本。

最终,在综合考虑了开发效率、学习成本、多端性能和一致性等因素,我们选择了自定义JS描述UI + JS引擎的解释运行转换思路,开发语言同时支持JS/TS,这样满帮Thresh便应运而生。

Thresh实现原理

构建Dart页面原理

在 Flutter 中描述视图组成的基本单位是 Widget,每一个 Widget 只包含当前部件的配置信息,它是一个轻量的、可被高效创建并销毁的数据结构。而许许多多的 Widgets 组合在一起,构建出了一个包含视图所有信息的 WidgetTree。之后 Flutter 会从 WidgetTree 中生成 ElementTree,再由 ElementTree 生成 RenderObjectTree。ElementTree 中的 Element 会同时持有其对应的 Widget 与 renderObject。

三棵树中,WidgetTree 会被频繁创建于销毁,但是 ElementTree 和 RenderObjectTree 只会在发生状态改变的时候才会改变,ElementTree 负责元素的更新与 diff,RenderObjectTree 则负责实际的布局与绘制。

核心思路是把 Flutter 的页面渲染逻辑中的三棵树中的第一棵树Widget,通过JS 来构造。这其中要完成JS与 Flutter 层完成基础组件映射,再通过JS引擎来生成UI描述,并传递给Dart层的 UIEngine,UIEngine 把UI描述转换为 Flutter 控件,最终渲染成页面。

Thresh框架完成了常用基础组件的定义与开发,能支撑95%以上业务场景的接入,语法定义规则支持React,对前端开发人员零成本接入。现支持的组件列表以及其部分属性:

Flutter初始化

Flutter 是由 main() 函数开始执行,执行以下几个步骤:

  • 建立与 Native 之间的通信渠道 MethodChannel 以保证所有的通信都能够被接收和发送;

  • 建立接收到消息时的所有处理方法的分发渠道,以保证所有合法的通信都能够在 Flutter 中被正确处理,同时通过 MethodChannel 向 JS 发送当前设备的媒介数据;

  • 注册拦截函数,以便在接收到渲染 JSON 数据后将 JSON 转换为 Widget;

  • 最后建立 Flutter App 的初始承载页面,该页面在接收到 JS 发送显示页面的消息之前将会一直处于等待状态;同时向 JS 发送 ready 消息,表示 Flutter 环境已准备完成,可以显示页面。

生成WidgetTree

依据 Flutter 中对 Widget 注册的所有拦截函数,JS 中会提供一套与之相对应的原子组件,以便在两种不同的 DSL 之间进行组件的互相转换。在 JS 中 UI 的构建通过 JSX 实现,借鉴了 React 的写法。

通过在 JS 中构建 UI 的描述层,再将 UI 描述转换为 JSON 格式字符串,经由 Native 发送到 Flutter ,由 Flutter 对 JSON 字符串进行解析后创建对应的 WidgetTree 并执行后续渲染操作。

JS与Flutter通信

在 JS 代码执行之前,Native 会向 JS 代码的执行环境中注册两个通信方法,一个为 JS 向 Flutter 传递消息的通道,另一个则是 Flutter 向 JS 传递消息的通道。

通过这两个通道,就可以实现所有数据在 JS 与 Flutter 之间的流转。

构建Flutter页面

对于当完成所有链路的数据转换后就会拿到ModelTree & WidgetTree,ModelTree会持有并缓存WidgetTree,最终构建一个Widget页面并渲染显示。页面构建渲染流程主要是:

Flutter 接收到渲染 JSON 数据后,会通过递归遍历的方式从最底层开始,将每一个独立的渲染数据节点解析为 Model 对象。Model 将会持有所有的渲染数据,同时会关联自己的父节点;同时 Model 会携带所有的渲染数据,通过 Widget 拦截函数生成其对应的 Widget 实例,并持有该 Widget 实例。

如 JS 中的 <Container/> 组件在 Flutter 中经过拦截函数将会被创建为一个名叫 DFContainer 的 widget 实例。DFContainer 等 widgets 是使用 Flutter 提供的原子组件单独封装的一套与 JS 中的原子组件一一对应的自定义组件。

当通过 model 创建 Widget 时,如果发现其 isStateful = true,则会在该 Widget 实例外层包裹一个 StatefulWidget,同时让 model 持有该 StatefulWidget 及其 state,以便之后进行更新操作。也就是说,如果一个 model 具有 isStateful = true,则其会同时持有 Widget & statefulWidget & state。

在遍历过程中,原先的 JSON 数据会被转换为两个树 —— ModelTree & WidgetTree。其中 WidgetTree 中的每个节点都会被 ModelTree 中对应的节点所持有。

对于首次显示的页面来说,会使用被创建的 WidgetTree 直接替换初始化时创建的承载页面的内容;而非首页则会直接通过 Navigator.push(),使用 WidgetTree 创建并显示一个新页面。

通信机制

JS 与 Flutter 是依赖于 Native 又完全独立的两端:JS 中的数据运算与流转不会直接影响到 Flutter 页面的渲染;Flutter 的渲染过程也不会阻塞 JS 的代码执行。

为了让完全独立的两者产生联系,我们找到了一个既能与 JS 产生联系,又能与 Flutter 传递消息的媒介 —— Native. 通过将一个消息从一端传递给 Native,再由 Native 完整传递给另一端,就实现了 JS 与 Flutter 之间的通信。

动态化Flutter 框架主要由这三部分构成,每一部分都处理不同的逻辑和绑定事件通信来更新渲染页面、事件响应,其核心渲染通信流程:Flutter ⇋ Native ⇋ JS 。

搭建三端通信链路

Flutter 初始化时,Flutter会与Native通过 methodChannel 建立通信关系,methodChannel 是一条双向通信的链路,既可以在 Flutter 中接收到 Native 的消息,也可以主动向 Native 发出消息。

同时,Native 在执行 JS 代码之前会向 JS 的 context 中注入一个方法,我们将这个方法命名为 methodChanneljscall_flutter,用来使 JS 能够向 Flutter 传递消息。

在 Flutter 动态化中,一个完整的跨三端的通信链路一般是:

从上面两个链路中会发现,第一条链路是完整的,消息可以顺利到达 Flutter;但是第二个链路在 Native 中断掉了,没有一个通道能够将消息传递到 JS 中。为了解决这个问题,JS 会在 context 中暴露一个名为 methodChannelfluttercall_js 的方法,该方法的参数即为消息内容,这样 Native 就能够直接调用该方法将消息传递到 JS。

“半双工”通信与通信反馈

在Thresh中,几乎所有的三端通信需求都是“半双工”的。此处的“半双工”指的是,当一方作为消息传递方时,无法通过当前传递消息的通道获得消息接受方的反馈。这就表示当传递方发送出一条消息后就会结束自己的通信行为,它们不需要去关心自己是否会得到反馈,而实际上也不会有任何反馈。

基于以上情况,Thresh中的所有通信链路都会使用这种模式进行通信:消息传递方只需要传递数据而不需要关心回调,消息接收方只需要处理数据而不需要返回处理结果。这种模式对于跨越三端的通信来说更便于管理和约束,也使得 Native 成为了一个完全的数据中转站,否则 Native 除了需要传送数据外,还需要处理结果的反馈工作。

于是,一条清晰的数据流转路线便形成了:数据传递方 -> 数据中转方 -> 数据接收方。

但是并不是所有的通信都不需要反馈,例如与 Native 通信的双端通信链路 bridge,在向 Native 发出通信消息后需要获得 Native 的处理结果。对于这种情况,简单粗暴的单向通信将无法直接满足需求。但如果换成携带回调的“全双工”通信,从而能够在同一个通信通道上实现结果的接收,将会破坏原有的通信模式,也为通信的管理增加了难度。

为了解决在“半双工”通信模式上的通信反馈问题,我们通过在传递方为每一个需要反馈的通信加上标识符,再将反馈处理方法通过标识符缓存;当接收方处理完成后,携带标识符通过另一个通信通道将处理结果作为一个新的消息传递给原本的传递方后(在这个新的通道中,原本的数据传递和接收方将会互换身份),传递方会根据标识符在缓存中查找到处理方法并执行处理逻辑。

确保首次通信的正确接收

JS 与 Flutter 的通信是 Flutter 动态化的基石,而首次通信的成功与否又是通信能否成功建立的首要条件。

由于所有的跨三端通信都是“半双工”的,而 JS 与 Flutter 的环境准备又各自完全独立,这也就导致如果任一方环境准备完成前,另一方就发送了消息,这就会出现环境未完成的一方无法接收到消息的情况,从而影响后面所有的通信,导致通信中断或错乱。

为了解决这种情况,JS 与 Flutter 中采取了以下策略来保证首次通信的顺利执行(以下以 A / B 代指 JS 与 Flutter 中的任一方):

  • A 环境准备完成后会立即向 B 发送通知;

  • 如果 B 已准备好则会立即回复一条通知,A 收到回复通知后标记双方环境已建立,可进行后续的通信;

  • 如果 B 未准备好,则 A 将不会收到任何回复,直到 B 准备好,此时A / B 身份互换,会重新回到步骤 1.

组件更新与事件传递
JS事件触发与传递

在将 JS 中的事件函数转换为 id 后,这个 id 也会与节点所属页面名称、节点 id 一起被携带到 Flutter 中,最终这三个信息会被包装为一个 Flutter 中的事件函数。

当在 Flutter 中触发事件时,首先会触发这个函数,该函数会向 JS 发送一条携带了页面名称、节点 id、事件 id 以及事件参数的消息。JS 接收到该消息后,首先会根据页面名称与节点 id 查找到触发了事件的节点,接着通过事件 id 在节点事件池中查找到对应的事件,传入参数并执行该事件。

JS组件更新

触发事件的目的大部分都是为了更新页面上的内容,在 JS 中,组件更新的基本单位是自定义组件。当一个自定义组件触发 setState() 后,会将该组件推入更新队列中等待更新。在节点进入队列之前会进行去重,从队列中进入第一个组件开始后的 16ms,队列将执行更新操作。在这 16ms 内进入队列中的其他待更新组件将会一同触发更新。

在实际进行更新操作前,会先对队列中的元素进行父节点的去重,即:依次获取所有待更新节点,同时向上获取该节点的父节点,如果其父节点存在于当前队列中,则从队列中移除该待更新节点,不存在则保留。这样做是因为只要队列中存在了父组件,则子组件就一定会被更新;其目的是为了执行最少次数操作,但实现尽可能多组件的更新。

组件的更新借鉴了 React 的组件更新 diff 算法,但是由于引入了 Flutter StatefulWidget 和 StatelessWidget 的概念,因此相比 React 的 diff 算法,thresh.js 的 diff 算法是粗粒度的。两者相同的地方在于:都会对每一个节点进行对比,以保证每一个节点的状态都正确,最终被正确更新。

不同点在于:React 除了会对同类型节点进行属性和状态的合并外,也会将新创建或被删除的节点在旧节点数组中进行插入或删除操作,操作和更新的基本单位是原子组件;而 thresh.js 只会关注那些更新前后依然保留的同类型节点,在完成属性与状态的合并后,会直接抛弃旧节点,保留新节点,最终新节点将替换待更新自定义组件中的旧节点,并使用更新后的自定义组件的数据向 Flutter 发出更新消息——更新的基本单位是自定义组件。

Flutter组件更新

JS 发送的更新消息有两部分组成:需要被更新的页面名称、更新节点 id 以及更新节点的 JSON 数据。当 Flutter 收到 JS 发送的更新消息后,首先会重复 json 转换为 Model 步骤,创建出 ModelTree & WidgetTree. 之后通过更新的页面名称和节点 id 在缓存中查找到需要被更新的 Model。

由于更新以 JS 中的自定义组件为最小单位,而每个自定义组件在 Flutter 中都会被创建为 StatefulWidget,因此在获取到新旧两个 Model 后会进行如下操作:

  • 将 newModel 的渲染数据、子节点 models 及其所持有的 newWidget 合并到 oldModel;

  • 通过 oldModel 所持有的 state 将 statefulWidget 中所包裹的 oldWidget 更新为 newWidget;

  • 通过 state 完成组件更新操作后,Flutter 会对被更新的组件进行 diff 与重新渲染,以保证页面能够显示新的内容。

Thresh工程化

Thresh架构设计

如上图所示,自下而上,CI/CD + 基础服务 + 监控上报等支撑了Thresh业务,最上面为架构图。

  • X-RAY为公司自研的生产发布平台,支持Bundle包的构建下发以及运维。

  • 顶部是整体Thresh的架构流程图,包含 页面开发、DSL 的转换、通信等等,用于构建页面与逻辑。

Thresh基础建设

Thresh动态化跨平台方案虽然在设计上有高性能渲染、一致性、开发效率、前端同学零成本接入等优势,但是考虑未来多业务方接入以及提升开发调试效率,推进了Thresh周边基础设施建设,下面简单介绍开发期、调试期、发布期。

开发期

支持plugin方式接入,业务方接入提供一套模板工程,能快速进入业务开发;另外Thresh兼容TS,能较低成本的让前端开发融入。

调试期

通过支持HotReload模式,秒级编译,极大的提升开发和调试效率,另外提供调试面板 + 动态调试能力也能极大地辅助提高调试效率。

发布期

依赖满帮自研的X-RAY灰度发布系统,具备分钟级别动态发版能力,能快速支撑业务和问题修复。

Thresh开发集成

Thresh的开发集成形成了一整套流程,涵盖三方集成、多业务模块接入、开发调试等等,其中涉及细节比较多,这个在开源仓库里面有详细介绍。

至此,Thresh的架构设计和开发集成能力都基本完成,相比于其他动态化跨平台开发框架,Thresh有如下优势:

  • 基于JS的自定义DSL,扩展性强,学习成本低

  • 多端一致性,拥有统一的自渲染引擎skia,较好的跨端兼容性适配

  • 支持Hot Reload,便于开发调试,秒级编译

  • 支持组件级别UI刷新,极佳的体验性

Thresh在满帮的应用

Thresh目前应用在满帮 运满满/货车帮的司机端、货主端4个主app中,场景涵盖列表、复杂交互、表单、展示等不同类型10+页面,日均PV超千万,JS错误率低于十万分之一。

主要应用核心场景如下:

Thresh团队也对数据指标做了详细统计( 包括FPS、Engine初始化、加载bundle包时长、加载JS时长、render时长、通信链路时长等),页面首屏时长(不含网络指标参考:bundle包大小=1M, Android平均首屏时长600ms iOS平均首屏时长500ms ), 从各项指标监控数据来看,基本满足了满帮技术团队对页面性能的要求,虽然在部分指标在低端机型表现有影响,也为我们持续优化提供了方向。

总结与展望

我们通过自定义DSL + JS引擎的解释运行转换思路,实现了这套完全动态化的Thresh跨平台方案,并建设了一套Thresh生态体系,涵盖了开发、调试、测试、发布、运维各个阶段。满帮的多个业务场景落地,大大提升了开发效率、缩短了需求发版周期。

未来我们进一步完善Thresh的生态建设以及提高性能:

  • 性能优化:1、页面首屏加载平均时长缩短到300ms内,主要通过优化bundle包页面加载通过函数驱动、引擎单例化等;2、通信链路优化,减少通信链路路径,探索基于dartNative的双向通信方案 。

  • 稳定性,持续跟踪并解决Thresh问题,以及Flutter SDK升级。

  • 调试开发效率提升,完善调试工具以及脚手架,Debug模式引入Webview容器支持动态调试,Bundle包构建、发布流程优化。


为你推荐


【第1903期】深入浅出动态化 SSR 服务(一):开发工具篇


【图书】Flutter:从0到1构建大前端应用


【第2108期】开源富文本编辑器技术的演进


欢迎自荐投稿,前端早读课等你来

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存