作者:yamichonghe,腾讯 WXG 客户端开发工程师
跨平台开发框架是客户端领域的经典课题,几乎从操作系统诞生开始就是我们软件从业者们的思考命题。为了促进 Flutter 在 4 个端的成熟,企业微信研发团队也和 Google 团队针对电脑端 Flutter 稳定版的落地做了多轮技术沟通。终于在近期的版本实现同一个功能跨平台 4 端同步上线。企业微信每一个迭代都需要确保 iOS、Android、Windows、Mac 四个客户端平台的版本功能完全一致,版本发布时间一致。这是非常大的挑战。任何研发投入都是 X4 的,且由于系统差异,相同功能的研发周期和技术方案也会有明显差异。我们前期实现了逻辑底层架构 4 端统一,但是 UI 层怎么办?迫切需要更优的跨平台方案。但是要在历史的 Native 代码行数已经过千万级的超大型软件系统——企业微信上引入新的跨平台框架何其困难。
经典案例:Flutter 实现跨平台人事管理系统
人事管理是企业经营运作中“人财物事”的核心组成部分,而花名册和人员入转调离管理能力又是人事大板块中最为基础的部分,是企业的共性基础需求,也是后续更多 HR 应用的底层系统支撑能力。整体需求逻辑复杂,涉及新的交互页面上百个,在企业微信框架内补齐人事板块需要 win、mac、ios、android 四个平台都支持。企业微信相关产研团队面临极大挑战如何在较小人力投入下短时间内能够顺利迭代出一套完善稳定的人事系统,而此时研发团队持续两年迭代沉淀的 Flutter 跨平台 ui 融合框架起到关键作用,全平台技术栈高度一体化,研发人效上比传统分平台开发模式。
提升 1 倍以上,后期产品、设计、测试验收协同成本也降低 50%以上。下面我们会详细为大家介绍企业微信在跨平台 ui 道路上的建设历程。
经过 Tob 业务的高速发展,市场的竞争也愈发激烈,包含了企业微信、钉钉、飞书等各类的办公软件,为了抢占 B 端市场,满足企业用户的业务需求,功能数量增长非常快。企业微信目前已经深耕众多行业,并且有 android/ios/mac/ windows/web 五大开发平台,使企业微信迅速发展成为了一个超大型应用。
我们也遇到了超大型 App 通常会存在的问题,每次版本迭代都需要五端进行同步迭代发版,各端人力开发成本急剧上升。为了提高开发效率,企业微信在跨平台上也一直有做一些尝试:
底层跨平台开发架构
企业微信客户端的设计架构采用的是四端 C++ 底层跨平台开发架构,将 db、网络、日志等能力通过 C++来实现,各端可以复用逻辑层接口。虽然逻辑层统一实现了,但是 UI 层仍然是由各平台独立开发,因此我们也在继续探索 UI 跨平台的方案。
小程序 UI/H5 跨平台
为了提高业务上层的开发效率以及与微信互通的能力,企业微信在早期就已经接入了小程序和 H5 的方案,但是小程序和 H5 的方案跟原生体验会有比较大的差别,无法满足所有的业务场景。
在跨平台的选型上,Flutter 在绘制上能够保持各端的一致性,并且拥有出色的性能,Dart 对于原生开发的同学在技术栈上也会更加友好。在综合对比了主流的跨平台框架后,我们决定将 Flutter 作为跨端开发的主要框架之一。
Flutter 移动端跨平台
2020 年开始企业微信就已经在探索跨平台开发框架,将 Flutter 作为企业微信移动端主要的 UI 跨平台开发框架之一。通过一年多的基础建设和业务上的开发,我们 Flutter 移动端建设也达到了工程化的架构,在架构上我们经历了原始的模块化到插件化的迭代,在跨平台的体验上,组件以及动画逐渐对齐了原生的体验效果。
在移动端在业务开发中,得益于 Flutter 强大的跨平台能力,为我们整个项目团队带来了一定的效率提升,所以我们希望将 Flutter 这项跨平台技术推动到整个客户端中心,来解决桌面端的人力紧张等问题。
Flutter 四端跨平台
在桌面端的平台上也是通过四端跨平台底层来进行开发的,四端的逻辑层能够得到了很好的复用,但是 Win/MAC 在开发原生应用的时候仍然是各平台来进行独立开发的,MAC 因为用户量较少等原因,人力相对 Win 来说比较紧张,人力上的不足就会导致 MAC 的需求很难跟上版本的节奏,但是依然有客户对 MAC 的功能有诉求的。
所以我们希望能够通过跨平台的能力来解决这部分的不足。企业微信在桌面端的跨平台建设上就已经支持小程序/electron 框架,小程序因为体验上跟原生应用有很大的差别、electron 无法适用于四端的跨平台开发。因此都无法满足我们日常需求开发。
在 2021 年的时候,我们就已经开始在桌面端接入 Flutter,期间针对多项难点问题持续攻坚,直到 Flutter 3.0 之后,Flutter 全平台进入了 stable,我们也逐步完善了 Flutter 跨四端的框架能力,企业微信四端统一技术栈的设想也正式走上轨道。
企业微信 Flutter 工程的整体架构图如下,从下往上:
在接入企业微信的过程中,需要攻克很多难点问题:
1)四端跨平台混合工程,整个企业微信客户端包含跨平台部分,拥有着千万行的代码量级,业务模块上百个,涉及界面上千个,并且跨多个团队协作开发,环境依赖复杂,需要保证不影响现有的架构下完成接入 Flutter 跨平台的开发能力。
2)多端跨语言的调用,Flutter 通过 dart 来进行开发,避免不了与原生平台进行通信,涉及到终端 dart/kotlin/objectC/c++ 等编程语言,需要有一套通用高性能的跨语言接口调用方案去解决四端的跨语言通信问题。
3)桌面端稳定性治理,Flutter 桌面端仍然处理早期的稳定版本,在桌面端落地的过程中,会遇到各式各样的坑,因此想要在桌面端落地,需要自主分析问题以及修改引擎来修复这些坑。
4)保障跨平台的用户体验,Flutter 通过 skia 渲染来达到跨平台开发的一致性,但是也因此失去了一些平台的 UI 组件特性,为了保障产品体验,需要在 Flutter 上持续完善原生组件能力。
整个企业微信客户端包含跨平台部分,代码量级超 1500 万行。客户端本地模块和业务的数量达到了上百个,相关页面超过 2000 个。企业微信接入 Flutter 之后,会影响到各端的编译流程和依赖结构,但是要保障现有的开发模式不受影响,并且提供一套完整的自动化以及容器化的方案。同时面对各端的复杂编译环境(gradle、bazel、xcode、cmake)同时保障 Flutter 环境的高效开发以及编译的稳定性,面对这个巨大体量的工程,为我们接入 Flutter 带来了一定的困难。
企业微信 Flutter 研发流程图
Flutter 在移动端提供了 add2app 的方式接入到原生的项目当中,并且提供了 Flutter module 的工程结构,可以很方便地将 Flutter 的 module 接入到原生工程进行打包和调试。
但是在桌面端,官方目前还没有提供混合工程的接入方案,因此我们需要在打包编译的时候做一些额外的配置,以支持混合工程的开发的目的。
虽然桌面端没有提供 add2app 的命令直接输出混合开发的产物,但是我们可以通过 Flutter application 工程,借助 Flutter build 相关的命令进行应用程序的打包,不同平台的主要产物如下:
Win:
Mac:
App.framework/app.so 为 dart 的 aot 编译产物,主要包含了项目的所有 dart 源码。
FlutterMacOS.framework/flutter_windows.dll 为 Flutter engine 层和 Embedder 平台嵌入层的代码, engine 主要是用来驱动 Flutter 运行的,平台的嵌入层是用于呈现所有 Flutter 内容的原生系统应用,它充当着宿主操作系统和 Flutter 之间的粘合剂的角色,主要是原生平台的代码。
这两个文件就是原生工程主要依赖的产物,另外一些资源/插件相关的文件也需要,需要将这些产物混合到原生工程里面并进行引用和编译,然后通过 FlutterMacOS.framework/flutter_windows.dll 引入的 sdk 来调用原生平台的代码启动 Flutter 页面。
对于 Flutter 的通信主要分为以下两部分:
1: 前面提到, 企业微信是通过 C++ 来实现逻辑层的跨平台,企业微信作为原生与 Flutter 跨平台融合工程,为了提高开发的效率,以及与原生平台的兼容,避免不了需要复用底层 C++已有的能力,并且由于调用量巨大,Flutter 上要能够通过高性能的通道直接调用到 C++层。
2: Flutter 上层的开发避免不了使用原生已有的接口,需要与宿主工程的接口打通,而宿主工程又包含 Android/iOS/MAC/Windows 四大平台,并且上层的接口使用的语言各不一样,因此需要考虑一套多端跨语言的通信建设。
dart 2.15 之后提供了 dart::ffi 的方式调用 c/c++ ,在项目的实际开发过程中,我们也遇到一些大型工程下 ffi 的使用问题:
1: dart 调用 c++操作步骤繁琐, 接口维护和约束困难
2: c++调用 dart 方法只支持静态方法或者顶层函数
3: dart 上开放了指针的分配和释放,调用 c++之后内存管理混乱,容易造成内存泄漏
4: 如果出现接口绑定不匹配的情况或者 so 忘记更新,会导致全局的异常,影响正常开发流程
为了解决以上的问题,我们参考 grpc 的设计流程,设计了一套跨语言的 rpc 调用模型,通过 protoc 插件来自动生成 dart client 端 和 c++ server 端的接口,简化了开发的成本,并且对接口进行了一定的约束。
另外调用 c++的接口不再受限于静态方法或者顶层函数,开发调用 c++的接口就跟调用本地的 dart 接口是一样的。
在 rpc 的调用过程中,通过将 rpc 的 transport 层,替换成各个语言之间的调用通道,在 Flutter 上就是利用单个 ffi 接口进行请求的收发,从而达到跨语言调用的目的,在框架内部进行线程以及内存的维护与管理。
调用的流程如下:
final GovernRpcServiceApi service = GovernRpcServiceApi(CppChannel());
final req = GetGovernMyReportListReq()..limit = 10;
final result = await service.getGovernMyReportListFromServer(req);
集成部署的情况下,能够通过 ffi 直接调用到底层,各端能够很好复用已有的能力。但是在 win 上,由于是企业微信采用的是多进程的架构,需要 Flutter 应用进行独立部署,与企业微信宿主之间的通信需要经过企业微信的 ipc 通道,如果是独立部署的 Flutter 应用,在 transport 层,将数据通道从 ffi 转换为 ipc 的通道,以此来达到调用企业微信跨平台底层的能力。虽然对于不同的部署方式 transport 的传输通道会有区别,但是对于开发者来说,调用确是透明的,开发不需要关心当前走的是 ffi 还是 ipc,也不需要关心当前 Flutter 应用的打包以及运行方式。
Flutter 提供了 channel 的方式进行原生平台接口的调用,如果只是依靠 channel 的方式来进行与原生平台通信,接口的维护就会变得非常麻烦,由于平台上的扩展,各端沟通成本也会提高,channel 不适合于大型工程上的开发。
官方推荐通过 pigeon 的方案来自动化生成接口,但是 pigeon 早期尚未支持桌面端,因此不适用于企业微信的业务开发,另一方面,pigeon 的接口依然是通过 channel 来维护的,企业微信的接口需要考虑服务发现、动态注册、安全校验等能力,通过 pigeon 的维护方式不便于处理这些场景。
因此,在 dart 调用 c++的基础上,我们继续扩展了 dart 调用其他平台接口的能力,并且实现了一套 channel 的自动化框架:rpc-channel,和 pigeon 的主要区别如下:
我们通过 protobuf 来统一各个平台的接口,并且实现 protoc plugin 为我们生成各个平台的接口代码,再由各端实现 grpc server 端的分发以及处理请求的能力。native 平台作为 server 端只需要实现对应的接口即可。
在 Flutter 端我们依然通过 grpc 的接口来进行调用,只不过调用所需要的 transport 通道变成了 platformChannel 的方式来调用,通过这种方式,我们收拢了所有的 channel 调用接口,并且都通过单个 channel 来做数据分发,单个 channel 的方案,更加方便于我们对所有的 channel 接口进行统一的管理,做服务发现、安全校验、统一日志等逻辑。
调用的方式如下:
//由CppChannel 变成了PlatformChannel,通道即发生了变化
final GovernRpcServiceApi service = GovernRpcServiceApi(PlatformChannel());
final req = GetGovernMyReportListReq()..limit = 10;
final result = await service.getGovernMyReportListFromServer(req);
在 windows 上,为了减少与主工程的耦合性,我们将 Flutter 插件作为独立的进程运行,跟其他端不一样的是, Flutter 与 原生工程的通信方式会有一些改变,包括我们的 channel 以及 底层的调用,因此我们在企业微信的 ipc 通信的基础上,实现了 channel/dart2cpp 的通信,具体的调用流程如下:
win 由于是独立进程,dart2cpp 以及 channel 的调用都是在独立进程下的,因此没办法直接调用到宿主的工程,要借助于企业微信的 ipc 通信,从上面介绍过 channel 以及 ffi 接口,由于我们对 channel 以及 dart2cpp 的接口进行统一的管理,所有的事件都会经过 stub 类来进行集中处理装包并进行数据的传递。我们在 stub 里面,额外对 win 进行了适配,如果是 win 会将请求通过 ipc 转发到宿主工程上,而不是直接调用分进程的接口,调用的过程如下。
在 Flutter 在 3.0 之后在 engine 层面提供了 32 位 windows 的编译选项,但是由于 dart 的限制,也是只允许编译 jit 的模式,并且 Flutter 层面的编译尚未支持,企业微信在探索 jit 的模式是在 3.0 之前,比官方更早地完成了 32 位 jit 的适配,并且包含了 Flutter 32 位 windows 编译选项的改造:
1: 由于 3.0 之后已经支持 32 位的编译,Flutter engine 可以编译 windows jit-release 产物,相关的 gn 命令以及 build 如下:
python .\flutter\tools\gn --target-os=win --windows-cpu=x86 --runtime-mode=jit_release --no-goma
ninja -C .\out\win_jit_release_x86
2: Flutter 编译改造 Flutter 编译 windows 主要是通过 Flutter build windows 相关的命令,默认是编译 64 位的包,并且没有相关的参数支持 32 位的编译,编译完 32 engine 之后,需要改造 flutter 仓库相关的代码,适配 32 位的 windows。
Flutter build 相关的命令主要是由 packages/flutter_tools/bin/flutter_tools.dart 经过 dart 编译得来的,修改 flutter_tools 的代码之后,删除 bin/cache/flutter_tools.snapshot,执行 flutter doctor 命令重新编译 flutter_tools,即可更新 flutter build 命令。这里我们根据 Flutter build windows 的流程, 增加 jit-release 的编译模式。
Flutter build windows 相关的流程核心主要是从 BuildWindowsCommand 开始。
主要要修改的是:_runCmakeGeneration:主要是通过 cmake 命令编译 win 产物,需要将 targetPlatform 作为参数传进来,如果是 x86 架构,cmake 命令后面要加上 Win32 参数,以便构造 Win32 的产物。
在线上的投诉中,有部分 win7 设备的用户反馈黑屏的问题,经过分析黑屏的用户都是在 win7 某一个特定的小版本上,Flutter 上也有相关的 issue 在跟进:
https://github.com/flutter/flutter/issues/89583
目前 issue 上提供的解决办法是安装.net 库解决,但是并没有定位的真正的原因,企业微信通过分析 DirectX 相关的库,发现黑屏的用户主要是缺少 d3dcompiler_47.dll 库引起的,通过内置这个库就可以解决这个问题,而不用引导用户安装.net。
问题:当点击 Flutter 的区域时,无法将企业微信窗口前置。
原因:由于 windows 采用了多进程模型,企业微信和 Flutter 不在同一个进程中,点击 Flutter 区域只是激活了 Flutter 进程的窗口,企微对应的窗口没有激活。
解决方案:在 Flutter 窗口收到鼠标激活消息时(WM_MOUSEACTIVATE),将该窗口对应的 Ancestor 窗口前置。
错误现象:
输入 nihao,按 1 确认输入你好,再继续输入其他文字,会把你好给删掉。
出错的跟本原因:
搜狗 在 win7(win7 SP1)系统上输入法确认输入的时候,会同时发 GCS_COMPSTR 和 GCS_RESULTSTR 两个输入法消息,在 win10 上是只有 GCS_RESULTSTR 一个的,这种消息的错乱直接导致 Flutter 在处理 composing 文字的时候出现反馈中的问题。
错误分析:
从收到的输入法消息上看,在确认输入的时候多了一个 GCS_COMPSTR commit 的消息,这个消息是个空的。
commit 为空消息会把当前正在输入的内容清空。Engine 层收到这个空的消息之后,会把 engine 层把正在输入的文字全部清掉,然后通过 channel 通知 Flutter,Flutter 收到消息之后,发如果个空的消息,就会通过 channel 通知 engine setText 为空(只有空文本这个时机才会触发 flutter->engine)。
问题在于 engine 通知 Flutter 的过程是个异步的,在通知 Flutter 之后,紧接着 RESULTR 事件来了,RESULT 事件将 engine 层的 text 设为“你好”,改完了之后,flutter 通知 engine 上一次处理 GCS_COMPSTR 事件来了,又把 engine 层的文本给清空,后面的事件中 Flutter 都不会通知 engine,所以在下一次输入的时候,engine 层认为输入框中正在输入的文字是空的。
引发出来的文字错乱问题:
前面的文字被莫名其妙删除之后,再输入文字,会出现重复的文本。
错误原因:
在 Flutter 通知 engine 更新 text 为空的时候,导致 Flutter 记录 composingRange 的数据出错, range 变成了(0,0), range 出错直接导致 UpdateComposingText 的过程中:
text*.replace(composing_range*.start(), composingrange.length(), text)
replace 就会在原有文本的 0 坐标下,替换成新的 text,但是由于 length 是 0,所以就出现了重复的情况。
解决办法:
调换处理 GCS_COMPSTR 和 GCS_RESULTSTR 的逻辑,让 GCS_COMPSTR 空的消息最后处理。这种解决办法的核心在于,engine 在处理 GCS_RESULTSTR 消息的时候,会有一个 CommitComposing 的逻辑处理,表示结束掉当前的 composing 状态,当结束 composing 之后,收到 GCS_COMPSTR 为空的时候,因为 composing 的文字为空了,再去处理 composing 中的文字已经没有意义了。
内存泄漏问题
1: 由于 Flutter 目前还没有考虑到混合工程的结构,因此在接入到企业微信之后,每次进出 Flutter 应用,发现对应的 FlutterEngine 都没有被释放,这种问题在独立应用中是没有的。因此我们开始分析并且解决了内存泄漏相关的问题。
泄露 FlutterEngine 的主要原因:FlutterEngine 中通过弱引用持有 viewController,当 viewController 退出的时候,会触发 engine.setViewController=nil,但是 viewController 因为弱引用的关系,已经变成 nil 了,导致后面 shutdownEngine 相关的逻辑都不会执行。
解决的办法:修改 Flutter Engine 的实现, engine.setViewController=nil 的情况正常触发后面的流程。
- (void)setViewController:(FlutterViewController*)controller {
if (_viewController != controller || controller == nil) {
//正常触发后面的逻辑
}
}
2: 退出 Flutter 页面, FlutterEmbedderKeyResponder 和 FlutterKeyboardManager 内存泄漏。
原因:FlutterEmbedderKeyResponder 通过 block 强引用了 FlutterKeyboardManager,而 FlutterKeyboardManager 又通过 addPrimaryResponder 引用 FlutterEmbedderKeyResponder 从而造成循环引用。
解决办法:修改 FlutterKeyboardManager.mm 的代码,通过弱引用来解除这个循环引用的关系。
低版本 OpenGL crash 析构引起的 crash
crash 的主要原因:为了解决内存泄漏,Flutter 在退出的时候完全释放到 Flutter 相关的引用,从而导致触发了 FlutterOpenGLRenderer 释放 OpenGLContext,在 10.13 的或者更低的系统上,openGLContext 在析构的时候会出现了 crash。解决办法:在 FlutterOpenGLRenderer 中,让 openGLContext 不要释放,来规避这个 crash。
在四端的 ui 组件上,我们分为了移动端和桌面端两套 UI 组件,在组件中我们除了完善企业微信现有组件外,对各端常遇到的体验问题也做了改进。
移动端组件体验优化
IOS 原生容器与 Flutter 容器切换导航栏优化
背景:
企业微信采用的是单容器多 Flutter 页面的混合栈方式,Flutter 内部通过 CupertinoNavigationBar 来模拟 IOS 导航栏的切换效果。但是 Flutter 的导航栏采用的是自渲染的方式,ios 的导航栏在切换到 Flutter 容器的时候,由于是两个不同的导航栏,导致原生导航栏的动画无法正常衔接上,就会出现两个导航栏同时位移的动画,如图所示:
为了解决以上的问题我们探索了两种方案:
1: Flutter 单页面单容器的方案,导航栏由原生来渲染,页面的切换动画完全由原生来控制。
2: 原生切换到 Flutter 容器的时候,先展示 IOS 的导航栏,动画消失后再把 IOS 的导航栏隐藏掉。第一种方案的好处是达到原生一致的效果,但是对于 Flutter 开发来说,导航栏的自定义性就会变得很差,如果要渲染 icon,响应点击事件就会变得非常麻烦,而且与导航栏相关的交互情况要考虑得也非常多,对现有的混合栈结构的改动非常大。
因此我们采用的是第二种方案,在容器和 Flutter 上实现了一套带原生动画的导航栏, 在进入 Flutter 容器动画的过程中,会先展示 ios 原生的导航栏,flutter 在导航栏渲染之后,会通过截图的方式将导航栏上的元素截给 native,native 通过图片的方式在导航栏上渲染 flutter 的元素,动画完成的过程之后,再隐藏掉原生容器的导航栏。
实现上述技术点的关键在于 Flutter 导航栏要做到:
1: IOS 的 NavigationBar 在页面初始化的时候就必须得准备好颜色和布局,后续动画的过程中不能对颜色和布局进行变更,在进入 Flutter 页面之前,先读配置文件或者由代码指定导航栏样式。另外由于 NavigationBar 的元素在动画的过程中也是不能进行变更的,我们利用 ImageView 提前在 NavigationBar 上占位,动画的过程中,只更新 ImageView 的内容。
2: Flutter 导航栏渲染出来的效果和 IOS 导航栏的渲染效果必须是完全一致的,这样在原生的导航栏消失之后才不会出现闪动的情况,因此需要我们对 Flutter 上的导航栏进行一些改造,对齐 IOS 的导航栏规范。
3: 需要对 Flutter 导航栏上的元素进行截图,并且遇到导航栏元素刷新的情况,截图有可能是多次的,如果不通过截图的方式,遇到 icon 或者 中英文、大小字体的情况,Flutter 的导航栏是很难对齐原生的,这里用图片进行传输,实测下来也并不会影响到实际体验。实现之后整体的效果如下,切换到 Flutter 容器跟其他原生页面是完全一致的体验。
IOS 导航栏内部切换效果优化
在实现完容器直接切换的动画之后,我们面临第二个问题,内部的导航栏动画优化,如果是两个相同背景颜色的导航栏之间的切换,Flutter 几乎是达到了原生一致的效果,但是如果两个导航栏上颜色不一致,企业微信上会有更加复杂的动画:
而 Flutter 对不同颜色的导航栏之间的切换采用的是渐变的方案,但是设计希望对齐企业微信以及微信原生的表现,页面和导航栏都有整体的拖动效果,但是导航栏的元素是不会产生较大的变化。
解决办法:我们改造了 CupertinoNavigationBar 的动画,CupertinoNavigationBar 在模拟器 IOS 动画的过程中,其实是利用了 Hero 相关的特性,通过 HeroFlightShuttleBuilder 了完全重写了 Hero 动画。
动画整体的思路在于,去掉渐变相关的动画,并且通过 Stack 的组件,在原有导航栏动画的基础上,新增与当前导航栏颜色一致的 Container, 利用 ModalRoute.of(context)的方式,拿到页面的转场动画(这里与 hero 的动画是有区别的),最后对 Conatiner 做 SlideTransition 转场动画。
额外需要注意的是,用户侧滑返回跟点击返回的动画是有区别的,需要做一些判断:实现的效果如下:
以上两个是 IOS 遇到的体验影响比较大的问题,还有其他一些对齐 IOS 点击态效果、文本输入框下划线对齐 IOS 背景色、侧滑返回快速点击无响应等体验问题我们也都在组件中完善并且解决了,并且提供了 demo 的独立程序。
桌面端组件完善
在桌面端接入 Flutter 之后,Flutter 目前对桌面端的组件完善程度并不够,我们也在完善桌面端相关的 UI 组件,并且提取了一些桌面端组件常见的问题:
1: Flutter 提供了 MouseRegion 来实现 Hover 态,开发在实现组件的时候需要关注桌面端组件与 Hover 的操作,这种表现在移动端是没有的。
2: 对键盘事件的处理,比如列表需要支持按住某个按键切换为横向滚动,实现上可以利用 Listener 监听鼠标的滚动事件,并且通过 pointerSignalResolver 做相应的拦截,拦截之后,将 controller jump 到横向指定的 offset。
下面是 Flutter 桌面端的组件库:
因为引入了分进程,Flutter 与企业微信不在同一进程中,通过分进程打开的 Flutter 页面属于分进程的一个独立窗口。窗口的生命周期和样式不在企微中管理,这种方式很难适配复杂的业务场景。相当于每个使用了 Flutter 的业务都要关心 Flutter 窗口的样式,在不满足业务场景时,要修改分进程代码支持。对业务方不友好且很难维护。
改进方案如下:
通过这两层封装在使用层面上将 window 降级 view,使用 Flutter 就可以和使用 Control 或者 Widget 一样方便。FlutterProcessManager 负责管理分进程,当创建 FlutterContainerView 时,如果分进程还没启动,则唤起分进程 IPCController 则负责和 Flutter 进行通信,通过 FlutterContainerView 告知分进程打开指定的 Flutter 页面。
封装之后,窗口的层次关系如下。Flutter 只负责展示业务内容,窗口的属性、样式等,都通过企业微信来设置。通过和其他 View 进行组合使用,可以达到如图所示的效果。
win 在文字渲染上遇到两个比较严重的问题:
文字渲染的细节不对
这里是因为 Flutter 默认使用 skia 的渲染模式是 grayscale 灰度字体渲染方式,但是在 win 客户端普遍使用的是 subpixel 渲染方式。导致文字渲染跟 win 有一些区别,这部分需要我们通过修改 engine 来修复核心代码为:
SkPixelGeometry pixel_geometry = kUnknown_SkPixelGeometry;
#ifdef WIN32
UINT structure = 0;
if (SystemParametersInfo(SPI_GETFONTSMOOTHINGORIENTATION, 0, &structure, 0)) {
if (structure == FE_FONTSMOOTHINGORIENTATIONRGB)
pixel_geometry = kRGB_H_SkPixelGeometry;
else if (structure == FE_FONTSMOOTHINGORIENTATIONBGR)
pixel_geometry = kBGR_H_SkPixelGeometry;
}
#endif
SkSurfaceProps surface_props(0, pixel_geometry);
#ifdef WIN32
font.setEdging(SkFont::Edging::kSubpixelAntiAlias);
修复前:
修复后:
渲染字体错乱
在某些 win 的机型上,如果当前系统语言不是简体中文,Flutter 渲染的字体会有明显的误差,文字展示比较奇怪,不是标准的简体中文。
主要原因是,Flutter 在渲染字体的时候,用系统当前默认的字体去渲染,当前的字体如果无法渲染这个文字,就会自动匹配一个字体来完成这个文字的渲染,这里由于 skia 的匹配算法匹配到了其他语言去,因此导致了渲染文字出错。
Flutter text 组件中提供了一个文字渲染失败的回调 fontFamilyFallback ,如果当前字体无法渲染字符的时候,会回调到 Flutter 上层,可以由 Flutter 上层指定要用字体,这里我们给这个回调指定了微软雅黑,从而解决语言错乱的问题。
修复前:
修复后:
整个环境搭建起来之后,因为 Flutter 四端跨平台的能力,移动端的同学也能够去开发一些桌面端的应用,但由于是混合开发的模式,开发别的平台应用的时候,需要别对应平台的工程代码,并且不同平台的开发环境以及仓库都不一样,桌面端工程的开发对于移动端的同学来说非常不方便。
现有的组件化模式本质还是一个大仓全代码的编译过程,虽然代码按模块隔离了,但是编译的时候没有做到隔离,debug 阶段还要严重依赖宿主工程。
为了提高开发以及走查的效率,我们将 Flutter 的主工程拆分为多个微应用,为每个业务模块提供 example application 的运行的能力,并且在 example 中依赖于 runner 的基础组件,runner 主要提供 grpc 的远程调用服务,负责将 channel/dart2cpp 的接口通过 grpc 远程调用发送给服务端,这里的服务端就是我们的宿主 app,通过这种模式,在调试阶段,将 Flutter 应用完全从企业微信的宿主 app 里面解耦开来,带来的好处是,更快的编译速度,更全的平台开发体验,更稳定的调试系统。
最后,在开发 Flutter 业务的时候,我们只需要 debug 版本的企业微信应用程序即可与原生进行通信,业务模块只需要依赖 Flutter 环境就可以独立运行起来。
企业微信使用 Flutter 统一了四端的 UI 开发框架,在业务开发上效率得到了明显的提升,以企业微信首个跨四端的大型应用人事助手为例,相比于四端独立开发,使用 Flutter 作为跨平台开发,整个需求的迭代协同效率大大提升:
1: UI 开发上我们统一了 dart 技术栈,不同平台的同事都可以参与到 Flutter 开发当中来,解决桌面端人力不足的问题。
2: 通过移动端跨平台+桌面端跨平台的方案+ mvvm 的架构,我们研发效能提升 1 倍以上。
3: 对于设计/产品走查都只需要移动端和桌面端各走查一次,测试对 ui 渲染层也只需要测单端,节约了 1 倍的人力。
得益于移动端的模块化架构,桌面端的工程可以很好复用移动端已有的基础组件能力。我们将 ui 的数据以及交互从各端 UI 中分离,由 provider 进行统一的处理,来简化各端 UI 上的开发成本,桌面端和移动端 UI 开发只需要简单的布局即可,结构如下:
例如在人事助手的首页中待处理消息的列表卡片 UI,两个卡片无论布局还是显示效果都有明显的差别,在 UI 上不能完全复用。
桌面端
移动端
通过上述的开发结构,整体的流程如下:
1: 通过 mixin TodoInfoAdapter 的方式,约束各平台 UI 组件所需要的数据字段,以及交互。
2: 桌面端和移动端分别使用对应的 ui 进行布局,将 ui Widget 和 TodoInfoAdapter 进行数据的绑定。
3: provider 作为 viewmodel, 在初始化的时候通过 cgi 请求,对 proto 数据进行处理,这里与 model 层进行交互。
4: provider 将 cgi 的 resp 中的相应数据转换成为 ToDoInfoAdapter,转换成功之后通过 notifyListener 刷新 ui 的数据。
5: provider 根据 resp 中的部门 id, 异步拉取部门的数据,拉到数据之后,更新 adapter,调用 notifyListener 重新更新 ui 数据。
对于 cell 的点击事件,也是作为 adpater 中的一个参数,在 viewmodel(HrSystemHomeProvider) 中统一处理。
由于四端的代码复用,桌面端首页卡片 Cell 减少了大约 48%的重复代码。
目前企业微信也在不断利用和完善 Flutter 四端的能力,也在自研引擎上修复了不少 Flutter 的问题,提高 Flutter 在跨平台上的开发体验。
在整个社区的贡献上,企业微信在 Flutter 方向参与过多次对外分享活动,整个 2022 年上半年 Google GDG 给我们发出了三次分享邀请: