NDK撩妹三部曲(五)— NDK 开发以及 so 库体积优化总结与学习笔记(深度干货,值得收藏)

  前段时间做完我们的 SDK 项目,没有关注 so 库大小这块,现在慢慢稳定了就需要追求 so 库体积了。小团队一般可能不会在意这个东西,毕竟现在流量已经不是几年前的奢侈品了。但是要知道so库的大小不仅影响的是应用商店app的大小,还有一个很大的影响就是在广告页面渠道要求的秒下载,太大的app下载速度慢用户会不耐烦,直接影响了这部分用户的转化。

1、从支持的abi架构入手优化

7种abi架构简介

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等)。

abi 兼容性

  • arm64-v8a : 能兼容 armeabi-v7a 和 armeabi
  • armeabi-v7a :armeabi-v7a向下兼容 armeabi
  • x86_64 : 兼容 x86
  • mips64 : 兼容 mips

即意味着 arm64-v8a 架构的 so 库是可以运行在 arm64-v8a、armeabi-v7a 和 armeabi 设备上的。armeabi-v7a 架构的 so 库是可以运行在 armeabi-v7a 和 armeabi 设备上的。

Android 加载so库顺序

这块的内容很多文章没有说清楚,我根据实测案例描述一遍(测试环境:小米10,android studio 3.1.3,NDK:r20):

Android 加载 so 库时是从当前手机支持的最高 CPU 架构文件夹开始:

  1. 假如当前手机是 arm64-v8a 架构(现在我们使用的很多新手机都是这个架构),你的 APK 存在 arm64-v8a 文件夹,则从 arm64-v8a 文件夹开始,如果 arm64-v8a 下面有库,且完整,则结束,安装的时候也安装的是这个文件夹下的 so库,哪怕此时你存在armeabi-v7a 文件夹,且里面的库不全也没关系,不会报错
  2. 假如你的 APK 存在 arm64-v8a 文件夹,且在 arm64-v8a 下没有找到库,不管是直接 load 的库还是依赖的库,找不到则直接报错:
java.lang.UnsatisfiedLinkError: Unable to load library 'soTest'
  1. 假如你的 APK 存在 arm64-v8a 文件夹,也存在 armeabi-v7a 文件夹,而且两者里面的库都完整,则 android 包管理器会安装 arm64-v8a 下面的文件,而忽略 armeabi-v7a 下面的库。

所以最好的情况便是分别编译不同 abi 架构的 so 库。

注意事项

  1. Android 包管理器安装 app 时,只有当前手机支持的 cpu 架构下的包才会被安装。即哪怕你打包里面有 arm64-v8a ,也有 armeabi-v7a,但是安装时只会安装其中的一个。比如我的小米手机里面有 arm64-v8a 和 armeabi-v7a 两个文件夹,但是安装完成后,使用 Native Libs Monitor 软件查看只安装了 arm64-v8a 下面的包。
  2. 与我上面提到的测试案例不同,假如你的手机是 armeabi-v7a 架构的,哪怕 arm64-v8a 文件夹下的库都有,而 armeabi-v7a 下面的库却不完整,app 会 crash 的。所以为了兼容性(因为你根本不知道你的目标用户手机架构是什么样的),一定要保证已经存在的 abi 文件夹下 so 库的数量一致,要么都支持,要么都不支持。
  3. 因为你使用的 so 库可能来自不同的源头,因此一定要保证这些库依赖了 相同的c++ 运行时,例如一个 abi 目录下只有一个 libc++_shared.so。

总结下来,有两种解决方案去优化 App 大小:

  1. 建议只提供一种 abi 架构的 so 库,就是 armeabi-v7a,损失一些性能。
  2. 现在的应用市场支持上传不同 abi 架构的 APK 包,因此建议针对不同的 abi 架构上传不同的 APK 包。

主流app支持的abi

  • 抖音:armeabi-v7a
  • 微信:微信下载的时候 apk 分为两个版本,一个 32 的,一个 64 的,下载 64 位的解压后发现只有 armeabi-v8a 文件夹,32 位解压后只有 armeabi-v7a 文件夹。
  • QQ:armeabi
  • 淘宝:arm64-v8a 和 armeabi-v7a

另外还发现个小彩蛋,抖音和QQ还没有使用 flutter 开发。

2、gcc/clang编译参数优化

从 abi 架构去优化 so 库体积,其实不是我们想要的方案,因为现在大多数应用已经不会附带 3 个以上的 abi 架构 so 库。因此这方面的优化程度有限。因此我们要从另外的方向,即编译指令上优化生成的 so 库体积。

