热修复中的资源修复

前言

昨天看到区长写了一篇关于Android中热修复里classLoader实现方案的文章,这段时间确实和他一起搞了hotpatch,既然他说了class的热修复,而且我也好久没有更新博客了,那么这里我就来谈谈我们是如何实现资源的热修复的。

准备

由于资源的热修复涉及到的东西比较多,所以在看这篇文章之前,同学们最好看过我博客之前的两篇文章:Android中资源查找过程分析记一次苦逼的资源逆向分析。这两篇文章算是让大家了解资源热修复的初衷和所需要的基础知识吧。

广为人知的资源插件化

有过插件化开发经验的同学对这个话题一定不会陌生,业界主流的做法就是反射调用AssetManager的addAssetPath方法,将插件apk的路径当作参数传进去,然后通过这个AssetManager去创建一个Resource对象就可以了。在Android中资源查找过程分析这篇文章中我阐述过,Android中的资源查找就是通过Resource中的AssetManager对象来进行的,所以通过这种方式可以实现动态的添加一份资源,因为apk中包含资源和对应的arsc文件。

下面让我们从源码的角度来看看为什么调用addAssetPath方法就可以实现这样的需求。

1
2
3
4
5
6
7
public final int addAssetPath(String path) {
synchronized (this) {
int res = addAssetPathNative(path);
makeStringBlocks(mStringBlocks);
return res;
}
}

可以看到,addAssetPath方法会调用addAssetPathNative,而这个方法是一个native的方法,所以我们还是要从native层找到答案。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
static jint android_content_AssetManager_addAssetPath(JNIEnv* env, jobject clazz,
jstring path)

{

ScopedUtfChars path8(env, path);
if (path8.c_str() == NULL) {
return 0;
}
AssetManager* am = assetManagerForJavaObject(env, clazz);
if (am == NULL) {
return 0;
}
void* cookie;
bool res = am->addAssetPath(String8(path8.c_str()), &cookie);
return (res) ? (jint)cookie : 0;
}

可以看到,该方法中获取了AssetManager对象并且调用了它的同名函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
bool AssetManager::addAssetPath(const String8& path, int32_t* cookie)
{
AutoMutex _l(mLock);

asset_path ap;

String8 realPath(path);
if (kAppZipName) {
realPath.appendPath(kAppZipName);
}
ap.type = ::getFileType(realPath.string());
if (ap.type == kFileTypeRegular) {
ap.path = realPath;
} else {
ap.path = path;
ap.type = ::getFileType(path.string());
if (ap.type != kFileTypeDirectory && ap.type != kFileTypeRegular) {
ALOGW("Asset path %s is neither a directory nor file (type=%d).",
path.string(), (int)ap.type);
return false;
}
}

// Skip if we have it already.
for (size_t i=0; i<mAssetPaths.size(); i++) {
if (mAssetPaths[i].path == ap.path) {
if (cookie) {
*cookie = static_cast<int32_t>(i+1);
}
return true;
}
}

ALOGV("In %p Asset %s path: %s", this,
ap.type == kFileTypeDirectory ? "dir" : "zip", ap.path.string());

// Check that the path has an AndroidManifest.xml
Asset* manifestAsset = const_cast<AssetManager*>(this)- >openNonAssetInPathLocked(
kAndroidManifest, Asset::ACCESS_BUFFER, ap);
if (manifestAsset == NULL) {
// This asset path does not contain any resources.
delete manifestAsset;
return false;
}
delete manifestAsset;

mAssetPaths.add(ap);

// new paths are always added at the end
if (cookie) {
*cookie = static_cast<int32_t>(mAssetPaths.size());
}

// Load overlays, if any
asset_path oap;
for (size_t idx = 0; mZipSet.getOverlay(ap.path, idx, &oap); idx++) {
mAssetPaths.add(oap);
}

if (mResources != NULL) {
appendPathToResTable(ap);
}

return true;
}

这个函数比较长,但是主要的逻辑就是将apk的路径path添加到mAssetPaths这个vector中。首先会判断对应的路径是否已经在mAssetPaths中了,如果在那么之后的操作就都不进行并且返回一个cookie,这个cookie大家可以看作是对应path的一个index值。如果不存在,也就是没有添加过,那么就将path添加到mAssetPaths的末尾并且生成cookie。值得一提的是,在这个函数的最后还会对overlay资源进行处理,也就是说存在资源覆盖的逻辑。正因为addAssetPath函数将对应插件apk的路径添加到了mAssetPaths中,所以在之后的某个合适的时刻,我们插件apk中的资源就可以被正确的加载。

资源热修复之我见

