本文作者: dyl

布景

软件使用除了功用外,还有许多非功用质量特点需求咱们重视,常见有功能、安全性、可用性、可扩展性等。除此之外,软件的体积也是咱们应该重视的重要质量特点。体积对发动速度、下载安装时长、安装成功率、磁盘空间占用、OOM 反常等都有深刻影响。

最近负责管理云音乐 Android 端 so 的体积,经过研讨探索总结了一些办法,主要从三个方面着手管理,分别是

  • 优化代码
  • 优化编译链接
  • 优化依靠。

用这些办法进行了一次大面积 so 管理后,so 整体从 30M+ 下降为 20M+,削减了 30%+ 的体积。本文对这些管理办法和布景知识进行了介绍,以供咱们参考。

优化代码

针对代码,主要重视在去重复代码和禁用贵重的 C++ 言语特性。Andorid NDK 下贵重的言语特性包括

  • 反常
  • RTTI
  • iostream 库

去除重复代码

重复的代码,不只带来体积问题,更是一种代码坏滋味。移除重复代码不管在质量上,仍是减小 so 体积上都有好处。咱们可选用代码静态检测工具检测重复代码,然后以提炼类或函数的重构办法进行处理。

  • 提炼函数:假如一个类的多个函数有重复代码,提炼独立函数,放入类中供其他函数运用。假如多个兄弟子类有重复代码,提炼独立函数,放入父类之中供子类运用。
  • 提炼类:假如不相关类有重复代码,提炼独立类放置重复代码,供这些类运用。

禁用贵重的 C++ 言语特性

在 Android NDK 下,有许多 C++ 特性是比较贵重的,在 Android NDK 官方文档亦有提及,要尽量防止运用。主要包括禁用 C++ 反常、禁用 C++ RTTI、防止运用 iostream。

C++ 反常会有一个误导,认为可以捕获让人头疼的空指针、内存越界等意料之外的错误,其实并不能。反常机制实践上是一种错误处理结构,捕获预先界说的错误,其意图是将正常逻辑和反常逻辑的处理分开,进步代码整齐度。而咱们每界说一处反常,在编译链接后都会刺进 C++ 库代码进行扩展,占用比编写的代码更多的空间。由于其功能和体积等问题,在实践中可考虑改用返回错误码来代替。

C++ RTTI 机制,在语意层面和多态是对立的。C++ 的多态,是经过基类指针指向派生类目标,在 Compile Time 时无须知道实践类型,在 Run Time 时方根据指向的类型,履行对应的虚函数完成,然后让咱们得以从依靠完成改为依靠接口。而 C++ RTTI,则是在 Compie Time 期间得知基类指针指向的实践类型,也即让咱们从依靠接口改为了依靠完成。此外,编译器完成 RTTI 机制往往会添加 class 的大小,比如为每个 class 发生额定的 RTTI 数据,包括类名和基类信息。当咱们运用到 RTTI 时应该细心考量,是否规划上呈现了问题。假如特别情况需求运用,也要清楚背后的体积本钱和规划本钱。

关于 iostream 库,经过咱们在实践场景中的检查,发现大部分只是是运用了 std::cout 输出日志。Android 自身供给有 log 办法,用 <android/log.h> 中的 log 进行日志输出,可移除 iostream 的依靠,然后削减体积。

优化代码实践办法

禁用上述言语特性,在 NDK Build 下无需特别指定,其默许禁用 C++ 反常和 RTTI。在 CMake 下禁用反常和 RTTI 的编译选项如下:

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

针对 iostream 库的引进,可查找整体代码,或用 objdump 反汇编 so 库,确认是否含有 iostream 信息,然后定位修改代码。

objdump -D demo.so | grep iostream

优化编译链接

