百度 APP Android 包体积优化实践(一)总览
01 前言
此前百度 APP 已经具备基本的包体积优化机制、约束和意识,但作为巨舰型 APP,业务的高速迭代仍然不可避免地造成了包体积爆炸式增长。包体积或直接或间接地影响着下载转化率、安装时间、运行内存、磁盘空间等重要指标,所以投入精力扫除积弊、发掘更深层次的体积优化项是十分必要的。
根据谷歌商店的内部数据,APK 体积每减少 10M,平均可增加~1.5%的下载转化率,如下图所示:
<p align=center>图 1 谷歌商店应用转化率增加幅度 / 10M [1]</p>
Android 包体积优化手段有很多,比如业务裁剪、插件化、混合开发、资源后下发等。本系列文章主要针对的是业务无关、集成在 APK 中的内容的体积优化,如 Dex 优化、资源优化、so 优化等,我们称之为基础机制优化。
包体积基础机制优化实践将会以系列文章的方式呈现,主要包括以下部分:心路历程、Dex 行号优化完整方案、资源优化实践与探索、Dex 优化实践与探索、so 优化探索、其他优化经验与总结。
本文讲述的是百度 APP 包体积基础机制优化心路历程,包括起持续指导作用的基本思想、优化对象分析、对现有优化工具的学习、以及最终产出的体积优化项。
02 基本思想
2.1 分而治之
我们的优化对象不只是 APK 这个最终产物,也包括 APK 中的内容,这些内容的体积优化思路与手段不尽相同。
2.2 可持续优化
好的优化机制不止生效于当下,也生效于未来。举例来说,从源码仓库删除当前的 Dead Code 属于一次性存量优化操作,而编译器的 DCE 机制(Dead Code Elimination)可持续生效于未来产生的 Dead Code。从长线考虑,我们应优先建立后者,然后倒推前者的执行。
2.3 站在前人的肩膀上
包体积优化并不是一个新鲜的话题,Android 官方和开发者们都在持续致力于优化体积。重复造轮子是不被提倡的,但对于不同的应用场景,尤其是巨舰型 APP,体积优化应该有定制化的方案。
2.4 明确代价,有所取舍
根据热力学第一定律,收益不会凭空产生,一定会伴随着代价,例如人力的投入、编译时间的增加、适配难度的增加等。明确代价后,我们才能决定某优化项是否要做、何时做、如何做。
2.5 约束与意识
除了自动化的优化机制,还需要配套有自动化的体积增长约束,同时从源头提升开发者的体积优化意识,多管齐下才能达到最优效果。
03 APK 结构分析
接下来我们会简单分析下 APK 内各组成部分,以及 APK 作为 ZIP,其标准结构是什么样的。
3.1 APK 内容分析
<p align=center>图 2 APK 结构</p>
classes.dex
APK 中可能包含一个或多个 classes.dex 文件,应用程序内的 Java/Kotlin 源码最终会以 dalvik 字节码的方式存在于 classes.dex 文件中。
resources.arsc
该文件是包含配置信息的资源查询表,起着链接代码与资源的作用。Dex 文件中的 R.class 仅包含资源 id,AssetManager 会利用 id 到 arsc 表中查询与当前设备信息最匹配的资源文件路径(或资源内容)。
res/
包含源码工程中 res 目录下除了 values 外的资源文件,这些文件路径同时会体现在 resources.arsc 中。
lib/
native libraries。即源码工程 jni 目录下的 so 文件,二级目录必须为 NDK 支持的 ABI。
assets/
与 res/ 资源目录不同,assets/ 下的资源文件不会在 resources.arsc 中生成查询条目,且 assets/ 下的资源目录可完全自定义,业务代码获取 assets 资源和 res 资源的方式也完全不同。
META-INF/
应用签名信息。该目录在应用签名后生成,包含以下三个文件:
MANIFEST.MF:摘要文件,包含 APK 内所有文件的路径及其 SHA1/SHA256 值。
CERT.SF:对摘要的签名文件,包含 APK 内所有文件的路径,及其在 MANIFEST.MF 中对应信息的 SHA1/SHA256 值。
CERT.RSA:保存公钥、加密算法及其私钥加密后的内容。
AndroidManifest.xml
应用清单文件,用于描述应用基本信息,主要包括应用包名、应用 id、应用组件、所需权限、设备兼容性等。
3.2 ZIP 结构分析
<p align=center>图 3 ZIP 标准结构示意图</p>
压缩源文件信息
Local file header:描述源文件信息。
File data:源文件数据。
Data descriptor:校验码及压缩前后大小。
中心目录区
Central directory
记录 ZIP 目录结构。每一条 file header 对应一个源文件,描述文件相关信息。
中心目录结束标识
End of central directory record
标识 ZIP 包结束,包含 ZIP 包及中心目录的简要信息。
04 现有优化工具介绍
对于发展初期的应用,体积优化的优先级较低,直接使用以下体积优化工具是性价比最高的选择。百度 APP 同样对比借鉴了以下工具,从中衍生出了全新的、定制化的优化需求。
4.1 ProGuard
在 AGP3.3 之前,ProGuard 作为官方体积优化工具,负责在编译完成之后对 class 文件进行缩减混淆等操作,其优化结果交给 Dx/D8 转化为 Dex 产物。
<p align=center>图 4 Proguard 处理对象及作用示意图 [9]</p>
ProGuard 的优化操作主要包括:
缩减:安全移除无用类、方法、字段和属性。
混淆:缩短类与成员的名称。
优化:指令级别的优化,合并重复指令、清理无用指令、提升指令执行效率。
4.2 R8
AGP 3.3 之后官方开始推荐使用 R8,R8 与 ProGuard 不只是简单的替代关系,它还将脱糖、D8 整合到了一起,极大的提升了构建效率。
图 5 R8 处理对象及作用示意图 [9]
R8 基本兼容此前的 ProGuard 规则,但仍存在些许差异(applymapping、行号处理、Kotlin 元数据处理、无用判定等)。R8 不再高优考虑兼容性问题后,两者会派生出越来越多的不同点,建议定期关注,博采众长。
丨 Jack & Jill
小插曲:官方在 2015 年推行过一段时间的 Jack & Jill 工具,它甚至把 javac 也囊括了进来,算是真正实现了端到端的编译。但 Jack 的性能与生态相比 javac 实在差距太大,官方出于成本考虑最后还是弃坑了。
4.3 AndResGuard
AndResGuard 是微信推出资源优化工具。它的基本思想类似于 ProGuard 中的混淆,体积优化是它的附加收益,同时还提供了压缩、加密等选项。
4.4 ByteX
ByteX 是字节开源的一套 Java 字节码插桩工具,目前主要包括优化与检查工作,其中一些子项最终会带来体积收益。包括 R 类内联、移除 debug 信息、access 方法内联等。
4.5 Booster
Booster 是滴滴开源的一套质量优化框架,其中包括体积优化专项,例如资源文件压缩、资源产物.ap_ 压缩、去冗余资源、R 类内联、DataBinding BR 内联等。
4.6 AGP
Android Gradle Plugin(AGP)包含了多个体积优化任务,提供了许多优化配置项,大部分任务已经作为 APK 打包的标配。
一般来讲,我们的优化任务会依赖于这些任务的执行。如果定制的优化无法兼容现存任务,则需要关闭或 hook 这些任务。接下来将按照编译顺序简单介绍几个优化任务与配置:
OptimizeResources
AGP4.2+ 新增的资源优化任务,目前只实现了资源文件路径的缩短,默认开启,可通过 android.enableResourceOptimizations 关闭。
StripSymbols
NDK 会利用 llvm-strip 移除掉 native libraries 中的 unneeded symbols,这部分优化工作也可以放在 so 编译期间完成。
MinifyWithR8/ProGuard
利用 R8 或 ProGuard 实现代码优化,此处就不再赘述了。
ShrinkResources
由 ShrinkResources 开关控制,启用前提是必须开启 minifyEnable。其作用是将未被引用的资源文件替换为一个体积很小的格式文件(仍存在占位体积,同时保留了该资源条目,所以 resources.arsc 体积并不会减少),可通过 res/raw/keep.xml 文件配置 shrinkMode 和白名单。
PackageOptions
打包时选项,包括过滤 exclude、相同文件仅打包 pickFirst、全部打包 merge、so 优化豁免 doNotStrip。
Splits
分包/过滤策略,配置项包括 ABI、资源配置(语言、分辨率等)。
**05 百度 APP 优化项项概览 **
5.1 Dex 优化
百度 APP 实现 Dex 的体积优化项可以分为两类:源码编译期间的优化;APK 打包期间对 Dex 文件的优化。两者的区别主要是优化对象不同,所以基于不同的优化工具实现,前者基于 Java 字节码工具实现(如 ASM),后者基于 Dex 字节码工具实现(如 Titan-Dex [10])。
丨 Titan-Dex
Titan-Dex 是百度开源的面向 Android Dalvik(ART)字节码(bytecode)格式的操纵框架,可以在二进制格式下实现修改已有的类,或者动态生成新的类。百度 Titan-hotfix 工具即基于此框架实现。
R 类优化
工程组件越多,R 类所占体积越大,未关闭资源依赖传递的情况下则更严重。我们在编译期将代码中调用 R.type.name 的地方全部替换成了对应的 id 常量,最终 R.class 会作为无用类被 R8/ProGuard 清理掉。
行号优化
Dex 中的 debug 区域占 5~10%的大小,但其最大的作用是分析崩溃堆栈时定位。该区域可以通过去除 ProGuard 规则 -keepattributes SourceFile,LineNumberTable 完全移除。我们选择在指令级别完成 debug infos 的映射与复用,同时联动百度性能平台(目前仅供公司内部使用,功能可类比腾讯 bugly)完成崩溃堆栈的还原,既优化了体积,又不会影响堆栈的分析。
注解优化
Dex 中注解分为三种类型:Build、Runtime、System。Build 和 Runtime 对应 ProGuard 规则 -keepattributes *Annotation*,可优化的 System 注解根据具体类型分别对应 -keepattributes InnerClasses, Signature, EnclosingMethod。跟行号一样,可以通过去除这些规则完成一刀切的优化。但由于我们接入的三方组件自带这些 ProGuard 规则,且部分类的 System 注解有保留的需要,我们选择后置地处理 Dex 文件,基于 Dex 字节码工具完成目标注解的移除。
5.2 资源优化
资源优化的对象分为两类,一是资源查询表 resources.arsc,部分优化操作会涉及到 res/ 及 R 文件的修改,但本质都是从 resources.arsc 出发的;二是原始资源文件,包括 res/和 assets/。
介绍优化项前,我们先看一张网上最经典的 resources.arsc 结构图(来源 CSDN 社区):
<p align=center>图 6 resources.arsc 结构图</p>
资源同名化
在实际应用中,我们默认通过资源 id 查找资源内容,对资源名的使用频率十分低,仅限于通过资源名反查资源 id 以及 通过资源 id 获取资源名两种情况。所以资源项名称字符串池所占据的空间即是我们的优化对象。极限优化结果是,这个池子里仅存放一个字符串,所有 ResTable_entry 的资源项名称 index 均指向这个池子里仅有的字符串,即所有资源的名字都变得一样了。实际场景中,我们会有豁免和降级为混淆的需求,例如通过资源名反向查询资源 id 等情况。
资源文件路径优化
与 AndResGuard 中的资源路径混淆效果相似,都是尽可能缩短资源文件的路径长度,从而减少 ResTable_entry 的 value 大小。我们将路径 Hash 值转换到碰撞最少的位数,作为最终的混淆结果,其优点在于混淆结果基本上是固定的,无需 applymapping。除此之外,我们还较为激进地去掉了大部分文件的后缀名。
资源配置优化
从 arsc 中的资源 id 包含了偏移量信息,系统通过偏移量在 arsc 中定位资源。所以图 7 中的空白区域必须保留一个 4 字节的占位,以满足偏移量查询方式。我们正在对此部分做优化,宗旨是通过优化不必要的 configuraion,达到减少对齐占位的目的。
<p align=center>图 7 resources.arsc 空白占位示意图</p>
图片压缩
由于 webp 格式受限于 minsdkversion18,我们目前还是针对 png 图片做压缩优化,使用的工具包括 TinyPng 和 ImageOptim。除了出图阶段压缩外,也会有后置流水线做压缩检查。
无用资源清理
如 4.5 中提到的,ShrinkResources 并不会真正移除未被引用的资源文件。不过我们可以拿到被 shrink 的 resources 列表,然后再利用资源优化工具做真正的删除。
丨 New ShrinkResource
2022.1 发布的 AGPv7.1.0 更新了资源缩减功能,添加了实验性选项 android.experimental.enableNewResourceShrinker.preciseShrinking,该选项设置为 true 后 ShrinkResources 会完全移除未使用的文件资源及 value 资源,但 arsc 中仍会存在这些资源的填充占位。
arsc 压缩
resources.arsc 的压缩体积收益很高,但对其进行压缩会影响启动速度和内存指标。具体原因是:系统在加载 arsc 文件时,若 arsc 文件未压缩,可使用 mmap 进行内存映射;若 arsc 文件被压缩了,则需要将其解压缩后读取到 RAM 缓冲区,会增加内存使用,也会拖慢启动速度。在业界大都压缩 arsc 的情况下,百度 APP 出于综合考量一直未对 arsc 文件进行压缩。无独有偶,官方出于同样的考虑,从 Android11 开始强制要求 resources.arsc 不可压缩且保持 4 位对齐,否则会直接安装失败。
<p align=center>图 8 Android11 强调 resources.arsc 压缩对齐问题</p>
5.3 ZIP 优化
压缩
目前我们使用的压缩算法有 7z 和 zopfli[11],后者压缩率和压缩耗时都有明显增加,稳定性还在验证中。采用新的压缩算法时需特别注意两点,一是不要压缩 resources.arsc;二是注意压缩、 对齐、签名操作的顺序。
文件路径优化
如章节 3.2 ZIP 结构分析所示,APK 中有三处体积与文件路径长度相关:META-INF/、压缩源文件数据区的 local file header、中心目录区的 file header,资源文件路径优化效果同样会体现在这里。同理控制 assets/ 下的文件路径也可带来体积收益。
5.4 其他
夜间模式优化
目前百度 APP 的夜间资源是一个 APK 包,此前实现方式是与主包中的资源名保持一致,通过反射的方法查询对应 id。现改为 id 一致,这样既避免了反射查询的耗时,章节 5.2 中的资源优化也可以应用到资源 APK,进一步减小体积。
混淆规则
ProGuard/R8 提供了多种多样的规则用以豁免代码优化操作,如果使用不当可能会造成体积的白白浪费。未来我们计划制定一套详细的 ProGuard 规则使用规范,并对每个组件的 ProGuard 规则都进行校验,例如不允许出现本组件包名范围外的 keep 规则、不允许出现包级别 keep 等。
体积流水线
主要包括仓库体积约束流水线,二进制组件体积检查流水线,以及 APK 组成体积分析流水线,分阶段进行约束与分析。目前百度 APP 建设了 APK 体积监控流水线,每当主线有代码合入、触发编译打包后,会即时对编译产物 APK 做体积分析,并与上一次编译产物进行比对,可以马上发现异常的体积增长。
06 总结
第五章介绍的优化项自 21 年 8 月至 22 年 2 月分批次上线,期间业务依旧在高速迭代,虽然有体积监控流水线作用,包体积仍会不可避免地增加。由于优化机制十分底层,需进行充分的线下测试与线上小流量灰度,验证稳定性后才能正式上线。上线/灰度前后百度 APP 包体积大小对比如下:
——————END——————
参考资料:
[1] 包大小与安装转化率
https://medium.com/googleplaydev/shrinking-apks-growing-installs-5d3fcba23ce2
[2] ZIP 格式
https://pkware.cachefly.net/webdocs/APPNOTE/APPNOTE-6.2.0.txt
[3] ProGuard
https://www.guardsquare.com/proguard
[4] R8
https://r8.googlesource.com/r8
[5] ProGuard 与 R8 对比
https://www.guardsquare.com/blog/proguard-and-r8
[6] AndResGuard
https://github.com/shwenzhang/AndResGuard
[7] ByteX
https://github.com/bytedance/ByteX
[8] Booster
https://github.com/didi/booster
[9] AGP
https://developer.android.com/studio/releases/gradle-plugin
[10] Titan-Dex
https://github.com/baidu/titan-dex
[11] zopfli
https://en.wikipedia.org/wiki/Zopfli
推荐阅读:
评论