Android修炼系列(37),so 动态加载的思考

3,583

之前写了一篇关于 APK 瘦身的文章:

Android修炼系列(22),我的 apk 瘦身知识点

里面提到了关于 so 部分的优化,在实际的项目里,so 库文件一直都是包体积难以减小的杀手。在构建过程中,apkbuilder 会将 so 文件和 dex 文件等一起打包成 apk 文件,在这个过程里,系统会对 so 文件进行压缩,算法采用的是级别为 8 的 zip deflate 压缩算法。关于 so 的优化,常用的主要有几种:

  1. 对于 so 文件,我们可以根据需要,减少对于 cpu 平台的支持。需要注意的一点是,现在 google 要求app 适配 v8a 了,国内的 vivo、小米等商店也开始推进 v8a 的适配了:
    ndk {
        abiFilters 'armeabi-v7a', 'arm64-v8a'
    }
  1. 对于 so 的占用包体积优化,我们还可以采用动态下发 so 的方式。

  2. 相对于 zip 压缩,我们可以采用压缩率更好的 7z lzma 算法压缩,基本单个文件的压缩率能提高约 10%,但有点需要注意,使用 7z 压缩后的 so,在加载之前,需要进行7z解压操作,这里会有一个耗时,建议此时项目加载 so 采用异步方式。

本节就来介绍下,我们将如何进行 so 文件的动态加载呢?

动态加载so

选择下发so名单

在介绍加载方案之前,我们先来思考下,我们要如何选择要下发的 so 库呢?即使我们需要减小包体积,但了稳定性考虑,肯定也不能一股脑的都采用动态下发的方式吧。

一般我们是通过 so 的加载频率来给 so 划优先级的,大致分为 3 类:

  1. 对于使用频率高,或关键功能的 so,一般不采用动态下发,直接留在apk内,随安装包发布。

  2. 对于使用低频的,不常用的 so,我们可以采用预下载的方式,即 App 启动时就要检查和下载对应 so。

  3. 对于基本不用的so,就可以啥时侯用啥时候再去检查下载即可。

那怎么统计 so 的使用频率呢?我们可以采用埋点上报的方式,但这种方式统计不了三方库内的 so,这时如有必要,可以使用字节码插桩的方式。

选择so加载方案

so 的加载方式,java方式就两种:

  1. 采用 System.load("libxxx.so")

  2. 使用 System.loadLibrary("xxx")

System.load 方法简单,直接传入本地 so 文件路径即可使用,但缺点也明显,就是顾不到三方库内的 so 文件,一般三方库内的 so 加载都是使用的 System.loadLibrary 的,如果采用字节码插桩方式将三方库内的 System.loadLibrary 都改成 System.load,且不说方案好不好,这本身就是有实现成本的。

这样说,System.loadLibrary 确实是不错的选择,但我们不传入 so 文件地址,本地的 so 文件,又如何被识别呢?

  // 如,libhello.so 的存储在了 /data/user/0/com.blog.a/app_libs 目录下了
  System.loadLibrary("hello");

很自然的想到,System.loadLibrary 加载 so 文件的时候,会去 ClassLoader 的 libs 路径数组里查找并加载 so,那是不是也让它在我们的自定义路径下也查找一遍就行了。

基于此,我们可以直接通过反射的方式将我们的自定义路径,注入 ClassLoader 管理的路径数组里即可。

那这个路径数组在哪里呢?就不贴源码了,感兴趣的可以去看下,传送门,简单写了下调用链:

System.loadLibrary("hello");
->Runtime#loadLibrary0(Reflection.getCallerClass(), libname);
->Runtime#loadLibrary0(classLoader, fromClass, libname);
->BaseDexClassLoader#findLibrary(libraryName)
->DexPathList#findLibrary(libraryName)

这里是关键代码,我们能看到 findLibrary 时,会遍历 natieLibraryPathElements 查找,那这个 natieLibraryPathElements 是什么呢?

[> src/main/java/dalvik/system/DexPathList.java]

image.png

通过下图可知,natieLibraryPathElementsnativeLibraryDirectoriessystemNativeLibraryDirectories 两者的并集,其中 nativeLibraryDirectories 内存放了 App 内所有 Native 库的路径,systemNativeLibraryDirectories 内存放了系统 Native 库的路径。

image.png

具体的反射方法,可以查看 tinker, 很全,而且适配了不同系统版本,这里以 version25 为例:

[tinker-android/tinker-android-lib/.../TinkerLoadLibrary.java]

