减小应用安装包的体积,对提升用户体验和下载转化率都大有益处。本文将结合美团平台的实践经验,分享 so 体积优化的思路、收益,以及工程实践中的注意事项。本文将先从 so 文件格式讲起,结合文件格式分析哪些内容可以优化,然后再具体讲解每项优化手段以及注意事项,最后介绍相关的工程实践经验。希望能对从事包体积优化的同学有所帮助或启发。
1. 背景
应用安装包的体积影响着用户的下载时长、安装时长、磁盘占用空间等诸多方面,因此减小安装包的体积对于提升用户体验和下载转化率都大有益处。Android 应用安装包其实是一个 zip 文件,主要由 dex、assets、resource、so 等各类型文件压缩而成。目前业内常见的包体积优化方案大体分为以下几类:
针对 dex 的优化,例如 Proguard、dex 的 DebugItem 删除、字节码优化等;
针对 resource 的优化,例如 AndResGuard、webp 优化等;
针对 assets 的优化,例如压缩、动态下发等;
针对 so 的优化,同 assets,另外还有移除调试符号等。
随着动态化、端智能等技术的广泛应用,在采用上述优化手段后, so 在安装包体积中的比重依然很高,我们开始思索这部分体积是否能进一步优化。
经过一段时间的调研、分析和验证,我们逐渐摸索出一套可以将应用安装包中 so 体积进一步减小 30%~60% 的方案。该方案包含一系列纯技术优化手段,对业务侵入性低,通过简单的配置,可以快速部署生效,目前美团 App 已在线上部署使用。为让大家能知其然,也能知其所以然,本文将先从 so 文件格式讲起,结合文件格式分析哪些内容可以优化。 2. so 文件格式分析
so 即动态库,本质上是 ELF(Executable and Linkable Format)文件。可以从两个维度查看 so 文件的内部结构:链接视图(Linking View)和执行视图(Execution View)。链接视图将 so 主体看作多个 section 的组合,该视图体现的是 so 是如何组装的,是编译链接的视角。而执行视图将 so 主体看作多个 segment 的组合,该视图告诉动态链接器如何加载和执行该 so,是运行时的视角。鉴于对 so 优化更侧重于编译链接角度,并且通常一个 segment 包含多个 section(即链接视图对 so 的分解粒度更小),因此我们这里只讨论 so 的链接视图。
通过 readelf -S 命令可以查看一个 so 文件的所有 section 列表,参考 ELF 文件格式说明,这里简要介绍一下本文涉及的 section:
.gnu.version、.gnu.version_d、.gnu.version_r:这三个 section 用于指定动态符号表中每个符号的版本,其中.gnu.version 是一个数组,其元素个数与动态符号表中符号的个数相同,即数组每个元素与动态符号表的每个符号是一一对应的关系。数组每个元素的类型为 Elfxx_Half,其意义是索引,指示每个符号的版本。.gnu.version_d 描述了该 so 定义的所有符号的版本,供.gnu.version 索引。.gnu.version_r 描述了该 so 依赖的所有符号的版本,也供 .gnu.version 索引。因为不同的符号可能具有相同的版本,所以采用这种索引结构,可以减小 so 文件的大小。
在进行优化之前,我们需要对这些 section 以及它们之间的关系有一个清晰的认识,下图较直观地展示了 so 中各个 section 之间的关系(这里只绘制了本文涉及的 section):
结合上图,我们从另一个角度来理解 so 文件的结构:想象一下,我们把所有的函数实现体都放到.text 中,.text 中的指令会去读取 .rodata 中的数据,读取或修改 .data 和 .bss 中的数据。看上去 so 中有这些内容也足够了。但是这些函数怎样执行呢?也就是说,只把这些函数和数据加载进内存是不够的,这些函数只有真正去执行,才能发挥作用。
我们知道想要执行一个函数,只要跳转到它的地址就行了。那外界调用者(该 so 之外的模块)怎样知道它想要调用函数的地址呢?这里就涉及一个函数 ID 的问题:外部调用者给出需要调用的函数的 ID,而动态链接器(Linker)根据该 ID 查找目标函数的地址并告知外部调用者。所以 so 文件还需要一个结构去存储“ID-地址”的映射关系,这个结构就是动态符号表的所有导出符号。
具体到动态符号表的实现,ID 的类型是“字符串”,可以说动态符号表的所有导出符号构成了一个“字符串-地址“的映射表。调用者获取目标函数的地址后,准备好参数跳转到该地址就可以执行这个函数了。另一方面,当前 so 可能也需要调用其他 so 中的函数(例如 libc.so 中的 read、write 等),动态符号表的导入符号记录了这些函数的信息,在 so 内函数执行之前动态链接器会将目标函数的地址填入到相应位置,供该 so 使用。所以动态符号表是连接当前 so 与外部环境的“桥梁”:导出符号供外部使用,导入符号声明了该 so 需要使用的外部符号(注:实际上 .dynsym 中的符号还可以代表变量等其他类型,与函数类型类似,这里就不再赘述)。
结合 so 文件结构,接下来我们开始分析 so 中有哪些内容可以优化。 3. so 可优化内容分析
在讨论 so 可优化内容之前,我们先了解一下 Android 构建工具(Android Gradle Plugin,下文简称 AGP)对 so 体积做的 strip 优化(移除调试信息和符号表)。AGP 编译 so 时,首先产生的是带调试信息和符号表的 so(任务名为 externalNativeBuildRelease),之后对刚产生的带调试信息和符号表的 so 进行 strip,就得到了最终打包到 apk 或 aar 中的 so(任务名为 stripReleaseDebugSymbols)。
strip 优化的作用就是删除输入 so 中的调试信息和符号表。这里说的符号表与上文中的“动态符号表”不同,符号表所在 section 名通常为 .symtab,它通常包含了动态符号表中的全部符号,并且额外还有很多符号。调试信息顾名思义就是用于调试该 so 的信息,主要是各种名字以 .debug_ 开头的 section,通过这些 section 可以建立 so 每条指令与源码文件的映射关系(也就是能够对 so 中每条指令找到其对应的源码文件名、文件行号等信息)。 之所以叫 strip 优化,是因为其实际调用的是 NDK 提供的的 strip 命令(所用参数为--strip-unneeded)。
注:为什么 AGP 要先编译出带调试信息和符号表的 so,而不直接编译出最终的 so 呢(通过添加 -s 参数是可以做到直接编译出没有调试信息和符号表的 so 的)?原因就在于需要使用带调试信息和符号表的 so 对崩溃调用栈进行还原。删除了调试信息和符号表的 so 完全可以正常运行,但是当它发生崩溃时,只能保证获取到崩溃调用栈的每个栈帧的相应指令在 so 中的位置,不一定能获取到符号。但是排查崩溃问题时,我们希望得知 so 崩溃在源码的哪个位置。带调试信息和符号表的 so 可以将崩溃调用栈的每个栈帧还原成其对应的源码文件名、文件行号、函数名等,大大方便了崩溃问题的排查。所以说,虽然带调试信息和符号表的 so 不会打包到最终的 apk 中,但它对排查问题来说非常重要。
AGP 通过开启 strip 优化,可以大幅缩减 so 的体积,甚至可以达到十倍以上。以一个测试 so 为例,其最终 so 大小为14 KB,但是对应的带调试信息和符号表的 so 大小为 136 KB。不过在使用中,我们需要注意的是,如果 AGP 找不到对应的 strip 命令,就会把带调试信息和符号表的 so 直接打包到 apk 或 aar 中,并不会打包失败。例如缺少 armeabi 架构对应的 strip 命令时提示信息如下:
Unable to strip library 'XXX.so' due to missing strip tool for ABI 'ARMEABI'. Packaging it as is.
除了上述 Android 构建工具默认为 so 体积做的优化,我们还能做哪些优化呢?首先明确我们优化的原则:
对于必须保留的内容考虑进行缩减,减小体积占用;
对于无需保留的内容直接删除。
基于以上原则,可以从以下三个方面对 so 继续进行深入优化:
精简动态符号表:上文已经提到,动态符号表是 so 与外部进行连接的“桥梁”,其中的导出表相当于是 so 对外暴露的接口。哪些接口是必须对外暴露的呢?在 Android 中,大部分 so 是用来实现 Java 的 native 方法的,对于这种 so,只要让应用运行时能够获取到 Java native 方法对应的函数地址即可。要实现这个目标,有两种方法:一种是使用 RegisterNatives 动态注册 Java native 方法,一种是按照 JNI 规范定义 java_*** 样式的函数并导出其符号。RegisterNatives 方式可以提前检测到方法签名不匹配的问题,并且可以减少导出符号的数量,这也是 Google 推荐的做法。所以在最优情况下只需导出 JNI_OnLoad(在其中使用 RegisterNatives 对 Java native 方法进行动态注册)和 JNI_OnUnload(可以做一些清理工作)这两个符号即可。如果不希望改写项目代码,也可以再导出 java_*** 样式的符号。除了上述类型的 so,剩余的 so 通常是被应用的其他 so 动态依赖的,对于这类 so,需要确定所有动态依赖它的 so 依赖了它的哪些符号,仅保留这些被依赖的符号即可。另外,这里应区分符号表项与实现体,符号表项是动态符号表中相应的 Elfxx_Sym 项(见上图),实现体是其在 .text、.data、 .bss、.rodata 等或其他部分的实体。删除了符号表项,实现体不一定要被删除。结合上文 so 文件结构示意图,可以预估出删除一个符号表项后 so 减小的体积为:符号名字符串长度+ 1 + Elfxx_Sym + Elfxx_Half + Elfxx_Word 。
ndk-build 默认会禁用 C++ 的异常机制,因此无需特意禁用(如果现有项目开启了 C++ 的异常机制,说明确有需要,需仔细确认后才能禁用)。 禁用 C++ 的 RTTI 机制
如果项目中没有使用 C++ 的 RTTI 机制(例如 typeid 和 dynamic_cast 等),可以通过禁用 C++ 的 RTTI ,来减小 so 的体积。
CMake 项目的配置方式:
ndk-build 默认会禁用 C++ 的 RTTI 机制,因此无需特意禁用(如果现有项目开启了 C++ 的 RTTI 机制,说明确有需要,需仔细确认后才能禁用)。 合并 so
以上都是针对单个 so 的优化方案,对单个 so 进行优化后,还可以考虑对 so 进行合并,能够进一步减小 so 的体积。具体来讲,当安装包内某些 so 仅被另外一个 so 动态依赖时,可以将这些 so 合并为一个 so。例如 liba.so 和 libb.so 仅被 libx.so 动态依赖,可以将这三个 so 合并为一个新的 libx.so。合并 so 有以下好处:
可以在不修改项目源码的情况下,在编译层面实现 so 的合并。 提取多 so 共同依赖库
上面“合并 so”是减小 so 总个数,而这里是增加 so 总个数。当多个 so 以静态方式依赖了某个相同的库时,可以考虑将此库提取成一个单独的 so,原来的几个 so 改为动态依赖该 so。例如 liba.so 和 libb.so 都静态依赖了 libx.a,可以优化为 liba.so 和 libb.so 均动态依赖 libx.so。提取多 so 共同依赖库,可以对不同 so 内的相同代码进行合并,从而减小总的 so 体积。
这里典型的例子是 libc++ 库:如果存在多个 so 都静态依赖 libc++ 库的情况,可以优化为这些 so 都动态依赖于 libc++_shared.so。 4.5 整合后的通用方案
通过上述分析,我们可以整合出普通项目均可使用的通用的优化方案,CMake 项目的配置方式(如果使用 GCC,应将 Oz 改为 Os):
说明:version script 方式指定所有需要导出的符号,不再需要 visibility 方式、attribute 方式、static 关键字和 exclude libs 方式控制导出符号。是否禁用 C++ 的异常机制和 RTTI 机制、合并 so 以及提取多 so 共同依赖库取决于具体项目,不具有通用性。
至此,我们总结出一套可行的 so 体积优化方案。但在工程实践中,还有一些问题要解决。 5. 工程实践 支持多种构建工具
美团有众多业务使用了 so,所使用的构建工具也不尽相同,除了上述常见的 CMake 和 ndk-build,也有项目在使用 Make、Automake、Ninja、GYP 和 GN 等各种构建工具。不同构建工具应用 so 优化方案的方式也不相同,尤其对大型工程而言,配置复杂性较高。
基于以上原因,每个业务自行配置 so 优化方案会消耗较多的人力成本,并且有配置无效的可能。为了降低配置成本、加快优化方案的推进速度、保证配置的有效性和正确性,我们在构建平台上统一支持了 so 的优化(支持使用任意构建工具的项目)。业务只需进行简单的配置即可开启 so 的体积优化。 配置导出符号的注意事项
注意事项有以下两点:
如果一个 so 的某些符号,被其他 so 通过 dlsym 方式使用,那么这些符号也应该保留在该 so 的导出符号中(否则会导致运行时异常)。
编写 version_script.txt 时需要注意 C++ 等语言对符号的修饰,不能直接把函数名填写进去。符号修饰就是把一个函数的命名空间(如果有)、类名(如果有)、参数类型等都添加到最终的符号中,这也是 C++ 语言实现重载的基础。有两种方式可以把 C++ 的函数添加到导出符号中:第一种是查看未优化 so 的导出符号表,找到目标函数被修饰后的符号,然后填写到 version_script.txt 中。例如有一个 MyClass 类:
class MyClass{
void start(int arg);
void stop();
};
要确定 start 函数真正的符号可以对未优化的 libexample.so 执行以下命令。因为 C++ 对符号修饰后,函数名是符号的一部分,所以可以通过 grep 加快查找:
上述配置可以导出 MyClass 的 start 和 stop 函数。其原理是,链接时链接器对每个符号进行 demangle(解构,即把修饰后的符号还原为可读的表示),然后与 extern "C++" 中的条目进行匹配,如果能与任一条目匹配成功就保留该符号。匹配的规则是:有双引号的条目不能使用通配符,需要全字符串完全匹配才可以(例如 stop 条目,如果括号之间多一个空格就会匹配失败)。对于没有双引号的条目能够使用通配符(例如 start 条目)。 查看优化后 so 的导出符号
业务对 so 进行优化之后,需要查看最终的 so 文件中保留了哪些导出符号,验证优化效果是否符合预期。在 Mac 和 Linux 下均可使用下述命令查看 so 保留了哪些导出符号:
nm -D --defined-only xxx.so
例如:
可以看出,libexample.so 的导出符号有两个:JNI_OnLoad 和 Java_com_example_MainActivity_stringFromJNI。 解析崩溃堆栈
本文的优化方案会移除非必要导出的动态符号,那 so 如果发生崩溃的话是不是就无法解析崩溃堆栈了呢?答案是完全不会影响崩溃堆栈的解析结果。
“so 可优化内容分析”一节已经提过,使用带调试信息和符号表的 so 解析线上崩溃,是分析 so 崩溃的标准方式(这也是 Google 解析 so 崩溃的方式)。本文的优化方案并未修改调试信息和符号表,所以可以使用带调试信息和符号表的 so 对崩溃堆栈进行完整的还原,解析出崩溃堆栈每个栈帧对应的源码文件、行号和函数名等信息。业务编译出 release 版的 so 后将相应的带调试信息和符号表的 so 上传到 crash 平台即可。
6. 方案收益
优化 so 对安装包体积和安装后占用的本地存储空间有直接收益,收益大小取决于原 so 冗余代码数量和导出符号数量等具体情况,下面是部分 so 优化前后占用安装包体积的对比: