Tinker使用及源码分析

Tinker的patch生成过程

2020-03-08  本文已影响0人  David_zhou

Tinker的patch生成方式有两种,一种是通过命令行,一种是通过gradle的方式。下面我们简单介绍下gradle的方式。这篇文章包括一下几部分

patch文件的组成
生成流程
patch文件的生成
dex的差分
so的差分
生成meta,版本文件等

涉及目录

Patch打包的涉及的目录是tinker-build。其中命令行涉及的目录是tinker-patch-cli, gradle涉及的目录是tinker-patch-gradle-plugin。命令行和gradle只是入口不一样,而patch生成的代码主要在tinker-patch-lib目录中。tinker中dex的差分是微信自己实现的一套dexdiff算法,差分的算法代码主要在tinker-commons中,其中DexPatchApplier是进行生成patch的入口。那么其他文件的差分呢,比如so,resource文件的差分是采用的什么算法呢?

patch文件的组成

changed_classes.dex
META_INF
----XXX.RSA
----XXX.SF
----XXX.MF
test.dex
assets
----package_meta.txt
----dex_meta.txt
YAPATCH.MF

其中changed_classes.dex和test.dex是dex。test.dex是为了验证dex的加载是否成功,test.dex中含有com.tencent.tinker.loader.TinkerTestDexLoad类,该类中包含一个字段isPatch,补丁的加载校验是在SystemClassLoaderAdder中的checkDexInstall方法。
checkDexInstall就是通过findField该字段判断是否加载成功。

需要注意的是这个test.dex是从目录下直接拷贝的,而不是直接生成的,test.dex中只有一个TinkerTestDexLoad类,而且其中的属性isPatch是true。因此如果没有加载patch,就会直接加载打包进apk中的TinkerTestDexLoad,此时的isPatch属性为false.如果加载补丁成功,就会从patch中的class.dex中读取TinkerTestDexLoad这个类,而class.dex中TinkerTestDexLoad的属性isPatch是true。因此可以使用读取patch的值来作为补丁是否加载成功的依据。

而changed_classes.dex可能因为改动代码的范围而生成多个changed_class。 根据不同的情况,最多有四个文件是以meta.txt结尾的:

package_meta.txt 补丁包的基本信息
dex_meta.txt dex补丁的信息
so_meta.txt so补丁的信息
res_meta.txt 资源补丁的信息

package_meta.txt中的格式范例如下:

#base package config field
#Tue Jun 25 15:32:59 CST 2019
NEW_TINKER_ID=XXXXXX-patch
TINKER_ID=XXXXXX-base

而dex_meta.txt中的格式范例如下:

changed_classes.dex,,5d4ce4b80d4d5168006a63a5a16d94b3,5d4ce4b80d4d5168006a63a5a16d94b3,0,0,0,jar
test.dex,,56900442eb5b7e1de45449d0685e6e00,56900442eb5b7e1de45449d0685e6e00,0,0,0,jar

而res_meta.txt文件的格式范例如下:

