- 刚发布的版本出现了严重的Bug,这就需要去解决Bug、测试打包重新发布,这会耗费大量的人力和物力,代价比较大。
- 已经更正了此前发布版本的Bug,如果下个版本是大版本,那么两个版本之间间隔时间会很长,这样要等到下个大版本发布再修复Bug,而之前版本的Bug会长期的影响用户。
- 版本升级率不高,并且需要长时间来完成版本迭代,前版本的Bug就会一直影响不升级的用户。
- 有一些小但是很重要的功能需要在短时间内完成版本迭代,比如节日活动
Tinker背景
热修复的方案有很多种,其中原理也各不相同。目前开源的比较有名的有阿里AndFix、美团Robust、qq的QZone以及tinker等。今天我们就来分析一下tinker热修复的原理。
热修复
热修复的优势:
1.无需重新发布新版本,省时省力。
2.用户无感知修复,也无需下载最新应用,代价小。
3.修复成功率高,把损失降到最低。
Tinker 的特点是:
- 支持类替换、So 替换,资源替换是采用类似 instant-run 的方案
- 补丁包较小,自研 diff 方案,下发的是差量包,包括的是变更的内容
- 支持 gradle,提供了 gradle-plugin,允许我们配置很多内容
- 采用全量 Dex 更新,不需要额外处理
CLASS_ISPREVERIFIED
问题
Tinker热修复分三部分:
class文件修复、资源文件修复和so文件修复。
tinker原理浅析
热修复听起来很高端,其实主要是要解决两个问题:
1:代码加载
2:资源加载
代码加载
关于代码的加载,首先我们需要了解下android的类加载机制,在android系统中有两种classload,分别是PathClassLoader和DexClassLoader,它们都继承自BaseDexClassLoader,这两个类加载器的主要区别是:Android系统通过PathClassLoader来加载系统类和主dex中的类。而DexClassLoader则可用于加载指定路径的apk、jar或dex文件。上述两个类都是继承自BaseDexClassLoader。我们可以看一下系统在加载一个类的时候是如何找到这个类的,下面是关键代码:
// DexPathList
public Class findClass(String name, List<Throwable> suppressed) { for (Element element : dexElements) {
DexFile dex = element.dexFile; if (dex != null) {
Class clazz = dex.loadClassBinaryName(name, definingContext, suppressed); if (clazz != null) { return clazz;
}
}
} if (dexElementsSuppressedExceptions != null) {
suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
} return null;
}
可以看到系统在加载一个类的时候其实是从一个dex数组去加载的,当在前面的dex文件加载到这个类的时候就会把这个类返回而不会去管后面的dex文件,基于这个原理,只要我们把出问题的类打包成一个新的dex,然后把这个新的dex插在数组的最前面这样系统在加载类的时候就会加载我们修复bug后的类从而达到类的替换,实际上不管是QZone还是tinker都是这样做的。形式如下图所示:
类加载机制-来自QZone
既然Qzone和tinker都是采用这种方式实现类的替换,为什么要说tinker性能好而QZone性能损耗大呢?这是因为在加载类的时候存在这样一个问题:假设A类在static方法,private方法,构造函数,override方法中直接引用到B类。如果A类和B类在同一个dex中,那么A类就会被打上CLASS_ISPREVERIFIED标记,被打上这个标记的类不能引用其他dex中的类,否则就会报错,那使用上述的热修复方法就会出问题,就会出现我在前文所说的unexpected DEX problem。
为了防止热修复失败,需要防止类被打上CLASS_ISPREVERIFIED的标志,Qzone热修复方案会对所有类进行插桩操作,也就是在所有类的构造函数中引用另外一个单独的dex文件 heck.dex文件中的类,这种插桩操作导致所有类都无法打上CLASS_ISPREVERIFIED标识,也就解决了之前描述的问题。但这有一个副作用,会直接导致所有的verify与optimize操作在加载类时触发。这会产生一定的性能损耗。
为了优化性能需要避免进行插桩操作,微信Tinker针对这一问题采用了一种更优的方案。首先我们先通过下图来总体了解下Tinker热修复方案的流程:
tinker热修复流程
tinker热修复流程主要概况为:
1:新dex与旧dex通过差分算法生成差异包patch.dex
2:将patch dex下发到客户端,客户端将patch dex与旧dex合成为新的全量dex
3:将合成后的全量dex 插入到dex elements前面(此部分和QQ空间机制类似),完成修复
可见,Tinker和QQ空间方案最大的不同是,Tinker 下发新旧DEX的差异包,然后将差异包和旧包合成新dex之后进行dex的全量替换,这样也就避免了QQ空间中的插桩操作。以上就是tinker热修复中代码加载实现的原理了。
资源加载
关于资源加载,其实大家的方案都是差不多的,都是用AssetManager的隐藏方法addAssetPath。在tinker中为了修复资源文件,主要是做了两件事,首先在客户端通过补丁包patch.apk和本地的包base.apk进行合并得到fix.apk,这个过程比较耗时所以tinker会单独新开一个进程进行合并,合并好之后当想要使用base.apk的资源文件的时候tinker会引导使用fix.apk中的文件来代替从而达到资源文件的修复。因为fix.apk并没有安装,所以在使用fix.apk中的资源文件的时候就需要使用AssetManager的隐藏方法addAssetPath了。
在开发中为了获取某个资源,都是调用的context.getResource().getxxxx,这个context的具体实现类是contextImpl,而contextImpl的getResource()方法得到的是它的属性mResources,mResources代表了一个资源包,也就是说如果mResources和fix.apk对应起来我们就完成了资源的修复了,那么mResources又是在哪里得到的呢?
通过contextImpl源码的分析可以看到mResources的初始化最后都走到如下方法:
Resources getTopLevelResources(String resDir, CompatibilityInfo compInfo) {
ResourcesKey key = new ResourcesKey(resDir, compInfo.applicationScale);
Resources r; synchronized (mPackages) { // Resources is app scale dependent.
if (false) {
Slog.w(TAG, "getTopLevelResources: " + resDir + " / "
+ compInfo.applicationScale);
}
WeakReference<Resources> wr = mActiveResources.get(key);
r = wr != null ? wr.get() : null; //if (r != null) Slog.i(TAG, "isUpToDate " + resDir + ": " + r.getAssets().isUpToDate());
if (r != null && r.getAssets().isUpToDate()) { if (false) {
Slog.w(TAG, "Returning cached resources " + r + " " + resDir
+ ": appScale=" + r.getCompatibilityInfo().applicationScale);
} return r;
}
} //if (r != null) {
// Slog.w(TAG, "Throwing away out-of-date resources!!!! "
// + r + " " + resDir);
//}
//关键代码
AssetManager assets = new AssetManager(); if (assets.addAssetPath(resDir) == 0) { return null;
} //Slog.i(TAG, "Resource: key=" + key + ", display metrics=" + metrics);
DisplayMetrics metrics = getDisplayMetricsLocked(null, false);
r = new Resources(assets, metrics, getConfiguration(), compInfo); if (false) {
Slog.i(TAG, "Created app resources " + resDir + " " + r + ": "
+ r.getConfiguration() + " appScale="
+ r.getCompatibilityInfo().applicationScale);
}
synchronized (mPackages) {
WeakReference<Resources> wr = mActiveResources.get(key);
Resources existing = wr != null ? wr.get() : null; if (existing != null && existing.getAssets().isUpToDate()) { // Someone else already created the resources while we were
// unlocked; go ahead and use theirs.
r.getAssets().close(); return existing;
}
// XXX need to remove entries when weak references go away
mActiveResources.put(key, new WeakReference<Resources>(r)); return r;
}
}
通过上面代码可知mResources初始化的关键在于AssetManager.addAssetPath(resDir)。也就是通过resDir给AssetManager设置属性从而创建mResources,也就是说mResources和resDir一一对应的,而这个resDir其实是LoadedApk的属mResourecDir。
因此整个逻辑其实只要修改LoadedApk的属性mResourecDir将它指向fix.apk就行了。tinker中也是这么做的,详细可看tinker中关于资源代码的加载:
public static void monkeyPatchExistingResources(Context context, String externalResourceFile) throws Throwable { if (externalResourceFile == null) { return;
} for (Field field : new Field[]{packagesFiled, resourcePackagesFiled}) { Object value = field.get(currentActivityThread); for (Map.Entry<String, WeakReference<?>> entry
: ((Map<String, WeakReference<?>>) value).entrySet()) { Object loadedApk = entry.getValue().get(); if (loadedApk == null) { continue;
} if (externalResourceFile != null) {
resDir.set(loadedApk, externalResourceFile);
}
}
} // Create a new AssetManager instance and point it to the resources installed under
if (((Integer) addAssetPathMethod.invoke(newAssetManager, externalResourceFile)) == 0) { throw new IllegalStateException("Could not create new AssetManager");
} // Kitkat needs this method call, Lollipop doesn't. However, it doesn't seem to cause any harm
// in L, so we do it unconditionally.
ensureStringBlocksMethod.invoke(newAssetManager); for (WeakReference<Resources> wr : references) {
Resources resources = wr.get(); //pre-N
if (resources != null) { // Set the AssetManager of the Resources instance to our brand new one
try {
assetsFiled.set(resources, newAssetManager);
} catch (Throwable ignore) { // N
Object resourceImpl = resourcesImplFiled.get(resources); // for Huawei HwResourcesImpl
Field implAssets = ShareReflectUtil.findField(resourceImpl, "mAssets");
implAssets.setAccessible(true);
implAssets.set(resourceImpl, newAssetManager);
}
clearPreloadTypedArrayIssue(resources);
resources.updateConfiguration(resources.getConfiguration(), resources.getDisplayMetrics());
}
} // Handle issues caused by WebView on Android N.
// Issue: On Android N, if an activity contains a webview, when screen rotates
// our resource patch may lost effects.// publicSourceDirField.set(context.getApplicationInfo(), externalResourceFile);
if (!checkResUpdate(context)) { throw new TinkerRuntimeException(ShareConstants.CHECK_RES_INSTALL_FAIL);
}
}
简单说一下上面的代码,其中参数externalResourceFile代表外部资源的路径也就是合成补丁包之后的fix.apk的路径。主要逻辑在那两个for循环,外层的for循环packagesFiled和resourcePackagesFiled代表的是ActivityThread的两个变量mPackages和mResourcePackages,这两个变量都是hashmap,以弱引用的形式将LoadedApk对象存起来。
里面的for循环就简单了,就是把mPackages和mResourcePackages这两个hashmap里存放的LoadedApk对象拿出来,然后通过反射的方式将这个loadApk的resDir属性设置为fix.apk的路径externalResourceFile。通过之前对mResources的初始化的分析可知,最后在加载资源的时候加载的资源文件就是fix.apk中的资源文件了从而达到了资源的加载。
3.tinker接入流程
tinker的接入过程其实也不简单,当然如果你是人民币玩家的话可以通过TinkerPatch 快速接入,如果不愿意花钱的话就接着往下看咯
gradle接入
在项目的build.gradle文件中添加tinker-patch-gradle-plugin依赖:
buildscript {
dependencies {
classpath("com.tencent.tinker:tinker-patch-gradle-plugin:${TINKER_VERSION}") {
changing = TINKER_VERSION?.endsWith("-SNAPSHOT")
exclude group: 'com.android.tools.build', module: 'gradle'
}
}
}
TINKER_VERSION是定义在gradle.properties文件中的全局变量代表着目前tinker的版本号。然后在app的gradle文件app/build.gradle我们需要添加tinker的库依赖:
dependencies {
//tinker的核心库
implementation("com.tencent.tinker:tinker-android-lib:${TINKER_VERSION}") { changing = true }
//可选,用于生成application类
annotationProcessor("com.tencent.tinker:tinker-android-anno:${TINKER_VERSION}") { changing = true }
compileOnly("com.tencent.tinker:tinker-android-anno:${TINKER_VERSION}") { changing = true }
implementation "com.android.support:multidex:1.0.1"}
当然在app的gradle文件中还需要配置大量的tinker相关的配置,这里就不一一写出来了具体的还需要哪些可以查看我在文尾部的demo。当一切都配置好之后就需要对我们的application类进行改造了。
自定义Application类
程序启动时会加载默认的Application类,这导致我们补丁包是无法对它做修改了。如何规避?在这里我们并没有使用类似InstantRun hook Application的方式,而是通过代码框架的方式来避免,这也是为了尽量少的去反射,提升框架的兼容性。其实这个改造起来也简单,先上代码:
@SuppressWarnings("unused")@DefaultLifeCycle(
application = ".MyApplication", //application类名
loaderClass = "com.tencent.tinker.loader.TinkerLoader", //loaderClassName, 我们这里使用默认即可!
flags = ShareConstants.TINKER_ENABLE_ALL,
loadVerifyFlag = false)public class MyApplicationLike extends DefaultApplicationLike { public static Application application; @Override
public void onBaseContextAttached(Context base) { super.onBaseContextAttached(base);
MultiDex.install(base);
MyApplicationLike.application = getApplication(); //should set before tinker is installed
TinkerManager.setUpgradeRetryEnable(true);
TinkerManager.installedTinker(this);
} //余下的代码省略了}
1:新建一个类比如叫MyApplicationLike继承DefaultApplicationLike
2:将工程原先的application类的代码都拷贝到MyApplicationLike中,并将之前application类中attachBaseContext方法实现要单独移动到MyApplicationLike的onBaseContextAttached中;
3:对MyApplicationLike中,引用application的地方改成getApplication();
4:对其他引用Application或者它的静态对象与方法的地方,改成引用MyApplicationLike的静态对象与方法;
5:将你工程的原先的application类删除,然后在AndroidManifest.xml里面声明Applicaiton的路径就是在MyApplicationLike中通过注解声明的application的路径
6:MyApplicationLike类上方注解中有四个参数的声明,application代表通过注解生成的application类的路径,用于填在AndroidManifest.xml中,第一次可能会报红,build一下工程就好,loaderClass是加载tinker的主类名,一般不需要修改,默认就好可以不写。
flags 是tinker运行时支持的补丁包中的文件类型,ShareConstants.TINKER_ENABLE_ALL的意思是支持所有文件类型,通常都是设置这个模式。loadVerifyFlag 也可以不写,默认是false表示加载时并不会去校验tinker文件的Md5,因为在补丁合成的时候已经已经校验了各个文件的Md5。
更详细的事例,大家可以参考tinker官方demo中SampleApplicationLike的做法。
好了到了这里基本上tinker的接入就做完了。接下来就是实操阶段了。tinker在每次编译打包之后都会帮我们生成基准包和基准包对应的R.txt
所以如果需要对某个版本打补丁包进行热修复的话,前期就需要把这个版本所对应的基准包和对应的R.txt记录下来,然后在app的gradle文件中添上对应的基准包和对应的R.txt。
这之后就可以打补丁包了,打补丁包的方式可以通过命令行也可以使用gradle插件,我这里是使用gradle的方式:
我这里是打的测试的补丁包,所以点击上图箭头所示就能成功打出补丁包了,然后在build目下就能找到补丁包了:
如上图所示,一共有三个apk文件供我们选择,从上到下分别代表签名后的打包,签名后通过压缩工具压缩后的打包以及未签名的打包,一般我们都是选择签名后压缩的包作为补丁包。拿到补丁包之后最好改一下文件名再下发给客户端合成,防止运营商对apk文件进行劫持。这里我为了试验直接patch_signed_7zip.apk这个apk文件重命名成patch文件放在工程目录下:
然后连上手机将这个patch文件推到手机中的某个目录比如:
adb push patch /storage/emulated/0/patch.apk
好了,这样通过adb push的方式来模拟客户端从服务器下载补丁文件过程,之后补丁文件就已经下发到手机了,这之后就可以通过tinker来合成补丁完成热修复工作了。tinker合成补丁也很简单:
TinkerInstaller.onReceiveUpgradePatch(getApplicationContext(), yourFilePath);
注意:因为补丁是需要从服务器上下载到本地,所以这里涉及到SD文件的读取,所以请自行处理APP权限的事情。
调用上面的代码就能完成补丁的合成工作了,合成成功之后应用会退出,然后再重启应用去见证奇迹吧~
文末
好了,分析到这里我们应该都明白tinker热修复的原理了!它的核心思想就是根据classLoader的加载机制在应用程序启动的时候把修复好的dex包加在有bug的dex包的前面实现对有bug的类的替换。
更多**Android核心技术学习**~获
但是tinker整个框架远远不是这么简单,因为作为一个框架它要考虑的东西要复杂得多,如文章开头提到的Android N混合编译以及其他如dex的验证机制还有针对Android各个版本的兼容性问题等等。