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

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

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

7种abi架构简介

armeabi 第5/6代 ARM v5TE,使用软件浮点运算,兼容全部ARM设备,通用性强,速度慢java

armeabi-v7a 第7代 ARM v7,使用硬件浮点运算,具备高级扩展功能(目前大部分手机都是这个架构)linux

arm64-v8a 第8代,64位,包含AArch3二、AArch64两个执行状态对应3二、64bitandroid

x86 intel 32位,通常用于平板c++

x86_64 intel 64位,通常用于平板(支持 x86 和 x86_64)web

mips 基本没见过(支持 mips)算法

mips64 基本没见过(支持 mips 和 mips_64)windows

  对于手机来讲,目前市面上占到 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 开发。

二、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 优化的原理是什么?

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