resources_out.zip,4019114434,6148149bd5ed4e0c2f5357c6e2c577d6
pattern:4
resources.arsc
r/*
res/*
assets/*
modify:1
r/g/ag.xml
add:1
assets/only_use_to_test_tinker_resource.txt

生成流程

下面分析比较常用的gradle方法生成patch的流程。gradle插件的入口是TinkerPatchPlugin。其中调用了多个task,名字及类型如下所示:

tinkerPatch${variantName}                 类型:TinkerPatchSchemaTask
tinkerProcess${variantName}Manifest       类型:TinkerManifestTask
tinkerProcess${variantName}ResourceId     类型:TinkerResourceIdTask
tinkerProcess${variantName}Proguard       类型:TinkerProguardConfigTask
tinkerProcess${variantName}MultidexKeep   类型:TinkerMultidexConfigTask

我们先重点看下tinkerPatch${variantName}这个task,这个task是TinkerPatchSchemaTask类型,在TinkerPatchPlugin的setPatchNewApkPath方法中有如下一句代码:

tinkerPatchBuildTask.dependsOn variant.assemble

因此在对应的variant执行完assemble之后,就会执行tinkerPatch${variantName}。

// com.tencent.tinker.build.gradle.task.TinkerPatchSchemaTask
@TaskAction
def tinkerPatch() {
    //开始打包patch
    configuration.checkParameter()
    configuration.buildConfig.checkParameter()
    configuration.res.checkParameter()
    configuration.dex.checkDexMode()
    configuration.sevenZip.resolveZipFinalPath()

    InputParam.Builder builder = new InputParam.Builder()
    if (configuration.useSign) {
        if (signConfig == null) {
            throw new GradleException("can't the get signConfig for this build")
        }
        builder.setSignFile(signConfig.storeFile)
                .setKeypass(signConfig.keyPassword)
                .setStorealias(signConfig.keyAlias)
                .setStorepass(signConfig.storePassword)
    }

    // patch的参数,从tinker.gradle中读取配置信息
    builder.setOldApk(configuration.oldApk)
            .setNewApk(buildApkPath)
            .setOutBuilder(outputFolder)
            .setIgnoreWarning(configuration.ignoreWarning)
            .setAllowLoaderInAnyDex(configuration.allowLoaderInAnyDex)
            .setRemoveLoaderForAllDex(configuration.removeLoaderForAllDex)
            .setDexFilePattern(new ArrayList<String>(configuration.dex.pattern))
            .setIsProtectedApp(configuration.buildConfig.isProtectedApp)//  note  isProtectedApp,是在tinker.gradle中配置
            .setIsComponentHotplugSupported(configuration.buildConfig.supportHotplugComponent)
            .setDexLoaderPattern(new ArrayList<String>(configuration.dex.loader))
            .setDexIgnoreWarningLoaderPattern(new ArrayList<String>(configuration.dex.ignoreWarningLoader))
            .setDexMode(configuration.dex.dexMode)
            .setSoFilePattern(new ArrayList<String>(configuration.lib.pattern))
            .setResourceFilePattern(new ArrayList<String>(configuration.res.pattern))
            .setResourceIgnoreChangePattern(new ArrayList<String>(configuration.res.ignoreChange))
            .setResourceIgnoreChangeWarningPattern(new ArrayList<String>(configuration.res.ignoreChangeWarning))
            .setResourceLargeModSize(configuration.res.largeModSize)
            .setUseApplyResource(configuration.buildConfig.usingResourceMapping)
            .setConfigFields(new HashMap<String, String>(configuration.packageConfig.getFields()))
            .setSevenZipPath(configuration.sevenZip.path)
            .setUseSign(configuration.useSign)
            .setArkHotPath(configuration.arkHot.path)
            .setArkHotName(configuration.arkHot.name)

    InputParam inputParam = builder.create()
    Runner.gradleRun(inputParam);
}

这个方法首先从tinker.gradle中读取相关配置,然后作为参数,开始调用Runner.gradleRun方法开始准备生成patch文件。gradleRun中调用来run方法,run中直接调用来tinkerPatch, 这个方法就真正开始创建patch。下面我们看下这个方法,代码如下:

// com.tencent.tinker.build.patch.Runner  
protected void tinkerPatch() {
    Logger.d("-----------------------Tinker patch begin-----------------------");

    Logger.d(mConfig.toString());
    try {
         //gen patch
         ApkDecoder decoder = new ApkDecoder(mConfig);
         decoder.onAllPatchesStart();
         decoder.patch(mConfig.mOldApkFile, mConfig.mNewApkFile);
         decoder.onAllPatchesEnd();

         //gen meta file and version file
         PatchInfo info = new PatchInfo(mConfig);
         info.gen();

         //build patch
         PatchBuilder builder = new PatchBuilder(mConfig);
         builder.buildPatch();

    } catch (Throwable e) {
        goToError(e, ERRNO_USAGE);
    }

    Logger.d("Tinker patch done, total time cost: %fs", diffTimeFromBegin());
    Logger.d("Tinker patch done, you can go to file to find the output %s", mConfig.mOutFolder);
    Logger.d("-----------------------Tinker patch end-------------------------");
}

前面讲过patch文件的组成,在tinkerPatch也是分几步生成,首先生成dex等patch文件,然后生成meta和版本等文件,最后将前两步生成的文件打包成patch。

patch文件的生成

下面我们先重点看下patch的第一部分的生成。 ApkDecoder的构造方法如下:

// com.tencent.tinker.build.decoder.ApkDecoder
public ApkDecoder(Configuration config) throws IOException {
        super(config);
        this.mNewApkDir = config.mTempUnzipNewDir;
        this.mOldApkDir = config.mTempUnzipOldDir;

        this.manifestDecoder = new ManifestDecoder(config);

        //put meta files in assets
        String prePath = TypedValue.FILE_ASSETS + File.separator;
        dexPatchDecoder = new UniqueDexDiffDecoder(config, prePath + TypedValue.DEX_META_FILE, TypedValue.DEX_LOG_FILE);
        soPatchDecoder = new BsDiffDecoder(config, prePath + TypedValue.SO_META_FILE, TypedValue.SO_LOG_FILE);
        resPatchDecoder = new ResDiffDecoder(config, prePath + TypedValue.RES_META_TXT, TypedValue.RES_LOG_FILE);
        arkHotDecoder = new ArkHotDecoder(config, prePath + TypedValue.ARKHOT_META_TXT);
        Logger.d("config: " + config.mArkHotPatchPath + " " + config.mArkHotPatchName + prePath + TypedValue.ARKHOT_META_TXT);
        resDuplicateFiles = new ArrayList<>();
    }

ApkDecoder有ManifestDecoder,UniqueDexDiffDecoder,BsDiffDecoder,ResDiffDecoder,ArkHotDecoder类型的多个Decoder,在构造方法将这几个成员变量初始化。 各个Decoder分别针对代码中不同的部分进行patch。都是继承自BaseDecoder,然后实现了patch,onAllPatchesStart,onAllPatchesEnd这个三个抽象方法。 创建了ApkDecoder实例decoder后,调用onAllPatchesStart进行patch之前的准备工作。onAllPatchesStart代码如下:

@Override
public void onAllPatchesStart() throws IOException, TinkerPatchException {
    manifestDecoder.onAllPatchesStart();
    dexPatchDecoder.onAllPatchesStart();
    soPatchDecoder.onAllPatchesStart();
    resPatchDecoder.onAllPatchesStart();
}

随后每个decoder分别执行自己的onAllPatchesStart方法。调用onAllPatchesStart方法后,就调用ApkDecoder的patch方法开始生成patch。patch方法的代码如下:

// com.tencent.tinker.build.decoder.ApkDecoder
public boolean patch(File oldFile, File newFile) throws Exception {
    writeToLogFile(oldFile, newFile);
    //check manifest change first
    manifestDecoder.patch(oldFile, newFile);

    unzipApkFiles(oldFile, newFile);

    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;
}

按照代码中的注释,先检查manifest文件的改动。然后调用Files.walkFileTree来生成patch。我们先看下manifestDecoder的patch的文件是如何检查manifest文件的,代码入下:

// com.tencent.tinker.build.decoder.ManifestDecoder
public boolean patch(File oldFile, File newFile) throws IOException, TinkerPatchException {
    try {
        AndroidParser oldAndroidManifest = AndroidParser.getAndroidManifest(oldFile);
        AndroidParser newAndroidManifest = AndroidParser.getAndroidManifest(newFile);

        // Android版本低于14直接返回,此处忽略
        final String oldXml = oldAndroidManifest.xml.trim();
        final String newXml = newAndroidManifest.xml.trim();
        final boolean isManifestChanged = !oldXml.equals(newXml);

        f (!isManifestChanged) {
            Logger.d("\nManifest has no changes, skip rest decode works.");
            return false;
        }
        ...... 省略对manifest变化的处理。
    }
}

这部分的逻辑是先调用AndroidParser的getAndroidManifest方法从文件中读取到manifest的内容,然后拿出其中的xml进行比较。如果没有变化,直接返回。一般情况下manifest的变化都是新增四大组件导致的, 而热更比较少新增,因此manifest变化的情况先跳过。另外manifest的修改了,后续进行patch的合成加载进行相应的处理。 回到ApkDecoder的patch方法继续看,将要打包patch的两个apk解压后。然后调用Files的walkFileTree方法来遍历新的apk解压后的目录。这个Files是NIO中的类,walkFileTree方法传递两个参数,一个是 要遍历的目录,第二个参数是遍历行为控制器FileVisitor,它是一个接口,里面定义了4个方法用来指定当你访问一个节点之前、之中、之后、失败时应该采取什么行动。这里看下ApkFilesVisitor这个类,实现了visitFile方法,制定访问文件时的操作行为。的构造方法代码如下:

// com.tencent.tinker.build.decoder.ApkDecoder
ApkFilesVisitor(Configuration config, Path newPath, Path oldPath, BaseDecoder dex, BaseDecoder so, BaseDecoder resDecoder) {
    this.config = config;
    this.dexDecoder = dex;
    this.soDecoder = so;
    this.resDecoder = resDecoder;
    this.newApkPath = newPath;
    this.oldApkPath = oldPath;
}

将在ApkDecoder构造方法中初始化的各种decoder传递进来,在遍历目录针对不同类型的文件调用不同的decoder。下面看下遍历文件visitFile这个方法,代码如下:

com.tencent.tinker.build.decoder.ApkDecoder 
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
    Path relativePath = newApkPath.relativize(file);
    Path oldPath = oldApkPath.resolve(relativePath);

    File oldFile = null;
    //is a new file?!
    if (oldPath.toFile().exists()) {
        oldFile = oldPath.toFile();
    }
    String patternKey = relativePath.toString().replace("\\", "/");

    if (Utils.checkFileInPattern(config.mDexFilePattern, patternKey)) {//mDexFilePattern   "classes*.dex""classes*.dex"
        //also treat duplicate file as unchanged
        if (Utils.checkFileInPattern(config.mResFilePattern, patternKey) && oldFile != null) {
            resDuplicateFiles.add(oldFile);
        }

        try {
            dexDecoder.patch(oldFile, file.toFile());
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
        return FileVisitResult.CONTINUE;
    }
    if (Utils.checkFileInPattern(config.mSoFilePattern, patternKey)) {//mSoFilePattern "lib/*/*.so"
        //also treat duplicate file as unchanged
        if (Utils.checkFileInPattern(config.mResFilePattern, patternKey) && oldFile != null) {
            resDuplicateFiles.add(oldFile);
        }
        try {
            soDecoder.patch(oldFile, file.toFile());
        } catch (Exception e) {
             throw new RuntimeException(e);
        }
        return FileVisitResult.CONTINUE;
    }
    if (Utils.checkFileInPattern(config.mResFilePattern, patternKey)) {// mResFilePattern  “res/*", "r/*", "assets/*", "resources.arsc"
        try {
            resDecoder.patch(oldFile, file.toFile());
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
        return FileVisitResult.CONTINUE;
    }
    return FileVisitResult.CONTINUE;
}

