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