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(configuration.dex.pattern))
.setIsProtectedApp(configuration.buildConfig.isProtectedApp)// note isProtectedApp,是在tinker.gradle中配置
.setIsComponentHotplugSupported(configuration.buildConfig.supportHotplugComponent)
.setDexLoaderPattern(new ArrayList(configuration.dex.loader))
.setDexIgnoreWarningLoaderPattern(new ArrayList(configuration.dex.ignoreWarningLoader))
.setDexMode(configuration.dex.dexMode)
.setSoFilePattern(new ArrayList(configuration.lib.pattern))
.setResourceFilePattern(new ArrayList(configuration.res.pattern))
.setResourceIgnoreChangePattern(new ArrayList(configuration.res.ignoreChange))
.setResourceIgnoreChangeWarningPattern(new ArrayList(configuration.res.ignoreChangeWarning))
.setResourceLargeModSize(configuration.res.largeModSize)
.setUseApplyResource(configuration.buildConfig.usingResourceMapping)
.setConfigFields(new HashMap(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动态资源加载原理和应用