针对符合config.mDexFilePattern命名规则的文件调用UniqueDexDiffDecoder进行patch,对符合config.mSoFilePattern命名规则的文件调用BsDiffDecoder进行patch,对符合config.mResFilePattern命名要求的文件调用ResDiffDecoder进行patch合成。mDexFilePattern,mSoFilePattern,mResFilePattern这结果值是在tinker.gradle中进行的配置,一般使用默认就行。下面先对UniqueDexDiffDecoder的patch进行分析,从名字上来看,我们离差分操作不远了。

dex的差分

dex文件的查分操作在UniqueDexDiffDecoder的patch方法中,UniqueDexDiffDecoder继承自DexDiffDecoder。UniqueDexDiffDecoder的patch操作主要是调用父类的patch操作,之后对文件名进行重名判断。我们主要看下DexDiffDecoder的patch方法,代码如下:

// com.tencent.tinker.build.decoder.DexDiffDecoder
@Override
public boolean patch(final File oldFile, final File newFile) throws IOException, TinkerPatchException {
    final String dexName = getRelativeDexName(oldFile, newFile);
    // first of all, we should check input files if excluded classes were modified.
    Logger.d("Check for loader classes in dex: %s", dexName);

    try {
        excludedClassModifiedChecker.checkIfExcludedClassWasModifiedInNewDex(oldFile, newFile);
    } catch (IOException e) {
        throw new TinkerPatchException(e);
    } catch (TinkerPatchException e) {
        // 省略异常处理
    }

    // If corresponding new dex was completely deleted, just return false.
    // don't process 0 length dex
    if (newFile == null || !newFile.exists() || newFile.length() == 0) {
        return false;
    }

    File dexDiffOut = getOutputPath(newFile).toFile();
    final String newMd5 = getRawOrWrappedDexMD5(newFile);

    //new add file
    if (oldFile == null || !oldFile.exists() || oldFile.length() == 0) {
        hasDexChanged = true;
        copyNewDexAndLogToDexMeta(newFile, newMd5, dexDiffOut);
        return true;
    }

    final String oldMd5 = getRawOrWrappedDexMD5(oldFile);
    if ((oldMd5 != null && !oldMd5.equals(newMd5)) || (oldMd5 == null && newMd5 != null)) {
        hasDexChanged = true;
        if (oldMd5 != null) {
            collectAddedOrDeletedClasses(oldFile, newFile);
        }
    }

    RelatedInfo relatedInfo = new RelatedInfo();
    relatedInfo.oldMd5 = oldMd5;
    relatedInfo.newMd5 = newMd5;

    // collect current old dex file and corresponding new dex file for further processing.
    oldAndNewDexFilePairList.add(new AbstractMap.SimpleEntry<>(oldFile, newFile));

    dexNameToRelatedInfoMap.put(dexName, relatedInfo);

    return true;
}

