作者:刘天宇 (谦风)
系列文章回顾《向工程腐化开炮 | proguard 治理》《向工程腐化开炮 | manifest 治理》《向工程腐化开炮:Java 代码治理》《向工程腐化开炮|资源治理》。本文为系列文章第五篇,聚焦于动态链接库 so,这一细分畛域。对工程腐化,间接开炮!
在 Android 技术畛域,动态链接库 so 个别应用 c /c++ 开发,近年随着 rust 的“闪耀“,无论在 aosp 零碎性能层面,还是 app 利用性能层面,都能看到其身影。但无论应用的开发语言是什么,最终在 apk 和运行时的存在模式,都是合乎 ELF 格局的 so 文件。本文聚焦于动态链接库 so 自身,对 abi 不兼容、反复、抵触、无用导出符号,这几种腐化状况,进行工具研发以及治理实际。
基础知识
本章并不会解说应用 c /c++ 等语言,编写动态链接库 so 的相干常识,而是站在 app 整体层面,尝试以“内部”(非 c /c++ 开发者)视角,来解说近些年在 Android 架构工作中,理解到的一些乏味知识点。
1.1 c++ 规范模版库(STL)
当应用 c ++ 开发动态链接库 so 时,如果应用到 C ++ 规范模版库,就须要指定具体应用哪一个。有以下几种可供选择:
- libc++。LLVM 的 libc++ 是 STL 标准的一种实现,Android 5.0 及当前版本 os 便开始应用此 STL,更近一步,在 ndkr18 开始成为惟一可用 STL。因而,libc++ 也是 Android 官网指定 STL;
- gnustl&gnustl_port。这两个都是 GNU 我的项目提供的 STL 标准实现,在旧版本 ndk 中提供了相干反对,正如上述所讲,ndkr18 后已废除。在以后开发时尽量避免应用此 STL;
- system。Android 零碎内置 STL 标准实现,仅提供 new 和 delete,个别不应用。同样,也在 ndkr18 后废除。
在选定具体 STL 后,还有两种链接形式可供选择:
- 动态链接。动态链接会将应用到的 stl 中代码,链接(拷贝)到 so 中;
- 动静链接。在链接时,并不会将 stl 代码拷贝到 so 中,而是将应用到的 STL 符号,保留在 so 的动静链接符号表中,在运行时绑定并调用这些 STL 中的符号(位于 STL 的 so 中)。
当 app 只有一个 so 时,倡议应用动态链接形式,以减小包尺寸;当 app 蕴含多个 so 时,全副应用动态链接,stl 代码实现会拷贝多份到不同 so 中,这会极大减少包大小,因而应该抉择动静链接。然而须要留神的是,无论是多个 so 动态链接同一个 STL,还是多个 so 动静链接多个不同 STL,都会导致运行时性能异样,甚至引发 crash 的危险,因而,最佳计划是:仅应用一种链接形式,同时,仅应用同一个 STL。
1.2 so 动静链接(依赖)
对于一个 c /c++ 源码开发的模块,如果须要援用其余模块提供的性能,与对 STL 的应用相似,也有动静链接和动态链接两种形式可供选择。这里须要留神的是,如果依赖的这些模块,曾经以动态链接库 so 模式,存在于 apk 中,那么在这里应该抉择动静链接模式;否则,应该应用动态链接模式。如果应用动静链接形式,援用了其它 so 中的符号,在最终 so 中会蕴含这种动静依赖关系的信息。具体来讲,这个信息存在于 so 文件的“.dynamic”段中,咱们能够通过 readelf 工具(比拟罕用的一种)来读取,举一个例子:
Dynamic section at offset 0x2d18 contains 27 entries:
Tag Type Name/Value
0x0000000000000001 (NEEDED) Shared library: [liblog.so]
0x0000000000000001 (NEEDED) Shared library: [libm.so]
0x0000000000000001 (NEEDED) Shared library: [libc++_shared.so]
0x0000000000000001 (NEEDED) Shared library: [libdl.so]
0x0000000000000001 (NEEDED) Shared library: [libc.so]
0x0000000000000001 (NEEDED) Shared library: [libslimlady_core.so]
0x000000000000000e (SONAME) Library soname: [libslimlady.so]
从上述输入能够看到,Type 为 SONAME 的条目,记录了 so 名称。留神,这个 so 名称仅用于其它 so 依赖这个 so 时,在搜寻门路中进行查找,与 so 文件名称不肯定完全一致,然而在 Android 环境下,个别咱们会将这二者保持一致。so 之间的动静依赖关系,记录在 Type 为 NEEDED 的条目中,对上述例子,libslimlady.so 动静链接(依赖)了六个 so,咱们别离来看下都是什么:
- libdl.so。动静链接器,提供动静加载其它 so 能力,Android 平台中的 so,都会蕴含此项依;
- libc.so、libm.so。这个能够认为是 c 语言的根底运行时库,能够认为所有 Android 中应用的 so 都蕴含;
- liblog.so。Android 平台 logcat 日志库,在 c /c++ 代码中如果须要将信息打印到 logcat 中,就须要动静链接这个库,并在代码中调用相干函数;
- libc++_shared.so。这就是上一大节讲到的 LLVM 版本规范模版库 libc++,动静链接模式的 so 名称;
- libslimlady_core.so。这是 apk 中另一个已存在的 so,libslimlady.so 通过动静链接形式,依赖这个 so,从而在代码中能够调用其定义的办法。
事实上,反对上述动静链接的零碎,还反对另一种更加灵便的 so 加载形式,即显式运行时链接。这种链接形式,不会在 so 文件中记录其依赖的 so,而是在运行时依据需要,动静将其它 so 加载进来(dlopen),获取指标符号的地址(dlsym),而后进行调用,在这里不具体开展。
1.3 so 加载过程剖析
接下来,咱们看看一个 so 的根本加载过程,是什么样的。
so 加载过程剖析
当咱们在代码中调用 System.loadLibrary 办法,加载一个 so 时,首先是在 Java API Framework 层查找 so 文件的绝对路径,这个搜寻门路存储地位如下:
- os 小于 6.0 时,位于 BaseDexClassLoade 对象,DexPathList 实例中的 nativeLibraryDirectories 成员变量;
- os 大于等于 6.0 时,位于 DexPathList 实例中的 nativeLibraryPathElements 成员变量。
在找到指标 so 文件的绝对路径后,java 虚构机会判断此 so 是否曾经加载,如果已加载那么间接返回。如果未加载,会持续调用到 nativeloader&linker 层,真正的加载也是在这层中实现。首先,会解析 so 文件头,收集此 so 动静链接的其它 so 汇合,如果为空或者均已加载实现,则持续判断指标 so 是否已加载(这里有并发问题,因而在 native 层会再进行判断),如果未加载便间接进行加载。留神,这里的流程是简化过的,这个动静链接 so 汇合是否均已加载的判断并不存在,实际上是通过遍历 so,并以广度优先准则,逐个实现各级依赖 so 的加载工作。在这个遍历过程中,同样须要依据 so 名称查找 so 文件绝对路径,这个搜寻门路起源如下:
- os 小于 7.0 时,就是在 java 层的搜寻门路中查找;
- os 大于等于 7.0 时,底层 so 的加载引入了 Namespace 概念,每当 BaseDexClassLoader 创立实例时,都会在 nativeloader 层创立一个 Namespace 与之对应,并将 java 层搜寻门路拷贝一份。
不同 os 版本的加载流程,并不完全一致,上述 so 加载过程,是一个形象简化后的示意流程,真实情况要简单很多。此外,so 加载是线程平安的,因而不会呈现一个 so 被加载多份到内存中的问题,也正因为如此,并发加载 so 有可能会导致阻塞期待状况呈现,这一点须要特地留神。另外,如果想要加载非 app 内置 so,有一种计划是在 java 层将外置门路增加进去,如果波及到几个 so 之间的动静链接(依赖)状况,java 层搜寻门路和 native 层搜寻门路不统一问题,绝不可漠视:如何指标 so 不在 apk 中,那么可能导致 so 找不到,如果指标 so 在 apk 中,可能导致外置 so 和内置 so 都被加载到内存状况产生。
好了,基础知识局部,就讲到这里,仅理解这些还远远不够,作为一名 Android 开发者,即便在理论工作中不须要开发 c /c++ 代码,多理解一些动态链接库 so 的相干常识,对全面理解 app 运行机制也大有裨益。举荐一本集体十分喜爱的书:程序员的自我涵养(链接、装载与库)。
治理实际
随着工程模块 & 性能减少,动静链路库 so 腐化逐渐积攒:对 c ++ 规范模版库的应用形形色色,大量动态链接 STL 导致不必要的包大小减少;新增或者更新 so 时,短少必须 abi,导致在对应设施上,因为找不到 so 而解体;偶有产生的反复 so 问题,也对包大小和稳定性等带来负面影响;无用导出符号逐渐积攒,同样导致包大小减少。上述这些问题,都是过往优酷与动态链接库 so“腐化”奋斗中,遇到的理论问题。通过相干工具建设无效的检测能力,并基于此造成日常研发卡口机制,在确保问题零新增前提下,逐渐消化已有存量问题。
在问题定位、排查过程中,疾速获取 so 来自哪个模块,是一个很天然的根本诉求。二、三方模块大量引入,以及 app 工程模块化水平进步,都使上述信息获取的老本变得越来越高。为此,首先开发了模块蕴含 so 列表性能,能够疾速查看指标 so,位于哪个模块(app 工程、subproject 工程、flat aar、内部依赖模块),示例后果:
com.youku.android:YNativer:1.2.20210119.2
|-- libPdora.so
| |-- armeabi
| |-- arm64-v8a
|-- libaua.so
| |-- armeabi
| |-- arm64-v8a
com.alient.media:Alier:2.20210202.16
|-- libalier.so
| |-- armeabi-v7a
| |-- arm64-v8a
此外,前文基础知识局部,也讲到了 so 之间能够具备动静依赖关系。一个 so 依赖哪些其它 so,能够通过相干工具间接查看,并不麻烦,然而站在整个 apk 视角,疾速获取一个 so,被哪些其它 so 所依赖,却并不容易。因而,作为辅助工具,还开发了 so 被依赖关系检测性能,在 apk 全局范畴内,剖析所有 so 之间的这种动静链接(依赖)关系。剖析后果中,仅列出一级依赖,即如果 A ->(依赖)B->(依赖)C,那么列表中,只会蕴含 C <-B,B<- A 这两组依赖,示例剖析后果:
* libc++_shared.so # so 名称
|-- armeabi-v7a () # abi,括号中是指标 abi/so,来自哪些模块,如果括号中是空,阐明在 apk 中不存在。| |-- libhnd.so (com.youku.arch:Hnd:2.8.15) # armeabi-v7a/libhnd.so,位于 Hnd 模块。依赖了 armeabi-v7a/libc++_shared.so。| |-- libslimlady.so (project.extaar.app:slimlady:1.0)
|-- arm64-v8a ()
| |-- Hnd.so (project:app:1.0,com.youku.arch:Hnd:2.8.15)
| |-- libslimlady.so (project.extaar.app:slimlady:1.0)
* libusb100.so
|-- armeabi-v7a (project:library-aar-2:1.0)
| |-- libUVCCamera.so (project:library-aar-2:1.0)
| |-- libuvc.so (project:library-aar-2:1.0)
| |-- libUSBAudioDevice.so (project:library-aar-2:1.0)
接下来,对各个 so“腐化”项的治理实际,逐个解说。
2.1 abi 不兼容
abi 是 application binary interface 的缩写,代表利用二进制接口。不同 Android 设施应用不同的 CPU,而不同 CPU 反对不同的指令集。CPU 与指令集的每种组合都有专属的利用二进制接口 (ABI),对于 Andriod 平台来说,次要差别局部有以下两个:
- 可应用的 CPU 指令集,以及扩大指令集;
- 利用和零碎之间传递数据的标准(包含对齐限度),以及零碎调用函数时,如何应用堆栈和寄存器。
在以后 Android 生态中,次要是 Arm 指令集 CPU,进一步开展则是 32 位和 64 位 arm 指令集。以后新手机设施,根本都是 64 位 cpu,然而因为历史起因,很多 app 都仅反对 32 位 arm,降级 app 到对 64 位 arm 反对,有多方面劣势:
- 性能。64 位 armCPU 对应的指令集,具备更高效的指令执行速度,充分利用这些指令集,能够无效进步 app 应用体验;
- 内存。32 位 app 过程,VirtualMemory 最大值为 2^32,即 4GB,因为 os 等占用,理论可用小于 4GB,随着屏幕分辨率、CPU 计算能力等硬件程度的进步,app 承载越来越简单的性能,因而对虚拟内存的需要也随之进步,进一步,虚拟内存有余导致的 OOM 问题愈发重大。反对 64 位后,在 64 位机型上,VirtualMemory 的限度值将超过 4GB,实践下限可达 2^48,可能极大缓解虚拟内存导致的 OOM 问题。
当然,64 位并非没有一点负面影响,包体积就是其中不可漠视的一项。对于同样的 c /c++/rust 等代码,编译后对应的 64 位 so,因为指令集、数据等占用 Byte 数减少,导致 so 文件也会有显著增大。不出意外的,这并不能阻塞 Android 生态对 64 位 app 反对的步调:googleplay 于 2019 年 8 月 1 日起,要求所有蕴含 so 的新增 & 降级 app,必须反对 64 位 arm,否则无奈通过审核;国内利用商店也相继跟上,例如三星和华为别离在 2020 年启动了相干限度或者推广,时至今日,已有更多利用商店退出到 64 位 app 反对的推动中。
在对 64 位 app 的反对模式上,googleplay 提供了 app bundle 这一组件化技术,将不同 abi 的 so 汇合,作为一个 feature module,商店依据设施进行 apk 组装,而在国内,不同利用商店对此的反对状况并不(也很难)对立。除了 app bundle,另一种对 64 位 app 的反对模式是“分包”:一个 32 位 apk,一个 64 位 apk,利用商店依据终端手机 cpu 信息,主动出现对应 apk,这同样也依赖利用商店反对,也面临反对状况不对立问题。当然,还有第三种反对模式是“合包”:一个 app 内既蕴含 32 位 apk,又蕴含 64 位 apk,这种模式不须要额定反对,然而 apk 大小却极速收缩。
64 位反对计划比照
app bundle& 分包两种模式,对于非应用商店渠道,为了保障 apk 可用性,只有两条路可选:应用 32 位 apk,或者 apk 合包。非应用商店渠道,个别都是真金白银换来的流量,过大的包体积会极大升高下载 & 装置转化率,所以 apk 合包模式很难满足需要,个别状况下只能就义 64 位 apk 带来的用户体验,而不得不应用 32 位 apk。当然,这两年在行业内,对于非应用商店渠道,头部 app 更偏向于应用独立的“极小包”来进行投放,抛开商业和经营等层面不谈,其中的技术实现也是一个比拟有意思的话题,但与本文关联较小,在此也不予开展。
优酷在 2020 年就采纳分包模式,实现了对 64 位 app 的反对,在革新过程中,有不少存量动态链接库,仅蕴含 32 位 so,导致 app 整体无奈兼容 64 位设施。另一方面,如何在 app 性能迭代过程中,始终保持对 32 和 64 位的兼容性,也是一个不小的挑战:无论是 so 的新增,还是现有 so 的降级迭代,都有可能呈现 32 位和 64 位 so 的缺失问题,而不可能每一次工程或者代码的改变,都会全量在 32 位和 64 位 apk 中进行双重验证。
为此,研发了 32/64 位 abi 兼容性检测工具,对于同名 so,当未同时具备 32 位 arm(armeabi 或者 armeabi-v7a)和 64 位 arm(arm64-v8a)时,即断定为 abi 不兼容。更近一步,提供选项,当检测后果不通过时,终止构建过程,造成卡口机制。示例检测后果如下,同时也给出了 so 来自于哪些模块:
libUVCCamera.so
|-- armeabi-v7a ([project:library-aar-2:1.0])
libjpeg-turbo1500.so
|-- armeabi-v7a ([project:library-aar-2:1.0])
优酷在 2021 年 1 月上线 so abi 不兼容卡口至今,累计拦挡 11 次,无效保障对 32/64 位设施的兼容性。事实上,无论应用哪种模式进行 64 位 apk 的反对,这项检测能力和卡口机制,都可能完全一致的施展预期作用。
abi 不兼容治理状况
2.2 反复 so
反复 so,是指雷同 abi 的不同名 so,其文件 md5 值统一,一般来讲这都是同一个 so 改文件名之后的后果。在 apk 构建过程中,反复 so 均会进入到 apk 中,导致包大小减少。此外,一旦被全副加载到内存中,会导致多种运行时危险,起因和第一章讲述的 STL 被多个 so 动态链接相似。示例检测后果如下:
[armeabi-v7a] md5: c0598ed0b87843147152e14bba2b036f
|-- libmitaec.so (com.youku.android:DQI4Android:1.2.0.10)
|-- libNlsAEC.so (com.youku.android:DQI4Android:1.2.0.10)
[armeabi] md5: 66a9cf3fcd1739ad01d637418e97ebc5
|-- libwxjst.so (com.tb.android:ws:0.26.4.45-youku)
|-- libwxjsb.so (com.tb.android:ws:0.26.4.45-youku)
反复 so,也同样提供选项,当检测后果不通过时,终止构建过程,造成卡口机制。在理论迭代过程中,这种状况应该呈现频率较低,毕竟失常开发过程,不会刻意批改一个 so 的文件名。优酷近 1 年多的实际过程中,仅发现存量的两个反复 so,在 2021 年 2 月卡口上线至今,未呈现此类问题导致的拦挡记录。之所以还要研发这样的检测能力,并部署上线对应卡口,是因为像这样不常见且没有任何“蛛丝马迹”的问题,一旦呈现后很难及时发现,可能会存在很久。而这,也正是“工程腐化”中暗藏较深的一种典型问题,不可不防。
2.3 抵触 so
抵触 so,是指雷同 abi 的同名 so,其文件 md5 值不统一。在 apk 构建过程中,雷同 abi 下的同名 so 依据构建配置(packaginggptions),会导致构建失败(default,不容易定位同名 so 来自哪一个模块),或抉择第一个遇到的(pickFirsts,具备“随机性”,会导致不确定性危险)。研发的本项冲突检测性能,次要是为了不便定位抵触 so,来自于哪些模块,因为 Android Gradle Plugin 在构建失败后,并不会给出这个 so,来源于哪些模块。示例检测内容如下:
[armeabi-v7a] libaceManager.so
|-- com.youku.arch:Hnd:2.8.16-SNAPSHOT (43392841f299f7b2e35df4bd85703272)
|-- com.youku.android:ALib:1.0.2 (b7f8d6fc7ba25073e8743c061ed9e92a)
因为 Android Gradle Plugin 默认曾经对此类问题,实现了间接的拦挡(打包失败),因而本项检测能力,并没有部署上线对应卡口,而是在日常工作中,作为一项辅助性能应用。
2.4 无用导出符号
导出符号 (exported symbol),是指在 so 内定义的对象、办法、全局变量,被设置为可被内部代码援用(导入)。而无用导出符号,正是在 apk 全局范畴内的所有 so 中,查找是否存在对此符号的导入(援用),如果没有就属于无用导出符号,能够在 so 构建过程的链接阶段,通过链接选项来进行清理。无用导出符号肯定是在 apk 全局范畴内,才可能失去无效的剖析,因为在各 so 编译阶段,除非是调用链最上层的 so,否则很难确定到底哪些符号没有被内部应用。剖析后果,依照模块、so 名称、abi 逐级展现,示例内容如下:
* project:library-aar-2:1.0
|-- libuvc.so
| |-- armeabi-v7a
| | |-- _uvc_status_callback
| | |-- uvc_print_format_desc_one
| | |-- uvc_find_frame_desc_stream
| | |-- uvc_any2iyuv420SP
| | |-- uvc_print_configuration_desc
| | |-- uvc_get_bus_number
| | |-- uvc_parse_vc_extension_unit
| | |-- uvc_get_stream_ctrl_format_size
| | |-- uvc_yuyv2yuv420P
| |-- arm-v8a
| | |-- _uvc_status_callback
| | |-- uvc_print_format_desc_one
| | |-- uvc_find_frame_desc_stream
对于检测后果,须要留神以下两点:
- JNI 办法已疏忽。os 在绑定 JNI 办法时,会应用到 JNI_OnLoad/JNI_OnUnload,以及所有“Java_”结尾的符号,然而在上述检测算法中,会被误检测为无用,因而在检测后果中,专门进行了剔除,避免出现误检状况;
- 通过 dlsym 形式加载并调用的符号,会被误检为无用,须要结合实际代码性能,进行最终判断。
无用导出符号,思考到存在实践上的误检问题,以及大量无用导出符号,在短期内存在的合理性,并没有进一步造成卡口,而是作为包大小剖析后果中,一个可瘦身项来出现。2021 年 12 月检测能力开发实现后,优酷这边存量无用导出符号约 3.5 万个,在进行了一轮集中式问题散发后,目前曾经降至约 2.8 万个。
2.5 治理全景
至此,对于动态链接库 so,进行了较全面无效的防腐化能力建设和治理。最初,给出一份全景图:
动态链接库 so 治理全景
还能做些什么
事实上,动态链接库 so 作为二进制模式程序代码,蕴含了很多信息,例如在优酷的包大小剖析工具中,将动态链接 STL、链接非标准 STL,做为可瘦身检测项之一,为包瘦身提供无效领导。同时,绝对于 java 的 jvm 字节码,so 的剖析难度要高很多,后续依然有宽泛的摸索空间,例如缺失导出符号、JNI 办法不匹配等。
在 Android 开发畛域,java/kotlin 这一下层技术栈,与 c /c++/rust 等底层技术栈,无论从源码编译过程、调试,还是运行时谬误定位剖析,都有着极大的差别。一方面,so 对应源码很多时候,是同一套代码编译为多端(Android/ios)应用的库,无论是源码还是编译选项,可能都短少对 Android 的深刻优化;另一方面,java/kotlin 代码与 so 相互调用局部,也因为技术栈上的 gap,容易呈现“腐坏”的代码。
这须要从事 Android 畛域的开发者,可能扩大本身的语言 & 技术栈,从而以更全面的视角,写出优良的代码实现。与工程腐化的奋斗,须要不同技术栈开发者,往前迈一小步,突破这种技术边界导致的腐化问题,与诸君共勉。
【参考文档】
- 【Book】程序员的自我涵养(链接、装载与库)
- 【google】C++ Library Support:https://developer.android.com…
关注【阿里巴巴挪动技术】微信公众号,每周 3 篇挪动技术实际 & 干货给你思考!