我的 Android 重构之旅:动态下发 SO 库(上)

由于公司的业务不断拓展,生产环境的 APK 大小也从我最初进入公司时的 70M 变为了160MB ,在分析了 APK 结构目录之后,常规的压缩方案已经收效甚微了,动态加载第三方的 SO 文件是下一个优化的重点。SO 文件本质上就是一种可动态加载并执行的文件,所以将 SO 动态下发没有技术风险,但是要将它从 APK 中剔除并保证稳定性并不是一件易事。

从0到1需要解决那些问题?

对于从 0 到 1 开发一套方案我们先把相关技术点先提出来,再带着问题去看看这些方案的解决思路这样开发起来时最高效的。对于动态下载 SO 库我们对它的基本期望是 APK 中不包含 SO 文件,这样就引申出了问题点:

  • 如何移除 APK 中的 SO 文件?

同时,我们希望能够兼容第三方 SDK 这样就出现了

  • 如何保证第三方 SDK 的 SO 不存在时的正常运行?

另外我们还希望 SO 版本发生变化了也能够不需要人工维护

  • 如何维护 SO 文件的正确性

只要解决了以上的几个问题,大致的动态下发框架就搭建完成了。


❌如何移除 APK 中的 SO 文件?

看到移除 SO 文件可能有些同学会说“啊这不是很简单么,只要把 libs 目录删掉就好了呀“,但是如果这样做的话我们就木有办法剔除 AAR 当中的 SO 文件,还有 SO 文件变化需要人工维护等问题,所以出于各种考虑编译时期动态剔除 SO 文件都是最优解。

Android Gradle Plugin 编译流程

在最新的编译流程图中,我们可以看到 Android Gradle Plugin 对资源文件进行了 Compiled Resouces 操作,同时在平常在编译的过程中在 Android Studio 的 Build 面板会输出很多 Task 的日志其中不乏有资源相关的字眼:

编译时机

由此,我们可以大胆假设一下,Android Gradle Plugin在打包的工程中是否有专门的 Task 是处理资源相关的逻辑?如果有了这个 Task 我们是不是就能过在这之前 or 之后进行 SO 文件剔除呢?

在查阅官方的文档资料后,终于找到了俩处专门用于处理 SO 文件的 Task,确实正如我们所想,在编译的过程中Android Gradle Plugin会整合不同目录的 SO 文件最终汇总至一起:

Task 对应实现类 作用 结果保存目录
mergeDebugNativeLibs MergeNativeLibsTask 合并所有依赖的 native 库 intermediates/merged_native_libs
stripDebugDebugSymbols StripDebugSymbolsTask 从 Native 库中移除 Debug 符号。 intermediates/stripped_native_libs

对于我们来说只要删除对应目录中的 SO 文件,最终打出来的 APK 中就不会包含该文件。

按最优解来说,应该在stripSymbols结束后去剔除 stripped_native_libs 目录下的文件,但是担心不同版本的Android Gradle Plugin会对这一步做不同的操作,所以决定选用mergeNativeLibs结束后去剔除原始的 so 来保证文件的 MD5 不发生变化,另外因为第三方 SO 一般都是 Release 编译出来的,就算进行了stripDebugDebugSymbols也不会有太大效果。

同时,为了能够让 APK 运行之后能够获取到 SO 文件,我们需要将被剔除的 SO 文件上传至远端,以供后面获取使用。


✅保证 SO 不存在时的稳定性

常常用第三方 SDK 的同学肯定知道,很多第三方的 SDK 要求应用启动时就完成初始化,它们内部往往一初始化就调用了System.loadLibrary() 方法进行 SO 的加载,如果这时候 SO 被我们剔除了那么系统就会出现UnsatisfiedLinkError的闪退,虽然有少部分 SDK 例如 MMKV 有提供初始化的回调供我们修改加载方法,但是这毕竟是少数情况,还是要想办法修改第三方 SDK 的加载方式。

由于我们无法直接修改第三方 SDK 的源码,这时候我们就只能依靠动态字节码也就是所谓的 AOP 在编译时期对第三方 SDK 进行修改了。常见的方式有很多例如AspectJJavassistASM等等,但是它们在使用上或多或少都有点麻烦,本着不(图)重(省)复(事)造轮子的原则,直接上 GitHub 上找了个基于Javassist封装的工具DroidAssist,利用它我们可以很轻易的就替换掉第三方 SDK 中的加载代码,配置如下:

    
        
        
            
                void System.loadLibrary(java.lang.String)
            
            
                
                hb.dynamic.NativeLibraryStore.getInstance().securityLoadNativeLibrary($1);
            
            
                
                com.meitu.mtlab.*
                org.webrtc.*
                com.zego.*
                com.faceunity.*
                io.agora.*
            
        
    

加载外部的 SO 文件

