写点什么

【最右】面向 TS 生态的新型 Flutter 框架

作者:刘剑
  • 2022 年 9 月 28 日
    北京
  • 本文字数:6899 字

    阅读完需:约 23 分钟

【最右】面向TS生态的新型Flutter框架

一、写在前面

这篇文章主要介绍最右在 Flutter 相关领域技术探索取得的新成果,以及实现过程中的一些思考,相较于之前跟大家分享的JS2Flutter框架,这次的技术成果更具创新性,其中也有不少有趣的难题。本文一来分享实践经验,供感兴趣的同学参考;二来算作对自己在这个工作过程中的总结。

二、背景

随着业务场景复杂度的提升,JS2Flutter在某些方面的支持逐渐显得有些力不从心,这种业务逻辑与 UI 渲染分离的实现方案注定有些事情很难做,例如:当遇到超长列表需求时,ListView.builder 这种方式在响应快速滑动的过程中不够流畅。


再加上 Dart 生态的不足,如果能打通 JS/TS 生态,前端多年沉淀的各种 package 都可以得到运用。最右对于动态化能力有比较强的诉求。我们既要解决频繁通信带来的性能瓶颈,又要解决动态化问题,还期望能面向 TS 生态,如何才能解决这个问题?


最右决定另辟蹊径,提出一种新的构想,打造一种 Flutter Web 与 Flutter Mobile 的结合体,同时解决这三大问题。彼时恰逢 Flutter 2.2 发布时,Flutter Web 版本逐渐稳定,我们决定基于 Flutter Web 探索出一种能兼容 JS/TS 部分生态,且能在移动端(非 WebView)的独立环境(类似 Flutter Mobile Engine)运行的小程序框架。

三、实现路径

新的构想确定之后,需要去探索具体的实现路径,从上而下依次可以分为这么几个要点:

3.1 必须提供一致的开发体验,保持 API 一致,即构建 TS 版本的 Flutter Widget。

Flutter 中的 Widget 在经历 dart2js 编译之后,基本上都变成了原型函数。面向开发者提供 TS 版本的 Flutter Widget,而且需要让这些 Widget 与 framework 中真正代表这些能力的原型函数进行关联。例如:Flutter Text Widget 在编译之后,可能已经变成了某个原型函数,我们提供的 TS 版本的 Text Widget,必须要关联上这个原型函数,这样才能使 TS 版本的 Text 具备真正的活力。

3.2 打造新的渲染模式,将 RenderTree 转化成 LayerTree,通过 JSBinding 传递给底层。

Flutter Web 关于 Render 树所需要的底层渲染支撑都是在 flutter_web_sdk 中实现,它实现了 html 和 canvaskit 两种渲染方案,考虑到性能以及移动端的实现成本,我们决定在 canvaskit 模式上进行改造,主要是因为 html 模式编译出的 html 标签过于复杂,另一方面 canvaskit 是 skia 的 wasm 版本,便于我们在移动端去模拟 canvaskit 的实现。这样我们基于 canvaskit 模式可以提供另一种新的渲染模式。

3.3 支撑新构建的渲染模式,提供渲染的 Engine 层。

这里我们可以参考 Flutter Mobile Engine 的实现,我们跟 Flutter Mobile 的区别无非是语言不同,我们需要构建与 Dart Runtime 对应的 JS Runtime。另外就是 Flutter Web 本身是单线程模型,我们要把线程模型改造成跟 Flutter Mobile 一致的线程模型,以及对外界纹理、Platform Channels 等的支持。

四、问题拆解

实现路径确定之后就要逐一拆解问题,探索关键核心问题基于现有资源实现的可能性。

4.1 面向开发者提供 TS 版本的 Flutter Widget,背后衔接 flutter framework 编译后生成的 js,这部分我们称之为胶水层。胶水层的目的很明确,提供跟 Flutter 一致的 TS 版本的 API,关键难点在于跟 flutter framework 编译后生成的 js 中对应 Widget 的原型函数进行关联。这个过程会涉及到以下问题。