在编译链接方面,要点是管理 so 的导出符号,添加相应的编译链接优化选项,供给满足的信息给编译器,由它在编译链接期间进行体积优化。 要了解 so 的导出符号,需求了解 ELF 文件格局。ELF 文件有多个分段(section),可以经过 readelf 命令检查细节。咱们重视的分段为:

  • .text:寄存编译后的机器代码
  • .date:寄存已初始化的全局/静态变量
  • .dynsym:动态符号表,包括导出符号和导入符号
  • .dynstr:动态符号字符串表
  • .symtab:整体符号表
  • .debug:调试信息表

其间动态符号表(.dynsym)是咱们重视的要点,它记录了动态库的导入导出符号,咱们需求保证导出的是必要且完整的符号集合,去除不必要的导出符号。

关于代码段(.text)和数据段(.data),在默许编译选项下,产出的目标文件会将多个函数聚集到一个代码段,多个变量放到一个数据段,最终合入到so中。咱们需求经过编译链接选项,帮助编译器只合入用到的函数和变量。

整体符号表(.symtab)和调试信息(.debug),则包括了丰富完整的符号信息,在分析 Crash 仓库时可复原符号。咱们保存一份带有 .symtab 和 .debug 的 so,并在发布时履行 strip 移除这些符号调试信息。就即可以发布小体积的 so,也可以在呈现 Crash 时用大体积 so 复原仓库符号。

限定动态符号表

ELF 中的动态符号表(.dynsym),记录了动态库的导入导出符号。在 Linux/Android NDK下,编译器默许将函数和全局变量,及引进运用的静态库的函数和全局变量,作为自己的动态符号全部导出,运用者在运用时也无需任何特别操作。尽管便利,但也简单导致 so 包括许多不应该导出的函数符号,甚至将内部运用的其他静态库的函数也进行导出。咱们需求对导出的动态符号做出约束,保证只露出外部依靠的符号,可以有用缩减动态符号表以及相关表项。关于第三方或无法清晰导出符号的 so,则强制不导出其它的静态库符号。咱们也强制要求不导出 C++ 库的符号。见下图暗示:

云音乐 Android so 体积治理实践

移除未运用函数和变量

默许编译选项下的目标文件在编译后,会将多个函数聚集到一个代码段,多个变量放到一个数据段。以代码段来说,其含有多个函数,哪怕咱们只用到其间一个函数,这个代码段就要整个保存,在链接阶段会整体合入 so,然后合入了并未运用的函数,增大了体积。数据段也是如此。咱们可以经过选项奉告编译器用更细粒度分段,让一个函数占一个代码段,一个变量占一个数据段,并奉告编译器收回未运用的代码段和数据段,然后移除并未运用的函数和变量。见下图暗示:

云音乐 Android so 体积治理实践

精简 JNI 原生接口符号

在 Android NDK 下的 JNI 原生接口注册办法有两种,分别是静态注册和动态注册。静态注册是以“Java+包名+类名+办法名”界说 native 办法,由 runtime 自己扫描注册。动态注册则是在 cpp 文件中界说 JNI_OnLoad 办法,咱们在此办法中调用 RegsiterNative 注册 JNI 接口。选用动态注册,关于支撑 JNI 的 so 只需求导出 JNI_OnLoadJNI_OnUnloadJava_* 可有用下降体积(规模更小、速度更快的同享库)。 RegsiterNatives 动态注册办法,可参考 Google 官网动态注册代码。

优化编译链接实践办法

交融上述的约束动态符号表,细化代码段和数据段,并收回未运用分段,可以让编译器移除没有被“导出函数”直接或直接依靠的函数和变量,然后大幅削减 so 的体积。

约束导出符号办法

可选用 version script 的办法,这也是 NDK 官网示例的办法。具体来说咱们编写一个相似 json 的文件,指明要导出的函数,并在链接选项中加入此脚本文件即可。version script 文件示例如下(注意导出类需求 extern “C++”,防止名称修饰问题):

{
    global: gValue;
            *someFuncs*;
            extern "C++" {
                CSemaphore::*;
                CCritical::*;
            };
    local: *;
};

CMake编译链接选项如下:

# 以 version script 指定导出函数
set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} -Wl,versionscript=${CMAKE_CURRENT_SOURCE_DIR}/funcs.map")            