patch这个方法主要是做一些准备工作,准备工作很繁琐,需要处理的情况真多。而进行差分工作主要在DexDiffDecoder的onAllPatchesEnd方法中,代码如下

// com.tencent.tinker.build.decoder.DexDiffDecoder
@Override
public void onAllPatchesEnd() throws Exception {
    if (!hasDexChanged) {
        Logger.d("No dexes were changed, nothing needs to be done next.");
        return;
    }
    // Whether tinker should treat the base apk as the one being protected by app
    // protection tools.
    // If this attribute is true, the generated patch package will contain a
    // dex including all changed classes instead of any dexdiff patch-info files.
    if (config.mIsProtectedApp) {
        generateChangedClassesDexFile();
    } else {
        generatePatchInfoFile();
    }
    addTestDex();
}

可以看到针对是否是加固的APP,有不同的处理。如果是加固的APP,则产生的package会包含所有的改动文件。而对于非加固的文件,只需要进行dexdiff算法后生成的patch-info文件。这部分的说明来自TinkerBuildConfigExtension中对于的isProtectedApp的注释。generateChangedClassesDexFile这个方法使用的是dexlib2库中的DexBuilder来生成dex文件。而generatePatchInfoFile这个方法使用的是DexPatchApplier,这个类是使用微信自研的dexDiff算法。对于这个算法,简单的介绍和示范在Android 热修复 Tinker 源码分析之DexDiff / DexPatch,全面详细的介绍在DexDiff。这个地方其实有点意外,因为一直以为dex的patch是使用微信自研的dexDiff算法,没想到加固的APP并不是这套算法,而是dexlib2这个框架。dex的patch这部分看了一天,看得很晕,感谢厉害的大佬们,以及更厉害的微信。

