Android 热修复小结
主流热更新方案对比
QZone超级补丁
-
实现原理:
-
将需要修复的类打包成dex补丁文件
-
客户端下载补丁包后,在下次app启动时检测到补丁包进行加载
-
在类加载器中会将加载的dex保存到一个数组里pathList:DexPathList,由于双亲委托机制,如果数组前面的dex文件已经包含需要加载的类,则不会从后面的dex中查找该类
-
Path/DexClassLoader->BaseDexClassLoader->ClassLoader->PathDexList->dexElements->makePathElements
-
所以应用启动时会将补丁包通过反射调用makePathElements方法,生成一个新的数组,然后与旧的dex数组合并,将补丁包的dex放到数组前面,这样就会优先加载修复后的类
-
-
优点:兼容性和稳定性比较好,修复的成功率比较高,可以整个类替换
-
缺点:
-
如果修复的类比较多,补丁包会比较大,加载补丁包会比较耗时
-
不能实时修复问题,需要重启后才可以
-
在低版本dalvik虚拟机中会因为一个类调用另一个Dex中的类,导致修复失败问题;出现这个问题是因为虚拟机对于没有引用另一个dex文件中的类会做一个标记叫做class_is_pre_verified,这样它就不会再去其他dex中查找引用到的类,如果原来引用的类是在同一个dex中,但是修复后的类在另一个dex中,就会报错
-
解决的方案是编译时钦通过修改字节码,在构造方法中去引用另一个dex中的固定类来避免被虚拟机标记
-
Tinker
-
实现原理:整体上与QZone流程一致,主要区别在于它是通过BSDiffer与原dex文件进行对比生成差分包;
-
优点:
-
补丁包相比QZone更小
-
差分包下发到客户端后会重新合成新的dex修复包,它加载时会整体替换旧的dex,避免了跨dex类调用问题
-
-
缺点:
-
不能实时修复问题,需要重启app才可以
-
dex的合并会在新的进程中进行,如果有多个dex需要合并时,合成的失败率可能也会高一些,而且下发的dex补丁包也会较多
-
-
资源修复:将下发的补丁包资源通过AssertManager反射调用方法构建,然后替换掉原来的AssertManager
-
so修复:使用Tinker的类去加载so库,它会先查找是否有so库的更新包,如果没有就加载旧的
AndFix
-
实现原理:核心原理是在native层查找到需要修复的方法结构体ArtMethod,然后把它替换成我们修复后的方法
-
优点:不需要重启app,可以实时生效
-
缺点:
-
兼容性不大好,因为每个Android系统版本对这个ArtMethod结构会有些变化,比较难适配
-
不支持新增字段、资源替换等
-
Robust
-
实现原理:在编译期间,通过Gradle插件进行代码插桩,在每个类里加上一个静态变量,静态变量指向修复后的接口,每个方法内部,插入if/else代码,判断如果静态变量不为空,则执行修复后的代码;修复补丁下发后,会先通过ClassLoader加载到内存中,然后通过反射修改需要修复的那个类里的静态变量,让它指向修复后的类,当下次调用该方法时就会if语句执行修复后的代码
-
优点:不需要重启app,可以实时生效,兼容性很好
-
缺点:
-
对项目代码有侵入性,增加了很多代码量,使得安装包会增大
-
不支持资源和so修复
-
版本兼容性问题
-
反射调用时源码上的差异
- 低版本上PathDexList中将dex转成数组的方法叫makeDexElements但是高本版上叫makePathElements,参数也不一样
-
Android N混合编译执行问题
-
Android N开始为了解决ART虚拟机安装app比较慢的问题,在安装过程中只编译使用比较多的热代码,剩下的则在系统闲置时去编译
-
在应用启用时会创建PathClassLoader,在构建PathClassLoader时,底层会优先加载已经编译好的热代码,这就导致正常情况下,我们无法再后续通过将补丁dex文件放到数组最前面来实现修复效果
-
解决的办法就是,在应用启动时,重新构建一个新的PathClassLoader,然后将旧的PathClassLoader关键数据拷贝过来,忽略掉已经加载了的热代码,然后将我们创建的PathClassLoader替换要原来的ClassLoader
-
-
class_is_pre_verified问题:
-
在dalvik虚拟机中,如果一个类没有引用其他dex里的类,就会被标记class_is_pre_verified,被标记后可以优化它的性能,如果修复的类里面,被引用的类又在另一个dex中,就会报错
-
解决办法是在编译时期通过Gradle插件对代码插桩,在构造方法中去引用另一个dex中固定的类,从而避免被打上标签
-
资源修复
SO库修复
-
加载so库的方式:
-
System.loadLibrary(libraryName):传入so库名称,位于libs目录下,最后会复制到apk安装目录
-
System.load(pathName):传入完整so库文件路径
-
最后都会调用nativeLoad方法去加载so库文件
-
-
JNI方法动态注册和静态注册区别
-
静态注册的方法需要在c/c++文件中以固定格式声明,Java开头,后面跟着完整类名+方法名;在方法调用时才去查找映射,效率较低
-
动态注册方法需要实现JNI_OnLoad方法,并且注册一个JNINativeMethod数组;在so库加载时就完成映射,调用方法时效率高
-
-
动态注册Native方法修复
-
由于动态注册的native方法,方法映射是在so库加载时调用JNI_OnLoad方法完成的;所以只需要再次加载修复后的so库就可以了
-
在ART虚拟机上是没问题的,但是在dalvik虚拟机里,它是根据so库文件名来判断so库是否已经加载过了,如果已经加载过了就不会去加载修复后的so库;ART虚拟机则是通过文件完整路径来判断是否加载过,所以没这个问题;我们可以通过改so库文件名来达到在dalvik虚拟机上修复的目的
-
-
静态注册Native方法修复
- JNI提供一个方法可以取消注册Native方法,但是重新加载so之后无法保证一定能修复,因为重新加载后的so可能在表中原来so的前面,也可能在后面
-
所以没有可以同时兼容动态注册和静态注册实时修复so的方案,只能通过重启app进行修复
-
冷修复so方案有两种:
-
一种是提供一个加载so库的方法去替换自带的System.loadBinary方法,然后在自定义方法里优先去加载更新文件夹中的so库;这种方式会稍微麻烦点,需要开发者改代码,但是如果是第三方库里的代码就无法修改,这个情况也可以在我们代码中提前加载好第三方so库去实现,优点是没有版本兼容性问题
-
另一种方法是通过反射修改DexPathList中存放so库路径的数组,将修复后的so路径添加到数组前面,这种方式就存在兼容性问题,如果Android版本对这个类有更新,就需要去适配,但是优点是可以做到无感修复,不需要开发者改代码
-
Resources资源修复
-
Android L(5.0)以及以上版本只要在原有AssertManager上反射调用addAssetPath方法将新apk资源路径添加进去就可以了
-
【普遍实现方案】AndroidL(5.0)以下版本,由于native层查找资源时会优先查找旧的资源,所以需要重新创建一个AssetManager对象,并反射替换掉原来的AssetManager
-
最佳实践:
- AssetManager里有提供一个destroy方法用于释放资源,可以先调用该方法释放当前AssertManager中的资源,然后重新调用native层的init方法重新初始化当前AssetManager对象,最后再调用addAssetPath添加修复后的资源路径,从而实现不改变原有AssetManager对象进行资源修复
补丁包管理
- 通过自定义Gradle插件,指定上个版本的混淆结果文件和R资源映射文件,在编译时期遍历所有的class文件并记录md5值,与上一个版本记录的md5值对比,将有修改过的class打包成dex补丁文件;或者打包结束后将生成的apk文件与上个版本apk文件使用差分工具bsdiff生成差分包作为补丁文件