前段时间做完我们的 SDK 项目,没有关注 so 库大小这块,现在慢慢稳定了就需要追求 so 库体积了。小团队一般可能不会在意这个东西,毕竟现在流量已经不是几年前的奢侈品了。但是要知道so库的大小不仅影响的是应用商店app的大小,还有一个很大的影响就是在广告页面渠道要求的秒下载,太大的app下载速度慢用户会不耐烦,直接影响了这部分用户的转化。
armeabi 第5/6代 ARM v5TE,使用软件浮点运算,兼容所有ARM设备,通用性强,速度慢
armeabi-v7a 第7代 ARM v7,使用硬件浮点运算,具有高级扩展功能(目前大部分手机都是这个架构)
arm64-v8a 第8代,64位,包含AArch32、AArch64两个执行状态对应32、64bit
x86 intel 32位,一般用于平板
x86_64 intel 64位,一般用于平板(支持 x86 和 x86_64)
mips 基本没见过(支持 mips)
mips64 基本没见过(支持 mips 和 mips_64)
对于手机来说,目前市面上占到 99% 的设备都是 armeabi 或者armeabi-v7a 和 arm64-v8a。虽然说 arm64-v8a 架构的手机慢慢发展起来了,但是其中 armeabi-v7a 还是占到绝大多数位置,但是随着现在手机更新换代的加速,arm64-v8a 慢慢的就会成为主流。
一般来说我们编译的 ABI 为 armeabi-v7a 的包已经能基本上能适配市面上绝大多数手机了,可以保证运行在 armeabi-v7a 架构上效率肯定是最高的,而在其他的架构上由于增加了模拟层,导致性能会有所损失。比如64位设备(arm64-v8a)能够运行32位的函数库,但是以32位模式运行,将丢失专为64位优化过的性能(ART,webview,media等)。
即意味着 arm64-v8a 架构的 so 库是可以运行在 arm64-v8a、armeabi-v7a 和 armeabi 设备上的。armeabi-v7a 架构的 so 库是可以运行在 armeabi-v7a 和 armeabi 设备上的。
这块的内容很多文章没有说清楚,我根据实测案例描述一遍(测试环境:小米10,android studio 3.1.3,NDK:r20):
Android 加载 so 库时是从当前手机支持的最高 CPU 架构文件夹开始:
java.lang.UnsatisfiedLinkError: Unable to load library 'soTest'
所以最好的情况便是分别编译不同 abi 架构的 so 库。
总结下来,有两种解决方案去优化 App 大小:
另外还发现个小彩蛋,抖音和QQ还没有使用 flutter 开发。
从 abi 架构去优化 so 库体积,其实不是我们想要的方案,因为现在大多数应用已经不会附带 3 个以上的 abi 架构 so 库。因此这方面的优化程度有限。因此我们要从另外的方向,即编译指令上优化生成的 so 库体积。
本来想直接说使用哪些指令优化,优化的效果是什么的,但是里面又牵扯一些其他知识,比如这个优化指令是谁的指令,编译器还是 ndk?那不同的编译器能使用相同的指令吗?如果不从头理一下这个流程,就感觉来的很突兀,容易让人摸不着头脑。
在此之前我们需要理清楚一个概念,即 Cmake、MakeFile、nmake、make 这些概念的联系和本质:
nmake makefiles
生成器(见下图),则最后编译的时候我们就需要选择 nmake 工具。[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-23VjDSJ4-1592311644999)(DED91F803B8C469281C1B0AD01A7D385)]
Native Development Kit
,是一个属于 Android 的开发工具包,和 Java 无关,有了它,让 Android 程序可以和 C/C++ 交互,它里面提供的工具可以将 so 库和 Android 代码一起打包成 APK。并且 NDK 里面提供的各种交叉编译器,可以生成不同 CPU 架构的动态库。Java Native Interface
,Java 本地接口,顾名思义,是接口定义,JNI 代码可以在 Java 代码里调用 C、C++ 等语言的代码 或 C、C++ 代码调用 Java 代码。由于 Java 语言的跨平台性,使得它和本地代码的交互能力很弱,因此才有了 JNI 可以增强 Java 和 本地代码交互的能力。看懂了上面的释义,然后我们再理一下一个 so 库从编译到可以在 Android 中运行所经历的过程(基于windows平台):
由于 NDK 从 r17 已经废弃了gcc,推荐使用 clang 编译,因此本文基于 cmake + clang + ndk r20 构建 so 库。
GCC特性:除支持C/C++/ Objective-C/Objective-C++语言外,还支持Java/Ada/Fortran/Go等;支持更多平台;更流行,广泛使用,支持完备。
Clang特性:编译速度快;内存占用小;兼容GCC;设计清晰简单、容易理解,易于扩展增强;基于库的模块化设计,易于IDE集成;出错提示更友好。
因此推荐以后不管是学习测试还是项目都使用 clang 进行编译。
上面提一嘴 gcc 与 clang 的原因是有一个容易让人陷入误区的地方。在 cmake 中有两个参数是:CMAKE_C_FLAGS 和 CMAKE_CXX_FLAGS
,用来设置编译器选项。但是我们知道 CFLAGS 参数和 CPPFLAGS 参数是 gcc 编译器才有的指令,clang 是没有这个指令的。那在 cmake 中设置了CMAKE_CXX_FLAGS
还会有效果吗?
重点:CMAKE_CXX_FLAGS != CXXFLAGS
即 cmake 中的 CMAKE_CXX_FLAGS 并不是 gcc 编译指令中的 CXXFLAGS。
CMAKE_CXX_FLAGS 只是 cmake 用来告诉编译器(不管是gcc还是clang)的编译指令,即 cmake 会解析 CMAKE_CXX_FLAGS 参数中的内容传递给具体的编译器。
因此对于 clang 编译器来说,cmake 中设置 CMAKE_CXX_FLAGS 也是生效的。只是说有可能 CMAKE_CXX_FLAGS 中的某些 gcc 指令 clang 不识别,或者说某些 clang 指令 gcc 不识别。比如说:-lz
指令在 clang 下编译会出现警告:
有了上面的内容,终于可以进入正题说下那些参数可以帮助我们减小 so 库的体积。
由于我们使用 ndk 编译时,编译器是 ndk 自带的,比如下面的编译器:
clang 是在 ndk 目录下,下面的参数都是 gcc 或者 clang 编译参数。
1.异常与运行时(gcc 和 clang)
-fno-exceptions
-fno-rtti
开启异常和运行时:3998kb
关闭异常和运行时:3998kb
默认情况下,ndk 中的 C++ 异常和运行时是被关闭的,如果项目打开这个选项了,可以考虑关闭,因为 ndk 对 C++ 异常支持的不够友好,所以大多数情况下异常是起不到实质作用的。 但是从上面我们的测试可以看出,so 库大小没变,可能和代码有关,但是也可以看出这两个选项对 so 库的大小影响有限,因此重要程度并不高。
2.导出函数可见性(gcc 和 clang)
-fvisibility=hidden
默认时:3998kb
设置 hidden 后:3933kb,减小了 0.01%
默认情况下,该选项是 default 的,即so库中大部分的函数或者全局变量都会被导出,且是可见的,-fvisibility=hidden可以显著地提高链接和加载共享库的性能,生成更加优化的代码,保证只有 export 修饰的函数才会导出。建议在编译共享库的时候使用它。
3.丢弃未使用的函数(只有gcc)
set(CMAKE_SHARED_LINKER_FLAGS "-Wl,--gc-sections")
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -ffunction-sections -fdata-sections")
编译的时候,加入-ffunction-sections, -fdata-sections 选项,在链接的时候,加入–gc-sections选项。
编译的时候,把每个函数作为一个section,每个数据(应该是指全局变量之类的吧)也作为一个section,这样链接的时候,–gc-sections会把没用到的section丢弃掉,最终的可执行文件就只包含用到了的函数和数据。
4. 产生与位置无关代码,避免so库加载重定位(gcc)
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fPIC")
-fPIC 作用于编译阶段,告诉编译器产生与位置无关代码(Position-Independent Code),则产生的代码中,没有绝对地址,全部使用相对地址,故而代码可以被加载器加载到内存的任意位置,都可以正确的执行。如果不加 -fPIC,则加载 so 文件的代码段时,代码段引用的数据对象需要重定位, 重定位会修改代码段的内容,这就造成每个使用这个 so 文件代码段的进程在内核里都会生成这个 so 文件代码段的 copy。
5. O1(gcc 和 clang)
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -O1")
目的是在不影响编译速度的前提下,尽量采用一些优化算法降低代码大小和可执行代码的运行速度。
6.O2(gcc 和 clang)
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -O2")
该优化选项会牺牲部分编译速度,除了执行 -O1 所执行的所有优化之外,还会采用几乎所有的目标配置支持的优化算法,用以提高目标代码的运行速度。
7.O3(gcc 和 clang)
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -O3")
该选项除了执行 -O2 所有的优化选项之外,一般都是采取很多向量化算法,提高代码的并行执行程度,利用现代CPU中的流水线,Cache 等。
8. Os(gcc 和 clang)
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Os")
这个优化标识和-O3有异曲同工之妙,当然两者的目标不一样,-O3的目标是宁愿增加目标代码的大小,也要拼命的提高运行速度,但是这个选项是在-O2的基础之上,尽量的降低目标代码的大小,这对于存储容量很小的设备来说非常重要。
9. Ofast(gcc 和 clang)
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Ofast")
该选项将不会严格遵循语言标准,除了启用所有的-O3优化选项之外,也会针对某些语言启用部分优化。如:-ffast-math。
10. -s(gcc 和 clang)
set(CMAKE_SHARED_LINKER_FLAGS "-Wl,-s")
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -s")
添加 -s 前:
添加 -s 后:
清除符号表信息,-s和-S的区别在于-S移除调试符号信息,而-s移除所有符号信息。
参考:Clang 11 documentation-Clang Compiler User’s Manual
参考:Using the GNU Compiler Collection (GCC)-Options That Control Optimization
参考:Using the GNU Compiler Collection (GCC)-Options Controlling C++ Dialect
参考:Android NDK: How to Reduce Binaries Size – The Algolia Blog
参考:GCC中-O1 -O2 -O3 优化的原理是什么?
如有帮助,请多多点赞支持,谢谢。