Flutter 产物分析与减包方案
in 技术 with 797 views and 0 comment

Flutter 产物分析与减包方案

in 技术 with 0 comment

在混合开发场景下,Flutter 的包增量略大一直是被大家诟病的一点,但 Google 官方明确表示了 Flutter 不会支持动态化,而且目前 Flutter SDK 官方还没有提供一套定制方案。因此想要瘦身,那么只能自己动手丰衣足食了。

所谓减包,前提条件是必须知道产物内容有什么?产物里有哪些部分可以减?被减掉的部分我们要怎么加回来?因此本文将围绕“产物分析”与“减包方案”两个主题来分别论述 iOS 与 Android 两端的 Flutter 减包原理与方案。

那么,先从 iOS 端开始吧。

注:本文数据与代码片段均来源于一个基于 Flutter 1.17.1 的 Flutter Module 在 Release(AOT Assembly)Mode 下构建后的产物,未经过任何压缩。

1. iOS 篇

1.1 产物构成

我们知道使用 flutter build ios-framework 即可将一个 Flutter Module 构建成一个 Framework 供 iOS 宿主集成,这种集成方式我们称之为产物集成,这么这个“产物”就是 Flutter 产物,它包含以下几个部分组成:

  1. App.framework
    • App: 这个是 Dart 业务代码 AOT 的产物
    • flutter_assets: Flutter 静态资源文件
  2. Flutter.framework
    • Flutter: Flutter Engine 的编译产物
    • icudtl.dat: 国际化支持数据文件

打出产物之后,我们在终端可以显示各个部分的体积,最后整理一下 iOS 端 Flutter 产物结构如下图所示:

需要注意的是 Mac Finder 中显示的体积会偏大,其换算倍率是 1000 而非 1024,需要我们用命令行拿到显示的体积之后再手动计算得到真实体积。

此外,Engine 产物的体积我们选用的是 profile 模式(arm64+arm32)下的体积,因 Flutter 1.17.1 release 存在 bug,bitcode 无法被压缩,导致体积有 351.47 MB,影响分析。具体原因可见:Flutter app size is too big · Issue #45519

1.2 减包方案

减包的基本方法有二:

  1. 删产物:把产物中没用的部分直接删掉
  2. 挪产物:把可以暂时移除的部分挪走改变为远端下发,同时需要修改产物加载逻辑,使 Flutter 支持动态加载远端下发的部分产物

我们针对前文中总结的产物结构一一来实现产物减包,首先是 App.framework 中的 App 部分。

1.2.1 App.framework/App

在说方案之前,我们先看看 App.framework 下的 App 是如何构建而来的,如下图所示:

首先,frontend_server 会将 Dart 源码编译成一个中间产物 dill,我们通过运行以下命令也可以实现通过的编译效果:

app.dill 是二进制字节码,我们通过 string app.dill 可以发现它其实就是 Dart 代码合并之后的产物:

而 Dart 在开发模式下提供的 Hot Reload 其实也正是通过将变动的代码通过 frontend_server 编译得到新增的 kernel(app.dill.incremental.dill),通过 WS 提交给 Dart VM Update 之后来进行整棵树的 Rebuild,从而实现 Hot Reload。

之后会通过两个平台侧的 gen_snapshot 进行编译得到 IL 指令集和优化代码,最后输出汇编产物。汇编产物通过 xcrun 工具得到单架构的 App 产物,最后经过 lipo 得到最后双 ARM 架构的 App 产物。所以,我们这里展示的 App.framework 下的 App 的体积是双架构的。

ARMv7: iPhone 5s 之前的 iOS 设备。
ARM64: iPhone 5s 及其之后的 iOS 设备。

接着,我们从删产物和挪产物两个层面来讲解如何减少该产物的体积。

删产物

这部分体积是 Dart 代码 AOT 之后的产物,体积较大,是我们减包过程中重点关照对象。

按照之前说的减包基本方法,我们首先试试“删产物”,看看有什么可以直接删掉的,使用 Flutter 提供的体积分析工具可以直接得到体积图:

我们发现确实有两个库业务中没有用到,直接删掉依赖即可。

除此之外还有一些优化,可以帮助我们减少代码体积:

此外,我们还可以删除一些符号来达到减包效果

注:dSYM 是保存 16 进制函数地址映射信息的中转文件,包含我们调试的 symbols,用来分析 crash report 文件,解析出正确的错误函数信息。

挪产物

接着,我们再看看如何实现“挪产物”,那就需要对 App.framework/App 中的内容做具体分析了。我们之前说它是 Dart 代码 AOT 之后的产物,没错,因为它主要由四个 AOT 快照库(snapshot)组成:

详情可以看官方 Wiki 中的介绍:https://github.com/flutter/flutter/wiki/Flutter-engine-operation-in-AOT-Mode