本来想直接说使用哪些指令优化,优化的效果是什么的,但是里面又牵扯一些其他知识,比如这个优化指令是谁的指令,编译器还是 ndk?那不同的编译器能使用相同的指令吗?如果不从头理一下这个流程,就感觉来的很突兀,容易让人摸不着头脑。

cmake、nmake、makefile、make概念详解

在此之前我们需要理清楚一个概念,即 Cmake、MakeFile、nmake、make 这些概念的联系和本质:

  • cmake :Cmake 是一个跨平台的编译构建工具,帮助我们在不同平台下生成工程,比如 linux 下的 makefile 工程,windows 下的 vcproj 工程。在 cmake 中,我们可以指定使用的编译器,比如 gcc/g++, 或者 clang/clang++,或者 cl/cl++ 等。
  • 生成器(generator):那这个 makefile 是根据什么生成的呢?就是根据“生成器”,下面的图可以看到我的 cmake3.11 当前支持这么多种生成器(使用cmake --help查看),生成器告诉 cmake 生成那种类型的 makefile 文件(即哪种工程)。
  • MakeFile: makefile 文件是一个描述文件,里面定义了我们项目所有源代码文件的编译规则和编译指令,目的是为了使用这一个脚本达到我们项目的“自动化构建”。makefile 文件根据不同的“生成器”所生成的格式是不同的,比如在 linux 常见的使用 autoconf 和 automake 生成 makefile 文件,然后使用 ./configure 和 make 便能编译出最终的可执行文件。比如 windows VS2017 下使用 “Visual Studio 15 2017” 生成器生成适用于 VS2017 的 vcproj 文件,虽然它不叫 makefile 文件,但是道理相同。
  • nmakemake、nmake、gmake 都是解析 makefile 文件的工具,在 linux 系统下会用到 make 或者 gmake,在 windows 下会使用 nmake。到底使用哪个工具取决于上面我们在 cmake 时选用的哪个“生成器”,比如如果选择 nmake makefiles生成器(见下图),则最后编译的时候我们就需要选择 nmake 工具。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-23VjDSJ4-1592311644999)(DED91F803B8C469281C1B0AD01A7D385)]

NDK和JNI的关系

  • NDKNative Development Kit,是一个属于 Android 的开发工具包,和 Java 无关,有了它,让 Android 程序可以和 C/C++ 交互,它里面提供的工具可以将 so 库和 Android 代码一起打包成 APK。并且 NDK 里面提供的各种交叉编译器,可以生成不同 CPU 架构的动态库。
  • JNIJava Native Interface,Java 本地接口,顾名思义,是接口定义,JNI 代码可以在 Java 代码里调用 C、C++ 等语言的代码 或 C、C++ 代码调用 Java 代码。由于 Java 语言的跨平台性,使得它和本地代码的交互能力很弱,因此才有了 JNI 可以增强 Java 和 本地代码交互的能力。
  • NDK与JNI的关系:NDK 是在 Android 中实现 JNI 的工具。而 JNI 只是 API 接口定义。有了 NDK,才能更方便的让 Java 调用 C/C++。简单说就是 JNI 负责 Java 与 C/C++ 进行互相操作,NDK 提供工具方便在 Android 平台使用 JNI。

so库的编译流程

看懂了上面的释义,然后我们再理一下一个 so 库从编译到可以在 Android 中运行所经历的过程(基于windows平台):

  1. 一段 C++ 代码,首先编写 cmakelist.txt 文件。
  2. 选择生成器(比如nmake)并使用 cmake 工具构建工程,cmake 中有参数可以指定 C/C++ 编译器,可以提前指定 ndk 版本等信息。
  3. 生成 “生成器”所能解释的 makefile 文件。
  4. 执行 make 指令。(即使用生成器根据 makefile 文件生成真正的工程)。
  5. 使用 cmake 中指定的编译器编译工程。
  6. 最终生成目标文件(可执行文件/动态库/静态库)。

NDK所使用的编译器

由于 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 下编译会出现警告:

在这里插入图片描述
或者说出现错误;

在这里插入图片描述

gcc/clang编译指令优化so库

有了上面的内容,终于可以进入正题说下那些参数可以帮助我们减小 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 优化的原理是什么?

如有帮助,请多多点赞支持,谢谢。

猜你喜欢

转载自blog.csdn.net/u012534831/article/details/106795173