上面介绍了插件化开发中添加插件资源的方法,这里我所使用的资源热修复的方案和这个方案是雷同的。

大家有没有很奇怪,addAssetPath方法只是将path添加到了mAssetPaths这个vector中,并没有做其他操作啊,为什么这样就可以实现动态资源的添加呢?秘密就在AssetManager对应查找资源的函数中,在Android中资源查找过程分析这篇文章中我提到,查找资源一般是通过AssetManager的loadResourceValue函数实现的,而在这个native函数中,AssetManager会先去构造一个ResTable,并且通过ResTable的getResource去最终实现逻辑,这个ResTable是通过AssetManager的getResources函数去构造的,最终会走到getResTable函数。这里由于代码太多,我就不贴上来了,具体的流程是getResTable()->appendPathToResTable()(在一个for循环中,遍历的就是mAssetPaths)->ResTable::add()->ResTable::addInternal()->ResTable::parsePackage()。最终会在将对应的packageGroup添加到成员变量mPackageGroups中。packageGroup大家可以看作是同一个PP段资源的集合。比如所有0x7f的资源会在一个packageGroup中。

试想一下,我们将patch的apk路径也这样添加,那么mPackageGroups对应的0x7f的packageGroup中就会含有一个patch的package。通过这样的方式构建出了一个ResTable后,我们就可以知道,这个ResTable中既含有app中的资源,也含有patch中的资源,在同一个packageGroup中。那么是不是这样就可以了呢?其实还需要最后一个操作,让我们看看Android系统在5.0以上和5.0以下资源查找的区别,具体的区别在ResTable的getResource函数中。我们以5.0以下的为例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
ssize_t ResTable::getResource(uint32_t resID, Res_value* outValue, bool mayBeBag,
uint32_t* outSpecFlags, ResTable_config* outConfig) const{
......

const ssize_t p = getResourcePackageIndex(resID);
const int t = Res_GETTYPE(resID);
const int e = Res_GETENTRY(resID);

......

const Res_value* bestValue = NULL;
const Package* bestPackage = NULL;
ResTable_config bestItem;
memset(&bestItem, 0, sizeof(bestItem)); // make the compiler shut up

if (outSpecFlags != NULL) *outSpecFlags = 0;

// Look through all resource packages, starting with the most
// recently added.

//Attention here!!!!!!!!!!!!
const PackageGroup* const grp = mPackageGroups[p];
......

size_t ip = grp->packages.size();
while (ip > 0) {
ip--;
int T = t;
int E = e;

const Package* const package = grp->packages[ip];
if (package->header->resourceIDMap) {
uint32_t overlayResID = 0x0;
status_t retval = idmapLookup(package->header->resourceIDMap,
package->header->resourceIDMapSize,
resID, &overlayResID);
if (retval == NO_ERROR && overlayResID != 0x0) {
// for this loop iteration, this is the type and entry we really want
......
T = Res_GETTYPE(overlayResID);
E = Res_GETENTRY(overlayResID);
} else {
// resource not present in overlay package, continue with the next package
continue;
}
}

const ResTable_type* type;
const ResTable_entry* entry;
const Type* typeClass;
//Attention here too!!!!!!!!!!
ssize_t offset = getEntry(package, T, E, &mParams, &type, &entry, &typeClass);
if (offset <= 0) {
// No {entry, appropriate config} pair found in package. If this
// package is an overlay package (ip != 0), this simply means the
// overlay package did not specify a default.
// Non-overlay packages are still required to provide a default.
if (offset < 0 && ip == 0) {
......
return offset;
}
continue;
}

if ((dtohs(entry->flags)&entry->FLAG_COMPLEX) != 0) {
......
continue;
}

......

const Res_value* item =
(const Res_value*)(((const uint8_t*)type) + offset);
ResTable_config thisConfig;
thisConfig.copyFromDtoH(type->config);

if (outSpecFlags != NULL) {
if (typeClass->typeSpecFlags != NULL) {
*outSpecFlags |= dtohl(typeClass->typeSpecFlags[E]);
} else {
*outSpecFlags = -1;
}
}

if (bestPackage != NULL &&
(bestItem.isMoreSpecificThan(thisConfig) || bestItem.diff(thisConfig) == 0)) {
// Discard thisConfig not only if bestItem is more specific, but also if the two configs
// are identical (diff == 0), or overlay packages will not take effect.
continue;
}

bestItem = thisConfig;
bestValue = item;
bestPackage = package;
}

......

if (bestValue) {
outValue->size = dtohs(bestValue->size);
outValue->res0 = bestValue->res0;
outValue->dataType = bestValue->dataType;
outValue->data = dtohl(bestValue->data);
if (outConfig != NULL) {
*outConfig = bestItem;
}
......
return bestPackage->header->index;
}

return BAD_VALUE;
}

