深入理解Android热修复技术原理之so库热修复技术

一、SO库加载原理

Java Api 提供以下两个接口加载一个 so 库

  • System. loadLibrary (String libName):传进去的参数:so库名称, 表示的so 库文件,位于apk压缩文件中的 libs 目录,最后复制到 apk安装目录下。
  • System, load (String pathName):传进去的参数: so库在磁盘中的完整 路径。加载一个自定义外部 so库文件。

上述两种方式加载一个 so 库,实际上最后都调用 nativeLoad 这个 native方法去加载 so库,这个方法的 fileName:so 库在磁盘中的完整路径名。

代码+图文的方式简述 so 库加载原理,下面的代码示例,stringFromJNI -> Java_com_taobao_jni_MainActivity_stringFromJNI 静态注册的 native 方 法,test->test 动态注册的 native 方法。

深入理解Android热修复技术原理之so库热修复技术_第1张图片

深入理解Android热修复技术原理之so库热修复技术_第2张图片

我们知道 JNI 编程中,动态注册的 native 方法必须实现 JNI_OnLoad方法,同时实现一个JNINativeMethod [] 数组,静态注册的 native 方法必须是Java+类完整路径+方法名的格式。

深入理解Android热修复技术原理之so库热修复技术_第3张图片

总结下:

  • 动态注册的 native 方法映射通过加载 so 库过程中调用 JNI_onLoad 方法调用完成。
  • 静态注册的 native 方法映射是在该native方法第一次执行的时候才完成映射,当然前提是该 so 库已经 load 过。

二、SO库热部署实时生效可行性分析

2.1、动态注册 native 方法实时生效

前面我们分析过 so 库的加载原理,我们知道动态注册的 native方法调用一次 JNI_OnLoad 方法都会重新完成一次映射,所以我们是否只要先加载原来的 so库, 然后再加载补丁 so 库,就能完成Java层 native 方法到 native 层 patch后的新方法映射,这样就完成动态注册native 方法的 patch 实时修复。一张图说明

深入理解Android热修复技术原理之so库热修复技术_第4张图片

实测发现 art 下这样是可以做到实时生效的,但是 Dalvik下做不到实时生效,通 过代码测试我们发现,实际上Dalvik 下第二次 load补丁 so库,执行的仍然是原来so 库的 JNI_0nLoad方法,而不是补丁so 库的 JNI_OnLoad 方法,所以 Dalvik 下做不到实时生效。我们来简单分析下,既然拿到的是原来 so 库的 JNI_OnLoad方法,那么我们首先怀疑以下两个函数是否有问题。

  • dlopen() :返回给我们一个动态链接库的句柄
  • disym() :通过一个 dlopen 得到的动态连接库句柄,来查找一个 symbol

首先来看下 Dalvik 虚拟机下面 dlopen 的实现,源码在 /bionic/linker/dlfcn.cpp 文件,方法调用链路:dlopen -> do_d.lopen -> find_library -> find_library_internal

深入理解Android热修复技术原理之so库热修复技术_第5张图片

findloadedlibrary 方法判断 name 表示的 so库是否已经被加载过,如果加载过直接返回之前加载 so库的句柄,没有加载过,调用 load_library尝试加载 so库

深入理解Android热修复技术原理之so库热修复技术_第6张图片

看代码注释,也知道其实这是Dalvik虚拟机下的一个 bug,这里它是通过 basename 去做查找,传进来的参数 name 实际上是 so库所在磁盘的完整路径,比如此时修复后的so库的路径为 /data/data/com. taobao. jni/files/libnative-lib.so。但是此时是通过 bname : libnative-lib.so 作为 key 去查找, 我们知道第一次加载原来的 so库 System.loadLibrary ( "native-lib");实际上已经在solist表中存在了 native-lib 这个 key,所以 Dalvik下面加载修复后的补丁so拿到的还是原so库文件的句柄,所以执行的仍然是原来 so库的JNI_ OnLoad方法,Art下不存在这个问题,是因为Art下这个地方是以name作为key 去查找而不是bname,所以art 重新load —遍补丁 so库:拿到的是补丁 so库的句柄,然后执行补丁库的JNI OnLoad。

所以为了解决 Dalvik 下面的这个问题,那么如果尝试对补丁 so进行改名,比如 此处补丁so 库的完整路径修改之后变成 /data/data/com.taobao.jni/files/ libnative-lib-123333.so,后面一串数字是当前时间戳,确保这个 bname是全局唯一的,按照上面的分析,在solist 中查找的 key已经是唯一的,所以此时可以做到Dalvik 下面动态注册的 native 方法的实时生效。

2.2、静态注册 native 方法实时生效

上面通过尝试对补丁 so库进行重命名为全局唯一的名称可以确保第二次加载补丁so 库可以做到 Dalvik 下和 Art下动态注册方法的实时生效,但要做到静态注册 native 方法的实时生效还需要更多工作。

前面我们说过静态注册 native 方法的映射是在 native方法第一次执行的时候就完成了映射,所以如果native方法在加载补丁 so 库之前已经执行过了,那么是否这种时候这个静态注册的 native 方法一定得不到修复?幸运的是,系统 JNI API提供 了解注册的接口。

