关于美团:Android对so体积优化的探索与实践

4次阅读

共计 15291 个字符,预计需要花费 39 分钟才能阅读完成。

减小利用安装包的体积,对晋升用户体验和下载转化率都大有益处。本文将联合美团平台的实践经验,分享 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:

  • .text:寄存的是编译后的机器指令,C/C++ 代码的大部分函数编译后就寄存在这里。这里只有机器指令,没有字符串等信息。
  • .data:寄存的是初始值不为零的一些可读写变量。
  • .bss:寄存的是初始值为零或未初始化的一些可读写变量。该 section 仅批示运行时须要的内存大小,不会占用 so 文件的体积。
  • .rodata:寄存的是一些只读常量。
  • .dynsym:动静符号表,给出了该 so 对外提供的符号(导出符号)和依赖内部的符号(导入符号)的信息。
  • .dynstr:字符串池,不同字符串以 ‘\0’ 宰割,供 .dynsym 和其余局部应用。
  • .gnu.hash.hash:两种类型的哈希表,用于疾速查找 .dynsym 中的导出符号或全副符号。
  • .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
  • 移除无用代码 :在理论的我的项目中,有一些代码在 Release 版中永远不会被应用到(例如历史遗留代码、用于测试的代码等),这些代码被称为 DeadCode。而依据上文剖析,只有动静符号表的导出符号间接或间接援用到的所有代码才须要保留,其余残余的所有代码都是 DeadCode,都是能够删除的(注:事实上 .init_array 等非凡 section 波及的代码也要保留)。删除无用代码的潜在收益较大。
  • 优化指令长度 :实现某个性能的指令并不是固定的,编译器有可能能用更少的指令实现雷同的性能,从而实现优化。因为指令是 so 的次要组成部分,因而优化这一部分的潜在收益也比拟大。

so 可优化内容如下图所示(可删除局部用红色背景标出,可优化局部是 .text),其中 funC、value2、value3、value6 因为别离被需保留局部应用,所以须要保留其实现体,只能删除其符号表项。funD、value1、value4、value5 可删除符号表项及其实现体(注:因为 value4 的实现体在 .bss 中,而 .bss 理论不占用 so 的体积,所以删除 value4 的实现体不会减小 so 的体积)。

在确定了 so 中能够优化的内容后,咱们还须要思考优化机会的问题:是间接批改 so 文件,还是管制其生成过程?思考到间接批改 so 文件的危险与难度较大,管制 so 的生成过程显然更稳当。为了管制 so 的生成过程,咱们先简要介绍一下 so 的生成过程:

如上图所示,so 的生成过程能够分为四个阶段:

  • 预处理 :将 include 头文件处扩大为理论文件内容并进行宏定义替换。
  • 编译 :将预处理后的文件编译成汇编代码。
  • 汇编 :将汇编代码汇编成指标文件,指标文件中蕴含机器指令(大部分状况下是机器指令,见下文 LTO 一节)和数据以及其余必要信息。
  • 链接 :将输出的所有指标文件以及动态库(.a 文件)链接成 so 文件。

能够看出,预处理和汇编阶段对特定输出产生的输入根本是固定的,优化空间较小。所以咱们的优化计划次要是针对编译和链接阶段进行优化。

4. 优化计划介绍

咱们对所有能管制最终 so 体积的计划都进行调研,并验证了其成果,最初总结出较为通用的可行计划。

4.1 精简动静符号表

应用 visibility 和 attribute 管制符号可见性

能够通过给编译器传递 -fvisibility=VALUE 管制全局的符号可见性,VALUE 常取值为 default 和 hidden:

  • default:除非对变量或函数特地指定符号可见性,所有符号都在动静符号表中,这也是不应用 -fvisibility 时的默认值。
  • 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 形式是针对单个符号的可见性开关。这两种形式联合就能管制源码中每个符号的可见性。

须要留神的是下面这两种形式,只能控制变量或函数是否存在于动静符号表中(即是否删除其动静符号表项),而不会删除其实现体。

应用 static 关键字管制符号可见性