so的差分

so的patch是在BsDiffDecoder的patch中进行,

// com.tencent.tinker.build.decoder.BsDiffDecoder
@Override
    public boolean patch(File oldFile, File newFile) throws IOException, TinkerPatchException {
        //first of all, we should check input files
        if (newFile == null || !newFile.exists()) {
            return false;
        }
        //new add file
        String newMd5 = MD5.getMD5(newFile);
        File bsDiffFile = getOutputPath(newFile).toFile();

        if (oldFile == null || !oldFile.exists()) {
            FileOperation.copyFileUsingStream(newFile, bsDiffFile);
            writeLogFiles(newFile, null, null, newMd5);
            return true;
        }

        //both file length is 0
        if (oldFile.length() == 0 && newFile.length() == 0) {
            return false;
        }
        if (oldFile.length() == 0 || newFile.length() == 0) {
            FileOperation.copyFileUsingStream(newFile, bsDiffFile);
            writeLogFiles(newFile, null, null, newMd5);
            return true;
        }

        //new add file
        String oldMd5 = MD5.getMD5(oldFile);
        if (oldMd5.equals(newMd5)) {
            return false;
        }
        if (!bsDiffFile.getParentFile().exists()) {
            bsDiffFile.getParentFile().mkdirs();
        }
        BSDiff.bsdiff(oldFile, newFile, bsDiffFile);
        if (Utils.checkBsDiffFileSize(bsDiffFile, newFile)) {
            writeLogFiles(newFile, oldFile, bsDiffFile, newMd5);
        } else {
            FileOperation.copyFileUsingStream(newFile, bsDiffFile);
            writeLogFiles(newFile, null, null, newMd5);
        }
        return true;
    }