深入理解Android热修复技术原理之so库热修复技术_第7张图片

UnregisterNatives 函数会把 jclazz 所在类的所有 native 方法都重新指向为 dvmResolveNativeMethod,所以调用 UnregisterNatives 之后不管是静态注册还是动态注册的native方法之前是否执行过在加载补丁 so的时候都会重新去做映射。所以我们只需要以下调用。

这里有一个难点,因为 native 方法的修改是在 so库中,所以我们的补丁工具很难检测出到底是哪个Java 类需要解注册 native 方法。这个问题暂且放下。假设我们能知道哪个类需要解注册native方法,然后 load补丁 so库之后,再次执行该 native 方法,这样看起来是可以让该 native方法实时生效,但是测试发现,在补丁 so 库重命名的前提下,java 层 native 方法可能映射到原so库的方法,也可能映射到补丁 so 库的修复后的新方法。

首先静态注册的 native方法之前从未执行,首先尝试解析该方法。或者调用了 unregisterJNINativeMethods 解注册方法,那么该方法将指向 meth->nativeFunc = dvmResolveNativeMethod,那么真正运行该方法的时候,实际上执行的是dvmResolveNativeMethod 函数。这个函数主要完成 java 层 native方法和native 层方法的映射逻辑。

深入理解Android热修复技术原理之so库热修复技术_第8张图片

深入理解Android热修复技术原理之so库热修复技术_第9张图片

gDvm.nativeLibs 是一个全局变量,它是一个hashtable,存放着整个虚拟机加载 so库的 SharedLib 结构指针。然后该变量作为参数传递给 dvmHashForeach 函数进行 hashtable 遍历。执行 findMethodInLib 函数看是否找到对应的 native函 数指针,如果第一个找到就直接return,不在进行下次的查找。

这个结构很重要,在虚拟机中大量使用到了 hashtable 这个数据结构,hashtable 的实现源码在 dalvik/vm/Hash.h 和 dalvik/vm/Hash.cpp 文件中,有兴趣可以自行查看源码,这里不进行详细分析。hashtable的遍历和插入都是在 dvmHashTableLookup 方法中实现,简单说下 java.hashtable 和 c.hashtable 的异同点:

  • 共同点:两者实际上都是数组实现,hashtable容量如果超过默认值都会进行扩容,都是对key进行hash计算然后跟hashtable的长度进行取模作为 bucket。
  • 不同点:Dalvik 虚拟机下 hashtable put/get操作实现方法,实际上实现要 比java hashmap 的实现要简单一些,java hashmap 的 put实现需要处理 hash冲突的情况,一般情况下会通过在冲突节点上新增一个链表处理冲突, 然后get实现会遍历这个链表通过equals方法比较value是否一致进行查找,davlik 下 hashtable 的 put 实现上 (doAdd=true) 只是简单的把指针 下移直到下一个空节点。get 实现 (doAdd=false) 首先根据 hash值计算出 bucket 位置,然后通过 cmpFunc函数比较值是否一致,不一致,指针下移。 hashtable 的遍历实际就是数组遍历实现

知道了 davlik 下 hashtable的实现原理,那我们再来看下前面提到的:补丁 so库重命名的前提下,为什么 java 层 native 方法可能映射到原 so 库的方法也可能映射到补丁 so库的修复后的新方法。一张图说明情况

深入理解Android热修复技术原理之so库热修复技术_第10张图片

所以我们可以得到结论:

对补丁 so库进行重命名后,如果这个补丁 so库在hashtable中的位置比原 so库的位置靠前,那么这个静态注册native方法就能够得到修复,位置如果靠后就得不到修复。

2.3、SO实时生效方案总结

基于上面的分析,so库的实时生效必须满足以下几点:

  • so库为了兼容Dalvik虚拟机下动态注册native方法的实时生效,必须对so 文件进行改名。
  • 针对so库静态注册native方法的实时生效,首先需要解注册静态注册的 native方法,这个也是难点,因为我们很难知道so库中哪几个静态注册的 native方法发生了变更。假设就算我们知道如果静态注册的native方法需要解注册,重新load补丁 so库也有可能被修复也有可能不被修复。
  • 上面对补丁 so进行了第二次加载,那么肯定是多消耗了一次本地内存,如果 补丁 so库够大,补丁 so够多,那么JNI层的OOM也不是没可能
  • 另外一方面补丁 so如果新增了一个动态注册的方法而dex中没有相应方法, 直接去加载这个补丁 so文件会报NoSuchMethodError异常,具体逻辑在 dvmRegisterJNIMethod中。我们知道如果dex如果新增了—native 方法,那么走不了热部署只能冷启动重启生效,所以此时补丁so就不能第二 次load 了。这种情况下so库的修复严重依赖于dex的修复方案。

可以看到 so库实时生效方案,对于静态注册的native方法有一定的局限性, 不能满足一般的通用性,所以最后我们放弃了 so库的实时生效需求,转而求次实现 so库修复的冷部署重启生效方案。

三、SO库冷部署重启生效实现方案