4.1.1 如何 Keep 想要保留的原型函数?

Flutter Web 项目编译成 js 文件的体积是比较庞大的,为了考虑体积压缩,我们必须使用 release 的方式让 dart2js 去开启最高级别的优化,生成体积最小的 js 文件。但是由于 dart2js 不支持混淆 Keep,我们必须实现自定义规则的混淆 Keep,保留住我们关心的函数,变量等。

4.1.2 如何让原型函数输出在稳定的作用域?

通常在 profile 和 release 模式下,编译出来的 js 中的原型函数都挂载在不同的作用域下,如 A,B,C。(这部分跟 dart2js 的实现方式有关,每个版本可能有些差异。)如果经历 flutter 升级,升级之后再编译,某个 Widget 是否还在原来对应的作用域下。例如,在 Flutter 2.2 的时候,对应的 dart2js 编译出来的常量、枚举会始终存放到 C 这个作用域下,但升级到 Flutter 2.10 的版本之后,常量、枚举不再完全集中在 C 这个作用域下。这是受 dart2js 版本实现方案的影响,所以我们必须修改 dart2js,维护好自己的命名规则,将我们想保留的类挂载到指定的作用域。

4.1.3 如何保持与 Dart 一致的泛型?

以 Animation 为例,在 dart2js 的过程中,其泛型经历了 Rti 模块擦除,由实现类指定类型决定其成为某一确定的类型。如果我们想保持跟 Flutter 一致的体验,我们需要在胶水层构建出泛型,这个过程有些复杂,在此先不展开。


胶水层主要的难点在以上这几个问题,实际实现过程中还会遇到一系列的细节问题,例如:如何快速有效的反 dart2js 的 Tree-Shaking?我们要保留各种 Widget 以及 API 类的属性及函数,甚至有些类由于多层继承关系的存在,可能会在编译时将原本在父类中实现的函数优化到子类中。

4.2 上面算是完成了胶水层面向开发者提供与 Flutter 一致的 TS 版本 Widget 能力的任务,但是它依赖的这些原型函数都集中在 flutter framework 中,能否将 framework 剥离出来,与胶水层一起做成 npm package,进入前端开发生态,是另一个比较大的问题。

4.2.1 如何剥离出 flutter framework?

这是一个非常关键的问题,通常情况下,我们的业务代码会和 flutter framework 代码以及 flutter_web_sdk 一起编译,最终编译到 dartProgram 函数体内部,当编译后的 js 加载执行时,业务代码也随之启动。而我们期望的是单独把 flutter framework 剥离出来,不受业务代码的影响,让胶水层面向开发者提供的 runApp 函数就跟 Flutter 提供的 runApp 函数体验一致。这个问题也容易解决,我们可以采用根容器占位的方式去实现,后续业务调用胶水层提供的 runApp 函数其实只是去刷新根容器内的 Widget 树。

4.2.2 如何构建新的渲染模式?

前面已经提到了,我们是基于 canvaskit 模式进行改造,如果直接采用 canvaskit 模式去实现也是可行的,但是没办法像 Flutter Mobile 那样构建 LayerTree 以及处理 RasterCache,所以为了让底层获得完整的 LayerTree 结构,整个 SceneBuilder、Scene、EngineLayer 都有独立的实现,通过 JSBinding 在底层 Engine 提供支撑。同时为了尽可能减少对 html 的依赖,避免走上了实现简化版 Web 浏览器的道路,我们需要摘除 dom、css 相关的这部分能力,这些修改主要集中在 flutter_web_sdk 中完成。当然摘除的远不止这些,还有 CanvasKit 下载初始化及 Flutter Web 处理字体相关的逻辑等。

4.3 现在就差最底层的环境支撑了,大致可以分为 JS Runtime、CanvasKit API Binding、部分 Web 标准 API、事件、线程模型、Platform Channels 等。

4.3.1 JS Runtime

