关于So库的动态加载

在Android日常开发中,经常需要用到一些第三方so库,往往so库的内存都挺大的(相对于APK的体积来说),随着so库使用越多,在整个APK体积中的占比就越多,为了解决这一问题就诞生出了so库的动态加载。

文章中代码使用的Demo

1. So库的加载原理

首先我们需要了解,系统加载So库的工作流程,当我们调用System.loadLibrary(“XXX”)后,Android的Framework层都干了什么?Android的So加载机制,大致可分为四个步骤:

  1. 安装APK的时候,PMS根据当前设备abi信息,从APK包拷贝相应的So文件
  2. 启动APP的时候,Android Framework创建应用的classloader实例,并将当前应用的相关所有so库文件所在的目录注入到当前的ClassLoader中。
  3. 调用System.loadLibrary("XXX")framework从上下文ClassLoader实例目录数组中查找并加载名为libXXX.so的文件
  4. 调用so库相关的JNI文件。

步骤1和2不熟悉的朋友,可以自行阅读相关源码和资料。这里我们直接分析第三步。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class System{
public static void loadLibrary(String libname) {
Runtime.getRuntime().loadLibrary0(Reflection.getCallerClass(), libname);
}
}
public class Runtime{
private synchronized void loadLibrary0(ClassLoader loader, Class<?> callerClass, String libname) {
if (libname.indexOf((int)File.separatorChar) != -1) {
throw new UnsatisfiedLinkError(
"Directory separator should not appear in library name: " + libname);
}
String libraryName = libname;

if (loader != null && !(loader instanceof BootClassLoader)) {
String filename = loader.findLibrary(libraryName);//加载so库
if (filename == null &&
(loader.getClass() == PathClassLoader.class ||
loader.getClass() == DelegateLastClassLoader.class)) {

filename = System.mapLibraryName(libraryName);
}
if (filename == null) {
throw new UnsatisfiedLinkError(loader + " couldn't find \"" +
System.mapLibraryName(libraryName) + "\"");
}
String error = nativeLoad(filename, loader);//加载so文件
if (error != null) {
throw new UnsatisfiedLinkError(error);
}
return;
}

}
}

BaseDexClassLoader

1
2
3
4
5
6
7
8
9
10
11
 public class BaseDexClassLoader{
@UnsupportedAppUsage
private final DexPathList pathList;
//...
@Override
public String findLibrary(String name) {
return pathList.findLibrary(name);
}

//...
}

DexPathList

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class DexPathList{

/** list of native library directory elements */
private final File[] nativeLibraryDirectories;
//...
public String findLibrary(String libraryName) {
String fileName = System.mapLibraryName(libraryName);

for (NativeLibraryElement element : nativeLibraryPathElements) {
String path = element.findNativeLibrary(fileName);

if (path != null) {
return path;
}
}

return null;
}
//...
}

System.loadLibrary方法时,系统最终会调用到DexPathList类的findLibrary方法,该方法会在nativeLibraryPathElements数组中查找对应的路径,我们将自己的so加入到nativeLibraryPathElements最前面,由此达到动态加入so的目标。
可以通过如下,(注意不同的Android版本实现的方法略不同,需要做好适配,可查看此处的代码)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
private object V25 {
@Throws(Throwable::class)
fun install(classLoader: ClassLoader, folder: File) {
val pathListField = ReflectUtil.findField(classLoader, "pathList")
val dexPathList = pathListField[classLoader]
val nativeLibraryDirectories =
ReflectUtil.findField(dexPathList, "nativeLibraryDirectories")
var origLibDirs = nativeLibraryDirectories[dexPathList] as MutableList<File>
if (origLibDirs == null) {
origLibDirs = ArrayList(2)
}
val libDirIt = origLibDirs.iterator()
while (libDirIt.hasNext()) {
val libDir = libDirIt.next()
if (folder == libDir) {
libDirIt.remove()
break
}
}
origLibDirs.add(0, folder)
val systemNativeLibraryDirectories =
ReflectUtil.findField(dexPathList, "systemNativeLibraryDirectories")
var origSystemLibDirs = systemNativeLibraryDirectories[dexPathList] as List<File>
if (origSystemLibDirs == null) {
origSystemLibDirs = ArrayList(2)
}
val newLibDirs: MutableList<File> =
ArrayList(origLibDirs.size + origSystemLibDirs.size + 1)
newLibDirs.addAll(origLibDirs)
newLibDirs.addAll(origSystemLibDirs)
val makeElements = ReflectUtil.findMethod(
dexPathList,
"makePathElements",
MutableList::class.java
)
val elements = makeElements.invoke(dexPathList, newLibDirs) as Array<Any>
val nativeLibraryPathElements =
ReflectUtil.findField(dexPathList, "nativeLibraryPathElements")
nativeLibraryPathElements[dexPathList] = elements
}
}