为了更好的兼容通用性,我们尝试通过冷部署重启生效的角度分析下补丁 so库的修复方案。

3.1、接口调用替换方案

sdk提供接口替换System默认加载so库接口

SOPatchManager.loadLibrary(String libName) -> System.loadLibrary(String libName) 

SOPatchManager.loadLibrary接口加载 so库的时候优先尝试去加载sdk 指定目录下的补丁so,加载策略如下:

如果存在则加载补丁 so库而不会去加载安装apk安装目录下的so库

如果不存在补丁so,那么调用System.loadLibrary去加载安装apk目录下的 so库。

深入理解Android热修复技术原理之so库热修复技术_第11张图片

我们可以很清楚的看到这个方案的优缺点:

  • 优点:不需要对不同 sdk 版本进行兼容,因为所有的 sdk 版本都有 System.loadLibrary 这个接口。
  • 缺点:调用方需要替换掉 System 默认加载 so 库接口为 sdk提供的接口, 如果是已经编译混淆好的三方库的so 库需要 patch,那么是很难做到接口的替换。

虽然这种方案实现简单,同时不需要对不同 sdk版本区分处理,但是有一定的局限性没法修复三方包的so库同时需要强制侵入接入方接口调用,接着我们来看下反射注入方案。

3.2、反射注入方案

前面介绍过 System. loadLibrary ( "native-lib"); 加载 so库的原理,其实native-lib 这个 so 库最终传给 native 方法执行的参数是 so库在磁盘中的完整路径,比如:/data/app-lib/com.taobao.jni-2/libnative-lib.so, so库会在 DexPathList.nativeLibraryDirectories/nativeLibraryPathElements 变量所表示的目录下去遍历搜索。

sdk<23 DexPathList.findLibrary 实现如下

深入理解Android热修复技术原理之so库热修复技术_第12张图片

可以发现会遍历 nativeLibraryDirectories数组,如果找到了 loUtils.canOpenReadOnly (path)返回为 true, 那么就直接返回该 path, loUtils.canOpenReadOnly (path)返回为 true 的前提肯定是需要 path 表示的 so文件存 在的。那么我们可以采取类似类修复反射注入方式,只要把我们的补丁so库的路径插入到nativeLibraryDirectories数组的最前面就能够达到加载so库的时候是补丁 库而不是原来so库的目录,从而达到修复的目的。

sdk>=23 DexPathList.findLibrary 实现如下

深入理解Android热修复技术原理之so库热修复技术_第13张图片

sdk23 以上 findLibrary 实现已经发生了变化,如上所示,那么我们只需要把补丁so库的完整路径作为参数构建一个Element对象,然后再插入到nativeLibraryPathElements 数组的最前面就好了。

深入理解Android热修复技术原理之so库热修复技术_第14张图片

  • 优点:可以修复三方库的so库。同时接入方不需要像方案1 —样强制侵入用 户接口调用
  • 缺点:需要不断的对 sdk 进行适配,如上 sdk23 为分界线,findLibrary接口实现已经发生了变化。

我们知道在不管是在补丁包中还是 apk 中一个 so 库都存在多种 cpu 架构的 so 文件,比如"armeabi","arm64-v8a","x86"等。加载肯定是加载其中一个 so库文件的,如何选择机型对应的 so 库文件将是重点所在。

四、如何正确复制补丁 SO库

上面提到的一个问题,这里不打算详细介绍。有需要的参考文档:Android动态 链接库加载原理及HotFix方案介绍,这篇文档有些观点不尽正确,但是我也能知道虚拟机究竟选择哪个abis目录作为参数构建PathClassLoader对象,一张图简单了解下原理:

深入理解Android热修复技术原理之so库热修复技术_第15张图片

实际上补丁 so也存在类似的问题,我们的补丁 so库文件放到补丁包的libs目录下面,libs目录和.dex文件和res资源文件一起打包成一个压缩文件作为最后的补丁包,libs目录可能也包含多种abis目录。所以我们需要选择手机最合适的 primaryCpuAbi,然后从libs目录下面选择这个primaryCpuAbi子目录插入到 nativeLibraryDirectories/nativeLibraryPathElements 数组中。所以怎么选择primaryCpuAbi是关键,来看下我们sdk具体的实现

深入理解Android热修复技术原理之so库热修复技术_第16张图片

深入理解Android热修复技术原理之so库热修复技术_第17张图片

  • sdk>=21 时,直接反射拿到 Applicationinfo 对象的 primaryCpuAbi 即可
  • sdk<21 时,由于此时不支持 64 位,所以直接把Build.CPU_ABI, Build.CPU_ABI2 作为 primaryCpuAbi 即可

五、本章小结

对于 so库的修复方案目前更多采取的是接口调用替换方式,需要强制侵入用户 接口调用。目前我们的so文件修复方案采取的是反射注入的方案,重启生效。具有更好的普遍性。如果有so文件修复实时生效的需求,也是可以做到的,只是有些限制情况。

以上就是深入理解Android热修复技术原理之so库热修复技术的详细内容,更多关于Android so库热修复的资料请关注脚本之家其它相关文章!

你可能感兴趣的:(深入理解Android热修复技术原理之so库热修复技术)