如果是新增的资源文件,则直接拷贝。否则使用BSDiff进行diff操作,这是二进制的差量算法,具体的介绍在这里BSDiff算法. 如果BSDiff.bsdiff方法生成的文件过大,就会直接当做新增加的文件来对待,以避免patch时间过长。

resource的差分

资源文件的diff在ResDiffDecoder这个类的patch方法中完成,采用的方法也是BSDiff算法。牵扯到知识真多,真是功夫在诗外。和so一样,如果新增,则直接拷贝。否则调用BSDiff算法,如果生成的文件过大,就直接当做新增来处理。不过资源文件还需要处理AndroidManifest.xml和resources.arsc这两个文件。

以上是dex,so,resource文件的diff,针对不同的情况和场景有好几种算法,只能说微信确实做到了极致,厉害了微信。这些算法现在功力不够,只能先跳过。另外就是如果看的仔细,还会发现ApkDecoder中有另外一个的一个ArkHotDecoder,这个貌似是华为的方舟编译器。

生成meta,版本文件等

完成了dex,so,resource文件的diff之后,回到Runner的tinkerPatch方法,还有剩下的产生meta,版本文件和打包成patch文件两部分。meta文件指的是package_meta.txt,这个文件会将Configuration中一些信息输出到文件中,以方便在后面进行补丁的合成时进行信息校验。生成patch文件的方法在PatchBuilder的buildPatch方法中,代码如下:

// public void buildPatch() throws Exception {
    final File resultDir = config.mTempResultDir;
    if (!resultDir.exists()) {
        throw new IOException(String.format(
            "Missing patch unzip files, path=%s\n", resultDir.getAbsolutePath()));
    }
    //no file change
    if (resultDir.listFiles().length == 0) {
        return;
    }
    generateUnsignedApk(unSignedApk);
    signApk(unSignedApk, signedApk);

    use7zApk(signedApk, signedWith7ZipApk, sevenZipOutPutDir);
    ......
}

第一步在进行dex,so,resource文件的diff时,将diff文件输出到目录下,然后调用generateUnsignedApk进行patch文件的压缩,之后对压缩后的文件再进行签名。

至此,我们大体上完成了tinker的patch文件的生成,虽然是囫囵吞枣,有很多流程也没有分析。最大的意外是加固模式下dex的算法竟然不是dexDiff,和一直以来想的都不一样。但万里长征第一步,我们大体上了解了patch文件的生成。tinker框架还有gradle插件部分,以及patch的合成以及加载。我们后续再详细分析。技术水平有限,有错误的地方清不吝指出,感谢。

参考文献

感谢tinker的开源以及先行者的无私分享
Android热更新开源项目Tinker源码解析系列之一:Dex热更新
Tinker源码分析
Android 热修复 Tinker 源码分析之DexDiff / DexPatch
Android动态资源加载原理和应用

上一篇下一篇

猜你喜欢

热点阅读