# 不导出所有引进的静态库的符号
set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} -Wl,--exclude-libs,ALL")

NDK build编译链接选项如下:

# 以 version script 指定导出函数
LOCAL_LDFLAGS += -Wl,--version-script=${LOCAL_PATH}/funcs.map 

# 不导出所有引进的静态库的符号
LOCAL_LDFLAGS += -Wl,--exclude-libs,ALL

帮忙编译器移除未运用函数和变量

咱们经过添加编译选项,可让编译器收回未运用的代码段和数据段,如下:

  1. 指定分段选项,此举会让编译器在编译目标文件或静态库时,将单函数和单变量放入单个独立的段。
    • -ffunction-sections
    • -fdata-sections
  2. 指定收回选项,此举会让编译器在链接阶段履行 DeadCode 检测,识别出未运用的函数和变量,进而移除未运用的段。
    • –gc-sections
# CMake 编译选项
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -flto -fdata-sections -ffunction-sections")
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -flto -fdata-sections -ffunction-sections")
set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} -O3 -flto -Wl,--gc-sections")
# NDK Build 编译选项:
LOCAL_CFLAGS += -Oz -flto -fdata-sections -ffunction-sections
LOCAL_LDFLAGS += -O3 -flto -Wl,--gc-sections

优化依靠

当一个静态库被多个 so 重复依靠时,会引进多份静态库代码,可以提取这个重复依靠库为独立 so,供其他 so 库共用。当一个 so 库只是被另一个 so 库依靠时,会发生相关导入/导出符号表项,可经过兼并两个 so 去除导入导出符号。这两个办法是依靠的管理思路。

在实践中,咱们要点推进了 libc++ 的依靠管理。一个使用不应运用多个 c++ 运行时,在 Android 下 libc++ 版别与 NDK 版别是相对应的,一致 NDK 版别及 libc++ 依靠办法非常重要,触及的不只仅是体积问题,还或许导致 App Crash 或者其他奇怪问题。经过检测咱们发现大部分自研 so 库都是选用静态依靠 libc++_static 且版别不一,所以要点推进了 NDK 版别的一致,并一致动态依靠 libc++_shared 。关于由于历史原因无法升级 NDK 版别的则坚持静态链接 libc++_static,但要保证不导出其符号。

一致 libc++ 的依靠办法

  • 确认一致的 NDK 版别以及相应的 libc++_shared.so,在 module 级约束 ndkVersion 为一致版别。
  • 发布根底 aar 包,内含 libc++_shared.so。
  • 在功用性 so 工程中,动态链接 libc++_shared.so。
# Module 级的 build.gradle,此举会自动将 libc++_shared.so 打入 aar 包
DANDROID_STL=c++_shared

# ndk-build 下,在 Application.mk中加入
APP_STL := c++_shared
  • 发布功用性 so aar 包时,扫除自身的 c++_shared.so,以防止抵触
packagingOptions {
    exclude '**/libc++_shared.so'
}
  • 假如不能对齐一致版别的 ndk,则选用静态链接 C++ 库的办法。由于 so 默许会将自己引进的静态库作为自己的导出符号全部导出,所以需求扫除 C++ 库的符号。
# 不导出 C++ 库的符号
LOCAL_LDFLAGS += -Wl,--exclude-libs,libc++_static.a -Wl,--exclude-libs,libc++abi.a

总结

经过上述办法,可以有用的管理和操控so的体积。除此之外,还需求不断发掘重复依靠的功用;一起为了防止劣化,需求在CI/CD机制中加上相关符号和编译选项的检测。这也需求咱们持续进行重视和完善。

参考资料

  • Android NDK文档 针对中间件供应商的建议

本文发布自网易云音乐技能团队,文章未经授权制止任何形式的转载。咱们终年接收各类技能岗位,假如你准备换工作,又恰好喜爱云音乐,那就加入咱们 grp.music-fe(at)corp.netease.com!