热修复早在几年前就已经被讨论的热火朝天了,对于现在来说也不是什么新的技术了,而关于热修复的方案主流的也有很多种,有修改C层的也有修改java层的,项目里打算接入tinker,在研究了一番之后打算把tinker的一些原理以及怎么接入记录一下,以备后面忘记的时候方便查阅。
1.主流热修复框架介绍
热修复就是在不重新打包发布新版本的前提下通过下发一个补丁包去把当前市场上的存在问题的版本给修复了,一个简单的场景就是当你刚发布一个版本的时候却发现里面有一个小的bug,这个时候如果又重新打包发布新版本就有点繁琐了,热修复在这种场景下就显得比较重要了。目前市场上关于热修复的方案如下:
可以看到tinker不仅支持类替换也支持so,资源的替换等等,而其他的方案则或多或少有点其他的问题。
一:AndFix是native层的实现。这套方案直接使用dalvik_replaceMethod替换class中方法的实现,所以他没有替换类,也修改不了字段,只能对方法做修复。
二:QZone是java层的实现,是基于classload来比较友好的替换掉出问题的类,但是由于QZone是把出问题的类打包成一个dex插在dex列表的最前面,这会引起在加载类的时候如果一个类和他引用的类不在一个dex文件就会引起unexpected DEX problem,而QZone为了解决这个问题采用了插桩的方式。这种方式对类的加载会有一定的性能损耗,所以这个方案性能损耗是比较大的,而且目前也没有开源。
三:tinker也是java层的实现,同QZone一样也是采用classload的形式来替换类,不同的是tinker是全量替换dex而不是仅打出差异dex,这样就不会有QZone出现的unexpected DEX problem,并且通过自研的DexDiff算法来合成新的全量dex的,性能损耗较小,这也是我们大部分人选择tinker的原因。
2.tinker原理浅析
热修复听起来很高端,其实主要是要解决两个问题:
1:代码加载
2:资源加载
代码加载
关于代码的加载,首先我们需要了解下android的类加载机制,在android系统中有两种classload,分别是PathClassLoader和DexClassLoader,它们都继承自BaseDexClassLoader,这两个类加载器的主要区别是:Android系统通过PathClassLoader来加载系统类和主dex中的类。而DexClassLoader则可用于加载指定路径的apk、jar或dex文件。上述两个类都是继承自BaseDexClassLoader。我们可以看一下系统在加载一个类的时候是如何找到这个类的,下面是关键代码:
// DexPathList
public Class findClass(String name, List 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和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热修复流程主要概况为:
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 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 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(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> entry
: ((Map>) 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 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要注意的事项还是有很多的,比如默认补丁升级成功后tinker会杀掉当前进程,让补丁包更快的生效,但是这在实际情况中肯定是行不通的,我们应该在应用处于后台的时候再去杀掉当前进程不给用户造成烦扰。具体的更多的细节请查看tinker官方文档Tinker wiki。随着android9.0对sdk调用隐藏方法的限制,很多人也在担心tinker会不会还能继续工作,这个我在9.0 的模拟器上试验过,使用tinker的最新版本在9.0的android机器上也是可以正常工作的,所以大家无须担心这个~
最后大家在集成tinker上有什么不明白的地方可以参考我的tinkerdemo
上面是我个人公众号,欢迎关注哈~