源代码出自老罗的[Android应用程序资源的查找过程分析]一文。代码很长,其中我有一个注释[Attention here],那里就是Android5.0以上和以下代码不同的地方,也是我们重点需要关注的。可以看到,5.0以上的代码中,我们是从后往前逆序遍历了对应的packageGroup,而在5.0以上的代码中,这段逻辑是从前往后顺序遍历的。而这之后的操作,就是比较对应package中entry的契合度,最终获取bestItem。这里要注意的是,这个package的概念不是我们常说的包名,就算patch的包名和app的一样,还是会存在两个的。而patch和app中资源的契合度都是一样的,所以不会存在哪个更好,于是,首先找到的那个就是系统所要的资源。

通过这段逻辑,我们可以得出一个结论,在同时存在patch和app的情况下,5.0以下的系统中,系统找到的是后面那个entry(逆序遍历),5.0以上的系统找到的是前面那个entry(顺序遍历)。所以,要实现资源的patch,我们必须区分系统的版本,在5.0以下,先添加app的路径,再添加patch的路径,而在5.0以上则相反。

存在的问题

通过这种方式,普通的资源patch其实应该没有问题的,但是如果你想将一个新增的资源通过patch包下发的对应的app,还是会有不少的坑。

你会发现,5.0以下的系统,如果你去获取这样新增的资源,app会直接crash,报出来的错就是resource not found。为什么呢?为什么新增的资源会出现这样的问题,并且只在5.0以下的系统呢?还是让我们看源码吧,具体的原因还是要从Retble::getResource方法中找,先来看5.0以下的。

其中我有注释[Attention here2]的地方,我们可以看到,如果获取到的entry的offset<0并且i==0,那么就直接return了,用白话来说,就是如果获取不到这个资源,并且这是最后一次遍历,那么就直接返回,并且在上层会抛出resource not found的错误。在5.0以下的系统中,对于一个新增的资源,我们会现在patch中找到它,但是当第二次遍历,也就是遍历app的资源的时候,我们找不到这个新增的资源,offset<0,并且这是最后一次遍历,刚好符合条件,于是就悲剧的return了,而对于非新增的资源,第二次遍历app的时候还是可以找到的,只不过后面计算契合度的时候会把它丢弃。而如果你去看5.0以上的源码,你会发现这个地方神奇的改return为continue,所以就不会返回错误了。

知道了原因,我们又应该怎么修改呢?我们可以从根本改变这个问题,那就是将新增的资源重新分区。举个例子,我们将一个新增的资源Res1的PP修改成0x71,这样它的packageGroup和其他非新增资源(0x7f)的packageGroup就不会是同一个了,从而在寻找资源的时候获取的packageGroup就不再是0x7f的那个packageGroup,也就避免了错误的发生。

至于如何实现资源分区,这里我先不说具体的方案,留给大家自己去实现,毕竟授人以鱼不如授人以渔嘛。网上关于修改aapt实现资源分区的文章还是挺多的,并且如果大家看过我博客中记一次苦逼的资源逆向分析这篇文章,你就会清楚,生成arsc文件的函数是ResourceTable::flatten,大家可以从这里入手,手动添加一个PP段不是0x7f的PackageGroup,加油吧少年~

解决了这个问题,还有其他问题嘛?当然有,这里会存在一个[交叉引用]的问题,具体点说就是没有改动的xml文件中引用了新增资源,如果不做处理新增资源的PP段是跟着XML_Root走的,也就是0x7f。这个要怎么解决呢,还是提供给大家一个思路,我们知道aapt处理XML文件是通过Resource:compileXmlFile这个方法去实现的,其中会把每个XML文件解析成XMLNode,并且通过XMLNode去写对应的文件结构。在这之后,有一个函数叫做XMLNode::flatten_node,其中就包含了所有写文件的操作,在这个函数中,和所有[类flatten]函数一样,遵循着arsc文件结构的规则,也就是每一个部分是一个chunk,一个chunk包含一个header等等,我们可以修改这个函数中对应写资源id的方法,动态的替换新增资源的PP段。这样apk中的XML文件就算存在交叉引用,也可以顺利的改变新增资源的id了。

未解决的问题

关于未解决的问题,最主要的就是bag资源无法进行热修复,这也会是笔者接下去主要需要fix的issue。如果有大神知道如何解决这个问题的话,还请不吝赐教啊~感谢!