这里在 Android 上我们采用 V8,同时也在 J2V8 的基础上进行改造,提供能自定义注册 Java Binding 类到 V8Runtime 的能力,方便部分能力直接从 Java 层提供,例如:对于 XMLHttpRequest 除了框架内置的默认实现外,我们可以开放给开发者自己去实现,可以有效的复用原生 App 中的网络请求框架。iOS 上我们采用 JSCore 去实现。

4.3.2 CanvasKit API Binding

这部分主要是模拟 CanvasKit 的 API,由于 CanvasKit 是 skia 的 wasm 版本,这一部分的实现相对比较容易,基本上都能从 CanvasKit 本身的实现中找到答案。

4.3.3 部分 Web 标准 API

Flutter Web 中某些依赖浏览器能力去实现的机制需要得到补全,这部分浏览器能力基本上都是 Web 标准,我们只需要按照标准去实现即可,例如:setTimeout,我们可以通过 JSBinding 在底层实现其能力。

4.3.4 事件

事件的实现本身不难,有两种方式去实现,一种是通过给根节点的 Element 设置事件监听,当 Platform 接受到事件后,转换成标准的 Web Touch 事件回调给 Element,后续的流程就会通过 Flutter Web 中的 PointerBinding 进行数据格式转换最终给到 framework。另一种是跟 Flutter Mobile 类似,当 Platform 接受到事件后就直接组装成 PointerDataPacket 形式的数据,然后传递给 framework。相比较来说,后一种方案经历了更少的数据转换,是更优秀的方案。

4.3.5 线程模型

这部分基本上可以直接参考 Flutter Mobile Engine 的实现,保证在 UI 线程提供 JS Runtime,提供类似的任务优先级机制,保证 Vsync 触发时,先暂停微任务,再执行 UI 渲染相关的逻辑,然后回调事件,最后恢复微任务执行。同时也要提供跟 Flutter Mobile Engine 一样的 Frame Pipeline 机制,由于 SceneBuilder 是通过 JSBinding 在底层实现,所以可以在底层获取完整的 LayerTree,每一帧产生的 LayerTree 会在 UI 线程被塞进 Pipeline,供 Raster 线程去消耗。Raster 线程处理 LayerTree 的 Preroll 和 Paint,进行光栅化,然后将命令发送给 GPU,最终 SwapBuffer 上屏。IO 线程比较重要的一点就是要跟 Raster 线程共享纹理,主要用在图片解码上传生成纹理,这样在实际绘制的时候省去了上传纹理的步骤会提高绘制的效率。

4.3.6 Platform Channels

这部分其实就是把 Dart 跟 Plaform 之间的信道换成了 JS 跟 Platform 之间的信道实现。我们可以像 Flutter Mobile 一样提供 FlutterPlugin,满足业务对 Platform 能力的依赖。

五、OctoFlutter 框架的诞生

从拆解的这些核心问题来说,最底层的能力对我们来说是最简单的,因为我们可以参考 Flutter Mobile Engine 的实现,绝大多数问题都可以找到答案。其次对于 flutter framework 的剥离,更多在于理清逻辑,修改 flutter_web_sdk 源码,看起来很庞大,其实难度可控。最不确定的因素就是胶水层的实现,主要是关于它的核心问题背后都指向对 dart2js 的修改。经过对 dart2js 的研究,我们探索出了可行的方案,于是我们开始正式构建这套框架。这套框架我们内部命名为 OctoFlutter,它的实现基本上也是围绕上面的几大问题细化展开的。


OctoFlutter 架构图


接下来把整个框架的关键点从下到上,做一些详述。

5.1 Engine

这部分跟 Flutter Mobile Engine 实现思路保持高度相似,像线程模型、Platform Channels、共享纹理、Frame Pipline、RasterCache 等机制。不同点基本上是因为 Dart、JS 这两种语言差异所引起的,如:JS Runtime、与 Platform 之间的信道实现。octoflutter 基于 canvaskit 模式改造后的渲染方式,缩小了 Web 和 Mobile 上的差异,仅需支持部分标准的 Web API。

5.2 Framework

