如何缩减接近 50% 的 Flutter 包体积,android 开发计算器课程设计
这是我今天分享的 5 个组成部分:
第一部分,针对 Flutter 包体积给大家讲讲 Flutter 包体积现状,以及它由哪几个部分组成。
接下来三个部分会针对这几个组成部分做针对性的优化。
最后一部分,总结优化手段,展望 Flutter 包体积的未来。
我们先来统一认识,包体积到底重要不重要?结论是很重要。
右图是 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,基本可以忽略不计。
在我们讲包体积优化前,先讲一下包体积优化的方法论。启动速度有方法论,包体积也有方法论。包体积的优化无非是三个方式:删、缩、挪。
删就是移除无用代码和无用资源,删有可能是你人肉手动删,有可能是机器自动删,或者编译的时候删除,比如刚才的 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 就是在最后读的。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Cy4CgelC-1576734770831)(https://upload-images.jianshu.io/upload_images/18452536-17dce6359baf72cd.jpg?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)]
那所以答案出来了,为什么可以挪?我们只需要把动态包下载完成,解压之后设置 Settings 各项路径,原来的时候是默认设置成包里的路径,现在你下载完成之后你强行改成自己的下载路径,再开始启动就可以了。那刚才我们提到,安卓为什么在没有插件化的情况下也可以把包体积缩小非常小?因为安卓的 so 文件本来就可以动态下发,那这样的 snapshot 文件也可以动态下发,资源文件,icudlt.dat 什么都可以动态下发的,包里基本就什么都没有了,插件化唯一比它好的是 flutter.jar 也可以动态下发,具体到 iOS 基本可以把大部分挪出去。
为什么不全挪?安卓可以全挪,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 这个功能那等于没有损失,这一块需要辩证来看,包体积优化之后是所有用户都收益,而损失的只是少部分用户,你需要平衡一下,看哪部分损失大,这个情况是不是可以接受,如果可以接受,你想要求稳就用内置压缩,如果你想更激进一点,那就用动态下发。
接下来包变成这个样子,是不是就没有优化空间了?并不是。还没有动心思优化的都是有优化空间的,只不过多和少而已。
在 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。
评论