Dutter | 钉钉 Flutter 跨四端方案设计与技术实践
《Dutter 系列文章》将阐述钉钉基于 Flutter 构建的跨四端应用框架(代号 Dutter)的技术实践与踩坑经验,共分为上、下两篇,本文为上篇,本周四将上线系列文章下篇《前车之鉴:聊聊钉钉 Flutter 落地桌面端踩过的“坑”》,欢迎追更 & 阅读。
本文主要介绍钉钉基于 Flutter 构建的跨四端应用框架(代号 Dutter),内容主要包含方案设计、最佳实践以及部分 FlutterEngine 层面的问题定位等。希望能通过本文的分享,为有类似诉求的团队提供一定参考。
Dutter 即 DingTalk Flutter,是钉钉内基于 Flutter 构建的跨四端研发框架。
Dutter 项目「起于 Flutter 但不止于 Flutter」。项目的主要目标是希望能够借助 Flutter 跨平台能力,在不降低用户体验的前提下,提升钉钉端侧研发效率,缓解钉钉端侧研发资源不足、各端人力不平衡的问题。
目前 Dutter 运行框架已完成钉钉四端集成,并完成了一系列共创业务的灰度和试点。现阶段钉钉内基于 Flutter 研发业务有「日程签到」「+面板」以及部分内部灰度业务:
我们选择 Flutter 并启动 Dutter 项目主要有两方面的考虑:
端侧研发提效;
跟进 Flutter 技术。
下面我们针对这简单展开做一下说明。
随着钉钉发展到第7个年头,客户端侧「业务需求」「研发资源」和「技术演进」这三者之间的矛盾愈发强烈:
业务产品同学有很多优秀的想法,为将想法落地需要去寻找各端 TL 争取资源,且因为研发资源紧张需反复沟通其需求的业务价值;
研发同学常常处于「1 vs N」的状态,业务需求、稳定性保障、技术支持、BugFix等,日常工作时间基本趋于饱和;
技术团队不只是要满足于现在,更要面向未来。在满足日常业务迭代同时,我们还需要安排部分资源投入到满足未来 3~5 年发展的技术项目上。
以上各点可汇总到一个问题:我们技术研发资源不足。为解决上述问题,有两个途径:1. 继续扩大技术团队规模;2. 提升团队研发效率。
以目前钉钉端侧将近150人的团队规模来看,总体量并不算小,继续扩招存在一定难度。既然团队规模无法无限扩张,我们就需要在研发效率上挖掘提升空间:
端侧技术同学被分割到5个平台,分割之后每个平台上上人力并不算充足;
不同平台下的同学技术栈上基本处于「隔绝」状态,不同平台下的同学无法相互补位;
任何业务需求需要需要 4+ 端以上的研发资源投入,任何一端人力欠缺都可能造成无法落地;
一份逻辑多份实现,很难导致完全一致,时常会出现不同平台业务表现不一致的情况,返工对焦进一步影响效率;
业务上线之后不同平台分别维护,在日常技术支持、BugFix 等场景下需要多份投入。
由此可见,如果我们能借助于跨平台技术,使技术同学可以通过「一份代码实现覆盖所有端」,将原来一个需求需要多个平台、多个同学分别做的事情收敛到 1~2 个同学上,即可极大的提高我们的研发效率。
钉钉内已经有「小程序」「H5」等跨端技术,我们需要提效是否可以直接使用现有技术栈来达成目标?对于钉钉端侧团队来说,选基于 Web 的方案来做跨平台理论上可行,但是实际很难达到预期效果。主要原因在于两方面:
「小程序技术」是目前较为热门跨端技术,其设计定位要满足三方生态多样性场景,其架构设计侧重「大而全」,而非在单点上的反复打磨。这与钉钉一方业务强调的「专而精」「追求极致」有出入;
对端侧同学来说,前端开发模式上手门槛高、研发模式差异性较大。需要需要有一定的使用以及开发经验积累才能具备较高的开发水平,也就是说前期需要有一定的「试错空间」。以钉钉目前对线上质量的要求,这一点也是很难满足的。
Flutter 作为最近几年发展起来跨平台技术,不同于 Web 生态,其基于类 Native 的架构设计,选择性放弃动态化、更关注于跨平台。在保证具有类似 Native 性能和体验基础之上,赋予开发者「一次开发多端构建运行」的能力。因此相比小程序技术,Flutter 更适合用于解决我们端侧技术团队的痛点。
除此以外,我们对国内跨平台技术进行摸底调研之后发现基于 Flutter 的跨平台项目后发优势明显,上限高、发展潜力大,更具长期投入价值。
在对业界跨平台方案的长期跟踪中我们发现,「自绘引擎」是现阶段一大热点,而大多「自绘引擎」方案,是在 Flutter 项目开源并热度上升之后开始启动。这个时间点上的巧合并非偶然,我们通过下面这种图来说明主流跨平台方案在技术实现上区别:
从上面这张图我们可以看到,对于跨平台方案设计者来说,Flutter 项目最大的价值是:为生态提供了一个开源的、设计优秀的、兼容性优良的、性能优异的、边界清晰的 自绘引擎。
基于这套开源的自绘引擎,具备技能能力的团队只要稍加修改即可将其应用到自己的跨平台方案中以替换掉 Native 组件,复用 Flutter 具备的跨平台一致性能力,提升方案业务与技术价值。
对于钉钉来说,考虑到现阶段我们在跨平台的投入和目标,还不是类似其它方案一样推出自己的跨平台自绘引擎。但是从技术方向来看,选择基于 Flutter 来做跨平台方案,一方面我们可以快速享受 Flutter 的技术红利,在交付产物性能和质量上与其它主流方案保持一致;另外一方面我们也可以在这个过程中培养相关技术团队,为后续更深层次的定制和改造做技术储备。
本章节会概要介绍钉钉 Dutter 跨端框架设计情况,并针对其中具有代表性的问题做一些补充说明。
Dutter 核心模块包含三大套件:
Dutter Runtime;
Dutter Dev Kit;
Dutter OPS Kit。
整体如下简图所示:
Dutter Runtime: 基于 Flutter 构建的 Dutter 运行时环境,是 Dutter 最核心的部分。除去 Flutter 提供基础功能以外,我们还提供了 容器化组件、API 插件、业务模块化框架等功能。并且在于集团 AliFlutter 项目基础上,进一步扩展了 Aion 动态化等功能。Dutter Runtime 也是我们项目运行到现在全力投入的部分;
Dutter Dev Kit:即研发套件,主要目的是解决不同技术栈同学在 跨4+端 研发时的支撑和效率问题。目前投入相对有限,后续可与 钉钉研发平台 合作整合;
Dutter OPS Kit: 即运维套件,主要承载是 Dutter 产物发布和运维相关功能,如大盘监控等。目前投入相对有限,后续可与 钉钉研发平台 合作整合。
把上述简图展开,即可得到框架整体模块图,大致如下:
从下向上以此为:
左下角部分为 「Dutter Runtime」 相关模块;
右下角为 「Dutter OPS Kit」相关模块;
右上角为「Dutter Dev Kit」相关模块;
左上角为业务部分。
数据通信这块主要就是指 Flutter 与平台侧两种主要通信方式:Channel 与 FFI。Channel 在 Flutter 应用中相对比较广泛,绝大部分设计到 Flutter 与平台通信都是基于此模式展开,其优势在于集成度高、封装好使用简单;劣势主要在于通信效率问题;FFI 在 Flutter 2.0 中已经作为正式特性推出,其最大特性在于同步调用、内存共享、执行效率高,但是在易用性、扩展性等方面还有一定提升空间。
1Channel
关于 Channel,钉钉侧使用相比官方文档并无本质差别,想分享的经验在于 Channel 数量管理上。官方原生资料并未太多涉及 Channel 管理相关内容,以钉钉实际使用经验来看,我们还是推荐大家在一方业务中,尽量将 Channel 收敛到 1~2 个做共享,并在共享 Chennel 基础之上封装供业务使用的「响应」和「分发」接口。
这样做主要有以下好处:
有利于性能稳定性,有限的的 Channel 可以降低通信异常概率、提升通信性能;
有利于管理,尤其是在「单引擎/多引擎」共存模式下,可以通过合理的封装抹平底层差异。
上述两点,尤其是第2点对钉钉做移动端与桌面端兼容有着巨大的意义。在「钉钉 Flutter 桌面端应用方案」中有说明,我们现在在移动端使用的是单引擎架构、但是桌面端部分采用的是多引擎架构。如果没有对 Channel 做合理的封装、让业务同学直接面向 FlutterEngine 来做注册与调用,则会极大的增加多引擎模式下的代码管理成本,并且会造成移动端和桌面端实现不一致。
我们现在的做法是将 FlutterEngine 与 Channel 封装到 Dutter 框架内部,对上层接口暴露统一封装之后的实例:DutterMethodChannel。对于业务层代码,已经无需感知底层架构是单引擎模式或者多引擎模式,仅需按照统一的规则和模式来注册或者调用相关服务。通过此模式,在降低了业务使用复杂度的同时,也为底层框架设计带来了极大的灵活性,为后续移动端切换多引擎方案提供了有力支撑。
2FFI
FFI 已经在 Flutter 2.0 版本正式发布,其相比 Channel 最大的优势在于执行效率更高,更适合于对性能要求较高的场景。此章节不涉及具体 FFI 的使用方法,而是想为大家简单分享在使用 FFI 时内存管理上所需注意的事项。
我们都知道,目前移动端开发(Java、OC、Swift)都有自动管理内存的的机制;Flutter 所使用的 dart 语言也有基于垃圾回收自动内存管理。各种语言在自己作用域中都可以按照各自规则来合理管理内存,保证内存空间合理稳定的应用。
但是 FFI 作为一种跨作用直调的方法,虽然基于内存共享的机制下简化调用链路,但是对内存管理也提出了更高的要求。在这种模式下,如果不能很好的管理(开辟&释放)内存空间,则有很大概率导致野指针或者内存泄漏问题。
在官方文档 Flutter FFI 与 Dart FFI 章节的介绍中,对内存管理上的说明较为有限。通过查阅相关接口资料可知,在 dart:ffi 中提供了手动管理内存的方式:
在此基础之上我们即可定义 Dutter FFI 内存管理策略。首先我们需要我们需要准确定义核心原则:
分配与释放同源:必须使用一套 alloc 与 free 算法,避免因为实现差异,导致内存分配释放异常;
必须满足「谁 alloc 谁 free」的原则。
在 1 和 2 的基础上,我们把 FFI 操作相关接口以及数据结构进行封装,统一到「Dutter FFI Bridge」模块。
在对覆盖面和复杂度充分考虑之后,Dutter FFI 接口中除默认基础类型外,我们仅增加对 String 类型的支持。对于其它数据类型,业务方可以通过将其序列化的方式来进行传递。在传递过程中,对定长字符串,可以直接通过「UTF-8 编码的 char * 数组」传递;如果是不定长字符串(如调用返回值),则需要使用使用自定义数据结构 DTFUInt8String 传递。具体到实现:
为满足「分配与释放同源」原则,在 Dutter 中,我们选择 dart:ffi 中的 allocate 和 free 方法作为统一分配和释放实现。Dutter 框架会在启动过程中做一次接口绑定,将我们自定义数据结构相关方法传递到 Native 侧,Native 侧所有 FFI 接口内存分配场景均通过绑定接口实现:
为满足「谁 alloc 谁 free」原则,在 Dutter FFI 接口中,我们默认约定以下3原则。在此基础上能够保证堆内存的分配都在 DTFUInt8String 控制范围内,只要处理好 DTFUInt8String 对象的生命周期,即可保证传递过程中内存管理的安全性:
接口设计时,对于需要不定长返回值的场景,使用 DTFUInt8String 来传递数据;
为提升传递效率,尽量以指针方式传递 DTFUInt8String ;
调用方负责创建以及释放 DTFUInt8String 。
「消息总线」是一个钉钉特色模块,我们主要是是为解决钉钉端侧基于不同技术栈实现的业务通信问题:比如一个基于 Flutter 实现的业务,希望通知一个基于小程序实现的页面刷新 UI,即可通过消息总线来实现此功能:
消息总线定位是一个轻量级「端」到「端」的超级通道,目标是让业务具备跨运行环境无缝通信的能力。在逻辑上包含「总线」「控制器」「注册发送」三大模块;在实现上通过「可持久化消息」「管道分级」「权限管控」等方式保证整体运行可靠、高效和安全。
因为钉钉端侧业务特点,我们非常注重模块化建设。Flutter 业务采用的模块化方案发展自钉钉 Native 侧模块化框架,我们在最初即坚持杜绝 Flutter 业务层直接耦合:
模块化之后并不仅仅只是对我们研发效能有提升,同时也带来了显著的业务和技术价值。比如:
为钉钉多版本提供了有力支撑,满足「标准钉」「大客户钉」「专有钉」等多个版本共享代码的诉求;
提供了良好的兼容性,通过对基础模块的灵活插拔,满足 Dutter 框架在移动端和桌面端同架构的诉求;
提供了丰富的扩展性,例如我们在做 Flutter 动态化尝试时,基于模块化能够以较低成本对现有模块做动态化改造而不影响其它模块的稳定性。
容器化是支撑 Flutter 在钉钉内快速落地的有力保障。通过钉钉在 H5 和 小程序项目中沉淀的容器基础,在 Flutter 场景我们继续参考容器化思想,在设计和能力上快速对接。一方面得以快速复用现有沉淀的基础设施;另外一方面降低业务开发上手复杂度,保证原容器常用能力在 Flutter 场景可以继续使用,技术栈得以延续。
从发展时间轴来看,钉钉端侧容器大致经历过3个版本:
v1.0 版本主要解决「有无」问题,定义容器相关核心概念;
v2.0 版本在原基础上抽象出「能力包」的概念,保证业务基础能力可跨运行环境复用;
v3.0 版本在 v2.0 基础上进一步抽象出「运行时」和「扩展」,将核心实现下层为「容器底座」,三者之间弱耦合。
在目前容器架构基础上,我们可以保证对未来新技术良好的兼容性。在后续发展中如再次需要对接类似 Flutter 新技术栈时,可以按照现有标准快速打通,并在概念、能力、基础设施上保证最大化复用。
钉钉 Flutter 目前使用的组件库有两套:dingui_flutter 以及 dingtalk_uikit,其中 dingui_flutter 是我们现阶段重点建设的部分,dingui_flutter 是按照钉钉视觉团队提出的 DingUI 视觉规范实现的一套 Flutter 版本组件,目前核心组件可以做到四端兼容:
dingui_flutter 目标是可贡献给社区,但现阶段因为稳定性、完善度等问题,暂时还在钉钉内部使用,后续发展成熟之后我们会将其尽早开源。
目前在钉钉桌面端中 Flutter 使用模式基本与移动端相同:Flutter 作为钉钉内的一个功能模块,客户端主体仍以原 Native 实现为主。对于部分基于 Flutter 实现的业务,在启动时通过 Dutter 框架封装的接口转场,根据特定转场模式执行转场动作。
为了达到上述效果,我们在桌面端应用中主要解决了以下三问题:
桌面端集成模式问题;
Widows 32位问题;
引擎架构兼容问题。
后面我们就针对上述问题分别做一下说明。
Flutter 在桌面端目前还仅支持以 FlutterApp 的模式来使用,移动端广泛使用的 FlutterModule 模式暂时还不支持。但期望通过 FlutterApp 来对现有客户端做大范围的改造,这既不合理也不现实。因此我们在桌面端落地 Flutter 遇到的第一个问题,即如何把 Flutter 作为一个模块集成到钉钉现有客户端。
我们在对 Flutter 构建产物做分析的时候发现,其实无论是 FlutterApp 还是 FlutterModule,其核心产物差别并不大。以 iOS 端 FlutterModule 和 macOS 下 FlutterApp 来举例,如下图所示:
我们可以看到,对于 App.framework, Flutter.framework, Plugins.framework 这些核心模块,无论是 FlutterApp 还是 FlutterModule,其产物中都是包含的。主要差别在于 FlutterModule 中多了一个用于辅助插件注册的 FlutterPluginRegistrant.framework。幸运的是这部分实现并不复杂,我们可以很轻易的通过自定义工具链的方式来生成。
沿着这个思路,我们就可以梳理出 Flutter 桌面端集成方案:
通过 FlutterApp 来组织桌面端 Flutter 相关模块,在官方工具链基础上做适当扩展。从原有构建产物中摘取作为模块化使用所需的部分,最后再补全部分用于插件注册所需的模板代码。最终产物集成到钉钉现有客户端之后,使用上与其它二方库并无本质差别,可参考现有 FlutterModule 的方法来使用。
最终流程如下:
Mac 和 Windows 端产物集成示意图:
Flutter 不支持 Windows 32位系统,应该是现阶段阻碍 Flutter 在国内桌面端生态铺开的核心阻碍之一。钉钉在解决此问题时,基本上尝试了我们能想到的所有方案:从最初的双进程,到中间的的整体升级64位,以及后面的 FFW,但上述方案最终还是因为各种各样的问题无法落地。
虽然最终未能落地,但是在上述尝试的过程中,我们了解到两个非常重要的信息:
DartVM 是可以运行在 Windows 32位设备上,但是仅支持以 JIT 模式加载 dart 代码;
Skia 可以编译 Windows 32位产物。
在以上两点的支持下,由钉钉 周镛 同学最终探索出了编译 Windows 32位 FlutterEngine 的方案,并通过 JIT 模式加载 Flutter 编译产物,最终满足在 Windows 端使用的诉求。
为了能够在 Windows 平台使用 Flutter,剥离细节之后我们大致做了以下几件事(详细资料后面会有文章做专门分享):
修改 FlutterEngine 的构建脚本,使其能够构建出 32位 的 flutter_windows.dll;
修改 flutter_tool 中 FlutterPlugin 编译 gn 参数,使其构建 32位 的产检产物;
将相关产物做安全混淆之后之后集成到钉钉客户端。
通过以上步骤我们即完成了在 Windows 32 位钉钉集成 Flutter 的主要工作,其后使用无论是 JIT 还是 AOT 在功能上并无本质区别,但是在性能上的差异较大。目前我们灰度过程中发现的主要问题有:
启动速度慢:首页加载时间在 2s 以上;
内存暂用高:每开辟一个 FlutterEngine 对象,大概需要消耗 70MB 左右的内存;
代码运行效率低:此问题虽然绝大部分场景并不明显,但是极端场景下还是会出现性能问题。
因此现阶段我们采用的仅能算作一个刊用方案,后续我们仍需在此部分加大投入,争取尽早让一个完全的 Flutter 集成到钉钉 Windows 端。
这个是我们在桌面端落地过程中遇到的第三个问题。由于在移动端我们使用的是基于 FlutterBoost 构建的单引擎架构,而桌面端则因为其特殊环境,只能使用多引擎架构:
因此对业务同学使用带来一些问题,其中最严重的即为多引擎环境下导致的通讯阻塞。
现阶段我们主要还是通过业务层兼容的方式来绕过:我们通过钉钉「消息总线」来支持多引擎环境下的通讯问题。但是长久来看我们还是需要有友好的支持多引擎,需要将目前移动端具备的 LightWeightEngine 能力扩展到桌面端,并在其基础上进行扩展,打通 isolate 让业务代码完全共享内存。目前此方案整作为技术项目在 AliFlutter 项目组内推进中,期待早日达成既定目标!
目前 Dutter 项目已经基本达成一阶段目标,后续我们大致会在以下5个方面继续投入:
基础设施升级:移动端 FlutterEngine升级、flutter_boost 升级、 探索落地动态化方案等;
性能体验精进:桌面端性能精进,最大化解决目前官方支持力度、基础设施完备度、桌面端特性等原因造成的性能问题,争取能对齐移动端水平;
研发套件完善:面向钉钉内提供一站式的研发环境,目前我们希望能在 AliBox 基础上、面向钉钉四端研发场景,定向扩展部分以满足钉钉内应用开发诉求;
稳定性增强:解决目前在桌面端、尤其是 Windows 端稳定性上存在的风险,满足钉钉端侧稳定性要求;
研发提效:扩大业务覆盖面,释放跨端宏利,进一步提升钉钉端侧研发人效。
以上即为钉钉 Flutter 跨四端框架在应用设计上的一些分享,希望能为大家带来一些帮助。