1.无需重新发版,实时高效热修复
2.用户无感知修复,无需下载新的应用,代价小
热修复比较:
第一种:QQ空间超级补丁方案======最开始的选择(2015年)
优势:
1.这一套方案目前的应用成功率也是最高的
2. 没有合成整包(和微信Tinker比起来),产物比较小,比较灵活
不足:
1. 不支持即时生效,必须通过重启才能生效。
2. 耗时,启动慢,为了实现修复这个过程,必须在应用中加入两个dex!dalvikhack.dex中只有一个类,对性能影响不大,但是对于patch.dex来说,修复的类到了一定数量,就需要花不少的时间加载。
对手淘这种航母级应用来说,启动耗时增加2s以上是不能够接受的事。
3. 在ART模式下,如果类修改了结构,就会出现内存错乱的问题。为了解决这个问题,就必须把所有相关的调用类、父类子类等等全部加载到patch.dex中,导致补丁包异常的大,进一步增加应用启动加载的时候,耗时更加严重。
第二种:阿里巴巴:AndFix
1、AndFix实现原理
AndFix不同于QQ空间超级补丁技术和微信Tinker通过增加或替换整个DEX的方案,提供了一种运行时在Native修改Filed指针的方式,实现方法的替换,达到即时生效无需重启,对应用无性能消耗的目的。
优势:
1. BUG修复的即时性
2. 对应用无侵入,几乎无性能损耗
3. 补丁包同样采用差量技术,生成的PATCH体积小
不足:
1. 不支持新增字段,以及修改<init>方法,也不支持对资源的替换。
2. 由于厂商的自定义ROM,对少数机型暂不支持。
3.总结以上2点,AndFix作为native解决方案,首先面临的是稳定性与兼容性问题,更重要的是它无法实现类替换,它是需要大量额外的开发成本的;
优势:
1. 合成整包,不用在构造函数插入代码,防止verify,verify和opt在编译期间就已经完成,不会在运行期间进行。
2. 性能提高。兼容性和稳定性比较高。
3. 开发者透明,不需要对包进行额外处理。
不足:
1. 与超级补丁技术一样,不支持即时生效,必须通过重启应用的方式才能生效。
2. 需要给应用开启新的进程才能进行合并,并且很容易因为内存消耗等原因合并失败。
3. 合并时占用额外磁盘空间,对于多DEX的应用来说,如果修改了多个DEX文件,就需要下发多个patch.dex与对应的classes.dex进行合并操作时这种情况会更严重,因此合并过程的失败率也会更高。
Tinker的已知问题
由于原理与系统限制,Tinker有以下已知问题:
- 在Android N上,补丁对应用启动时间有轻微的影响;华为厂家反映了这个问题
- 不支持部分三星android-21机型,加载补丁时会主动抛出
"TinkerRuntimeException:checkDexInstall failed"
; - 对于资源替换,不支持修改remoteView。例如transition动画,notification icon以及桌面图标。
- Tinker不支持修改AndroidManifest.xml,Tinker不支持新增四大组件;
- 由于Google Play的开发者条款限制,不建议在GP渠道动态更新代码;
分别接入三种热修复服务,根据腾讯提供超级补丁技术和Tinker的数据,那么会变成以下的场景:
1. 阿里百川HotFix:启动时间几乎无增加,不增加运行期额外的磁盘消耗。
2.启动加载时间过长 ,易造成应用的ANR和Crash
由于多DEX加载导致了启动时间变长,这样更容易引发应用的ANR。我们知道当应用在主线程等待超过5s以后,就会直接导致长时间无响应而退出。超级补丁技术为保证ART不出现地址错乱问题,需要将所有关联的类全部加入到补丁中,而微信Tinker采取一种差量包合并加载的方式,都会使要加载的DEX体积变得很大。这也很大程度上容易导致ANR情况的出现。
除了应用ANR以外,多DEX模式也同样很容易导致Crash情况的出现。在ART设备中为了保证不出现地址错乱,需要把修改类的所有相关类全部加入到补丁中,这里会出现一个问题,为了保证补丁包的体积最小,能否保证引入全部的关联类而不引入无关的类呢?一旦没有引入关联的类,就会出现以下的异常:
QQ空间超级补丁技术:如果应用有700个类,启动耗时增加超过2.5s,达到5.5s以上。
微信Tinker:假设应用有5个DEX文件,分别修改了这5个DEX,产生5个patch.dex文件,就要进行5次的patch合并动作,假设每个补丁1M,那么就要多占用7.5M的磁盘空间。
我们可以看到,超级补丁技术和Tinker都选择在Application的attachBaseContext()进行补丁dex的加载,即时这是加载dex的最佳时机,但是依然会带来很大的性能问题,首当其冲的就是启动时间太长。
对于补丁DEX来说,应用启动时虚拟机会进行dexopt操作,将patch.dex文件转换成odex文件,这个过程本身非常耗时。而这个过程又要求在主线程中,以同步的方式执行,否则无法成功进行修复。就DEX的加载时间,大概做了以下的时间测试。
QQ空间超级补丁技术和微信Tinker 支持新增类和资源的替换,在一些功能化的更新上更为强大,但对应用的性能和稳定会有的一定的影响;阿里百川HotFix虽然暂时不支持新增类和资源的替换,对新功能的发布也有所限制,但是作为一项定位为线上紧急BUG的热修复的服务来说,能够真正做到BUG即时修复用户无感知,同时保证对应用性能不产生不必要的损耗,在热修复方面不失为一个好的选择。
-
AndFix作为native解决方案,首先面临的是稳定性与兼容性问题,更重要的是它无法实现类替换,它是需要大量额外的开发成本的;
-
Qzone方案可以做到发布产品功能,但是它主要问题是插桩带来Dalvik的性能问题,以及为了解决Art下内存地址问题而导致补丁包急速增大的。
一、QQ空间超级补丁技术
超级补丁技术基于DEX分包方案,使用了多DEX加载的原理,大致的过程就是:把BUG方法修复以后,放到一个单独的DEX里,插入到dexElements数组的最前面,让虚拟机去加载修复完后的方法。
当patch.dex中包含Test.class时就会优先加载,在后续的DEX中遇到Test.class的话就会直接返回而不去加载,这样就达到了修复的目的。
原理:
一个ClassLoader可以包含多个dex文件,每个dex文件是一个Element,多个dex文件排列成一个有序的数组dexElements,当找类的时候,会按顺序遍历dex文件,然后从当前遍历的dex文件中找类,如果找类则返回,如果找不到从下一个dex文件继续查找。(来自:安卓App热补丁动态修复技术介绍)
理论上,如果在不同的dex中有相同的类存在,那么会优先选择排在前面的dex文件的类,如下图:
那么这样的话,我们可以在这个dexElements中去做一些事情,比如,在这个数组的第一个元素放置我们的patch.jar,里面包含修复过的类,这样的话,当遍历findClass的时候,我们修复的类就会被查找到,从而替代有bug的类。
在此基础上,我们构想了热补丁的方案,把有问题的类打包到一个dex(patch.dex)中去,然后把这个dex插入到Elements的最前面,如下图:
但是有一个问题是,当两个调用关系的类不在同一个DEX时,就会产生异常报错。我们知道,在APK安装时,虚拟机需要将classes.dex优化成odex文件,然后才会执行。
在这个过程中,会进行类的verify操作,如果调用关系的类都在同一个DEX中的话就会被打上`CLASS_ISPREVERIFIED`的标志,然后才会写入odex文件。这样被引用的类就不能进行热修复操作了.
所以,为了可以正常地进行打补丁修复,必须避免类被打上`CLASS_ISPREVERIFIED`标志,QQ空间的方法是在所有引用到该类的构造函数中插入一段代码,代码引用到别的类. 具体的做法就是单独放一个类在另外DEX中,让其他类调用。
阻止相关类去打上CLASS_ISPREVERIFIED
标志,就是为了解决上面这问题!
从代码上来看,如果两个相关联的类在不同的dex中就会报错,但是拆分dex没有报错这是为什么,原来这个校验的前提是:
如果引用者(也就是ModuleManager)这个类被打上了CLASS_ISPREVERIFIED标志,那么就会进行dex的校验
但是源码方式的引用会将引用的类打入同一个dex中,所以我们需要找到一种既能编译通过并且将两个互相引用的类分离到不同的dex中
总结下:
其实就是两件事:1、动态改变BaseDexClassLoader对象间接引用的dexElements;2、在app打包的时候,阻止相关类去打上CLASS_ISPREVERIFIED
标志。
根据上面的文章,在虚拟机启动的时候,当verify选项被打开的时候,如果static方法、private方法、构造函数等,其中的直接引用(第一层关系)到的类都在同一个dex文件中,那么该类就会被打上CLASS_ISPREVERIFIED
标志。
那么,我们要做的就是,阻止该类打上CLASS_ISPREVERIFIED
的标志。
怎么阻止:通过不同的DEX加载进来,然后在每一个类的构造方法中引用其他DEX中的唯一类AnitLazyLoad,避免类被打上CLASS_ISPREVERIFIED标志。
如何打包补丁包的操作:
1. 空间在正式版本发布的时候,会生成一份缓存文件,里面记录了所有class文件的md5,还有一份mapping混淆文件。
2. 在后续的版本中使用-applymapping选项,应用正式版本的mapping文件,然后计算编译完成后的class文件的md5和正式版本进行比较,把不相同的class文件打包成补丁包
之前研究过AndFix,主要是使用native方法,来修改出现bug的方法,虽然支持的系统版本也很广(2.3~7.0),但是具有一定的局限性,只能修改方法的内部实现,不支持新增方法,新增、修改成员变量等;而基于ClassLoader的各种实现,由于采用在字节码中在构造方法注入一段代码,防止被打上CLASS_ISPREVERIFIED标记,在dalvik中比较影响性能,而且支持系统也不全面,所以都不是特别的完美。而且他们都有一个缺点,不能修改资源文件。
这次Tinker的发布,打破了原有的性能问题,功能局限,实现了,支持对library、java类、res文件的修复,并且除了小部分版本的设备(wiki上说是部分三星api19版本),其他基本都可以覆盖到(yunOS另说)。相信在wx几位大神的维护下,tinker会越来越好。官方Tinker热补丁技术交流群:377388954
QQ热修复总结:dex插入到elemnets的前面去,避免IpreVerfiy问题
微信的Tinker修复:
大家只需要明白,Android使用PathClassLoader作为其类加载器,DexClassLoader可以从.jar和.apk类型的文件内部加载classes.dex文件就好了。
若采用插桩导致所有类都非preverify,这导致verify与optimize操作会在加载类时触发。这会有一定的性能损耗,微信分别采用插桩与不插桩两种方式做过两种测试,一是连续加载700个50行左右的类,一是统计微信整个启动完成的耗时。
平均每个类verify+optimize(跟类的大小有关系)的耗时并不长,而且这个耗时每个类只有一次。但由于启动时会加载大量的类,在这个情况影响还是比较大的。
微信针对QQ空间超级补丁技术的不足提出了一个提供DEX差量包,整体替换DEX的方案。主要的原理是与QQ空间超级补丁技术基本相同,
区别在于不再将patch.dex增加到elements数组中,而是差量的方式给出patch.dex,然后将patch.dex与应用的classes.dex合并,然后整体替换掉旧的DEX文件,以达到修复的目的。
原理:
可以看出:
tinker将old.apk和new.apk做了diff,拿到patch.dex,然后将patch.dex与本机中apk的classes.dex做了合并,生成新的classes.dex,运行时通过反射将合并后的dex文件放置在加载的dexElements数组的前面。
运行时替代的原理,其实和Qzone的方案差不多,都是去反射修改dexElements。
两者的差异是:Qzone是直接将patch.dex插到数组的前面;而tinker是将patch.dex与app中的classes.dex合并后的全量dex插在数组的前面。
tinker这么做的目的还是因为Qzone方案中提到的CLASS_ISPREVERIFIED
的解决方案存在问题;而tinker相当于换个思路解决了该问题。
(1)加载patch
加载的代码实际上在生成的Application中调用的,其父类为TinkerApplication,在其attachBaseContext中辗转会调用到loadTinker()方法,在该方法内部,反射调用了TinkerLoader的tryLoad方法。
public class TinkerLoader extends AbstractTinkerLoader { private static final String TAG = "Tinker.TinkerLoader"; /** * the patch info file */ private SharePatchInfo patchInfo; /** * only main process can handle patch version change or incomplete */ @Override public Intent tryLoad(TinkerApplication app, int tinkerFlag, boolean tinkerLoadVerifyFlag) { Intent resultIntent = new Intent(); long begin = SystemClock.elapsedRealtime(); tryLoadPatchFilesInternal(app, tinkerFlag, tinkerLoadVerifyFlag, resultIntent); long cost = SystemClock.elapsedRealtime() - begin; ShareIntentUtil.setIntentPatchCostTime(resultIntent, cost); return resultIntent; }
步骤如下:
- 找到PathClassLoader(BaseDexClassLoader)对象中的pathList对象
- 根据pathList对象找到其中的makeDexElements方法,传入patch相关的对应的实参,返回Element[]对象
- 拿到pathList对象中原本的dexElements方法
- 步骤2与步骤3中的Element[]数组进行合并,将patch相关的dex放在数组的前面
- 最后将合并后的数组,设置给pathList
集成的时候有一些问题1.MyApplication的问题: 需要把 Application 隔离,其他的可能是Application的一个代理
为什么要隔离Application,对它的改造
步骤:
如下是接入指南:
通俗易懂的
文件名 | 描述 |
---|---|
patch_unsigned.apk | 没有签名的补丁包 |
patch_signed.apk | 签名后的补丁包 |
patch_signed_7zip.apk | 签名后并使用7zip压缩的补丁包,也是我们通常使用的补丁包。但正式发布的时候,最好不要以.apk 结尾,防止被运营商挟持。 |
Properties里面添加了
添加gradle依赖
在项目的build.gradle中,添加tinker-patch-gradle-plugin
的依赖
buildscript {
dependencies {
classpath ('com.tencent.tinker:tinker-patch-gradle-plugin:1.7.9')
}
}
1.主模块依赖
dependencies { compile fileTree(dir: 'libs', include: '*.jar') //dex拆分包依赖注入 releaseCompile project(path: ':Tools_check', configuration: 'release') debugCompile project(path: ':Tools_check', configuration: 'debug') releaseCompile project(path: ':CXBase', configuration: 'release') debugCompile project(path: ':CXBase', configuration: 'debug') releaseCompile project(path: ':DataModule', configuration: 'release') debugCompile project(path: ':DataModule', configuration: 'debug') releaseCompile project(path: ':CXPhoto_sdk', configuration: 'release') debugCompile project(path: ':CXPhoto_sdk', configuration: 'debug') releaseCompile project(path: ':CXLauncherBase', configuration: 'release') debugCompile project(path: ':CXLauncherBase', configuration: 'debug') releaseCompile project(path: ':HJModule', configuration: 'release') debugCompile project(path: ':HJModule', configuration: 'debug') releaseCompile project(path: ':CXQuest_module', configuration: 'release') debugCompile project(path: ':CXQuest_module', configuration: 'debug') compile project(':tinkerUse') provided("com.tencent.tinker:tinker-android-anno:${TINKER_VERSION}") { changing = true } }
project.afterEvaluate { //sample use for build all flavor for one time if (hasFlavors) { task(tinkerPatchAllFlavorRelease) { group = 'tinker' def originOldPath = getTinkerBuildFlavorDirectory() for (String flavor : flavors) { def tinkerTask = tasks.getByName("tinkerPatch${flavor.capitalize()}Release") dependsOn tinkerTask def preAssembleTask = tasks.getByName("process${flavor.capitalize()}ReleaseManifest") preAssembleTask.doFirst { String flavorName = preAssembleTask.name.substring(7, 8).toLowerCase() + preAssembleTask.name.substring(8, preAssembleTask.name.length() - 15) project.tinkerPatch.oldApk = "${originOldPath}/${flavorName}/${project.name}-${flavorName}-release.apk" project.tinkerPatch.buildConfig.applyMapping = "${originOldPath}/${flavorName}/${project.name}-${flavorName}-release-mapping.txt" project.tinkerPatch.buildConfig.applyResourceMapping = "${originOldPath}/${flavorName}/${project.name}-${flavorName}-release-R.txt" } } } task(tinkerPatchAllFlavorDebug) { group = 'tinker' def originOldPath = getTinkerBuildFlavorDirectory() for (String flavor : flavors) { def tinkerTask = tasks.getByName("tinkerPatch${flavor.capitalize()}Debug") dependsOn tinkerTask def preAssembleTask = tasks.getByName("process${flavor.capitalize()}DebugManifest") preAssembleTask.doFirst { String flavorName = preAssembleTask.name.substring(7, 8).toLowerCase() + preAssembleTask.name.substring(8, preAssembleTask.name.length() - 13) project.tinkerPatch.oldApk = "${originOldPath}/${flavorName}/${project.name}-${flavorName}-debug.apk" project.tinkerPatch.buildConfig.applyMapping = "${originOldPath}/${flavorName}/${project.name}-${flavorName}-debug-mapping.txt" project.tinkerPatch.buildConfig.applyResourceMapping = "${originOldPath}/${flavorName}/${project.name}-${flavorName}-debug-R.txt" } } } }
AndroidManifest.xml
中指定TINKER_ID?????
Tinker的使用方式如下,以gradle接入的release包为例:
- 每次编译或发包将安装包与mapping文件备份;
- 若有补丁包的需要,按自身需要修改你的代码、库文件等;
- 将备份的基准安装包与mapping文件输入到tinkerPatch的配置中;
- 运行tinkerPatchRelease,即可自动编译最新的安装包,并与输入基准包作差异,得到最终的补丁包。
简单来说,我们通过完全使用了新的Dex,那样既不出现Art地址错乱的问题,在Dalvik也无须插桩。当然考虑到补丁包的体积,我们不能直接将新的Dex放在里面。但我们可以将新旧两个Dex的差异放到补丁包中,这里我们可以调研的方法有以下几个:
-
BsDiff;它格式无关,但对Dex效果不是特别好,而且生成产物大小非常不稳定。当前微信对于so与部分资源,依然使用bsdiff算法;
-
DexMerge;它主要问题在于合成时内存占用过大,一个12M的dex,峰值内存可能达到70多M;
-
DexDiff;通过深入Dex格式,实现一套生成产物小,内存占用少以及支持增删改的算法。
如何选择?在“高可用”的核心诉求下,性能问题也尤为重要。非常庆幸微信在当时那个节点坚决的选择了自研DexDiff算法,这过程虽然有苦有泪,但也正是有它,才有现在的Tinker。
1.无需重新发版,实时高效热修复
2.用户无感知修复,无需下载新的应用,代价小
热修复比较:
第一种:QQ空间超级补丁方案======最开始的选择(2015年)
优势:
1.这一套方案目前的应用成功率也是最高的
2. 没有合成整包(和微信Tinker比起来),产物比较小,比较灵活
不足:
1. 不支持即时生效,必须通过重启才能生效。
2. 耗时,启动慢,为了实现修复这个过程,必须在应用中加入两个dex!dalvikhack.dex中只有一个类,对性能影响不大,但是对于patch.dex来说,修复的类到了一定数量,就需要花不少的时间加载。
对手淘这种航母级应用来说,启动耗时增加2s以上是不能够接受的事。
3. 在ART模式下,如果类修改了结构,就会出现内存错乱的问题。为了解决这个问题,就必须把所有相关的调用类、父类子类等等全部加载到patch.dex中,导致补丁包异常的大,进一步增加应用启动加载的时候,耗时更加严重。
第二种:阿里巴巴:AndFix
1、AndFix实现原理
AndFix不同于QQ空间超级补丁技术和微信Tinker通过增加或替换整个DEX的方案,提供了一种运行时在Native修改Filed指针的方式,实现方法的替换,达到即时生效无需重启,对应用无性能消耗的目的。
优势:
1. BUG修复的即时性
2. 对应用无侵入,几乎无性能损耗
3. 补丁包同样采用差量技术,生成的PATCH体积小
不足:
1. 不支持新增字段,以及修改<init>方法,也不支持对资源的替换。
2. 由于厂商的自定义ROM,对少数机型暂不支持。
3.总结以上2点,AndFix作为native解决方案,首先面临的是稳定性与兼容性问题,更重要的是它无法实现类替换,它是需要大量额外的开发成本的;
优势:
1. 合成整包,不用在构造函数插入代码,防止verify,verify和opt在编译期间就已经完成,不会在运行期间进行。
2. 性能提高。兼容性和稳定性比较高。
3. 开发者透明,不需要对包进行额外处理。
不足:
1. 与超级补丁技术一样,不支持即时生效,必须通过重启应用的方式才能生效。
2. 需要给应用开启新的进程才能进行合并,并且很容易因为内存消耗等原因合并失败。
3. 合并时占用额外磁盘空间,对于多DEX的应用来说,如果修改了多个DEX文件,就需要下发多个patch.dex与对应的classes.dex进行合并操作时这种情况会更严重,因此合并过程的失败率也会更高。
Tinker的已知问题
由于原理与系统限制,Tinker有以下已知问题:
- 在Android N上,补丁对应用启动时间有轻微的影响;华为厂家反映了这个问题
- 不支持部分三星android-21机型,加载补丁时会主动抛出
"TinkerRuntimeException:checkDexInstall failed"
; - 对于资源替换,不支持修改remoteView。例如transition动画,notification icon以及桌面图标。
- Tinker不支持修改AndroidManifest.xml,Tinker不支持新增四大组件;
- 由于Google Play的开发者条款限制,不建议在GP渠道动态更新代码;
分别接入三种热修复服务,根据腾讯提供超级补丁技术和Tinker的数据,那么会变成以下的场景:
1. 阿里百川HotFix:启动时间几乎无增加,不增加运行期额外的磁盘消耗。
2.启动加载时间过长 ,易造成应用的ANR和Crash
由于多DEX加载导致了启动时间变长,这样更容易引发应用的ANR。我们知道当应用在主线程等待超过5s以后,就会直接导致长时间无响应而退出。超级补丁技术为保证ART不出现地址错乱问题,需要将所有关联的类全部加入到补丁中,而微信Tinker采取一种差量包合并加载的方式,都会使要加载的DEX体积变得很大。这也很大程度上容易导致ANR情况的出现。
除了应用ANR以外,多DEX模式也同样很容易导致Crash情况的出现。在ART设备中为了保证不出现地址错乱,需要把修改类的所有相关类全部加入到补丁中,这里会出现一个问题,为了保证补丁包的体积最小,能否保证引入全部的关联类而不引入无关的类呢?一旦没有引入关联的类,就会出现以下的异常:
QQ空间超级补丁技术:如果应用有700个类,启动耗时增加超过2.5s,达到5.5s以上。
微信Tinker:假设应用有5个DEX文件,分别修改了这5个DEX,产生5个patch.dex文件,就要进行5次的patch合并动作,假设每个补丁1M,那么就要多占用7.5M的磁盘空间。
我们可以看到,超级补丁技术和Tinker都选择在Application的attachBaseContext()进行补丁dex的加载,即时这是加载dex的最佳时机,但是依然会带来很大的性能问题,首当其冲的就是启动时间太长。
对于补丁DEX来说,应用启动时虚拟机会进行dexopt操作,将patch.dex文件转换成odex文件,这个过程本身非常耗时。而这个过程又要求在主线程中,以同步的方式执行,否则无法成功进行修复。就DEX的加载时间,大概做了以下的时间测试。
QQ空间超级补丁技术和微信Tinker 支持新增类和资源的替换,在一些功能化的更新上更为强大,但对应用的性能和稳定会有的一定的影响;阿里百川HotFix虽然暂时不支持新增类和资源的替换,对新功能的发布也有所限制,但是作为一项定位为线上紧急BUG的热修复的服务来说,能够真正做到BUG即时修复用户无感知,同时保证对应用性能不产生不必要的损耗,在热修复方面不失为一个好的选择。
-
AndFix作为native解决方案,首先面临的是稳定性与兼容性问题,更重要的是它无法实现类替换,它是需要大量额外的开发成本的;
-
Qzone方案可以做到发布产品功能,但是它主要问题是插桩带来Dalvik的性能问题,以及为了解决Art下内存地址问题而导致补丁包急速增大的。
一、QQ空间超级补丁技术
超级补丁技术基于DEX分包方案,使用了多DEX加载的原理,大致的过程就是:把BUG方法修复以后,放到一个单独的DEX里,插入到dexElements数组的最前面,让虚拟机去加载修复完后的方法。
当patch.dex中包含Test.class时就会优先加载,在后续的DEX中遇到Test.class的话就会直接返回而不去加载,这样就达到了修复的目的。
原理:
一个ClassLoader可以包含多个dex文件,每个dex文件是一个Element,多个dex文件排列成一个有序的数组dexElements,当找类的时候,会按顺序遍历dex文件,然后从当前遍历的dex文件中找类,如果找类则返回,如果找不到从下一个dex文件继续查找。(来自:安卓App热补丁动态修复技术介绍)
理论上,如果在不同的dex中有相同的类存在,那么会优先选择排在前面的dex文件的类,如下图:
那么这样的话,我们可以在这个dexElements中去做一些事情,比如,在这个数组的第一个元素放置我们的patch.jar,里面包含修复过的类,这样的话,当遍历findClass的时候,我们修复的类就会被查找到,从而替代有bug的类。
在此基础上,我们构想了热补丁的方案,把有问题的类打包到一个dex(patch.dex)中去,然后把这个dex插入到Elements的最前面,如下图:
但是有一个问题是,当两个调用关系的类不在同一个DEX时,就会产生异常报错。我们知道,在APK安装时,虚拟机需要将classes.dex优化成odex文件,然后才会执行。
在这个过程中,会进行类的verify操作,如果调用关系的类都在同一个DEX中的话就会被打上`CLASS_ISPREVERIFIED`的标志,然后才会写入odex文件。这样被引用的类就不能进行热修复操作了.
所以,为了可以正常地进行打补丁修复,必须避免类被打上`CLASS_ISPREVERIFIED`标志,QQ空间的方法是在所有引用到该类的构造函数中插入一段代码,代码引用到别的类. 具体的做法就是单独放一个类在另外DEX中,让其他类调用。
阻止相关类去打上CLASS_ISPREVERIFIED
标志,就是为了解决上面这问题!
从代码上来看,如果两个相关联的类在不同的dex中就会报错,但是拆分dex没有报错这是为什么,原来这个校验的前提是:
如果引用者(也就是ModuleManager)这个类被打上了CLASS_ISPREVERIFIED标志,那么就会进行dex的校验
但是源码方式的引用会将引用的类打入同一个dex中,所以我们需要找到一种既能编译通过并且将两个互相引用的类分离到不同的dex中
总结下:
其实就是两件事:1、动态改变BaseDexClassLoader对象间接引用的dexElements;2、在app打包的时候,阻止相关类去打上CLASS_ISPREVERIFIED
标志。
根据上面的文章,在虚拟机启动的时候,当verify选项被打开的时候,如果static方法、private方法、构造函数等,其中的直接引用(第一层关系)到的类都在同一个dex文件中,那么该类就会被打上CLASS_ISPREVERIFIED
标志。
那么,我们要做的就是,阻止该类打上CLASS_ISPREVERIFIED
的标志。
怎么阻止:通过不同的DEX加载进来,然后在每一个类的构造方法中引用其他DEX中的唯一类AnitLazyLoad,避免类被打上CLASS_ISPREVERIFIED标志。
如何打包补丁包的操作:
1. 空间在正式版本发布的时候,会生成一份缓存文件,里面记录了所有class文件的md5,还有一份mapping混淆文件。
2. 在后续的版本中使用-applymapping选项,应用正式版本的mapping文件,然后计算编译完成后的class文件的md5和正式版本进行比较,把不相同的class文件打包成补丁包
之前研究过AndFix,主要是使用native方法,来修改出现bug的方法,虽然支持的系统版本也很广(2.3~7.0),但是具有一定的局限性,只能修改方法的内部实现,不支持新增方法,新增、修改成员变量等;而基于ClassLoader的各种实现,由于采用在字节码中在构造方法注入一段代码,防止被打上CLASS_ISPREVERIFIED标记,在dalvik中比较影响性能,而且支持系统也不全面,所以都不是特别的完美。而且他们都有一个缺点,不能修改资源文件。
这次Tinker的发布,打破了原有的性能问题,功能局限,实现了,支持对library、java类、res文件的修复,并且除了小部分版本的设备(wiki上说是部分三星api19版本),其他基本都可以覆盖到(yunOS另说)。相信在wx几位大神的维护下,tinker会越来越好。官方Tinker热补丁技术交流群:377388954
QQ热修复总结:dex插入到elemnets的前面去,避免IpreVerfiy问题
微信的Tinker修复:
大家只需要明白,Android使用PathClassLoader作为其类加载器,DexClassLoader可以从.jar和.apk类型的文件内部加载classes.dex文件就好了。
若采用插桩导致所有类都非preverify,这导致verify与optimize操作会在加载类时触发。这会有一定的性能损耗,微信分别采用插桩与不插桩两种方式做过两种测试,一是连续加载700个50行左右的类,一是统计微信整个启动完成的耗时。
平均每个类verify+optimize(跟类的大小有关系)的耗时并不长,而且这个耗时每个类只有一次。但由于启动时会加载大量的类,在这个情况影响还是比较大的。
微信针对QQ空间超级补丁技术的不足提出了一个提供DEX差量包,整体替换DEX的方案。主要的原理是与QQ空间超级补丁技术基本相同,
区别在于不再将patch.dex增加到elements数组中,而是差量的方式给出patch.dex,然后将patch.dex与应用的classes.dex合并,然后整体替换掉旧的DEX文件,以达到修复的目的。
原理:
可以看出:
tinker将old.apk和new.apk做了diff,拿到patch.dex,然后将patch.dex与本机中apk的classes.dex做了合并,生成新的classes.dex,运行时通过反射将合并后的dex文件放置在加载的dexElements数组的前面。
运行时替代的原理,其实和Qzone的方案差不多,都是去反射修改dexElements。
两者的差异是:Qzone是直接将patch.dex插到数组的前面;而tinker是将patch.dex与app中的classes.dex合并后的全量dex插在数组的前面。
tinker这么做的目的还是因为Qzone方案中提到的CLASS_ISPREVERIFIED
的解决方案存在问题;而tinker相当于换个思路解决了该问题。
(1)加载patch
加载的代码实际上在生成的Application中调用的,其父类为TinkerApplication,在其attachBaseContext中辗转会调用到loadTinker()方法,在该方法内部,反射调用了TinkerLoader的tryLoad方法。
public class TinkerLoader extends AbstractTinkerLoader { private static final String TAG = "Tinker.TinkerLoader"; /** * the patch info file */ private SharePatchInfo patchInfo; /** * only main process can handle patch version change or incomplete */ @Override public Intent tryLoad(TinkerApplication app, int tinkerFlag, boolean tinkerLoadVerifyFlag) { Intent resultIntent = new Intent(); long begin = SystemClock.elapsedRealtime(); tryLoadPatchFilesInternal(app, tinkerFlag, tinkerLoadVerifyFlag, resultIntent); long cost = SystemClock.elapsedRealtime() - begin; ShareIntentUtil.setIntentPatchCostTime(resultIntent, cost); return resultIntent; }
步骤如下:
- 找到PathClassLoader(BaseDexClassLoader)对象中的pathList对象
- 根据pathList对象找到其中的makeDexElements方法,传入patch相关的对应的实参,返回Element[]对象
- 拿到pathList对象中原本的dexElements方法
- 步骤2与步骤3中的Element[]数组进行合并,将patch相关的dex放在数组的前面
- 最后将合并后的数组,设置给pathList
集成的时候有一些问题1.MyApplication的问题: 需要把 Application 隔离,其他的可能是Application的一个代理
为什么要隔离Application,对它的改造
步骤:
如下是接入指南:
通俗易懂的
文件名 | 描述 |
---|---|
patch_unsigned.apk | 没有签名的补丁包 |
patch_signed.apk | 签名后的补丁包 |
patch_signed_7zip.apk | 签名后并使用7zip压缩的补丁包,也是我们通常使用的补丁包。但正式发布的时候,最好不要以.apk 结尾,防止被运营商挟持。 |
Properties里面添加了
添加gradle依赖
在项目的build.gradle中,添加tinker-patch-gradle-plugin
的依赖
buildscript {
dependencies {
classpath ('com.tencent.tinker:tinker-patch-gradle-plugin:1.7.9')
}
}
1.主模块依赖
dependencies { compile fileTree(dir: 'libs', include: '*.jar') //dex拆分包依赖注入 releaseCompile project(path: ':Tools_check', configuration: 'release') debugCompile project(path: ':Tools_check', configuration: 'debug') releaseCompile project(path: ':CXBase', configuration: 'release') debugCompile project(path: ':CXBase', configuration: 'debug') releaseCompile project(path: ':DataModule', configuration: 'release') debugCompile project(path: ':DataModule', configuration: 'debug') releaseCompile project(path: ':CXPhoto_sdk', configuration: 'release') debugCompile project(path: ':CXPhoto_sdk', configuration: 'debug') releaseCompile project(path: ':CXLauncherBase', configuration: 'release') debugCompile project(path: ':CXLauncherBase', configuration: 'debug') releaseCompile project(path: ':HJModule', configuration: 'release') debugCompile project(path: ':HJModule', configuration: 'debug') releaseCompile project(path: ':CXQuest_module', configuration: 'release') debugCompile project(path: ':CXQuest_module', configuration: 'debug') compile project(':tinkerUse') provided("com.tencent.tinker:tinker-android-anno:${TINKER_VERSION}") { changing = true } }
project.afterEvaluate { //sample use for build all flavor for one time if (hasFlavors) { task(tinkerPatchAllFlavorRelease) { group = 'tinker' def originOldPath = getTinkerBuildFlavorDirectory() for (String flavor : flavors) { def tinkerTask = tasks.getByName("tinkerPatch${flavor.capitalize()}Release") dependsOn tinkerTask def preAssembleTask = tasks.getByName("process${flavor.capitalize()}ReleaseManifest") preAssembleTask.doFirst { String flavorName = preAssembleTask.name.substring(7, 8).toLowerCase() + preAssembleTask.name.substring(8, preAssembleTask.name.length() - 15) project.tinkerPatch.oldApk = "${originOldPath}/${flavorName}/${project.name}-${flavorName}-release.apk" project.tinkerPatch.buildConfig.applyMapping = "${originOldPath}/${flavorName}/${project.name}-${flavorName}-release-mapping.txt" project.tinkerPatch.buildConfig.applyResourceMapping = "${originOldPath}/${flavorName}/${project.name}-${flavorName}-release-R.txt" } } } task(tinkerPatchAllFlavorDebug) { group = 'tinker' def originOldPath = getTinkerBuildFlavorDirectory() for (String flavor : flavors) { def tinkerTask = tasks.getByName("tinkerPatch${flavor.capitalize()}Debug") dependsOn tinkerTask def preAssembleTask = tasks.getByName("process${flavor.capitalize()}DebugManifest") preAssembleTask.doFirst { String flavorName = preAssembleTask.name.substring(7, 8).toLowerCase() + preAssembleTask.name.substring(8, preAssembleTask.name.length() - 13) project.tinkerPatch.oldApk = "${originOldPath}/${flavorName}/${project.name}-${flavorName}-debug.apk" project.tinkerPatch.buildConfig.applyMapping = "${originOldPath}/${flavorName}/${project.name}-${flavorName}-debug-mapping.txt" project.tinkerPatch.buildConfig.applyResourceMapping = "${originOldPath}/${flavorName}/${project.name}-${flavorName}-debug-R.txt" } } } }
AndroidManifest.xml
中指定TINKER_ID?????
Tinker的使用方式如下,以gradle接入的release包为例:
- 每次编译或发包将安装包与mapping文件备份;
- 若有补丁包的需要,按自身需要修改你的代码、库文件等;
- 将备份的基准安装包与mapping文件输入到tinkerPatch的配置中;
- 运行tinkerPatchRelease,即可自动编译最新的安装包,并与输入基准包作差异,得到最终的补丁包。
简单来说,我们通过完全使用了新的Dex,那样既不出现Art地址错乱的问题,在Dalvik也无须插桩。当然考虑到补丁包的体积,我们不能直接将新的Dex放在里面。但我们可以将新旧两个Dex的差异放到补丁包中,这里我们可以调研的方法有以下几个:
-
BsDiff;它格式无关,但对Dex效果不是特别好,而且生成产物大小非常不稳定。当前微信对于so与部分资源,依然使用bsdiff算法;
-
DexMerge;它主要问题在于合成时内存占用过大,一个12M的dex,峰值内存可能达到70多M;
-
DexDiff;通过深入Dex格式,实现一套生成产物小,内存占用少以及支持增删改的算法。
如何选择?在“高可用”的核心诉求下,性能问题也尤为重要。非常庆幸微信在当时那个节点坚决的选择了自研DexDiff算法,这过程虽然有苦有泪,但也正是有它,才有现在的Tinker。