减小应用安装包的体积,对提升用户体验和下载转化率都大有益处。本文将结合美团平台的实践经验,分享 so 体积优化的思路、收益,以及工程实践中的注意事项。本文将先从 so 文件格式讲起,结合文件格式分析哪些内容可以优化,然后再具体讲解每项优化手段以及注意事项,最后介绍相关的工程实践经验。希望能对从事包体积优化的同学有所帮助或启发。
应用安装包的体积影响着用户的下载时长、安装时长、磁盘占用空间等诸多方面,因此减小安装包的体积对于提升用户体验和下载转化率都大有益处。Android应用安装包其实是一个 zip 文件,主要由 dex、assets、resource、so 等各类型文件压缩而成。目前业内常见的包体积优化方案大体分为以下几类:
随着动态化、端智能等技术的广泛应用,在采用上述优化手段后, so 在安装包体积中的比重依然很高,我们开始思索这部分体积是否能进一步优化。
经过一段时间的调研、分析和验证,我们逐渐摸索出一套可以将应用安装包中 so 体积进一步减小 30%~60% 的方案。该方案包含一系列纯技术优化手段,对业务侵入性低,通过简单的配置,可以快速部署生效,目前美团 App 已在线上部署使用。为让大家能知其然,也能知其所以然,本文将先从 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:
在进行优化之前,我们需要对这些 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 中有哪些内容可以优化。
在讨论 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 可优化内容如下图所示(可删除部分用红色背景标出,可优化部分是.text),其中 funC、value2、value3、value6 由于分别被需保留部分使用,所以需要保留其实现体,只能删除其符号表项。funD、value1、value4、value5 可删除符号表项及其实现体(注:因为 value4 的实现体在.bss中,而.bss实际不占用 so 的体积,所以删除 value4 的实现体不会减小 so 的体积)。
在确定了 so 中可以优化的内容后,我们还需要考虑优化时机的问题:是直接修改 so 文件,还是控制其生成过程?考虑到直接修改 so 文件的风险与难度较大,控制 so 的生成过程显然更稳妥。为了控制 so 的生成过程,我们先简要介绍一下 so 的生成过程:
如上图所示,so 的生成过程可以分为四个阶段:
可以看出,预处理和汇编阶段对特定输入产生的输出基本是固定的,优化空间较小。所以我们的优化方案主要是针对编译和链接阶段进行优化。
我们对所有能控制最终 so 体积的方案都进行调研,并验证了其效果,最后总结出较为通用的可行方案。
可以通过给编译器传递-fvisibility=VALUE控制全局的符号可见性,VALUE 常取值为 default 和 hidden:
CMake 项目的配置方式:
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -fvisibility=hidden")
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fvisibility=hidden")
ndk-build 项目的配置方式:
LOCAL_CFLAGS += -fvisibility=hidden
另一方面,针对单个变量或函数,可以通过 attribute 方式指定其符号可见性,示例如下:
__attribute__((visibility("hidden")))
int hiddenInt=3;
其常用值也是 default 和 hidden,与 visibility 方式意义类似,这里不再赘述。
attribute 方式指定的符号可见性的优先级,高于 visibility 方式指定的可见性,相当于 visibility 是全局符号可见性开关,attribute 方式是针对单个符号的可见性开关。这两种方式结合就能控制源码中每个符号的可见性。
需要注意的是上面这两种方式,只能控制变量或函数是否存在于动态符号表中(即是否删除其动态符号表项),而不会删除其实现体。
在 C/C++ 语言中,static 关键字在不同场景下有不同意义,当使用 static 表示 “该函数或变量仅在本文件可见” 时,那么这个函数或变量就不会出现在动态符号表中,但只会删除其动态符号表项,而不会删除其实现体。static 关键字相当于是增强的 hidden(因为 static 声明的函数或变量编译时只对当前文件可见,而 hidden 声明的函数或变量只是在动态符号表中不存在,在编译期间对其他文件还是可见的)。在项目开发中,使用 static 关键字声明一个函数或变量 “仅在本文件可见” 是很好的习惯,但是不建议使用 static 关键字控制符号可见性:无法使用 static 关键字控制一个多文件可见的函数或变量的符号可见性。
上述 visibility 方式、attribute 方式和 static 关键字,都是控制项目源码中符号的可见性,而无法控制依赖的静态库中的符号在最终 so 中是否存在。exclude libs 就是用来控制依赖的静态库中的符号是否可见,它是传递给链接器的参数,可以使依赖的静态库的符号在动态符号表中不存在。同样,也是只能删除符号表项,实现体仍然会存在于产生的 so 文件中。
CMake 项目的配置方式:
set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} -Wl,--exclude-libs,ALL")#使所有静态库中的符号都不被导出
set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} -Wl,--exclude-libs,libabc.a")#使 libabc.a 的符号都不被导出
ndk-build 项目的配置方式:
LOCAL_LDFLAGS += -Wl,--exclude-libs,ALL #使所有静态库中的符号都不被导出
LOCAL_LDFLAGS += -Wl,--exclude-libs,libabc.a #使 libabc.a 的符号都不被导出
version script 是传递给链接器的参数,用来指定动态库导出哪些符号以及符号的版本。该参数会影响到上面 “so 文件格式” 一节中.gnu.version和.gnu.version_d的内容。我们现在只使用它的指定所有导出符号的功能(即符号版本名使用空字符串)。开启 version script 需要先编写一个文本文件,用来指定动态库导出哪些符号。示例如下(只导出 usedFun 这一个函数):
{
global:usedFun;
local:*;
};
然后将上述文件的路径传递给链接器即可(假定上述文件名为version_script.txt)。
CMake 项目的配置方式:
set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} -Wl,--version-script=${CMAKE_CURRENT_SOURCE_DIR}/version_script.txt") #version_script.txt 与当前 CMakeLists.txt 同目录
ndk-build 项目的配置方式:
LOCAL_LDFLAGS += -Wl,--version-script=${LOCAL_PATH}/version_script.txt #version_script.txt 与当前 Android.mk 同目录
看上去,version script 是明确地指定需要保留的符号,如果通过 visibility 结合 attribute 的方式控制每个符号是否导出,也能达到 version script 的效果,但是 version script 方式有一些额外的好处:
综上所述,version script 方式优于 visibility 结合 attribute 的方式。同时,使用了 version script 方式,就不需要使用 exclude libs 方式控制依赖的静态库中的符号是否导出了。
LTO 是 Link Time Optimization 的缩写,即链接期优化。LTO 能够在链接目标文件时检测出 DeadCode 并删除它们,从而减小编译产物的体积。DeadCode 举例:某个 if 条件永远为假,那么 if 为真下的代码块就可以移除。进一步地,被移除代码块所调用的函数也可能因此而变为 DeadCode,它们又可以被移除。能够在链接期做优化的原因是,在编译期很多信息还不能确定,只有局部信息,无法执行一些优化。但是链接时大部分信息都确定了,相当于获取了全局信息,所以可以进行一些优化。GCC 和 Clang 均支持 LTO。LTO 方式编译的目标文件中存储的不再是具体机器的指令,而是机器无关的中间表示(GCC 采用的是 GIMPLE 字节码,Clang 采用的是 LLVM IR 比特码)。
CMake 项目的配置方式:
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -flto")
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -flto")
set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} -O3 -flto")
ndk-build 项目的配置方式:
LOCAL_CFLAGS += -flto
LOCAL_LDFLAGS += -O3 -flto
使用 LTO 时需要注意几点:
这是传递给链接器的参数,GC 即 Garbage Collection(垃圾回收),也就是对无用的 section 进行回收。注意,这里的 section 不是指最终 so 中的 section,而是作为链接器的输入的目标文件中的 section。
简要介绍一下目标文件,目标文件(扩展名 .o )也是 ELF 文件,所以也是由 section 组成的,只不过它只包含了相应源文件的内容:函数会放到.text样式的 section 中,一些可读写变量会放到.data样式的 section 中,等等。链接器会把所有输入的目标文件的同类型的 section 进行合并,组装出最终的 so 文件。
GC sections 参数通知链接器:仅保留动态符号(及.init_array等)直接或者间接引用到的 section,移除其他无用 section。这样就能减小最终 so 的体积。但开启 GC sections 还需要考虑一个问题:编译器默认会把所有函数放到同一个 section 中,把所有相同特点的数据放到同一个 section 中,如果同一个 section 中既有需要删除的部分又有需要保留的部分,会使得整个 section 都要保留。所以我们需要减小目标文件 section 的粒度,这需要借助另外两个编译参数-fdata-sections和-ffunction-sections,这两个参数通知编译器,将每个变量和函数分别放到各自独立的 section 中,这样就不会出现上述问题了。实际上 Android 编译目标文件时会自动带上-fdata-sections和-ffunction-sections参数,这里一并列出来,是为了突出它们的作用。
CMake 项目的配置方式:
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -fdata-sections -ffunction-sections")
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fdata-sections -ffunction-sections")
set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} -Wl,--gc-sections")
ndk-build 项目的配置方式:
LOCAL_CFLAGS += -fdata-sections -ffunction-sections
LOCAL_LDFLAGS += -Wl,--gc-sections
编译器根据输入的 -Ox 参数决定编译的优化级别,其中 O0 表示不开启优化(这种情况主要是为了便于调试以及更快的编译速度),从 O1 到 O3,优化程度越来越强。Clang 和 GCC 均提供了 Os 的优化级别,其与 O2 比较接近,但是优化了生成产物的体积。而 Clang 还提供了 Oz 优化级别,在 Os 的基础上能进一步优化产物体积。
综上,编译器是 Clang,可以开启 Oz 优化。如果编译器是 GCC,则只能开启 Os 优化(注:NDK 从 r13 开始默认编译器从 GCC 变为 Clang,r18 中正式移除了 GCC。GCC 不支持 Oz 是指 Android 最后使用的 GCC4.9 版本不支持 Oz 参数)。Oz/Os 优化相比于 O3 优化,优化了产物体积,性能上可能有一定损失,因此如果项目原本使用了 O3 优化,可根据实际测试结果以及对性能的要求,决定是否使用 Os/Oz 优化级别,如果项目原本未使用 O3 优化级别,可直接使用 Os/Oz 优化。
CMake 项目的配置方式(如果使用 GCC,应将 Oz 改为 Os):
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -Oz")
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Oz")
ndk-build 项目的配置方式(如果使用 GCC,应将 Oz 改为 Os):
LOCAL_CFLAGS += -Oz
如果项目中没有使用 C++ 的异常机制(例如try...catch等),可以通过禁用 C++ 的异常机制,来减小 so 的体积。
CMake 项目的配置方式:
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fno-exceptions")
ndk-build 默认会禁用 C++ 的异常机制,因此无需特意禁用(如果现有项目开启了 C++ 的异常机制,说明确有需要,需仔细确认后才能禁用)。
如果项目中没有使用 C++ 的 RTTI 机制(例如 typeid 和 dynamic_cast 等),可以通过禁用 C++ 的 RTTI ,来减小 so 的体积。
CMake 项目的配置方式:
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fno-rtti")
ndk-build 默认会禁用 C++ 的 RTTI 机制,因此无需特意禁用(如果现有项目开启了 C++ 的 RTTI 机制,说明确有需要,需仔细确认后才能禁用)。
以上都是针对单个 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。例如 liba.so 和 libb.so 都静态依赖了 libx.a,可以优化为 liba.so 和 libb.so 均动态依赖 libx.so。提取多 so 共同依赖库,可以对不同 so 内的相同代码进行合并,从而减小总的 so 体积。
这里典型的例子是 libc++ 库:如果存在多个 so 都静态依赖 libc++ 库的情况,可以优化为这些 so 都动态依赖于libc++_shared.so。
通过上述分析,我们可以整合出普通项目均可使用的通用的优化方案,CMake 项目的配置方式(如果使用 GCC,应将 Oz 改为 Os):
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -Oz -flto -fdata-sections -ffunction-sections")
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Oz -flto -fdata-sections -ffunction-sections")
set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} -O3 -flto -Wl,--gc-sections -Wl,--version-script=${CMAKE_CURRENT_SOURCE_DIR}/version_script.txt") #version_script.txt 与当前 CMakeLists.txt 同目录
ndk-build 项目的配置方式(如果使用 GCC,应将 Oz 改为 Os):
LOCAL_CFLAGS += -Oz -flto -fdata-sections -ffunction-sections
LOCAL_LDFLAGS += -O3 -flto -Wl,--gc-sections -Wl,--version-script=${LOCAL_PATH}/version_script.txt #version_script.txt 与当前 Android.mk 同目录
其中version_script.txt较为通用的配置如下,可根据实际情况添加需要保留的导出符号:
{
global:JNI_OnLoad;JNI_OnUnload;Java_*;
local:*;
};
说明:version script 方式指定所有需要导出的符号,不再需要 visibility 方式、attribute 方式、static 关键字和 exclude libs 方式控制导出符号。是否禁用 C++ 的异常机制和 RTTI 机制、合并 so 以及提取多 so 共同依赖库取决于具体项目,不具有通用性。
至此,我们总结出一套可行的 so 体积优化方案。但在工程实践中,还有一些问题要解决。
美团有众多业务使用了 so,所使用的构建工具也不尽相同,除了上述常见的 CMake 和 ndk-build,也有项目在使用 Make、Automake、Ninja、GYP 和 GN 等各种构建工具。不同构建工具应用 so 优化方案的方式也不相同,尤其对大型工程而言,配置复杂性较高。
基于以上原因,每个业务自行配置 so 优化方案会消耗较多的人力成本,并且有配置无效的可能。为了降低配置成本、加快优化方案的推进速度、保证配置的有效性和正确性,我们在构建平台上统一支持了 so 的优化(支持使用任意构建工具的项目)。业务只需进行简单的配置即可开启 so 的体积优化。
注意事项有以下两点:
class MyClass{
void start(int arg);
void stop();
};
要确定 start 函数真正的符号可以对未优化的 libexample.so 执行以下命令。因为 C++ 对符号修饰后,函数名是符号的一部分,所以可以通过 grep 加快查找:
可以看到 start 函数真正的符号是_ZN7MyClass5startEi。如果想导出该函数,version_script.txt相应位置填入_ZN7MyClass5startEi即可。
第二种方式是在version_script.txt中使用 extern 语法,如下所示:
{
global:
extern "C++" {
MyClass::start*;
"MyClass::stop()";
};
local:*;
};
上述配置可以导出 MyClass 的 start 和 stop 函数。其原理是,链接时链接器对每个符号进行 demangle(解构,即把修饰后的符号还原为可读的表示),然后与 extern "C++" 中的条目进行匹配,如果能与任一条目匹配成功就保留该符号。匹配的规则是:有双引号的条目不能使用通配符,需要全字符串完全匹配才可以(例如 stop 条目,如果括号之间多一个空格就会匹配失败)。对于没有双引号的条目能够使用通配符(例如 start 条目)。
业务对 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 平台即可。
优化 so 对安装包体积和安装后占用的本地存储空间有直接收益,收益大小取决于原 so 冗余代码数量和导出符号数量等具体情况,下面是部分 so 优化前后占用安装包体积的对比:
so | 优化前大小 | 优化后大小 | 优化百分比 |
---|---|---|---|
A 库 | 4.49 MB | 3.28 MB | 27.02% |
B 库 | 995.82 KB | 728.38 KB | 26.86% |
C 库 | 312.05 KB | 153.81 KB | 50.71% |
D 库 | 505.57 KB | 321.75 KB | 36.36% |
E 库 | 309.89 KB | 157.08 KB | 49.31% |
F 库 | 88.59 KB | 62.93 KB | 28.97% |
下面是上述 so 优化前后占用本地存储空间的对比:
so | 优化前大小 | 优化后大小 | 优化百分比 |
---|---|---|---|
A 库 | 10.67 MB | 7.04 MB | 34.05% |
B 库 | 2.35 MB | 1.61 MB | 31.46% |
C 库 | 898.14 KB | 386.31 KB | 56.99% |
D 库 | 1.30 MB | 771.47 KB | 41.88% |
E 库 | 890.13 KB | 398.30 KB | 55.25% |
F 库 | 230.30 KB | 146.06 KB | 36.58% |
对 so 体积进行优化不仅能够减小安装包体积,而且能获得以下收益:
我们对后续工作做了如下的规划: