Android热修复升级探索——SO库修复方案

摘要: 一般状况下,大多数人但愿android下热补丁方案可以作到补丁的全方位修复,包括类修复/资源修复/so库的修复。 这里主要介绍热补丁之so库修复思路。java

1、前言
一般状况下,大多数人但愿android下热补丁方案可以作到补丁的全方位修复,包括类修复/资源修复/so库的修复。 这里主要介绍热补丁之so库修复思路。android

2、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方法. 。数组

图片描述

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

图片描述

总结下:架构

动态注册的native方法映射经过加载so库过程当中调用JNI_OnLoad方法调用完成。app

静态注册的native方法映射是在该native方法第一次执行的时候才完成映射,固然前提是该so库已经load过。ionic

3、so库热部署实时生效可行性分析
1.动态注册native方法实时生效
前面咱们分析过so库的加载原理, 咱们知道动态注册的native方法调用一次JNI_OnLoad方法都会从新完成一次映射, 因此咱们是否只要先加载原来的so库,,而后再加载补丁so库,就能完成Java层native方法到native层patch后的新方法映射, 这样就完成动态注册native方法的patch实时修复。一张图说明:函数

图片描述

实测发现art下这样是能够作到实时生效的,可是Dalvik下作不到实时生效,经过代码测试咱们发现, 实际上Dalvik下第二次load补丁so库, 执行的仍然是原来so库的JNI_OnLoad方法, 而不是补丁so库的JNI_OnLoad方法, 因此Dalvik下作不到实时生效。 咱们来简单分析下, 既然拿到的是原来so库的JNI_OnLoad方法, 那么咱们首先怀疑如下两个函数是否有问题。工具

dlopen():返回给咱们一个动态连接库的句柄
dlsym(): 经过一个dlopen获得的动态链接库句柄,来查找一个symbol

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

图片描述
findloadedlibrary方法判断name表示的so库是否已经被加载过, 若是加载过直接返回以前加载so库的句柄,没有加载过, 调用load_library尝试加载so库 。

图片描述

看代码注释, 也知道其实这是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库的句柄, 而后执行补丁so库的JNI_OnLoad。

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

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

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

图片描述

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层方法的映射逻辑。

图片描述

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库的修复后的新方法。 一张图说明状况 :

图片描述

因此咱们能够获得结论:
对补丁so库进行重命名后, 若是这个补丁so库在hashtable中的位置比原so库的位置靠前, 那么这个静态注册native方法就可以获得修复, 位置若是靠后就得不到修复。

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库修复的冷部署重启生效方案。

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

方案1. 接口调用替换
sdk提供接口替换System默认加载so库接口

图片描述

SOPatchManager.loadLibrary接口加载so库的时候优先尝试去加载sdk指定目录下的补丁so, 加载策略以下:
若是存在则加载补丁so库而不会去加载安装apk安装目录下的so库。
若是不存在补丁so, 那么调用System.loadLibrary去加载安装apk目录下的so库。

咱们能够很清楚的看到这个方案的优缺点:

优势:不须要对不一样sdk版本进行兼容, 由于全部的sdk版本都有System.loadLibrary这个接口。
缺点: 调用方须要替换掉System默认加载so库接口为sdk提供的接口, 若是是已经编译混淆好的三方库的so库须要patch, 那么是很难作到接口的替换。
虽然这种方案实现简单, 同时不须要对不一样sdk版本区分处理,可是有必定的局限性无法修复三方包的so库同时须要强制侵入接入方接口调用, 因此来看下方案2. 反射注入。

方案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实现以下:

图片描述

能够发现会遍历nativeLibraryDirectories数组, 若是找到了IoUtils.canOpenReadOnly(path)返回为true, 那么就直接返回该path, IoUtils.canOpenReadOnly(path)返回为true的前提确定是须要path表示的so文件存在的。 那么咱们能够采起相似类修复反射注入方式, 只要把咱们的补丁so库的路径插入到nativeLibraryDirectories数组的最前面就可以达到加载so库的时候是补丁so库而不是原来so库的目录, 从而达到修复的目的。
sdk>=23 DexPathList.findLibrary实现以下 :

图片描述

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

图片描述

优势: 能够修复三方库的so库。 同时接入方不须要像方案1同样强制侵入用户接口调用。
缺点: 须要不断的对sdk进行适配, 如上sdk23为分界线, findLibrary接口实现已经发生了变化。
咱们知道在不论是在补丁包中仍是apk中一个so库都存在多种cpu架构的so文件, 好比"armeabi","arm64-v8a", "x86"等。 加载确定是加载其中一个so库文件的, 如何选择机型对应的so库文件将是重点所在。

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

图片描述

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

图片描述

sdk>=21下, 直接反射拿到ApplicationInfo对象的primaryCpuAbi便可
sdk<21下, 因为此时不支持64位, 因此直接把Build.CPU_ABI, Build.CPU_ABI2做为primaryCpuAbi便可 。

6、小结
最后作一个简单的小结:

so文件修复方案目前更多采起的是接口调用替换方式, 须要强制侵入用户接口调用。 目前咱们的so文件修复方案采起的是反射注入的方案, 重启生效, 具备更好的广泛性。

同时若是有so文件修复实时生效的需求, 也是能够作到的,只是有些限制状况, 详见以上分析。

相关文章
相关标签/搜索