同一个进程里可以有很多个 Isolate,但两个 Isolate 的堆是不能共享的。Dart VM 开发团队早就考虑到了交互的问题,于是就设计了一个 VM Isolate,它是运行在 UI 线程中 Isolate 之间交互的桥梁。Dart VM 中 isolate 之间的关系如下图所示:

因此 isolate 对应的 AOT Snapshot 就是 kDartIsolateSnapshot,其又分为指令段和数据段;VM Isolate 对应的 AOT Snapshot 就是 kDartVmSnapshot,其也分为指令段和数据段。

根据以上分析,App.framework 的结构我们可以进一步拆分成下图所示:

我们知道 App Store 审核条例不允许动态下发可执行二进制代码,因此对于以上 4 个快照,我们只能下发数据段的内容(kDartIsolateSnapshotData 与 kDartVmSnapshotData),而指令段的内容(kDartIsolateSnapshotInstructions 与 kDartVmSnapshotInstructions)则依然要留在产物里。

那么,我们要在哪里分离这个快照库呢?

在 Dart VM 启动时的数据加载阶段,如下图所示,修改 settings 里面快照库的读取路径即可:

修改之后的具体实现本文不做讲解,在 《Q 音直播 Flutter 包裁剪方案 (iOS)》 一文有详细的代码修改介绍。

1.2.2 App.framework/flutter_assets

flutter_assets 是 Flutter Module 中使用到的本地静态资源,对于这部分我们不可能“删”的只能“挪”,我们有两种方案来挪产物——常规方案依然是在 Dart VM 启动时的数据加载阶段来修改 settings 里的 flutter_assets 路径,来做到远程加载,常规情况下我们使用这种方式就可以移除 flutter_assets 了。

那么有没有什么方式可以不修改 Flutter Engine 的代码移除 flutter_assets?有的,可以使用 CDN 图片 + 磁盘缓存 + 预加载的组合方案实现同样的效果,步骤如下:

  1. 封装一个 Image 组件,根据编译模式选择使用本地图还是网络图,即开发环境下使用本地图快速开发,生产环境下使用 CDN 图。
  2. 改造 CI,持续集成时移除 flutter_assets 并发布包内的图片到 CDN 上。
  3. 扩展增强 Image 组件的能力,引入 cached_network_image,支持磁盘缓存。
  4. Flutter 模块加载时,使用 precacheImage 方法对 CDN 图片进行预加载。

这套方案稍显麻烦了一些,而且还要区分环境,因此还是建议修改 Flutter Engine 来实现远端加载 flutter_assets。

1.2.3 Flutter.framework/icudtl.dat

icudtl.dat 是国际化支持数据文件,不建议直接删掉,而是同上述挪产物的方案一样,在 Dart VM 启动时的数据加载阶段修改 settings 里的 icudtl.dat 路径(icu_data_path)来实现远端加载:

1.2.4 Flutter.framework/Flutter

引擎修改

这一部分是 Flutter Engine (C++)的编译后的二进制产物,是产物里占据体积最大的部分,目前我们参考字节跳动的分享《如何缩减接近 50% 的 Flutter 包体积》,可优化的部分目前有以下两点:

  1. 编译优化
  2. 引擎裁剪

Flutter Engine 使用 LLVM 进行编译,其中链接时优化(LTO)有一个 Clang Optimization Level 编译参数,如下图所示(在 buildroot 里):

我们将这里 iOS 平台的 Engine 编译参数从 -Os 参数改成使用 -Oz 参数,最终可以减小 700 KB 左右体积。

而引擎裁剪也有两个部分可以裁剪:

  1. Skia: 去掉一些参数,在不影响性能的情况下可以减少 200KB 的体积。
  2. BoringSSL: 若使用客户端代理请求,则不需要 Dart HttpClient 模块,这部分可以完全移除,且代理请求会比 HttpClient 的性能更好,这部分可以减少 500KB 的体积

附: 在 https://github.com/flutter/flutter/issues/40345 中提及了另一个角度的编译优化,即函数编译优化。同一个加法函数,Dart 实现编译之后有 36 条指令,而 Objective-C 只有 11 条指令,36 条指令中有头部 8 条和尾部 6 条的对齐指令,是可以移除的,中间有 5 条栈溢出检查也是可以移除的,即 Dart 编译后的 36 条指令可以优化成 13 条指令。这里需要等 Google 官方来进行优化。

引擎编译

修改完之后我们需要编译引擎,首先先介绍一下 Flutter Engine 编译时需要用到的工具:

编译工具介绍具体可见 Flutter 官方 Wiki:Setting up the Engine development environment - Flutter wiki

具体编译分为三步,首先创建一个 .gclient 文件,来拉取源码和所有对应的依赖,如下图所示:

第二步,执行 gclient sync 下载依赖。

需要注意的是以上几点修改都是依赖(如 buildroot,skia 等)而非源码,因此需要我们 fork 一份 flutter engine,然后先改好依赖之后,获取对应依赖的 commit 号