在 C /C++ 语言中,static 关键字在不同场景下有不同意义,当应用 static 示意“该函数或变量仅在本文件可见”时,那么这个函数或变量就不会呈现在动静符号表中,但只会删除其动静符号表项,而不会删除其实现体。static 关键字相当于是加强的 hidden(因为 static 申明的函数或变量编译时只对以后文件可见,而 hidden 申明的函数或变量只是在动静符号表中不存在,在编译期间对其余文件还是可见的)。在我的项目开发中,应用 static 关键字申明一个函数或变量“仅在本文件可见”是很好的习惯,然而不倡议应用 static 关键字管制符号可见性:无奈应用 static 关键字管制一个多文件可见的函数或变量的符号可见性。

应用 exclude libs 移除动态库中的符号

上述 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 管制符号可见性

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 形式有一些额定的益处:

  1. version script 形式能够管制编译进 so 的动态库的符号是否导出,visibility 和 attribute 形式都无奈做到这一点。
  2. visibility 联合 attribute 形式须要在源码中表明每个须要导出的符号,对于导出符号较多的我的项目来说是很繁冗的。version script 把须要导出的符号对立地放到了一起,可能直观不便地查看和批改,对导出符号较多的我的项目也十分敌对。
  3. version script 反对通配符,* 代表 0 个或者多个字符,? 代表单个字符。比方 my*; 就代表所有以 my 结尾的符号。有了通配符的反对,配置 version script 会更加不便。
  4. 还有十分非凡的一点,version script 形式能够删除 __bss_start 这样的一些符号(这是链接器默认加上的符号)。

综上所述,version script 形式优于 visibility 联合 attribute 的形式。同时,应用了 version script 形式,就不须要应用 exclude libs 形式管制依赖的动态库中的符号是否导出了。

4.2 移除无用代码

开启 LTO

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 时须要留神几点:

  1. 如果应用 Clang,编译参数和链接参数中都要开启 LTO,否则会呈现无奈辨认文件格式的问题(NDK22 之前存在此问题)。应用 GCC 的话,只须要编译参数中开启 LTO 即可。
  2. 如果我的项目工程依赖了动态库,能够应用 LTO 形式从新编译该动态库,那么编译动静库时,就能移除动态库中的 DeadCode,从而减小最终 so 的体积。
  3. 通过测试,如果应用 Clang,链接器须要开启非 0 级别的优化,LTO 能力真正失效。通过理论测试(NDK 为 r16b),O1 优化成果较差,O2、O3 优化成果比拟靠近。
  4. 因为须要进行更多的剖析计算,开启 LTO 后,链接耗时会明显增加。

开启 GC sections

这是传递给链接器的参数,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

4.3 优化指令长度

应用 Oz/Os 优化级别

编译器依据输出的 -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

4.4 其余措施

禁用 C++ 的异样机制

如果我的项目中没有应用 C++ 的异样机制(例如 try...catch 等),能够通过禁用 C++ 的异样机制,来减小 so 的体积。

CMake 我的项目的配置形式:

set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fno-exceptions")

ndk-build 默认会禁用 C++ 的异样机制,因而无需特意禁用(如果现有我的项目开启了 C++ 的异样机制,说明确有须要,需认真确认后能力禁用)。

禁用 C++ 的 RTTI 机制

如果我的项目中没有应用 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 合并为一个 so。例如 liba.so 和 libb.so 仅被 libx.so 动静依赖,能够将这三个 so 合并为一个新的 libx.so。合并 so 有以下益处:

  1. 能够删除局部动静符号表项,减小 so 总体积。具体来讲,就是能够删除 liba.so 和 libb.so 的动静符号表中的所有导出符号,以及 libx.so 的动静符号表中从 liba.so 和 libb.so 中导入的符号。
  2. 能够删除局部 PLT 表项和 GOT 表项,减小 so 总体积。具体来讲,就是能够删除 libx.so 中与 liba.so、libb.so 相干的 PLT 表项和 GOT 表项。
  3. 能够加重优化的工作量。如果没有合并 so,对 liba.so 和 libb.so 做体积优化时须要确定 libx.so 依赖了它们的哪些符号,能力对它们进行优化,做了 so 合并后就不须要了。链接器会主动剖析援用关系,保留应用到的所有符号的对应内容。
  4. 因为链接器对原 liba.so 和 libb.so 的导出符号领有了更全的上下文信息,LTO 优化也能获得更好的成果。

能够在不批改我的项目源码的状况下,在编译层面实现 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):

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 体积优化计划。但在工程实际中,还有一些问题要解决。

5. 工程实际

反对多种构建工具

