Android技术知识Android开发Android开发

Tinker资源补丁原理解析

2020-04-06  本文已影响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原理解析

上一篇下一篇

猜你喜欢

热点阅读