Tinker资源补丁原理解析

作者: 好重 | 来源:发表于2020-04-06 19:39 被阅读0次

    Tinker是Android上一套强大的补丁工具,它不仅支持dex的补丁,还支持资源和so的补丁,本文带大家来分析一下Tinker进行资源补丁的原理。

    假设线上版本是1.0,当前开发完成的版本是2.0,我们要对1.0的版本下发补丁,使之升级到2.0。

    1. 概览

    使用Tinker完成一次补丁,要进行三个步骤:

    1. 生成差量补丁包(Diff)
      补丁包也就是差量包,就是使用tinker-patch-cli工具,输入1.0和2.0的apk包,生成补丁包patch.zip。
    2. 合成全量资源包(Merge)
      当客户端收到补丁包时,会在一个独立的进程,用补丁包与客户端的1.0的apk包进行合并,生成全量的新的资源包resource.apk。
    3. 加载全量资源包(Load)
      在下一次启动app时,会通过反射注入的方式,改变LoadedApk的mResDir,使之指向resource.apk的目录,以及新创建一个包含resource.apk目录的AssetManager对象,设置到ResourcesManager中的Resources对象中。

    2. 资源的定义

    首先,我们需要知道一个APK包中哪些文件是资源。Diff的过程需要输入一个tinker_config.xml文件,其中定义了匹配资源文件名的正则表达式列表,如下所示:

        <issue id="resource">
            <!--what resource in apk are expected to deal with tinkerPatch-->
            <!--it support * or ? pattern.-->
            <!--you must include all your resources in apk here-->
            <!--otherwise, they won't repack in the new apk resources-->
            <pattern value="res/*"/>
            <pattern value="r/*"/>
            <pattern value="assets/*"/>
            <pattern value="resources.arsc"/>
            <pattern value="AndroidManifest.xml"/>
            <!--ignore add, delete or modify resource change-->
            <!--Warning, we can only use for files no relative with resources.arsc, such as assets files-->
            <!--it support * or ? pattern.-->
            <!--Such as I want assets/meta.txt use the base.apk version whatever it is change ir not.-->
            <ignoreChange value="assets/*"/>
    
            <!--ignore any warning caused by add, delete or modify changes on resources specified by this pattern.-->
            <ignoreChangeWarning value="" />
            <!--default 100kb-->
            <!--for modify resource, if it is larger than 'largeModSize'-->
            <!--we would like to use bsdiff algorithm to reduce patch file size-->
            <largeModSize value="100"/>
    
        </issue>
    

    因此,只要apk包中文件的名字匹配到这些正则表达式,那么就认为是资源,在生成和合成资源补丁的过程,就会被考虑到。

    3. 生成差量补丁包

    生成补丁包的步骤可以参考Tinker接入指南(https://github.com/Tencent/tinker/wiki/Tinker-%E6%8E%A5%E5%85%A5%E6%8C%87%E5%8D%97),可以使用如下命令:

    java -jar tinker-patch-cli.jar -old old.apk -new new.apk -config tinker_config.xml -out output_path
    

    指定old.apk,new.apk,也就是本文中的1.0和2.0的apk,以及tinker_config.xml文件,和输出文件夹。

    该命令的第一步是解析tinker_config.xml,得到新旧apk的文件目录、各种类型的补丁对应的pattern、输出文件夹的位置以及签名的文件等。

    # CliMain.java
    loadConfigFromXml(configFile, outputFile, oldApkFile, newApkFile); // 加载配置文件
    tinkerPatch(); // 生成补丁包
    

    生成补丁包具体的逻辑是在ApkDecoder,关键的代码是:

    # ApkDecoder.java
    public boolean patch(File oldFile, File newFile) throws Exception {
        writeToLogFile(oldFile, newFile);
        //check manifest change first
        manifestDecoder.patch(oldFile, newFile);
        // 将新旧apk分别解压到output_path/new和output_path/old目录
        unzipApkFiles(oldFile, newFile);
    
        // 遍历output_path/new目录中的每个文件,根据pattern使用对应的decoder进行patch
        Files.walkFileTree(mNewApkDir.toPath(), new ApkFilesVisitor(config, mNewApkDir.toPath(), mOldApkDir.toPath(), dexPatchDecoder, soPatchDecoder, resPatchDecoder));
    
        // get all duplicate resource file
        for (File duplicateRes : resDuplicateFiles) {
            // resPatchDecoder.patch(duplicateRes, null);
            Logger.e("Warning: res file %s is also match at dex or library pattern, "
                + "we treat it as unchanged in the new resource_out.zip", getRelativePathStringToOldFile(duplicateRes));
        }
    
        soPatchDecoder.onAllPatchesEnd();
        dexPatchDecoder.onAllPatchesEnd();
        manifestDecoder.onAllPatchesEnd();
        resPatchDecoder.onAllPatchesEnd();
        arkHotDecoder.onAllPatchesEnd();
    
        //clean resources
        dexPatchDecoder.clean();
        soPatchDecoder.clean();
        resPatchDecoder.clean();
        arkHotDecoder.clean();
    
        return true;
    }
    

    关键的地方在于

    Files.walkFileTree(mNewApkDir.toPath(), new ApkFilesVisitor(config, mNewApkDir.toPath(), mOldApkDir.toPath(), dexPatchDecoder, soPatchDecoder, resPatchDecoder));
    

    它遍历output_path/new目录中的每个文件,根据文件名匹配的pattern使用对应的decoder进行patch,对于资源类型的文件,使用的是ResDiffDecoder,它会根据output_path/new目录的资源文件名的相对路径,找到output_path/old对应相对路径的old文件来进行patch。

    1. 如果old文件不存在,那就把new文件加到addedSet中,并把new文件输出到output_path\tinker_result中。
    2. 如果是AndroidManifest.xml,则跳过,因为不能补AndroidManifest.xml文件
    3. 如果文件长度小于tinker_config.xml定义的largeModSize,则把new文件加入到modifiedSet中,并把new文件输出到output_path\tinker_result中。如果大于largeModSize,则使用bsdiff对new和old文件进行差分,得到增量文件,并把增量文件输出到output_path\tinker_result中。这里的目的是降低补丁包的大小。

    最后,生成res_meta.txt文件,即这次资源补丁的总结概要,用于下一步的补丁包合成过程。该文件内容是如下形式,

    resources_out.zip,2506242433,9c73ca515dcaa812d5d0b5cecac687f6
    pattern:4
    resources.arsc
    r/*
    res/*
    assets/*
    large modify:1
    resources.arsc,9c73ca515dcaa812d5d0b5cecac687f6,2836495678
    modify:2
    res/drawable-xxhdpi-v4/icon.png
    res/layout/layout_splash.xml
    add:1
    assets/only_use_to_test_tinker_resource.txt
    store:1
    res/drawable-xxhdpi-v4/icon.png

    其中,第一行第二个字段是旧apk中resources.arsc文件的crc校验码,如果与收到补丁的app的resources.arsc校验不通过,就不会进行补丁。

    另一个需要注意的是large modify的信息中,每行的第二个字段是新文件的md5,第三个字段是新文件的crc校验码,原因是在合成时要用bsdiff生成新文件,需要进行正确性校验。

    4. 合成全量资源包

    假设补丁包包的md5是417b677a56c2818832b5e0390f34d29c。

    客户端收到补丁之后,开始补丁的合成,该过程的目标是生成一个完整的resource.apk文件,其包含了2.0版本运行所需的所有资源。该resource.apk位于 /data/data/包名/tinker/patch-417b677a/res 目录中。

    资源合成的入口代码是:

    # UpgradePatch.java
    @Override
    public boolean tryPatch(Context context, String tempPatchPath, PatchResult patchResult) {
        Tinker manager = Tinker.with(context);
        
        ... 
            
        if (!ResDiffPatchInternal.tryRecoverResourceFiles(manager, signatureCheck, context, patchVersionDirectory, destPatchFile)) {
            TinkerLog.e(TAG, "UpgradePatch tryPatch:new patch recover, try patch resource failed");
            return false;
        }
    }
    

    首先把补丁包拷贝到目录:/data/data/包名/tinker/patch-417b677a/,其中patchVersionDirectory就是这个目录,而destPatchFile就是这个拷贝后的补丁的文件路径,signatureCheck用于确认补丁的签名是否和当前app的签名一致。

    合成补丁的核心是解析res_meta.txt文件,明确哪些资源文件是新增的、哪些是修改的、哪些是需要通过bsdiff合成的,然后拿补丁包与ApplicationInfo.sourceDir指向的旧apk去做合成,最终生成resource.apk,放在/data/data/包名/tinker/patch-417b677a/res目录中。

    5. 加载全量资源包

    正常情况下,app的资源是通过LoadedApk对象从 /data/app/包名/base.apk 中获取的,那么加载补丁就需要修改这个路径,使之指向我们上一步生成的resource.apk的路径。

    加载补丁资源的时机是在Application的attachBaseContext之前,代码在TinkerApplication中。App接入Tinker需要定义一个继承自TinkerApplication的Application类,这个类是App真正的Application类,然后我们原先的Application的实现类需要改为继承自Tinker提供的DefaultApplicationLike类,设置到那个真正的Application中作为代理实现类。

    加载资源的代码路径是这样的:

    1. TinkerApplication的loadTinker()方法
    2. TinkerLoader的tryLoad()方法
    3. TinkerResourceLoader的loadTinkerResources()方法
    4. TinkerResourcePatcher.monkeyPatchExistingResources()方法

    我们来看一下代码:

    # TinkerResourcePatcher.java
    /**
     * @param context
     * @param externalResourceFile
     * @throws Throwable
     */
    public static void monkeyPatchExistingResources(Context context, String externalResourceFile) throws Throwable {
        if (externalResourceFile == null) {
            return;
        }
    
        final ApplicationInfo appInfo = context.getApplicationInfo();
    
        final Field[] packagesFields;
        if (Build.VERSION.SDK_INT < 27) {
            packagesFields = new Field[]{packagesFiled, resourcePackagesFiled};
        } else {
            packagesFields = new Field[]{packagesFiled};
        }
        for (Field field : packagesFields) {
            final Object value = field.get(currentActivityThread);
    
            for (Map.Entry<String, WeakReference<?>> entry
                    : ((Map<String, WeakReference<?>>) value).entrySet()) {
                final Object loadedApk = entry.getValue().get();
                if (loadedApk == null) {
                    continue;
                }
                final String resDirPath = (String) resDir.get(loadedApk);
                if (appInfo.sourceDir.equals(resDirPath)) {
                    // 1. 设置 LoadedApk对象的mResDir属性的值,指向补丁资源全量包resource.apk的路径
                    resDir.set(loadedApk, externalResourceFile);
                }
            }
        }
    
        // 2. 创建一个新的AssetManager,并调用其addAssetPath方法使之指向补丁资源全量包resource.apk的路径
        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.
        if (stringBlocksField != null && ensureStringBlocksMethod != null) {
            stringBlocksField.set(newAssetManager, null);
            ensureStringBlocksMethod.invoke(newAssetManager);
        }
        // 3. 遍历ResourcesManager中mActiveResources列表中的Resources对象,将新的AssetManager对象设置进去
        for (WeakReference<Resources> wr : references) {
            final Resources resources = wr.get();
            if (resources == null) {
                continue;
            }
            // Set the AssetManager of the Resources instance to our brand new one
            try {
                //pre-N
                assetsFiled.set(resources, newAssetManager);
            } catch (Throwable ignore) {
                // N
                final Object resourceImpl = resourcesImplFiled.get(resources);
                // for Huawei HwResourcesImpl
                final Field implAssets = findField(resourceImpl, "mAssets");
                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.
        // for 5.x/6.x, we found Couldn't expand RemoteView for StatusBarNotification Exception
        if (Build.VERSION.SDK_INT >= 24) {
            try {
                if (publicSourceDirField != null) {
                    publicSourceDirField.set(context.getApplicationInfo(), externalResourceFile);
                }
            } catch (Throwable ignore) {
                // Ignored.
            }
        }
    
        if (!checkResUpdate(context)) {
            throw new TinkerRuntimeException(ShareConstants.CHECK_RES_INSTALL_FAIL);
        }
    }
    

    上边已经加了注释,主要的步骤有三个:

    1. 设置 LoadedApk对象的mResDir属性的值,指向补丁资源全量包resource.apk的路径
    2. 创建一个新的AssetManager,并调用其addAssetPath方法使之指向补丁资源全量包resource.apk的路径
    3. 遍历ResourcesManager中mActiveResources列表中的Resources对象,将新的AssetManager对象设置进去

    至此,资源补丁的过程就结束了。

    扩展阅读资料:
    Android热补丁之Tinker原理解析

    相关文章

      网友评论

        本文标题:Tinker资源补丁原理解析

        本文链接:https://www.haomeiwen.com/subject/wwjbphtx.html