摘要: 通常情况下,大多数人希望android下热补丁方案能够做到补丁的全方位修复,包括类修复/资源修复/so库的修复。 这里主要介绍热补丁之so库修复思路。

一、前言
通常情况下,大多数人希望android下热补丁方案能够做到补丁的全方位修复,包括类修复/资源修复/so库的修复。 这里主要介绍热补丁之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方法. 。

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

总结下:

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

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

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

四、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库文件将是重点所在。

五、如果正确复制补丁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即可 。

六、小结
最后做一个简单的小结:

so文件修复方案目前更多采取的是接口调用替换方式, 需要强制侵入用户接口调用。 目前我们的so文件修复方案采取的是反射注入的方案, 重启生效, 具有更好的普遍性。

同时如果有so文件修复实时生效的需求, 也是可以做到的,只是有些限制情况, 详见以上分析。

更多相关文章

  1. Android:Touch事件拦截机制
  2. Android(安卓)实现对图片 Exif 的修改(Android(安卓)自带的方法)
  3. StevGuo系列文章翻译之Android系统属性
  4. Android(安卓)的进程与线程
  5. Android(安卓)总结4种线程中操作UI界面的方法
  6. Android(安卓)静默安装和智能安装的实现方法
  7. Android中WebView的缓存
  8. Android(安卓)侧边栏开发的方法
  9. Android(安卓)万能适配器 节省你的开发时间

随机推荐

  1. jsp form表单方法示例
  2. PHP sdk实现在线打包代码示例
  3. JSP request(return String)用法详例
  4. PHP sdk文档处理常用代码示例解析
  5. 插画用PS怎么画?ps画插画的步骤
  6. JSP request(return String)用法详例
  7. PHP如何解决微信文章图片防盗链
  8. JSP request(return String)用法详例
  9. 常用函数类型与常用数据类型的学习
  10. 1. 实例演示常用函数类型 2.实例演示常用