EngineGroup:让 Flutter 桌面端引擎“飘”起来
1 引言
在我们之前分享的《Dutter | 钉钉 Flutter 跨四端方案设计与技术实践》《Dutter | 前车之鉴:聊聊钉钉 Flutter 落地桌面端踩过的“坑”》文章中,有为大家简单介绍过钉钉 Flutter 桌面端应用的一些情况。在文章中我们有提到,因为需要支持多窗口、窗口内嵌等场景,在桌面端我们无法使用 FlutterBoost 一类的中间件来共享 FlutterEngine,只能采用多引擎方案来驱动多画布同时渲染。
此方案虽然能满足现阶段钉钉业务使用,但未来随着业务盖度、复杂度的提升,方案的弊端也愈加明显:引擎启动偶现卡顿、首帧耗时略长、内存占用高等。尤其是钉钉 Windows 端因为32位兼容问题,目前仍以 JIT 模式在运行 Flutter 页面,情况相比 AOT 模式更加差一些。以钉钉 Windows 目前线上业务为例,若不做任何优化,启动首帧展示耗时大概在 1000ms~2600ms 之间,每个引擎内存占用大概在 70MB 左右。
我们选择基于 Flutter 来构建钉钉跨4+端研发框架(Dutter) 的主要初衷即看中其在性能和体验上具备可媲美 Native 运行的能力,在完成前期基础设施搭建和核心链路验证之后,多引擎下的性能问题已成为阻碍我们达成既定目标的主要障碍,需要攻坚解决掉。
本文主要为大家介绍一下我们在此问题上探索方向以及最终方案,并以实际应用收益来为做一个直观展示,希望能为关注此领域的同学和团队提供一定参考。
2 性能收益
最终我们选择的优化方向,是将 Flutter 在移动端已经支持的 LightweightEngine 方案在桌面端应用起来。经过一段时间的攻坚改造,我们目前完全基本开发验证工作,后续准备开始内部灰度。
下面通过钉钉 App 内以及独立 Demo 来分别展示下优化收益。
2.1 钉钉内表现
在钉钉内,我们以「日程签到」的表现来展示一下优化收益:
表格视频
独立多引擎 | 共享第一个引擎资源 |
内存开销 70MB*N | 内存开销(↓50.0% ~ +∞) 首个引擎 70MB,其余约 300KB |
启动耗时 1.23s | 启动耗时(↓63.5%) 0.45s |
注:素材较老,共享引擎花屏的问题已处理
2.2 Demo 表现
为了排除业务数据影响的情况,更好的展示优化效果,我们以独立 Demo 的方式也做了一个对比测试,测试会连续打开8个窗口,观察在不同模式下的表现:
独立多引擎 | 共享第一个引擎资源 |
内存开销 | 内存开销(↓65.1%) |
启动耗时 0.67s | 启动耗时(↓77.2%) 0.15s |
3 前期探索
在选择桌面端对齐 LightweightEngine 方案之前,我们在钉钉内部也有尝试过一些优化方案,寄希望于通过上层优化来改善桌面端(主要是 Windows)性能问题。我们尝试过的方案有:
探索方案1:使用 FFI 替换 Channel,提升数据通讯效率。
基于此方案来改造 Dutter 现有核心流程,确实可以取到一定收益。经过实验室测试,首帧加载速度大致可以提升 5%~10% 之间。
但是此方案的弊端也很明显:收益有限但对开发限制过大,改造 ROI 太低,最终并未在 Dutter 内落地。
探索方案2:构建"引擎池",利用池化技术改善加载性能。
池化技术有比较多的成功案例,且此方案对 Dutter 框架部分影响很小,通过对引擎的预热+保活,可以大大的改善业务加载性能。
但此毕竟是一个「内存换时间」的折中方案,虽然性能达标了,但是内存开销上会大大提升,因此只能作为特定业务场景下的优化手段,不可作为通用方案推广。
探索方案3:在桌面端对齐 LightweightEngine。
LightweightEngine 是 Flutter 2.0 推出的重磅功能,对外提供的能力叫做 FlutterEngineGroup。其在内部通过引擎间资源共享来降低派生引擎的资源开销、提升派生引擎的启动性能,这一点与钉钉诉求是高度结合的。
但因为 Flutter 移动端和桌面端引擎架构有一定的差异,如果我们自己来实现此功能,若无法提交到官方,为了会带来一些引擎维护成本,故最初并未将此方案作为首选方案。
在一次偶然机会上,我们通过较低成本实现一个比较粗糙的 POC,发现基于此方案可以极大提升 Windows 端 JIT 模式下的启动性能、大大降低内存开销,相比 Mac 端 AOT 模式的收益更大。故在方案1和方案2收益不佳之后,我们选定 LightweightEngine 作为我们下一步性能优化的主攻方向。
4 方案分析
前面也有提到,LightweightEngine 是 Flutter 2.0 推出的重磅功能,对外提供的能力叫做 FlutterEngineGroup。如果我们要将其迁移到桌面端,必然需要先梳理清楚其移动端实现方案,然后再结合桌面端引擎架构做改造实现,故在本小节我们先对 LightweightEngine 的实现方案做一下简单分析。
4.1 移动端实现
详细方案可以参考官方文档:http://flutter.dev/go/multiple-engines,下面梳理一下上述文档重点内容。
此方案的核心思想在于「资源共享」,在 EngineGroup 内的实例会共享一下内容:
Threads; Skia Contexts; Graphics Contexts; Image Caches; Isolate Groups; Fonts;
从上述列表我们可以看到,正式借助于将高开销的资源做共享,EngineGroup 内的引擎可以以更快的速度、更低的成本创建出来。
结合类的关系图,我们可以更直观的看下具体共享了哪些内容:
更详细的内容就不展开了,大家感兴趣的话可以查阅官方的设计文档。
4.2 桌面对齐成本
优先我们还是希望官方能将此功能补齐,但比较遗憾的是此需求目前在官方排期中优先级不高,短期也没落地计划【ISSUE】https://github.com/flutter/flutter/issues/95076:
既然官方短期无法支持、我们又有比较强的诉求,那就只能撸起袖子自己干了!不过为了避免「水太深」,出现一脚踏进去爬不出来的情况,在开工之前我们还是通过对引擎架构分析以及 POC 的方式对可行性做了一次验证。
此前,我们有对 Flutter 移动端和桌面端引擎架构做过简单对比:
从上图我们可以看到:
在 Shell 层以下部分,移动端和桌面端部分实现是共享的,这部分是平台无关的内容; 在 Shell 层以上部分,桌面端相比移动端主要增加一部分「桌面平台无关的」Embedder 层,这部分对 Shell 做了更进一步的封装,以降低桌面端接入成本;
基于以上分析我们可知:
Shell 层的改造和实现,桌面端可以直接共享 LightweightEngine 移动端的实现,仅需少量适配即可; 对于 Shell 之上部分的改造,桌面端改造的重点在于两方面:
改造 Embedder 层接口以及实现以配合 LightweightEngine 资源生命周期管理; 配合平台层具体实现,封装暴露接口以及共享资源桥接;
并且在对移动端 LightweightEngine 方案学习之后,我们判定 Shell 以下部分的实现是整个方案的核心以及难点部分;Shell 以上部分的改造挑战虽然有,但仍在可控范围内,并且也不会与官方演进方向发生分化,具备继续推进落地的可行性。
5 方案详解
虽然可以有移动端成功案例做参考和对齐,但是因为 Flutter 自身在上层架构上移动端和桌面端存在较大差异,我们在实际落地过程中仍处理了一系列比较有挑战性的,下面为大家做一下介绍。
5.1 总体结构
从前文分析可知,LightweightEngine 实现核心在于「资源共享」,桌面端对齐又尤以 Shell 层以上部分为主。FlutterEngine 移动端和桌面端核心类图:
从上图我们可以看到,桌面端 Shell 以上部分设计相比移动端更下复杂,在移动端由同一个 Class 实现的部分被拆分为两部分。以 Surface 为例,在 iOS 端只需要一个IOSSurface 类族来实现即可;但是在桌面端被拆分为跨平台层的 EmbedderSurface 以及平台层的FlutterRender 两部分。并且桌面在进入真实渲染流程时,其对渲染 Context 的操作还需要收到 Flutter Resize 模块控制,总的来说桌面端实现复杂度相比移动端要更高一些。
在梳理完桌面端相关模块之后,参考移动端方案,我们整理出桌面端在 LightweightEngine 模式下相关资源模块的共享关系图:
将此图与移动端类图做对比我们可以看到:
在移动端引擎是 Shell + Platform 的两层架构,EngineGroup 下共享资源大部分在 Shell 层,上层只有 ThreadHost 需要共享; 桌面端引擎架构在 Shell 和 Platform 中间多了一层跨平台的 Embedder,虽然 Embedder 的设计是为了降低 Platform 层的实现复杂度。但在桌面端实际发展过程中,随着功能复杂度越来越高,Embedder 的存在虽然使 Platform 层功能开发工作量降低、但是增加了配置与交互的复杂度。因此在实现 LightweightEngine 的功能时,我们需要在 Platform 和 Embedder 层同时做改造,具体内容包含: EmbedderThreadHost:引擎线层管理控制器,对等移动端 TheadHost,需要配合平台层 TaskRunner 使用; Setting:Embedder 层配置服务模块,Platform 层对 Shell 层引擎的设置和注入,都需要通过 Setting 来承载; FlutterEngineProcTable:负责与 Embedder 层函数地址绑定; EGLContext&Display: 控制平台层渲染上下文,根据平台分别对应 OpenGL、Metal、D3D等; TaskRunner: 平台层实现的 Runner,负责与当前平台线程交互,与 EmbedderThreadHost 配合; Platform 层: Embedder 层:
支持我们即基本梳理出桌面端支持 LightweightEngine 总体改造方案以及改造范围,但产出方案仅是万里长征第一步,从方案设计到落地存在不少要处理的问题。
下面我们以 Windows 端实现为例,为大家介绍一下我们在方案落地过程中翻越的"三座大山":
GPU Context 共享; Skia Context 共享; ThreadHost 共享;
5.2 三座大山
5.2.1 GPU Context 共享
在 Windows 端 Flutter 支持两种渲染模式 CPU 渲染 以及 DirectX 渲染;其中为了降低上层实现复杂度以及接入成本,在 Windows 端 Flutter 使用 Angle 库将 DirectX 接口封装为 OpenGL 接口,上层实现可与其它端实现共享。OpenGL 适用范围最广,我们即以 OpenGL 的实现来做一下说明。
GPU Context 共享是 LightweightEngine 实现中的核心部分,并且也是完成整个方案关键。并且在 DartVM 底层共享的情况下、不贡献 GPU Context 则会出现因资源访问冲突导致异常,无法正常渲染页面:
在 Windows 端 Platform 层实现中,GPU 相关资源由 AngleSurfaceManager 来管理,为了满足共享诉求,我们需要对 AngleSurefaceManager 做改造,将其内部需要负责上下文管理的部分定义为 AngleEGLContext,各 AngleSurfaceManager 共享 AngleEGLContext 以达到 GPU 资源共享的诉求:
在 EngineGroup 内的所有 FlutterEngine 实例释放之后,AngleEGLContext 方可进入销毁流程,并释放相关 GPU 资源。
5.2.2 Skia Context 共享
在处理完 OpenGL Context 之后,下一步我们要处理的即 Skia Context 问题。此处需要注意,如果 Skia 与 OpenGL 对应生命周期管理出现紊乱,则会出现诡异的渲染问题,例如:
Skia Context 共享的处理方式相比 GPU Context 更复杂一些:GPU Context 完全由上层管理,改造位置相对收敛;但是 Skia Context 与 Sureface 强绑定,由 Embedder 层负责创建,但是因为 FlutterEngine 相关生命周期最终由 Platform 层来控制,因此需要在 Embedder 暴露的 Open API 增加对 Skia Context 的存取接口。
最终核心改造内容包含:
SkiaContext 仅对 Embedder 层暴露;Platform 层使用封装类(屏蔽底层实现)持有相裸指针; Embedder 新增暴露接口:
FlutterOpenGLRendererConfig 新增 SetSkiaContext, GetSkiaContext 相关回调方法; 新增销毁 SkiaContext 相关方法;
改造之后的最终效果:GPU Context 与 Skia Context 一一对应,且生命周期基本对齐,至此渲染相关逻辑自洽且闭环。
5.2.3 ThreadHost 共享
在前面的分析中我们有介绍,在移动端由 FlutterEngine 持有的 ThreadHost,在桌面端被拆分为两部分:Embedder 层的 EmbedderThreadHost 以及 Platform 层的 TaskRnner。若实现 ThreadHost 共享,我们需要在 platform 以及 Embedder 层同时做改造。
在进入实际改造流程之前,我们首现需要梳理一下相关实现:
从上图可以大概梳理出相关 Class/Struct 之间的关系:默认情况下各对象是一对一的关系,并且底层与上层之间是通过 lambda 回调。在经过 LightWeightEngine 改造之后,Shell 层实例只会持有一份共享的 TaskRunners 以及 ThreadHost。
在 Windows 端中,Platform 层 TaskRunner 的具体实现类为 TaskRunnerWin32,其最主要的动作是在触发回调时,调用 Embedder 层接口,在与其绑定的 _engine 实例上执行相关 Task。
进一步查找底层 RunTask 的实现:
可以发现,最终是调用到 flutter::EmbedderThreadHost 的 PostTask 方法来执行 Task。并且我们从上文分析可知在底层 EngineGroup 内的 ThreadHost 是共享的,即调用 EngineGroup 内的任意一个引擎实例的 PostTask 方法都是合法且一致的。
故此即可得出我们改造处理方案:flutter::TaskRunnerWin32 做封装: TaskRunnerWin32Wrapper,在各个引擎直接贡共享封装之后的实例。在 TaskRunnerWin32Wrapper 内部会关联当前 EngineGroup 内的所有引擎实例,在需要执行回调时,选定任一可用实例来执行:
当所有绑定实例都销毁时,组内关联的 TaskRunnerWin32Wrapper 对象也会进入析构流程,至此即可完成 ThreadHost 部分资源的共享。
6 小结
目前钉钉侧已经完成 EngineGroup(LightweightEngine) 能力对接的绝大部分工作,完成冒烟测试之后,在实验室环境下跑出了很不错的成绩。在短期钉钉 Windows x64 版本无法长期铺开、业务启动性能要求高、内存压力大的情况下,EngineGroup 对我们来讲是一个极具吸引力的性能优化方案,后续会逐步在钉钉桌面端铺开。
后面我们会进一步与 Hummer 团队同学共建,争取能将相关改动贡献进 Hummer 主干,集大家力量来完善繁荣 Flutter 研发应用生态!