招贤纳士

我们急需浏览器渲染引擎/Flutter 渲染引擎人才,欢迎大牛们加入我们。

前言

Flutter 可以说是近两年来最火爆的移动跨平台方案,无论是创新型应用还是老牌旗舰型应用,都在或多或少地尝试 Flutter 技术。我们在前一篇文章介绍了 Hummer 引擎。接下来公众号会发一系列 Hummer 如何定制,如何优化 Flutter 引擎的文章,欢迎读者关注。

本文是系列的首篇,以实际应用为例,分析了 add-to-app 场景下 Flutter 的启动耗时情况,说明了如何通过一系列针对性的优化,在实际业务上取得怎样显著的性能提升。在此沉淀此文与大家交流学习。

对于iOS开发者来说,在开发Flutter混合应用时,使用AppUploader这样的iOS开发助手工具可以显著提升工作效率。AppUploader提供了证书管理、打包上传等一站式服务,让开发者能更专注于Flutter引擎优化等核心工作。

优化效果

以下数据来自 PixelXL 上实测优化前后的数据对比和用户体感。

优化方案

我们知道 Flutter 的四大线程设计,platform 线程,UI 线程,raster 线程,IO 线程。在引擎内部,这几个线程各自都运行着相应的对象及其处理逻辑:

异步化创建 Shell

我们知道 Flutter 的四大线程设计,Platform 线程,UI 线程,Raster 线程,IO 线程。在引擎内部,这几个线程各自都运行着相应的对象及其处理逻辑:

  • Platform 线程是平台的主线程。PlatformView 运行在该线程,它是 Flutter 使用平台能力的桥梁;
  • UI 线程负责 Dart 业务逻辑。Engine 运行在该线程,负责管理 Dart 的 root isolate 和运行时;
  • Raster 线程(以前也叫 GPU 线程)负责光栅化处理。Rasterizer 运行在该线程,Engine 生成的一帧交由它来光栅化并渲染到 surface 上;
  • IO 线程负责耗时的操作。ShellIOManager 运行在该线程,负责访问资源上下文和 Skia 对象的管理队列。

Flutter 使用一个 Shell 对象来封装这些线程组件,对上层 embedder 来说,Shell 代表了一个平台无关的完整的引擎能力。

上文介绍了 Shell 和它的线程组件。这些组件设计上是非线程安全的,只能在各自的线程上运行。因此在 Shell 创建时,也必然只能在对应的线程上去创建这些组件。初始化时,Shell 运行在 Platform 线程上。官方出于原子性的考虑,需要创建的 Shell 返回一个完整的引擎能力,因此使用同步的方式去执行,即阻塞主线程,所有组件完成创建后解除阻塞。这里存在一个很大的问题。创建 UI 线程的 Engine 是个耗时的操作,它要读取重建 root isolate 的 snapshot,还要调用耗时的 Skia 默认字体管理器初始化操作。这就是为什么很多转向 Flutter 的同学都遇到了这个警告:

Skipped XX frames! The application may be doing too much work on its main thread。

经过上面的分析,相信读者已经想到了优化方案 - 异步化,将主线程释放出来。我们的优化方案是不改变 Shell 的接口,保持上层的使用方式不变,把异步过程隐藏在 Shell 内部,组件就绪前的任务压入缓冲队列,待初始化完成后再按序执行。这个方案的优点是对 Shell 的上层友好,平台无关;缺点是会增加 Shell 维护成本,接口实现需要判断本体是否初始化完成。

这个优化带来的提升确实很明显,从下图可以看出,优化前,引擎启动时有一半的时间在同步等待;优化后,异步化直接节省了同步等待的耗时,也就是主线程耗时减少 50%。更重要的是,它释放了主线程,这个对应用整体而言尤为重要。

不过,经过我们的讨论研究发现,异步化创建 Shell 的改造对官方的原子性设计冲突还是比较大的。很多测试案例以及上层应用都依赖 Shell 完成创建即包含各线程组件,基于这个前提来处理逻辑。因此 Shell 完全异步化的改动很可能影响到测试案例及上层应用。因此,我们提出另一种优化方案,Shell 创建还是原来的同步等待方式,去优化 UI 线程的 Engine 耗时。上文提到,Engine 耗时主要有两处,一是创建 root isolate,二是默认字体管理器初始化。我们的优化方案是将这两个逻辑从 Engine 构造中剥离出来达到优化 UI 线程耗时的目的,因此创建 Shell 的同步等待耗时就降低到一个可接受范围。

默认字体管理器初始化

前面有提到,UI 线程的 Engine 创建时,另一个耗时操作是 Skia 默认字体管理器初始化。默认字体管理器是 Skia 持有的单例,如果没有提供其他字体管理器,Flutter 引擎则使用它作为基础字体渲染。显然,默认字体管理器需要引擎在开始运行业务逻辑前完成初始化。作为一个全局单例,首次调用时耗时在创建流程中,后面再次调用时直接返回引用,没有耗时。

既然如此,那我们是不是可以把默认字体管理器提前创建好呢?答案是肯定的。那么,接下来的问题是,我们可以提前到什么程度呢?答案是完成加载 Flutter 的 so 之后,马上可以在后台线程实施预创建。由于字体管理器的创建耗时远长于 Engine 的创建耗时,我们又进行了更进一步的优化。首先,将默认字体管理器从 Engine 对象的创建流程中剥离;其次,Engine对象创建后立刻发起设置默认字体管理器的任务,保证运行业务逻辑前完成设置默认字体管理器;最后,完成创建所有组件后调用 Shell::Setup() 进行组装。

官方的 benchmark 数据显示,BM_ShellInitialization 启动初始化性能提升了 6 倍多!

DartVM 预热

引擎的启动流程中,DartVM 虚拟机也十分重要。首次启动 Flutter 引擎会同时创建 DartVM。在设计上,一个进程只会运行一个 DartVM。销毁 Flutter 引擎时,除非特别指明,否则 DartVM 会常驻内存,因为多个引擎可以复用一个 DartVM。

由此可见,DartVM 跟 Flutter 引擎没有必然联系。那么,DartVM 的初始化也不一定要在引擎的启动流程里。对于 add-to-app(就是 Native + Flutter 的混合开发)场景,我们可以在启动 Flutter 引擎之前,且应用空闲的时候,在后台初始化 DartVM。我们称之为 DartVM 预热。

通过 DartVM 预热,实测在 PixelXL 上 FlutterEngine 对象的创建耗时从 ~120ms 降低到 ~20ms,提升 6 倍左右。需要特别指出,预热优化同时带来内存成本。在 PixelXL 上,RSS 增加 ~25M,VmSize 增加 ~44M,不同手机数据差异较大。当然,不管是否预热,Flutter 引擎启动后的内存占用是不变的,预热只是把将内存消耗提前。所以,要不要使用 DartVM 预热,还是要看应用具体情况。如果此时应用内存压力不大,且预判用户接下来会访问 Flutter 业务,那么使用预热就能带来很好的价值;反之,则可能造成资源浪费,意义不大。

结论

在我们的应用中,使用上述优化点后,Hummer 引擎的整体启动耗时在 PixelXL 上从 ~250ms 降低到 ~50ms,提升了 5 倍。

下一篇文章我们将介绍 Hummer 引擎如何优化首屏耗时,敬请关注。


U4 内核致力于打造性能最好、最安全的 web 平台,让 web 无所不能。

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