光解决了 SO 加载不闪退还是不够的,平常的开发过程中都是通过系统的方法System.loadLibrary()加载 SO ,这时候如果我们自己加载外部目录的 SO 文件就可能出现系统找不到文件、SO 间的互相依赖无法成功等问题。由于动态加载 SO 文件相关的技术在插件化、热修复框架中以及相当成熟了,在参考了市面上主流框架的实现之后总结了以下俩种方式:

  • 重新构建一个 ClassLoader 将 SO 的地址传入 LibrarySearchPath 当中,并替换掉原先的 ClassLoader。
  • 使用反射 ClassLoader 将 SO 包的地址写入 LibrarySearchPath 当中 。

这俩种方案都不可避免的修改到 ClassLoader ,由于担心替换 ClassLoader 的风险这里选择了反射修改 LibrarySearchPath 地址 TinkerLoadLibrary#installNativeLibraryPath(ClassLoader, File),正当我以为已经完美解决的时候,在 Android N 版本以上的手机出现了闪退:

E/ExceptionHandler: Uncaught Exception java.lang.UnsatisfiedLinkError: dlopen failed: library "libpldroid_beauty.so" not found
at java.lang.Runtime.loadLibrary0(Runtime.java:)
at java.lang.System.loadLibrary(System.java:)

在查阅相关资料后发现由于 Android N 更改了 SO 文件路径的寻找方式,恰巧我们的libpldroid_beauty.so依赖了另外一个日志输出的文件liblog.so导致了libpldroid_beauty.so寻找不到。

Android Native 用来链接 so 库的 Linker.cpp dlopen 函数 的具体实现变化比较大(主要是引入了 Namespace 机制):以往的实现里,Linker 会在 ClassLoder 实例的 nativeLibraryDirectories 里的所有路径查找相应的 so 文件;更新之后,Linker 里检索的路径在创建 ClassLoader 实例后就被系统通过 Namespace 机制绑定了,当我们注入新的路径之后,虽然 ClassLoader 里的路径增加了,但是 Linker 里 Namespace 已经绑定的路径集合并没有同步更新,所以出现了 libxxx.so 文件能找到,而 liblog.so 找不到的情况。
至于 Namespace 机制的工作原理了,可以简单认为是一个以 ClassLoader 实例 HashCode 为 Key 的 Map,Native 层通过 ClassLoader 实例获取 Map 里存放的 Value(也就是 so 文件路径集合)。

如果想解决这个问题,思路有这么几种:

  • 自定义 System.loadLibrary,加载 SO 前,先解析 SO 的依赖信息,再递归加载其依赖的 SO 文件 SoLoader。
  • 自定义 Linker,完全自己控制 SO 文件的检索逻辑 ReLinker。
  • 替换 ClassLoader 。

由于项目中使用到的 SoLoaderLinker都可以解决这个问题,在权衡了俩种的调用方式之后这里使用SoLoader作为 SO 的加载工具。


如何维护 SO 文件的正确性

由于 SO 文件经常会发生变更,我们希望保证每个版本的 APK 都能加载到对应版本的 SO 文件,为此需要在 APK 中包含一份“基准文件”,用于确认 SO 信息与校验文件安全。

基准文件格式
{
    "uploadTime": xx,
    "soFile": [
        {
            "soMD5": "xxx",
            "soName": "MTlabKit",
            "version": 1,
            "url": "https://xxx",
            "soSize": xx
        }
    ]
}

基准文件包含了文件的 md5 信息、版本号、下载地址、文件大小等信息,在我们加载 SO 文件前先读取“基准文件”,确认 SO 信息正确之后再将它加载至内存当中。
鉴于目前 SO 文件剔除流程是在编译时期做的,我们也顺理成章的将基准文件生成放到编译时期,利用 Android Gradle Plugin 会 mergedAssets 资源的逻辑,我们将基准文件保存至 merged_assets 下,自然会打包至 APK 当中。

Task 作用 结果输出目录
mergeDebugAssets 合并所有 assets 文件 intermediates/merged_assets/

每次打包时,将先读取上次的“基准文件信息”后和本次剔除的 SO 文件的 md5 进行比对后判断文件是否发生了变化,如果发生变化了,重写“基准文件”。
APK 运行时将读取“基准文件”的信息,这是加载 SO 文件的唯一信息。

基准文件生成流程

小结

至此,我们已经将 SO 文件的移除、安全加载、版本跟踪的思路整理了出来,下面我们来一起回顾下。

  1. 如何移除 APK 中的 SO 文件?
    mergeNativeLibs Task 之后移除 merged_native_libs 目录当中需要剔除的 SO 文件。

  2. 如何保证第三方 SDK 的 SO 不存在时的正常运行?
    利用动态字节码技术Javassist替换系统的 SO 方法保证文件不存在也不会发生闪退。

  3. 如何维护 SO 文件的正确性?
    编译时期根据 SO 文件信息生成基准文件,APK 运行时依靠读取基准文件保证正确性。

至此上篇的内容就结束了,下篇的内容将着重的介绍代码的实现以及实现过程中遇到的问题与细节的完善。由于时间关系,难免有些问题或 BUG 出现,欢迎大家指出缺点与问题。咱们下期见~


参考资料

  1. 动态下发 so 库在 Android APK 安装包瘦身方面的应用
  2. Gradle构建过程
  3. Apk 打包流程

你可能感兴趣的:(我的 Android 重构之旅:动态下发 SO 库(上))