腾讯最核心的——微信Tinker框架热修复,你学过多少?

  • 刚发布的版本出现了严重的Bug,这就需要去解决Bug、测试打包重新发布,这会耗费大量的人力和物力,代价比较大。
  • 已经更正了此前发布版本的Bug,如果下个版本是大版本,那么两个版本之间间隔时间会很长,这样要等到下个大版本发布再修复Bug,而之前版本的Bug会长期的影响用户。
  • 版本升级率不高,并且需要长时间来完成版本迭代,前版本的Bug就会一直影响不升级的用户。
  • 有一些小但是很重要的功能需要在短时间内完成版本迭代,比如节日活动

Tinker背景

热修复的方案有很多种,其中原理也各不相同。目前开源的比较有名的有阿里AndFix、美团Robust、qq的QZone以及tinker等。今天我们就来分析一下tinker热修复的原理。

热修复

热修复的优势:

1.无需重新发布新版本,省时省力。
2.用户无感知修复,也无需下载最新应用,代价小。
3.修复成功率高,把损失降到最低。

Tinker 的特点是:

  1. 支持类替换、So 替换,资源替换是采用类似 instant-run 的方案
  2. 补丁包较小,自研 diff 方案,下发的是差量包,包括的是变更的内容
  3. 支持 gradle,提供了 gradle-plugin,允许我们配置很多内容
  4. 采用全量 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都是这样做的。形式如下图所示:

img

类加载机制-来自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热修复方案的流程:

img

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

img

所以如果需要对某个版本打补丁包进行热修复的话,前期就需要把这个版本所对应的基准包和对应的R.txt记录下来,然后在app的gradle文件中添上对应的基准包和对应的R.txt。

img

这之后就可以打补丁包了,打补丁包的方式可以通过命令行也可以使用gradle插件,我这里是使用gradle的方式:

img

我这里是打的测试的补丁包,所以点击上图箭头所示就能成功打出补丁包了,然后在build目下就能找到补丁包了:

img

如上图所示,一共有三个apk文件供我们选择,从上到下分别代表签名后的打包,签名后通过压缩工具压缩后的打包以及未签名的打包,一般我们都是选择签名后压缩的包作为补丁包。拿到补丁包之后最好改一下文件名再下发给客户端合成,防止运营商对apk文件进行劫持。这里我为了试验直接patch_signed_7zip.apk这个apk文件重命名成patch文件放在工程目录下:

img

然后连上手机将这个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各个版本的兼容性问题等等。

猜你喜欢

转载自blog.csdn.net/Androidxiaofei/article/details/125322251