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

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

1. 概览

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

生成差量补丁包(Diff)

补丁包也就是差量包,就是使用tinker-patch-cli工具,输入1.0和2.0的apk包,生成补丁包patch.zip。

合成全量资源包(Merge)

当客户端收到补丁包时,会在一个独立的进程,用补丁包与客户端的1.0的apk包进行合并,生成全量的新的资源包resource.apk。

加载全量资源包(Load)

在下一次启动app时,会通过反射注入的方式,改变LoadedApk的mResDir,使之指向resource.apk的目录,以及新创建一个包含resource.apk目录的AssetManager对象,设置到ResourcesManager中的Resources对象中。

2. 资源的定义

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

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

3. 生成差量补丁包

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。

如果old文件不存在,那就把new文件加到addedSet中,并把new文件输出到output_path\tinker_result中。

如果是AndroidManifest.xml,则跳过,因为不能补AndroidManifest.xml文件

如果文件长度小于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中作为代理实现类。

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

TinkerApplication的loadTinker()方法

TinkerLoader的tryLoad()方法

TinkerResourceLoader的loadTinkerResources()方法

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> entry
: ((Map>) 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 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);
}
}

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

设置 LoadedApk对象的mResDir属性的值,指向补丁资源全量包resource.apk的路径

创建一个新的AssetManager,并调用其addAssetPath方法使之指向补丁资源全量包resource.apk的路径

遍历ResourcesManager中mActiveResources列表中的Resources对象,将新的AssetManager对象设置进去

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