2. 需要注意的问题

2.1 依赖库dlopen失败问题

如果我们使用了2个so文件:LibA.so和LibB.so,libA依赖libB。当我们调用系统的System.loadLibrary("A")的时候,Android Framework最终会使用dlopen加载LibA.so 文件,并通过其依赖关系的信息自动使用dlopen加载libB.so
在Android N以前,只要将LibA.so和LibB.so所在的文件目录路径注入到当前的classLoader的nativeLibraryPathElements里,则加载so文件的时候,这两个文件都能正常被找到。从 N 开始,libA.so 能正常加载,而 libB.so 会出现加载失败错误。具体原因是Android Native用来链接so库的Linker.cpp dlopen函数有改动,以前Linker会从ClassLoader实例的nativeLibraryElements里的所有路径查找相应的so文件。更新后,更新之后,Linker 里检索的路径在创建 ClassLoader 实例后就被系统通过 Namespace 机制绑定了,当我们注入新的路径之后,虽然 ClassLoader 里的路径增加了,但是 Linker 里 Namespace 已经绑定的路径集合并没有同步更新,所以出现了 libA.so 文件能找到,而 libB.so 找不到的情况。
面对这个问题有以下几种解决方案:

  • 自定义System.loadLibrary,加载So文件前,先解析So的头文件中的依赖信息,再递归加载依赖的So文件,比如说SoLoader开源库就如此。我自己实现的Demo也是这么做的
  • 自定义Linker,完全自己控制So文件的检索逻辑,这就是开源库Relinker
  • 替换classLoader,但不建议这么做
  • 2.1 如何动态化so库相关流程

为了在开发阶段调试简单,第三方so库会存放在lib文件夹下,还是会用Cmake编译So库,最终所有的打包进APK中,代码中加载So库的方式还是用系统的System.loadLibrary("xxx")。最终发布正式版本的时候难道需要人工把so库文件从移除掉,代码中System.loadLibrary("xxx")手动修改吗?
为了解决这些问题可以从AGP构建流程中着手

2.2 通过variant类型移除so库

我看网上很多方案通过在merge${Variant.name}NativeLibs(合并所有依赖的native库)strip${Variant.name}DebugSymbols(从Native库中移除debug的标志)这两个Task之间插入一个自定义task,用来删除merge${Variant.name}NativeLibs这个任务中需要动态下发So库文件。但这样就会破坏Gradle的缓存机制。其实官方已经给我提供了移除SO库的配置,我们可以在构建Release包的情况下使用如下方案:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
androidComponents.onVariants { variant ->
println("onVariant---${variant.name}")
val abi = arrayListOf("lib/arm64-v8a/", "lib/armeabi-v7a/", "lib/x86/", "lib/x86_64/")
val excludeSo = arrayListOf("libnativeLib.so", "libnative1.so")

if (variant.name.contains("release", true)) {
// println("${variant.externalNativeBuild?.abiFilters?.get()}")
abi.forEach { abiPath ->
excludeSo.forEach { so ->
println("$abiPath$so")
variant.packaging.jniLibs.excludes.add("$abiPath$so")
}
}
}
}

ASM替换System.loadLibrary

通过instrumentation API(AGP 7.0之前使用 Transform API )字节码“插桩”的方式把项目中的System.loadLibrary("xxx")替换成你自己封装的So库加载方式:DynamicSoHelp.loadSoLibrary("xxx")。具体如何使用instrumentation API字节码替换,由于篇幅问题就不介绍了。

3.其他

还有很多其它细节需要考虑,比如so库的上传下载,压缩和解压以及安全校验,整个流程中减少人为的操作,尽量通过自动化的流程来完成。在我思考这个过程中,在网上看到货拉拉关于So库动态加载系统化文章,很建议大家阅读。虽然方案没有开源,但介绍的的很详细,可以借鉴一下。