这部分实际上是 flutter_web_sdk 和 flutter framework 两部分代码通过 dart2js 编译后的产物。flutter_web_sdk 相当于是 Flutter Engine 在 Web 上的实现,但是 octoflutter 基于 canvaskit 模式改造后的渲染方式削弱了这一层的影响,它移除了 dom、css 相关的影响以及改变了初始化流程。flutter framework 中除了修复 Flutter 本身的一些 bug 基本上没有改动。

5.3 胶水层

胶水层主要面向开发者提供 TS 版跟 Flutter 一致的 Widget,背后衔接 framework 中 Keep 的原型函数,最终以 npm package 的形式存在。另外还基于 webpack 提供一些开发相关的脚手架工具等。

5.4 业务开发

开发者最终接触到的是 octoflutter-web 的一个 npm package,然后以 Flutter 的方式去写 TS,开发过程中,可以在浏览器中进行 UI 调试,通过脚手架工具,可以将编译出的业务 app.js 以及资源打包成业务 zip,framework.js 和分片的 js 以及内置的 Icon 字体文件会打包成 framework.zip,通过小程序资源管理平台,分发至移动设备运行。


示例代码:

import {  BuildContext,  Center,  runApp,  Scaffold,  Text,  Widget,  MaterialApp,  Theme,  FloatingActionButton,  StatefulWidget,  State,  MainAxisAlignment,  Column,  AppBar,  Icon,  Icons,} from 'octoflutter-web'import {PageOne, PageTwo, PageThree} from './pages'
class HomePage extends StatefulWidget { createState(): State<StatefulWidget> { return new HomePageState() }}
class HomePageState extends State<HomePage> { _counter: number = 0
build(context: BuildContext): Widget { return new Scaffold({ appBar: new AppBar({ title: new Text('OctoFlutter'), }), body: new Center({ child: new Column({ mainAxisAlignment: MainAxisAlignment.center, children: [ new Text('You have pushed the button this many times:'), new Text('' + this._counter, { style: Theme.of(context).textTheme.headlineMedium, }), ], }), }), floatingActionButton: new FloatingActionButton({ child: new Icon(Icons.add), onPressed: () => { this._counter++ this.setState(() => {}) }, }), }) }}
function main() { runApp( new MaterialApp({ routes: new Map([ ['/page1', (ctx) => new PageOne()], ['/page2', (ctx) => new PageTwo()], ['/page3', (ctx) => new PageThree()], ]), home: new HomePage(), }) )}
复制代码


调试显示


移动端显示:


六、不止于 Flutter

6.1 多 AppBundle 共享 Engine

Flutter 本身是不支持多业务的,即某一次编译产物只能在一个独立的 Engine 中去运行,在某些独立闭环的业务场景中,这是能满足需求的,但如果进入一个原生页面与 Flutter 业务频繁交替的场景,就会遇到一个头疼的问题,要么所有的业务都集中在一个 Flutter 产物中去,通过路由统一控制原生页面与 Flutter 页面切换,要么每个业务独立,然后去开不同的 Engine,显然后者开销过大,前者又限制了业务互相独立的灵活性,有没有一种方式既能共享 Engine,又能让不同的业务灵活装载/卸载。


我们在 OctoFlutter 去解决了这个问题,在 Flutter 的基础上,架构一层 AppBundle,它代表某一个业务,我们需要管理好 AppBundle 的生命周期,同时因为每个业务对于容器的需求不同(比如:有的业务需要全屏,有的业务是弹窗),我们要适当的改造 PlatformPlugin 以绑定当前正在活动的容器等。同时提供了 AppBundlePlugin,针对某个具体业务所需要的能力进行注册,它只在这个业务中发挥作用。原有的 FlutterPlugin 相当于是面向所有业务提供的通用能力。这样 Engine 有两种启动模式,独占模式会在业务容器关闭时销毁 Engine,共享模式可以一直存活直到主动销毁,存活期间可支持多个不同 AppBundle 启动/退出。此外还需要考虑业务间的资源隔离和代码隔离等。

6.2 Octo 拓展