美团有泛滥业务应用了 so,所应用的构建工具也不尽相同,除了上述常见的 CMake 和 ndk-build,也有我的项目在应用 Make、Automake、Ninja、GYP 和 GN 等各种构建工具。不同构建工具利用 so 优化计划的形式也不雷同,尤其对大型工程而言,配置复杂性较高。

基于以上起因,每个业务自行配置 so 优化计划会耗费较多的人力老本,并且有配置有效的可能。为了升高配置老本、放慢优化计划的推动速度、保障配置的有效性和正确性,咱们在构建平台上对立反对了 so 的优化(反对应用任意构建工具的我的项目)。业务只需进行简略的配置即可开启 so 的体积优化。

配置导出符号的注意事项

注意事项有以下两点:

  1. 如果一个 so 的某些符号,被其余 so 通过 dlsym 形式应用,那么这些符号也应该保留在该 so 的导出符号中(否则会导致运行时异样)。
  2. 编写 version_script.txt 时须要留神 C++ 等语言对符号的润饰,不能间接把函数名填写进去。符号润饰就是把一个函数的命名空间(如果有)、类名(如果有)、参数类型等都增加到最终的符号中,这也是 C++ 语言实现重载的根底。有两种形式能够把 C++ 的函数增加到导出符号中:第一种是查看未优化 so 的导出符号表,找到指标函数被润饰后的符号,而后填写到 version_script.txt 中。例如有一个 MyClass 类:
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 进行优化之后,须要查看最终的 so 文件中保留了哪些导出符号,验证优化成果是否合乎预期。在 Mac 和 Linux 下均可应用下述命令查看 so 保留了哪些导出符号:

nm -D --defined-only xxx.so

例如:

能够看出,libexample.so 的导出符号有两个:JNI_OnLoadJava_com_example_MainActivity_stringFromJNI

解析解体堆栈

本文的优化计划会移除非必要导出的动静符号,那 so 如果产生解体的话是不是就无奈解析解体堆栈了呢?答案是齐全不会影响解体堆栈的解析后果。

“so 可优化内容分析”一节曾经提过,应用带调试信息和符号表的 so 解析线上解体,是剖析 so 解体的规范形式(这也是 Google 解析 so 解体的形式)。本文的优化计划并未批改调试信息和符号表,所以能够应用带调试信息和符号表的 so 对解体堆栈进行残缺的还原,解析出解体堆栈每个栈帧对应的源码文件、行号和函数名等信息。业务编译出 release 版的 so 后将相应的带调试信息和符号表的 so 上传到 crash 平台即可。

6. 计划收益

优化 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%

7. 总结与后续打算

对 so 体积进行优化不仅可能减小安装包体积,而且能取得以下收益:

  • 删除了大量的非必要导出符号从而晋升了 so 的安全性。
  • 因为 .data .bss .text 等运行时占用内存的 section 减小了,所以也能减小利用运行时的内存占用。
  • 如果优化过程中缩小了 so 对外依赖的符号,还能够放慢 so 的加载速度。

咱们对后续工作做了如下的布局:

  • 晋升编译速度。因为应用 LTO、gc sections 等会减少编译耗时,打算调研 ThinLTO 等计划对编译速度进行优化。
  • 具体展现保留各个函数 / 数据的起因。
  • 进一步欠缺平台优化 so 的能力。

8. 参考资料

  1. https://www.cs.cmu.edu/afs/cs/academic/class/15213-f00/docs/elf.pdf
  2. https://llvm.org/docs/LinkTimeOptimization.html
  3. https://gcc.gnu.org/onlinedocs/gccint/LTO-Overview.html
  4. https://sourceware.org/binutils/docs/ld/VERSION.html
  5. https://clang.llvm.org/docs
  6. https://gcc.gnu.org/onlinedocs/gcc

9. 本文作者

洪凯、常强,来自美团平台 /App 技术部。

浏览美团技术团队更多技术文章合集

前端 | 算法 | 后端 | 数据 | 平安 | 运维 | iOS | Android | 测试

| 在公众号菜单栏对话框回复【2021 年货】、【2020 年货】、【2019 年货】、【2018 年货】、【2017 年货】等关键词,可查看美团技术团队历年技术文章合集。

| 本文系美团技术团队出品,著作权归属美团。欢送出于分享和交换等非商业目标转载或应用本文内容,敬请注明“内容转载自美团技术团队”。本文未经许可,不得进行商业性转载或者应用。任何商用行为,请发送邮件至 tech@meituan.com 申请受权。

正文完
 0