private static void install(ClassLoader classLoader, File folder) throws Throwable {
    // step1: 获取 pathList
    final Field pathListField = ShareReflectUtil.findField(classLoader, "pathList");
    final Object dexPathList = pathListField.get(classLoader);
    // step2: 拿到我们的 nativeLibraryDirectories
    final Field nativeLibraryDirectories = ShareReflectUtil.findField(dexPathList, "nativeLibraryDirectories");
    List<File> origLibDirs = (List<File>) nativeLibraryDirectories.get(dexPathList);
    // step3: 如果 origLibDirs 内有我们的路径了,移除掉
    ...
    // step4: 将我们的路径放在集合首位,这样会优先加载,想想是不是也可以实现so的替换呢?
    origLibDirs.add(0, folder);
    // step5: 获取 pathList 系统路径集合
    final Field systemNativeLibraryDirectories = ShareReflectUtil.findField(dexPathList, "systemNativeLibraryDirectories");
    List<File> origSystemLibDirs = (List<File>) systemNativeLibraryDirectories.get(dexPathList);
    // step6: addAll 两者所有路径
    final List<File> newLibDirs = new ArrayList<>(origLibDirs.size() + origSystemLibDirs.size() + 1);
    newLibDirs.addAll(origLibDirs);
    newLibDirs.addAll(origSystemLibDirs);
    // step7: 生成一个新的 natieLibraryPathElements 集合
    final Method makeElements = ShareReflectUtil.findMethod(dexPathList, "makePathElements", List.class);
    final Object[] elements = (Object[]) makeElements.invoke(dexPathList, newLibDirs);
    // step8: 覆盖掉原有的路径集合
    final Field nativeLibraryPathElements = ShareReflectUtil.findField(dexPathList, "nativeLibraryPathElements");
    nativeLibraryPathElements.set(dexPathList, elements);
}

代码很少,看完后我们想想,我们明明可以直接通过反射做到修改 nativeLibraryDirectories 的值,那为何还要创建一个新的集合来覆盖呢?这是因为 nativeLibraryDirectories 就是一个 ArrayList< File > ,是线程不安全的,所以要避免并发修改异常 ConcurrentModificationException,代价就是会出现并发读脏数据问题,但影响不大。

link依赖问题

我们知道,在 Native 开发的时候,如果 libA.so 依赖了 libB.so,则要在 cmake 中配置 target_link_libraries ,则能 link A to B,具体方法可看下我之前写的关于编译so库的博客

# 通过 link 可将源文件构建的库和三方库都加载进来
target_link_libraries( # 源文件库的名字
                       A
                       # 引用的三方库
                       B
                       # 三方库log, included in the NDK.
                       ${log-lib} )

现有场景:

我们动态下发 libA.so 和 libB.so 到本地,并通过上面方法,将自定义路径注入到 nativeLibraryDirectories 内。

正常情况下,如果我们通过 System.loadLibrary("A") 来加载 libA.so,那么系统应该会自动通过 A 的依赖信息,调用 dlopen 来帮我们加载 libB.so,我们根本不需要管理 libB.so 的加载才对。

Android N 之前也确实是这样的,但不幸的是,N 以后 直接调用 System.loadLibrary("A") 就会执行报错:

java.lang.UnsatisfiedLinkError: dlopen failed: library "libB.so" not found

为啥会这样呢?

引用 cloud.tencent.com/developer/a…

Android Native 用来链接 so 库的 Linker.cpp dlopen 函数 的具体实现变化比较大(主要是引入了 Namespace 机制):以往的实现里,Linker 会在 ClassLoder 实例的 nativeLibraryDirectories 里的所有路径查找相应的 so 文件;更新之后,Linker 里检索的路径在创建 ClassLoader 实例后就被系统通过 Namespace 机制绑定了,当我们注入新的路径之后,虽然 ClassLoader 里的路径增加了,但是 Linker 里 Namespace 已经绑定的路径集合并没有同步更新,所以出现了 libA.so 文件能找到,而 libB.so 找不到的情况。

至于 Namespace 机制的工作原理了,可以简单认为是一个以 ClassLoader 实例 HashCode 为 Key 的 Map,Native 层通过 ClassLoader 实例获取 Map 里存放的 Value(也就是 so 文件路径集合)。

那我们要怎么解决呢?其实如果动态下发的 so 库的依赖关系,我们都清楚,我们只需要按照顺序手动加载即可,要注意顺序,顺序不对,就会直接crash:

// 在加载 A 之前,手动将依赖库 B 先加载起来就ok
System.loadLibrary("B")
System.loadLibrary("A")

实测是有效的。

但这个方案的缺点也很明显,那就是必须要知道 so 库的依赖关系,如果我们要动态下发的是个三方库,就会很麻烦,需要不断尝试加载顺序。

好的是,目前已有了多种解决方案:

  1. 自定义 System#load,加载 libxxx.so 前,先解析 libxxx.so 的依赖信息,再递归加载其依赖的 so 文件,就不用我们手动梳理了(推荐 SoLoader
  2. 自定义 Linker,完全自己控制 so 文件的检索逻辑,从根本上解决 link 情况加载失效问题(推荐 ReLinker
  3. 类似 Tinker,在合适的时机替换 ClassLoader 实例,新的 ClassLoader 实例绑定最新的路径集合。

好了,具体方案实现就不说了,感兴趣的自己看吧。

本节完。