OctoFlutter 提供了一系列的拓展 Widget,我们称之为 Octo 拓展,其中一部分是为了弥补 flutter 生态上的不足,将常用的第三方库(例如 Lottie)内置集成。另一部分是为了提供特有能力的 Widget,例如:OctoRepaintBoundary 提供了直接启用子树产生 RasterCache 逻辑,让开发者也可以自行管控,OctoImage 背后支持开发者复用原生 App 已有的图片加载能力。这些拓展会跟随 flutter framework 一起编译,以分片的形式存在于 framework.zip 中。

6.3 融入 JS/TS 生态

事实上各种与 html 无关且基于 JS/TS 的第三方能力都可以在 OctoFlutter 中运行,甚至开发者还可以向 JS Runtime 注入自己的实现能力。

七、缺陷

7.1 兼顾 Flutter 现有生态

跨越到 JS/TS 生态,会让原本 Dart 生态中一些优秀的第三方能力引入成本变高了,它们必须由 OctoFlutter 框架开发者集成到 Octo 拓展中,随 framework 一起编译,并实现其相应的胶水层,好在我们对 framwork 增量编译做到了向前兼容,不会引来新 framework 发版影响旧业务的问题。

7.2 Flutter 的升级

Flutter 一直在持续迭代,无论是修复已有的 bug,还是提供新的能力,唯有能持续同步跟随 Flutter 升级,才能保持 OctoFlutter 始终能跟上 Flutter 的步伐。好在我们只是做 API 级别的原型函数 Keep,只要 Flutter 的 Widget 入参不变,胶水层就不用变,但每次升级还是少不了对所有 Widget 的 API 进行校验,这部分工作后续需要通过程序进行静态分析,自动化完成校验、变化提示等。Flutter 的升级也很可能伴随着 dart2js 的升级,要关注 dart2js 内部实现变化,是否会影响原型函数的稳定性以及自定义混淆规则的能力。

7.3 略微下降的性能

OctoFlutter 的体验是 Flutter Web 和 Flutter Mobile 的中间态,更接近于 Mobile。Flutter Mobile 的 release 版本毕竟是 AOT 的,相对于 JS 来说有不少优势,好在 V8 足够强大,实际上的差异也很难察觉。

八、在最右中的实践

OctoFlutter 大概是从 2021 年 5 月开始尝试,在 2021 年 11 月的时候(最右七周年),OctoFlutter 框架基本成型,也迎来了这套框架的首次实践,这次实践的内容是一个多人实时对战的小游戏,游戏之外会有故事背景、榜单、弹窗、截图、动画等相关业务,算是同时检验 Widget、Animation、CustomPaint 以及 Plugin 等的绝佳机会。


总的来说,这次实践还是相当成功的,虽然过程中也发现了些许 bug,但终究还是扛住了百万级 DAU 的挑战,这里有当时这次活动的录屏,有兴趣的同学可以查看。(https://apk.izuiyou.com/hot_app/catch_octopus.mp4)


时至今日,OctoFlutter 距离成型已经过去将近一年时间,期间经历了对 Flutter 的升级,以及 AppBundle 共享模式改造等,陆陆续续在最右的各种活动或者业务需求场景中得以实践。

九、结束语

OctoFlutter 是最右在 Flutter 技术领域探索的新成果,可以看作是 Flutter Web 与 Flutter Mobile 的一种结合体,这是一种全新的思路去实现双端动态化且打通 JS/TS 生态,作为最右全新的小程序框架。同时 OctoFlutter 正逐步演化成具备动态装载/卸载各种 AppBundle 业务模块能力的业务框架,后续将在更多的业务场景发挥作用。

发布于: 刚刚阅读数: 3
用户头像

刘剑

关注

还未添加个人签名 2020.06.28 加入

最右 App Android 工程师,自2019年初加入最右,主要从事 Flutter 相关领域的技术探索,负责 Flutter 的动态化在最右 App 落地及成功实践。

评论

发布
暂无评论
【最右】面向TS生态的新型Flutter框架_typescript_刘剑_InfoQ写作社区