如何缩减接近 50% 的 Flutter 包体积
以下是字节跳动移动平台部 Flutter 资深工程师李梦云的分享主题沉淀,《如何缩减接近 50% 的 Flutter 包体积》。
演讲内容大纲:
- 包体积问题现状
- Dart 编译产物优化
- Flutter 引擎编译产物优化
- 机器码指令优化
- 总结与展望
个人介绍
我叫李梦云,任职于字节跳动移动平台部,负责移动端部分基础设施平台的建设与落地,前两年落地插件化平台和热修复平台,这两个平台现在基本字节跳动所有的 APP 都在使用,也已经比较成熟了,现在我的主要精力在 Flutter 这边,负责 Flutter Engine 和 Dart Runtime 这两个底层方向上的一些工作。
今天在座的各位一定正在用 Flutter 或者想用 Flutter,发现 Flutter 包体积有点偏大、有点控制不住。可能还有一部分同学并没有注意到这个问题,但是随着使用 Flutter 的深入程度增多,大家最终都会发现这个痛点的,那我们今天就来解决这个痛点。
这是我今天分享的 5 个组成部分:
第一部分,针对 Flutter 包体积给大家讲讲 Flutter 包体积现状,以及它由哪几个部分组成。
接下来三个部分会针对这几个组成部分做针对性的优化。
最后一部分,总结优化手段,展望 Flutter 包体积的未来。
一、包体积问题现状
image我们先来统一认识,包体积到底重要不重要?结论是很重要。
右图是 Google 2016 年公布的研究报告,核心思想是包体积每上升 6MB 就会带来下载转化率降低 1%,当包体积增大到 100MB 时就会有断崖式的下跌。这是 2016 年的数据,现在流量虽然变得更廉价一点,但是用户的心理是不会变的。可能 6MB 这个数据现在变成 10MB 或者 20MB,但是当你 APP 出现在应用市场的相同位置时,包体积越大,用户下载意愿就越低,这是毫无疑问的。所以我们的结论是:包体积很重要,需要优化。
那现状是什么?结合今日头条的数据:Android 可以动态下发,我们现在使用的插件化框架,包体积增量约等于 0,即便是大家没有插件化,也可以用各种方式使包体积增量约等于 0。至于为什么安卓可以做我们后续会讲到。但 iOS 上是什么情况呢?今日头条 APP 优化前包体积是 167M,Flutter 产物占 18MB,占比超过了 10%。那看到这个数据的结论就是:现阶段需要重点关注并优化 Flutter 在 iOS 平台上的包体积问题。
那我们引用 Flutter 之后会对现有的包体积产生多大影响呢?结论很出乎意料,iOS 平台上,如果用 OC 写,它大概是一个线性增长的关系,随着代码量增加,包体积也会这样增加;但是 Flutter 不是,它不是一个线性的关系,它是这样的一个曲线:初始增长速度极快,随着代码增多,增长速度逐渐减缓,最终趋近线性增长。原因是 Flutter 有一个 Tree Shaking 机制,从 Main 方法开始,逐级引用,最终没有被引用的代码,诸如类和函数都会被裁剪掉。
这个机制在 iOS 里没有,但是在 Android 里挺常见的,类似 ProGuard,安卓开发工程师应该很熟悉这个概念。一开始引入 Flutter 之后随便写一个业务,你就会大量用到 Flutter/Dart SDK 代码,这样初期 Flutter 包体积极速增加,但是过了一个临界点,用户包体积的增加就基本取决于你 Flutter 业务代码增量,不会增长得那么快。
所以我们分析 Flutter Release 产物的时候是不能用太简单的 Demo 的,如果你只是在屏幕上绘制一个 Hello World Text,包体积就会非常小,脱离实际的小,因为大部分 Flutter SDK 就都会被 Tree Shaken 掉了。但是实际的项目不是这样的,我们需要写个稍微复杂一点的项目让包体积超过临界点,但是又不能超过太多,否则编译时间就会非常长,优化包体积时需要反复的编译,这样开发效率和优化效率就会降低。我们就写了这么一个简单的 Demo,这个 Demo 有一个按钮、用到了 Material Design 库的一些控件,屏幕背后还用一些类做了一些别的事情,最终编译出来之后长成这样子。
组成部分是两个 Framework,一个是 APP Framework,还有一个是 Flutter Framework,后面会讲这两个 Framework 主要由什么组成。
画了一张图给大家详细解释一下:
第一部分是 App Framework,里面的 App 在我这个 Demo 工程下是 9.2M,主要来源是 Dart 代码 AOT 编译产物,它是一个动态链接库;还有一部分是 Flutter 静态资源,内含图片,字体等,注意这一部分是一个变值,它是随着你的业务变化而变化的,有可能增加,有可能减少。在我的这个工程里,flutter_assets 基本没有东西,但是不等于你的项目 flutter_assets 没有东西,同样这个 9.2M 的 App 在你的工程里可能就不是 9.2MB 了。
而 Flutter.Framework 里则是一个定值,一个固定的值。第一部分是 Flutter 这个动态编译库,也就是我们的 Flutter Engine,他是由用 Flutter 底层和 Dart 语言的的 C++代码编译而成的。这个部分的大小主要是看用哪个分支或者哪个版本打出来的,基本上编译 100 次,无数次都是这么大,我们现在是 7.3MB。还有一个 icudtl.dat,国际化支持相关数据文件,883KB,基本可以忽略不计。
二、Dart 编译产物生成与优化
在我们讲包体积优化前,先讲一下包体积优化的方法论。启动速度有方法论,包体积也有方法论。包体积的优化无非是三个方式:删、缩、挪。
删就是移除无用代码和无用资源,删有可能是你人肉手动删,有可能是机器自动删,或者编译的时候删除,比如刚才的 Tree Shaking 机制就是编译时自动删除。
当你删不动时可以想一下压缩,压缩典型的有压缩图片资源等。
当删和缩都没有办法解决问题时,最有效的办法就是挪,从包里直接挪出去,挪到远端,典型是远端下发插件或者安卓里拆 App Bundle。这个挪,难度是三个中最大的,因为功能是有损的,需要特殊处理,而且一个功能挪出去之后,需要再动态下发才能跑起来。虽然功能是有损的,但是它的收益往往是最大的,随随便便挪一个插件或者挪一个 App Bundle 出去就可以带来几 MB 或者十几 MB 收益,只是它的技术难度大而已,并不是做不了。
结合 Flutter Tree Shaking 做,能删的代码删掉,能压的代码也压缩,还有其他的什么手段吗?能不能在 Flutter 中挪?事实是可以的。如下图动画,让大家感受一下 Flutter 是怎么“挪”的:
- 第一就是将 Dart 的编译产物分成两部分,Part1 和 Part2,把 Part2 挪出去;
- 第二是把 flutter_assets 这个文件夹挪出去,也是动态下发;
- 第三是把 icudtl.dat 挪出去,这样包体积就只剩下了最后这两部分。
核心思想是:移除非必要产物,动态下发。
那为什么可以挪?我先结合这张 Dart 编译流程图详细解释一下 Dart 的编译流程:
这是 Dart 的源码,灰色是编译工具,蓝色是编译产物或者编译中间文件,黄色表示编译内层。当 Dart 代码经过 front server 以后,编译成 Dart Kernel,安卓上叫 app.dill,这部分属于 Debug 编译,编译完成之后 Dart 代码的 Debug 编译就结束了,front server 主要做了词法分析和语法分析,注意这是编译原理的 front。经过在 Debug 编译之后,在 Release 就多了 precompile 的流程,把抽象语法树给编译成中间代码,这个时候就相当于是编译原理的中层,底下是生成机器代码,这相当于编译原理的后端,编译流程也符合现代的编译思想分三层。
今天毕竟不是讲编译原理,我们主要关注编译完成之后编译产物的生成,编译产物为编译期生成机器码内存数据的文件形态,最终我们需要把内存打包到成文件。
有两种模式:
第一种是 Blob Mode,仅在安卓平台上支持,Flutter 1.7.8 版本之前 Android 平台上的默认模式,分四个部分:两个指令段,两个数据段。第二种是 Library Mode,安卓和 iOS 都支持,需要把机器码导出成汇编然后使用平台提供的工具编译成动态库。iOS 是 xcrun,Android 是 ndkCompiler,注意这三种形态,内容是一样的,用 nm App 查看动态库可以发现它里面有只有 4 个符号,跟 Blob Mode 的这 4 个 snapshot 是完全对应的。我们只需要知道 AOT 的编译产物编译出来相当于四块机器码内存。
那编译完之后我们需要把它拼起来,拼起来的话首先需要把打到包里面的东西让它加载起来,这是 Flutter 加载 Isolate 的代码,Android 是从第二段里面读的,最终从默认 Native Library 里读,iOS 就是在最后读的。
那所以答案出来了,为什么可以挪?我们只需要把动态包下载完成,解压之后设置 Settings 各项路径,原来的时候是默认设置成包里的路径,现在你下载完成之后你强行改成自己的下载路径,再开始启动就可以了。那刚才我们提到,安卓为什么在没有插件化的情况下也可以把包体积缩小非常小?因为安卓的 so 文件本来就可以动态下发,那这样的 snapshot 文件也可以动态下发,资源文件,icudlt.dat 什么都可以动态下发的,包里基本就什么都没有了,插件化唯一比它好的是 flutter.jar 也可以动态下发,具体到 iOS 基本可以把大部分挪出去。
image为什么不全挪?安卓可以全挪,iOS 为什么不能全挪?Part1 和 Part2 又是什么?
问题出在加载后的运行时阶段。我们看一下这段运行代码:
加载到内存以后,所谓指令段是需要可执行权限,大家可以看到这里设置了 ImagePage 的 Executable 等于 True,两个指令段需要可执行权限。iOS 不像安卓,没有办法随意标记执行,指令段那必须在动态库里下发,才能获得可执行权限,这就是为什么不能把 iOS 包里面的内容全部给挪出去,就是因为这是平台限制,虽然 Flutter 提供了完整的 Settings 扩展支持,但仍然必须保证它可执行权限的那部分内存一定放在动态库里的。
那我们就完整回顾动态下发方案的原貌,原来相当于 APP 里分成四个组成部分,我们挪了两个 Data 过来,挪了资源过来,挪了 icudtl 过来,如下图所示:
那这个收益是多少?收益就是刚才的 APP 从 9.2M 变成了 3.8M,Data 段一般情况来说它的体积是要大于指令段的,大家可以自己随便拿一个 Flutter 工程编译一下安卓的包,安卓会编译成 4 个 snapshot,Data 段体积一般是要比指令段大的。所以如果你采用动态下发方案,对于 App 动态库文件优化收益一定是大于 50%的,但我不确定结合大家具体实际工程的话会不会有这样的收益,只是在我们 Demo 里确定可以使 App 的体积缩减一半以上。那我们还可以看到 flutter_assets 已经没了,整体移出来了,虽然在 Demo 上收益很小,但是实际中收益应该很大,因为实际项目中不太可能没有资源。最后 icudtl 整体移除,优化 883KB,在用 nm App 查看一下动态库就会发现动态库就只有两个指令段,没有了两个 Data 段。
动态下发模式示例,引擎下发动态包演示,大家可以看我们把引擎挪出来的部分压缩成了一个 Zip 包。这里有一个需要注意的地方,就是你打出来机器码是分架构的,32 位和 64 位的 Data 段是不一样的。那你就需要生成两个 Zip 文件,根据自己 iOS 设备做针对性下发。
那就有一个问题了,我是一个纯 Flutter 应用,或者我一启动就立刻要用这个功能,接受不了 Flutter 需要动态下发,这时候怎么办?我们可以变通一下,把这个引擎 Zip 包直接从远端放到包里,这样首次使用需要解压,会牺牲首次使用的启动速度,那收益会比动态下发模式要小,就达不到标题所说的接近 50%了,但是仍然不失为一个有很大收益的方法。
动态下发模式包体积减少 6.3MB,这个减少部分压缩之后体积是 2.5MB,我们内置压缩需要把 2.5MB 再放回到到包里去,这样优化收益就少了 2.5MB,方案收益就变成了 3.8MB,当然 3.8M 这也是一个不小的包优化收益了。
同样的问题,如果你想在 iOS 上支持 32 和 64 双架构的话,Data 段文件不通用,最终还是有两份 Zip 文件,不可能内置两份 Zip 包,然后根据设备针对性的解压,那包体积可能不减反增。解决思路是将引擎 Zip 包置于 APP 动态库内来规避这个问题。然后 App Store 可以针对动态库自动实现分架构下发,就是你上传的双架构,但实际用户下载的还是单架构,我们可以巧妙利用这点让 App Store 替我们完成这个事情。参考方案挺多,典型的 Dart 有一个 Observatory Server 的 Web 静态资源,是整个直接打到 Dart 的运行时里的。
风险应对。无论你采用哪种方案一定有风险的,比如下载失败、解压失败。应对策略也是,我们肯定需要提供引擎是否 ready 的 API,但是很难解释清楚,功能虽然打进包里,但仍然可能用不了。研发需要转换思维,这两种模式下不要假设 Flutter 一定可用,因为动态下发或者内置压缩就绝对达不到百分之百的成功率,因为总有用户的磁盘是满的,总有网络不可达的情况。这时候 PM 就会说接受不了这部分损失,但实际上你的功能没那么重要了。最终实际损失是用 Flutter 覆盖率乘以 Flutter 功能的渗透率。Flutter 覆盖率目前应该可以达到三个 9,因为我们用了内部压缩方案。Flutter 功能的渗透率,有用户虽然没有用 Flutter,但是他如果不用 Flutter 这个功能那等于没有损失,这一块需要辩证来看,包体积优化之后是所有用户都收益,而损失的只是少部分用户,你需要平衡一下,看哪部分损失大,这个情况是不是可以接受,如果可以接受,你想要求稳就用内置压缩,如果你想更激进一点,那就用动态下发。
三、优化 Engine 编译产物
接下来包变成这个样子,是不是就没有优化空间了?并不是。还没有动心思优化的都是有优化空间的,只不过多和少而已。
在 Flutter 引擎编译时,安卓和 iOS 的编译参数不同,安卓是-OZ,iOS 是-OS。如果想追求极致包体积是需要用 OZ 的,不能用 OS,OZ 只是性能稍微差一点,但是基本可以忍受。为什么 iOS 性能普遍都比安卓好一点,但是为什么它反而在这个性能好的平台上反而用 -OS 呢?它其实是之前的 build-tools 不统一,考虑到链接时优化的顺序问题,OZ 反而增加了包大小。只需要升级最新的 build-tools,改 OS 为 OZ,收益为 723.17KB,这是头条自己的数据,大家的情况可能不一样,但是这个收益是肯定有的。
除了统一编译参数之后,第二部分是定制化编译,这块结合各个厂商、各个 APP 可能不一样。但是有两点大家都可以借鉴的:
第一部分,移除 boringSSL,可用 Method Channel 调用源生网络库来替代 Dart Http 功能,就跟在 Android 上我们基本上从来不会裸用 OKHttp 一样,我们总得做点动态选路、失败重连这种,还有各种对国内网络做针对性的优化,Dart 的原生网络库性能一定是比不过 Native 针对国内环境做过专门优化的网络库的,这时候我们就可以用 Method Channel 调用源生网络库替代 Dart Http 功能,这样性能绝对有提升,不会反而下降,同时还能带来包体积的收益。具体到 Flutter Engine 收益是 0.5MB。官方也发现这个问题,他们也已经计划把 Dart 的网络功能交给上层来代理实现。
第二部分是 Skia,它的参数很多,其中有 3 个我们试过了,去掉之后在 Benchmark 上看不会对性能产生影响,把它禁掉的话最终得到收益不到 200KB。大家可以根据自己的情况做针对定制优化。官方有更高端的概念叫模块化编译,核心思想是把 Engine 拆成不同的 Modular,根据自己的情况选择哪些打进去、哪些不能打进去,这样就能保证 Flutter Engine 里的所有东西都是必要的、必须的,但这只停留在计划阶段,未来 Google 的方向是这样,如果大家等不及可以先采用定制化编译思路。
四、机器码指令优化
现在 Flutter.Framework 里的 Flutter 动态库也得到了优化,还有最后一部分是这两个指令段。
这两个指令段能不能优化呢?其实是可以的,要深入 Dart 的编译原理、机器码生成等一大堆。我们一开始并不是特别在意这个,都是机器码,那 OC 出来的机器码就比 Dart 厉害吗?结论还真是,目前 OC 写出来的机器码就是厉害一点。
我们做了包体积增量对比实验,为什么做这个实验?是因为将来如果有一天 Flutter 铺开以后,所有的业务代码都用 Flutter 写,那就涉及一个问题,之前用 OC 开发一个业务可能包体积是 200KB,现在用 Flutter 开发同样一个业务发现包体积变成 400KB,翻倍怎么办?会不会有这个风险?其实是有的。
做个简单的实验,这样一个函数返回自定义的 View,不停的复制,一直复制到 1000,这时候没有引用任何新增代码,包体积增量完全取决于你自己 Copy 的新增代码,这个时候你的增长就是完全线性的。但是这个线性的斜率是不一样的,Dart 的斜率远高于 OC。
这一块是怎么优化的?我们先分析了一下这个背后的原因,写了一个更简单的函数,返回一个自定义的 View 太复杂,我们就直接打印了一个 Int:
OC 的版本用 Hopper 反编译,得到 11 条指令,因为 a=3+4 在编译期直接被优化,0×3 是我当时编译时用 1+2 得出来的,第 4 个指令 orr 应该是改成 0×7,结论是:函数生成 11 条汇编指令。
Dart 呢?我们写了这个相同的函数,如果你想得到机器码指令需要修改 Dart 源码,在编译时把指令打出来,我们看它不是 11 条,而是 32 条,这时候包体积斜率不一样的原因就找到了。Dart 汇编指令多了很多。
我们发了一个 Issue,这是标题(The ipa size grows too fast due to Flutter's incompact machine code instructions #40345,https://github.com/flutter/flutter/issues/40345)。Google 很快给我们回复了,这是负责 Dart 团队的 Mraleph 分析的结果。
核心的结论说所有函数前 8 个都有指令对齐头,后 6 位都有对齐指令,有的是基于历史的原因,有的可能是基于性能的原因,但是一头一尾都是可以移除。如果是一个特别小的函数,中间制定机器码指令反而没有这前后加起来的 14 条指令多。如果你的包里面全部是小函数的话,那 Dart 和 OC 差出来就比较多,如果正常写的话肯定不是这样。我这个实验其实对 Dart 非常不友好,但变相放大暴露这个问题其实挺好的,让我们知道指令里头有这么大的优化空间。中间还有 18 条指令,其中 5 条是为了做栈溢出检查,OC 没有这个指令,还剩 13 条必要指令,基本与 OC 11 条持平,也存在优化空间。
最终大家可以跟进一下这个 Issue,Google 的大神 Mraleph 给创建了 5 个 Task,有一些已经落地了,有一些还在推进中,这个问题他们非常重视,会持续性跟进。他们给出的结论是最终认为 OC 的机器码应该跟 Dart 基本持平,不存在谁更厉害的问题。
五、总结与展望
总结
第一部分,我们分析了 Dart 的编译产物,对 Dart 编译产物做了针对性优化。
有两个优化思路:
- 动态下发:剥离 Data 段及一切非必要产物,打包后动态下发。
- 内置压缩:以二进制形态内置动态下发包。
第二部分是 Flutter 引擎编译产物优化,主要优化思路有升级 Bulild Tools 统一双编译参数,定制化编译裁剪引擎内部部分特定无用功能。
第三部分是机器码指令优化,精简机器码指令,Google 也回复称未来 Dart 与 OC 基本持平。
展望
刚才我们看到 Dart 是这样的,Dart 未来这个斜率低于 iOS,如果 OC 跟 Dart 基本持平,我们可以把占包体积一半的 Dart 指令的 Data 段挪出去,即便是 OC 的机器码只有它的一半,我们仍然可以保证最终的包体积 Dart 和 OC 基本持平。如果 Google 能够优化得更好,能到跟 OC 持平,那 Flutter 未来包体积增量一定比 iOS 小,这个问题在未来就可能不是一个问题了。当然,这个前提是发生在动态下发。内置压缩这个斜率稍微高一点,但是至于比 OC 高还是低,我没办法准确预测,但是我觉得应该是一个可以接受的程度。
今天的分享就到这里。
Q&A
提问:谷歌的引擎是一直在迭代的,如果我从现代的版本开始修改引擎,以后谷歌的引擎更新了,我要不要马上跟进?
回答:这个问题很多人都问过我们,在 Flutter 团队引擎不停迭代时,你的自定义引擎如何跟上节奏?这个问题是我们不需要紧跟潮流,我们挑一个稳定版本,154 做有针对性的优化,过两个月再判断一下,比如现在 191 适合不适合做适配、做迁移,如果适合,那我们就做,如果不适合,或者业务方没有紧急需求,那就不升。为什么有些团队升到 178?因为海外要支持双架构,原来的不够,只能支持单架构,那么 178 默认模式就改成 Library Mode,支持 32 位和 64 位,是为了这个事情。如果你没有这个强烈的需求,反而用自己公司内部的引擎更稳定一点。
提问:我是一名移动端研发。我们通过“挪”的方式使包体积变小了,但是用户在使用实际模块当中又要挪回来,我想问的是用户在使用某个模块时,把我们挪出去的这部分挪回来的时候,这个转场我们应该怎么去处理?或者有什么更好的方案让用户无感知的加载我们挪出去的这部分东西?
回答:还是跟刚才的问题一样。这个问题对于字节跳动的 Android 研发来讲还挺司空见惯的,一个功能挪出去以后,构成插件以后,也会面临你这个相同的问题。这很简单,就判断一下插件是否存在,iOS 也要判断引擎是否存在,要么是否展示接口、是否展示功能入口。你如果一定要在启动阶段首页展示这个功能的话,那你就只能阻塞一下了。
提问:您提到有一个字节码优化的问题,刚才讲到 Dart 语言应该是运行在虚拟机上的,这个字节码优化是优化编译的中间语言?还是由 Dart 虚拟机最终生成?
回答:不,它是机器,在 Release 模式下运行的是机器码。在编译器由 Dart 虚拟机生成的,但是实际运行的时候它是一个完完全全的机器码。
提问:关于刚才提到字节码指令的问题,Dart 针对你举的例子里,同一条 OC 是 11 条,Dart 是 32 条,这种情况对于我们来说,我们自己没办法做这个优化,但是实际过程中要不要尽量减少小函数,这样是不是也是一种方式?
回答:实际使用中应该不会写到像我今天展示这么小的函数,应该不会直接打出一个 Int,实际使用的函数远比这个要复杂,在实际使用过程中 Dart 的代码和 OC 的区别没有今天展示的那么大,我只是为了演示冗余指令,专门挑了一个特别对 Dart 不友好的 demo,但实际上没有那么大的区别。如果已经用上动态下发模式的话,留在包里的指令段真的是非常少的一部分,我们只是追求极致把事情做到尽善尽美,但是这部分就算不优化也应该是可以接受的,我们线上已经在跑着、已经在广泛用 Flutter 了,这应该不是阻碍你发布或者采用它这个技术栈的原因。
原文作者:李梦云
原文链接:Flutter 沙龙回顾 | 如何缩减接近 50% 的 Flutter 包体积
来源:微信公众号字节跳动技术团队
推荐阅读资料:Flutter 完全手册