前言
Android资源的热修复,就是在app不重新安装的情况下,利用下发的补丁包直接更新本app中的资源。
我们在开发阿里云移动热修复(Sophix)的过程中,对Android资源的加载原理做了深入的探究,最终在资源修复方法上取得了突破性进展!新的资源修复方法不论是在使用便捷性、补丁包大小以及运行时效率方面,相比其他实现都有巨大的优势。
普遍的实现方式
目前市面上的很多资源热修复方案基本上都是参考了Instant Run的实现。
首先,我们简单来看一下Instant Run是怎么做到资源热修复的。
Instant Run资源热修复的核心代码就是这个monkeyPatchExistingResources方法:
@com/android/tools/fd/runtime/MonkeyPatcher.java
public static void monkeyPatchExistingResources(@Nullable Context context,
@Nullable String externalResourceFile,
@Nullable Collection<Activity> activities) {
if (externalResourceFile == null) {
return;
}
try {
// %% Part 1. 创建一个新的AssetManager,并通过反射调用addAssetPath添加/sdcard上的新资源包.
// 这样就构造出了一个带新资源的AssetManager
// Create a new AssetManager instance and point it to the resources installed under
// /sdcard
AssetManager newAssetManager = AssetManager.class.getConstructor().newInstance();
Method mAddAssetPath = AssetManager.class.getDeclaredMethod("addAssetPath", String.class);
mAddAssetPath.setAccessible(true);
if (((Integer) mAddAssetPath.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.
Method mEnsureStringBlocks = AssetManager.class.getDeclaredMethod("ensureStringBlocks");
mEnsureStringBlocks.setAccessible(true);
mEnsureStringBlocks.invoke(newAssetManager);
// %% Part 2. 反射得到Activity中AssetManager的引用处,全部换成刚才新构建的newAssetManager
if (activities != null) {
for (Activity activity : activities) {
Resources resources = activity.getResources();
try {
Field mAssets = Resources.class.getDeclaredField("mAssets");
mAssets.setAccessible(true);
mAssets.set(resources, newAssetManager);
} catch (Throwable ignore) {
Field mResourcesImpl = Resources.class.getDeclaredField("mResourcesImpl");
mResourcesImpl.setAccessible(true);
Object resourceImpl = mResourcesImpl.get(resources);
Field implAssets = resourceImpl.getClass().getDeclaredField("mAssets");
implAssets.setAccessible(true);
implAssets.set(resourceImpl, newAssetManager);
}
... ...
pruneResourceCaches(resources);
}
}
// %% Part 3. 得到Resources的弱引用集合,把他们的AssetManager成员替换成newAssetManager
// Iterate over all known Resources objects
Collection<WeakReference<Resources>> references;
if (SDK_INT >= KITKAT) {
// Find the singleton instance of ResourcesManager
Class<?> resourcesManagerClass = Class.forName("android.app.ResourcesManager");
Method mGetInstance = resourcesManagerClass.getDeclaredMethod("getInstance");
mGetInstance.setAccessible(true);
Object resourcesManager = mGetInstance.invoke(null);
try {
Field fMActiveResources = resourcesManagerClass.getDeclaredField("mActiveResources");
fMActiveResources.setAccessible(true);
@SuppressWarnings("unchecked")
ArrayMap<?, WeakReference<Resources>> arrayMap =
(ArrayMap<?, WeakReference<Resources>>) fMActiveResources.get(resourcesManager);
references = arrayMap.values();
} catch (NoSuchFieldException ignore) {
Field mResourceReferences = resourcesManagerClass.getDeclaredField("mResourceReferences");
mResourceReferences.setAccessible(true);
//noinspection unchecked
references = (Collection<WeakReference<Resources>>) mResourceReferences.get(resourcesManager);
}
} else {
Class<?> activityThread = Class.forName("android.app.ActivityThread");
Field fMActiveResources = activityThread.getDeclaredField("mActiveResources");
fMActiveResources.setAccessible(true);
Object thread = getActivityThread(context, activityThread);
@SuppressWarnings("unchecked")
HashMap<?, WeakReference<Resources>> map =
(HashMap<?, WeakReference<Resources>>) fMActiveResources.get(thread);
references = map.values();
}
for (WeakReference<Resources> wr : references) {
Resources resources = wr.get();
if (resources != null) {
// Set the AssetManager of the Resources instance to our brand new one
try {
Field mAssets = Resources.class.getDeclaredField("mAssets");
mAssets.setAccessible(true);
mAssets.set(resources, newAssetManager);
} catch (Throwable ignore) {
Field mResourcesImpl = Resources.class.getDeclaredField("mResourcesImpl");
mResourcesImpl.setAccessible(true);
Object resourceImpl = mResourcesImpl.get(resources);
Field implAssets = resourceImpl.getClass().getDeclaredField("mAssets");
implAssets.setAccessible(true);
implAssets.set(resourceImpl, newAssetManager);
}
resources.updateConfiguration(resources.getConfiguration(), resources.getDisplayMetrics());
}
}
} catch (Throwable e) {
throw new IllegalStateException(e);
}}
简要说来,Instant Run中的资源热修复分为两步,
1、构造一个新的AssetManager,并通过反射调用addAssetPath,把这个完整的新资源包加入到AssetManager中。这样就得到了一个含有所有新资源的AssetManager。
2、找到所有之前引用到原有AssetManager的地方,通过反射,把引用处替换为AssetManager。
其实仔细看可以发现,大量代码都是在处理兼容性问题和找到所有AssetManager的引用处。真正的实现逻辑其实很简单。
这其中的重点,自然是addAssetPath这个函数。现在我们来看一下它的底层实现逻辑。
以Android6.0为例,addAssetPath最终调用到了native方法。
@frameworks/base/core/java/android/content/res/AssetManager.java
/**
* Add an additional set of assets to the asset manager. This can be
* either a directory or ZIP file. Not for use by applications. Returns
* the cookie of the added asset, or 0 on failure.
* {@hide}
*/
public final int addAssetPath(String path) {
synchronized (this) {
int res = addAssetPathNative(path);
makeStringBlocks(mStringBlocks);
return res;
}
}
... ...
private native final int addAssetPathNative(String path);
Java层的AssetManager只是个包装,真正关于资源处理的所有逻辑,其实都位于native层由C++实现的AssetManager。
执行addAssetPath就是解析这个格式,然后构造出底层数据结构的过程。整个解析资源的调用链是:
public final int addAssetPath(String path)
=jni=> android_content_AssetManager_addAssetPath
=> AssetManager::addAssetPath => AssetManager::appendPathToResTable => ResTable::add => ResTable::addInternal => ResTable::parsePackage
解析的细节比较繁琐,就不细细说明了,有兴趣的可以一层层追下去。
大致过程就是,通过传入的资源包路径,先得到其中的resources.arsc,然后解析它的格式,存放在底层的AssetManager的mResources成员中。
@frameworks/base/include/androidfw/AssetManager.h
class AssetManager : public AAssetManager {
... ...
mutable ResTable* mResources;
... ...
AssetManager的mResources成员是一个ResTable结构体:
class ResTable
{
mutable Mutex mLock;
// 互斥锁,用于多进程间互斥操作。
status_t mError;
ResTable_config mParams;
// Array of all resource tables.
Vector<Header*> mHeaders;
// 表示所有resources.arsc原始数据,这就等同于所有通过addAssetPath加载进来的路径的资源id信息。
// Array of packages in all resource tables.
Vector<PackageGroup*> mPackageGroups;
// 资源包的实体,包含所有加载进来的package id所对应的资源。
// Mapping from resource package IDs to indices into the internal
// package array.
uint8_t mPackageMap[256];
// 索引表,表示0~255的package id,每个元组分别存放 该id所属PackageGroup 在mPackageGroups中的index
uint8_t mNextPackageId;
};
一个Android进程只包含一个ResTable,ResTable的成员变量mPackageGroups就是所有解析过的资源包的集合。任何一个资源包中都含有resources.arsc,它记录了所有资源的id分配情况以及资源中的所有字符串。这些信息是以二进制方式存储的。底层的AssetManager做的事就是解析这个文件,然后把相关信息存储到mPackageGroups里面。
资源文件的格式
整个resources.arsc文件,实际上是由一个个ResChunk(以下简称chunk)拼接起来的。从文件头开始,每个chunk的头部都是一个ResChunk_header结构,它指示了这个chunk的大小和数据类型。
/**
* Header that appears at the front of every data chunk in a resource.
*/
struct ResChunk_header
{
// Type identifier for this chunk. The meaning of this value depends
// on the containing chunk.
uint16_t type;
// Size of the chunk header (in bytes). Adding this value to
// the address of the chunk allows you to find its associated data
// (if any).
uint16_t headerSize;
// Total size of this chunk (in bytes). This is the chunkSize plus
// the size of any data associated with the chunk. Adding this value
// to the chunk allows you to completely skip its contents (including
// any child chunks). If this value is the same as chunkSize, there is
// no data associated with the chunk.
uint32_t size;
};
通过ResChunk_header中的type成员,可以知道这个chunk是什么类型,从而就可以知道应该如何解析这个chunk。
解析完一个chunk后,从这个chunk + size的位置开始,就可以得到下一个chunk起始位置,这样就可以依次读取完整个文件的数据内容。
一般来说,一个resources.arsc里面包含若干个package,不过默认情况下,由打包工具aapt打出来的包只有一个package。这个package里包含了app中的所有资源信息。
资源信息主要是指每个资源的名称以及它对应的编号。我们知道,Android中的每个资源,都有它唯一的编号。
编号是一个32位数字,用十六进制来表示就是0xPPTTEEEE。PP为package id,TT为type id,EEEE为entry id。
它们代表什么?在resources.arsc里是以怎样的方式记录的呢?
- 对于package id,每个package对应的是类型为RES_TABLE_PACKAGE_TYPE的ResTable_package结构体,ResTable_package结构体的id成员变量就表示它的package id。
- 对于type id,每个type对应的是类型为RES_TABLE_TYPE_SPEC_TYPE的ResTable_typeSpec结构体。它的id成员变量就是type id。但是,该type id具体对应什么类型,是需要到package chunk里的Type String Pool中去解析得到的。比如Type String Pool中依次有attr、drawable、mipmap、layout字符串。就表示attr类型的type id为1, drawable类型的type id为2,mipmap类型的type id为3,layout类型的type id为4。所以,每个type id对应了Type String Pool里的字符顺序所指定的类型。
- 对于entry id,每个entry表示一个资源项,资源项是按照排列的先后顺序自动被标机编号的。也就是说,一个type里按位置出现的第一个资源项,其entry id为0x0000,第二个为0x0001,以此类推。因此我们是无法直接指定entry id的,只能够根据排布顺序决定。资源项之间是紧密排布的,没有空隙,但是可以指定资源项为ResTable_type::NO_ENTRY来填入一个空资源。
举个例子,我们随便找个带资源的apk,用aapt解析一下,看到其中的一行是:
$ aapt d resources app-debug.apk
... ...
spec resource 0x7f040019 com.taobao.patch.demo:layout/activity_main: flags=0x00000000
... ...
这就表示,activity_main.xml这个资源的编号是0x7f040019。它的package id是0x7f,资源类型的id为0x04,Type String Pool里的第四个字符串正是layout类型,而0x04类型的第0x0019个资源项就是activity_main这个资源。
运行时资源的解析
默认由Android SDK编出来的apk,是由aapt工具进行打包的,其资源包的package id就是0x7f。
系统的资源包,也就是framework-res.jar,package id为0x01。
在走到app的第一行代码之前,系统就已经帮我们构造好一个已经添加了安装包资源的AssetManager了。
@frameworks/base/core/java/android/app/ResourcesManager.java
Resources getTopLevelResources(String resDir, String[] splitResDirs,
String[] overlayDirs, String[] libDirs, int displayId,
Configuration overrideConfiguration, CompatibilityInfo compatInfo) {
... ...
AssetManager assets = new AssetManager();
// resDir就是安装包apk
if (resDir != null) {
if (assets.addAssetPath(resDir) == 0) {
return null;
}
}
... ...
因此,这个AssetManager里就已经包含了系统资源包以及app的安装包,就是package id为0x01的framework-res.jar中的资源和package id为0x7f的app安装包资源。
如果此时直接在原有AssetManager上继续addAssetPath的完整补丁包的话,由于补丁包里面的package id也是0x7f,就会使得同一个package id的包被加载两次。这会有怎样的问题呢?
在Android L之后,这是没问题的,他会默默地把后来的包添加到之前的包的同一个PackageGroup下面。
而在解析的时候,会与之前的包比较同一个type id所对应的类型,如果该类型下的资源项数目和之前添加过的不一致,会打出一条warning log,但是仍旧加入到该类型的TypeList中。
status_t ResTable::parsePackage(const ResTable_package* const pkg,
const Header* const header)
... ...
TypeList& typeList = group->types.editItemAt(typeIndex);
if (!typeList.isEmpty()) {
const Type* existingType = typeList[0];
if (existingType->entryCount != newEntryCount && idmapIndex < 0) {
ALOGW("ResTable_typeSpec entry count inconsistent: given %d, previously %d",
(int) newEntryCount, (int) existingType->entryCount);
// We should normally abort here, but some legacy apps declare
// resources in the 'android' package (old bug in AAPT).
}
}
Type* t = new Type(header, package, newEntryCount);
t->typeSpec = typeSpec;
t->typeSpecFlags = (const uint32_t*)(
((const uint8_t*)typeSpec) + dtohs(typeSpec->header.headerSize));
if (idmapIndex >= 0) {
t->idmapEntries = idmapEntries[idmapIndex];
}
typeList.add(t);
... ...
但是在get这个资源的时候呢?
status_t ResTable::getEntry(
const PackageGroup* packageGroup, int typeIndex, int entryIndex,
const ResTable_config* config,
Entry* outEntry) const
{
const TypeList& typeList = packageGroup->types[typeIndex];
... ...
// %% 从第一个type开始遍历,也就是说会先取得安装包的资源,然后才是补丁包的。
// Iterate over the Types of each package.
const size_t typeCount = typeList.size();
for (size_t i = 0; i < typeCount; i++) {
const Type* const typeSpec = typeList[i];
int realEntryIndex = entryIndex;
int realTypeIndex = typeIndex;
bool currentTypeIsOverlay = false;
if (static_cast<size_t>(realEntryIndex) >= typeSpec->entryCount) {
ALOGW("For resource 0x%08x, entry index(%d) is beyond type entryCount(%d)",
Res_MAKEID(packageGroup->id - 1, typeIndex, entryIndex),
entryIndex, static_cast<int>(typeSpec->entryCount));
// We should normally abort here, but some legacy apps declare
// resources in the 'android' package (old bug in AAPT).
continue;
}
const size_t numConfigs = typeSpec->configs.size();
for (size_t c = 0; c < numConfigs; c++) {
... ...
if (bestType != NULL) {
// Check if this one is less specific than the last found. If so,
// we will skip it. We check starting with things we most care
// about to those we least care about.
if (!thisConfig.isBetterThan(bestConfig, config)) {
if (!currentTypeIsOverlay || thisConfig.compare(bestConfig) != 0) {
continue;
}
}
}
bestType = thisType;
bestOffset = thisOffset;
bestConfig = thisConfig;
bestPackage = typeSpec->package;
actualTypeIndex = realTypeIndex;
// If no config was specified, any type will do, so skip
if (config == NULL) {
break;
}
}
}
}
在获取某个Type的资源时,会从前往后遍历,也就是说先得到原有安装包里的资源,除非后面的资源的config比前面的更详细才会发生覆盖。而对于同一个config而言,补丁中的资源就永远无法生效了。所以在Android L以上的版本,在原有AssetManager上加入补丁包,是没有任何作用的,补丁中的资源无法生效。
而在Android KK及以下版本,addAssetPath只是把补丁包的路径添加到了mAssetPath中,而真正解析的资源包的逻辑是在app第一次执行AssetManager::getResTable的时候。
@android-4.4.4_r2/frameworks/base/libs/androidfw/AssetManager.cpp
const ResTable* AssetManager::getResTable(bool required) const
{
// %% mResources已存在,直接返回,不再往下走。
ResTable* rt = mResources;
if (rt) {
return rt;
}
// Iterate through all asset packages, collecting resources from each.
AutoMutex _l(mLock);
if (mResources != NULL) {
return mResources;
}
if (required) {
LOG_FATAL_IF(mAssetPaths.size() == 0, "No assets added to AssetManager");
}
if (mCacheMode != CACHE_OFF && !mCacheValid)
const_cast<AssetManager*>(this)->loadFileNameCacheLocked();
const size_t N = mAssetPaths.size();
for (size_t i=0; i<N; i++) {
// ... %% 真正解析package的地方 ...
}
if (required && !rt) ALOGW("Unable to find resources file resources.arsc");
if (!rt) {
mResources = rt = new ResTable();
}
return rt;
}
而在执行到加载补丁代码的时候,getResTable已经执行过了无数次了。这是因为就算我们之前没做过任何资源相关操作,Android framework里的代码也会多次调用到那里。所以,以后即使是addAssetPath,也只是添加到了mAssetPath,并不会发生解析。所以补丁包里面的资源是完全不生效的!
所以,像Instant Run这种方案,一定需要一个全新的AssetManager时,然后再加入完整的新资源包,替换掉原有的AssetManager。
另辟蹊径
而一个好的资源热修复方案是怎样的呢?
首先,补丁包要足够小,像直接下发完整的补丁包肯定是不行的,很占用空间。
而像有些方案,是先进行bsdiff,对资源包做差量,然后下发差量包,在运行时合成完整包再加载。这样确实减小了包的体积,但是却在运行时多了合成的操作,耗费了运行时间和内存。合成后的包也是完整的包,仍旧会占用磁盘空间。
而如果不采用类似Instant Run的方案,市面上许多实现,是自己修改aapt,在打包时将补丁包资源进行重新编号。这样就会涉及到修改Android SDK工具包,即不利于集成也无法很好地对将来的aapt版本进行升级。
针对以上几个问题,一个好的资源热修复方案,既要保证补丁包足够小,不在运行时占用很多资源,又要不侵入打包流程。我们提出了一个目前市面上未曾实现的方案。
简单来说,我们构造了一个package id为0x66的资源包,这个包里只包含改变了的资源项,然后直接在原有AssetManager中addAssetPath这个包。然后,就可以了。
真的这么简单?
没错!由于补丁包的package id为0x66,不与目前已经加载的0x7f冲突,因此直接加入到已有的AssetManager中就可以直接使用了。补丁包里面的资源,只包含原有包里面没有而新的包里面有的新增资源,以及原有内容发生了改变的资源。
而资源的改变包含增加、减少、修改这三种情况,我们分别是如何处理的呢?
- 对于新增资源,直接加入补丁包,然后新代码里直接引用就可以了,没什么好说的。
- 对于减少资源,我们只要不使用它就行了,因此不用考虑这种情况,它也不影响补丁包。
- 对于修改资源,比如替换了一张图片之类的情况。我们把它视为新增资源,在打入补丁的时候,代码在引用处也会做相应修改,也就是直接把原来使用旧资源id的地方变为新id。
用一张图来说明补丁包的情况,是这样的:
图中绿线表示新增资源。红线表示内容发生修改的资源。黑线表示内容没有变化,但是id发生改变的资源。×表示删除了的资源。
新增的资源及其导致id偏移
可以看到,新的资源包与旧资源包相比,新增了holo_grey和dropdn_item2资源,新增的资源被加入到patch中。并分配了0x66开头的资源id。
而新增的两个资源导致了在它们所属的type中跟在它们之后的资源id发生了位移。比如holo_light,id由0x7f020002变为0x7f020003,而abc_dialog由0x7f030004变为0x7f030003。新资源插入的位置是随机的,这与每次aapt打包时解析xml的顺序有关。发生位移的资源不会加入patch,但是在patch的代码中会调整id的引用处。
比如说在代码里,我们是这么写的
imageView.setImageResource(R.drawable.holo_light);
这个R.drawable.holo_light是一个int值,它的值是aapt指定的,对于开发者透明,即使点进去,也会直接跳到对应res/drawable/holo_light.png,无法查看。不过可以用反编译工具,看到它的真实值是0x7f020002。所以这行代码其实等价于:
imageView.setImageResource(0x7f020002);
而当打出了一个新包后,对开发者而言,holo_light的图片内容没变,代码引用处也没变。但是新包里面,同样是这句话,由于新资源的插入导致的id改变,对于R.drawable.holo_light的引用已经变成了:
imageView.setImageResource(0x7f020003);
但实际上这种情况并不属于资源改变,更不属于代码的改变,所以我们在对比新旧代码之前,会把新包里面的这行代码修正回原来的id。
imageView.setImageResource(0x7f020002);
然后再进行后续代码的对比。这样后续代码对比时就不会检测到发生了改变。
内容发生改变的资源
而对于内容发生改变的资源(类型为layout的activity_main,这可能是我们修改了activity_main.xml的文件内容。还有类型为string的no,可能是我们修改了这个字符串的值),它们都会被加入到patch中,并重新编号为新id。
而相应的代码,也会发生改变,比如,
setContentView(R.layout.activity_main);
实际上也就是
setContentView(0x7f030000);
在生成对比新旧代码之前,我们会把新包里面的这行代码变为
setContentView(0x66020000);
这样,在进行代码对比时,会使得这行代码所在函数被检测到发生了改变。于是相应的代码修复会在运行时发生,这样就引用到了正确的新内容资源。
删除了的资源
对于删除的资源,不会影响补丁包。
这很好理解,既然资源被删除了,就说明新的代码中也不会用到它,那资源放在那里没人用,就相当于不存在了。
对于type的影响
可以看到,由于type0x01的所有资源项都没有变化,所以整个type0x01资源都没有加入到patch中。这也使得后面的type的id都往前移了一位。因此Type String Pool中的字符串也要进行修正,这样才能使得0x01的type指向drawable,而不是原来的attr。
所以我们可以看到,所谓简单,指的是运行时应用patch变的简单了。
而真正复杂的地方在于构造patch。我们需要把新旧两个资源包解开,分别解析其中的resources.arsc文件,对比新旧的不同,并将它们重新打成带有新package id的新资源包。这里补丁包指定的package id只要不是0x7f和0x01就行,可以是任意0x7f以下的数字,我们默认把它指定为0x66。
构造这样的补丁资源包,需要对整个resources.arsc的结构十分了解,要对二进制形式的一个一个chunk进行解析分类,然后再把补丁信息一个一个重新组装成二进制的chunk。这里面很多工作与aapt做的类似,实际上开发打包工具的时候也是参考了很多aapt和系统加载资源的代码。
更优雅的替换方式
对于Android L以后的版本,直接在原有AssetManager上应用patch就行了。并且由于用的是原来的AssetManager,所以原先大量的反射修改替换操作就完全不需要了,大大提高了加载补丁的效率。
但之前提到过,在Android KK和以下版本,addAssetPath是不会加载资源的,必须重新构造一个新的AssetManager并加入patch,再换掉原来的。那么我们不就又要和Instant Run一样,做一大堆兼容版本和反射替换的工作了吗?
对于这种情况,我们也找到了更优雅的方式,不需要再如此地大费周章。
在AssetManager的源码里面,有一个有趣的东西。
@frameworks/base/core/java/android/content/res/AssetManager.java
public final class AssetManager {
... ...
private native final void destroy();
... ...
明显,这个是用来销毁AssetManager并释放资源的函数,我们来看看它具体做了什么吧。
static void android_content_AssetManager_destroy(JNIEnv* env, jobject clazz)
{
AssetManager* am = (AssetManager*)
(env->GetIntField(clazz, gAssetManagerOffsets.mObject));
ALOGV("Destroying AssetManager %p for Java object %p\n", am, clazz);
if (am != NULL) {
delete am;
env->SetIntField(clazz, gAssetManagerOffsets.mObject, 0);
}
}
可以看到,首先,它析构了native层的AssetManager,然后把java层的AssetManager对native层的AssetManager的引用设为空。
AssetManager::~AssetManager(void)
{
int count = android_atomic_dec(&gCount);
//ALOGI("Destroying AssetManager in %p #%d\n", this, count);
delete mConfig;
delete mResources;
// don't have a String class yet, so make sure we clean up
delete[] mLocale;
delete[] mVendor;
}
native层的AssetManager析构函数会析构它的所有成员,这样就会释放之前加载了的资源。
而现在,java层的AssetManager已经成为了空壳。我们就可以调用它的init方法,对它重新进行初始化了!
@frameworks/base/core/java/android/content/res/AssetManager.java
public final class AssetManager {
... ...
private native final void init();
... ...
这同样是个native方法,
static void android_content_AssetManager_init(JNIEnv* env, jobject clazz)
{
AssetManager* am = new AssetManager();
if (am == NULL) {
jniThrowException(env, "java/lang/OutOfMemoryError", "");
return;
}
am->addDefaultAssets();
ALOGV("Created AssetManager %p for Java object %p\n", am, clazz);
env->SetIntField(clazz, gAssetManagerOffsets.mObject, (jint)am);
}
这样,在执行init的时候,会在native层创建一个没有添加过资源,并且mResources没有初始化的的AssetManager。然后我们再对它进行addAssetPath,之后由于mResource没有初始化过,就可以正常走到解析mResources的逻辑,加载所有此时add进去的资源了!
@android-4.4.4_r2/frameworks/base/libs/androidfw/AssetManager.cpp
const ResTable* AssetManager::getResTable(bool required) const
{
ResTable* rt = mResources;
// %% mResources没有初始化过,为空,因此不会return。
if (rt) {
return rt;
}
... ...
// %% 这时就会走到这里,进行所有add进去的path的加载。
const size_t N = mAssetPaths.size();
for (size_t i=0; i<N; i++) {
// ... 解析package ...
}
... ...
return rt;
}
这个方案的实现代码如下:
... ...
Method initMeth = assetManagerMethod("init");
Method destroyMeth = assetManagerMethod("destroy");
Method addAssetPathMeth = assetManagerMethod("addAssetPath", String.class);
// %% 析构AssetManager
destroyMeth.invoke(am);
// %% 重新构造AssetManager
initMeth.invoke(am);
// %% 置空mStringBlocks
assetManagerField("mStringBlocks").set(am, null);
// %% 重新添加原有AssetManager中加载过的资源路径
for (String path : loadedPaths) {
LogTool.d(TAG, "pexyResources" + path);
addAssetPathMeth.invoke(am, path);
}
// %% 添加patch资源路径
addAssetPathMeth.invoke(am, patchPath);
// %% 重新对mStringBlocks赋值
assetManagerMethod("ensureStringBlocks").invoke(am);
}
private Method assetManagerMethod(String name, Class<?>... parameterTypes) {
try {
Method meth = Class.forName("android.content.res.AssetManager")
.getDeclaredMethod(name, parameterTypes);
meth.setAccessible(true);
return meth;
} catch (Exception e) {
LogTool.e(TAG, "assetManagerMethod", e);
return null;
}
}
private Field assetManagerField(String name) {
try {
Field field = mAssetManagerClass.getDeclaredField(name);
field.setAccessible(true);
return field;
} catch (Exception e) {
LogTool.e(TAG, "assetManagerField", e);
return null;
}
}
这里需要注意的地方是mStringBlocks。它记录了之前加载过的所有资源包的String Pool,因此很多时候访问字符串是通过它来找到的。如果不进行重新构造,在后面使用到它时就会导致崩溃。
由于我们是直接对原有的AssetManager进行析构和重构,所有原先对AssetManager对象的引用是没有发生改变的,这样,就不需要像Instant Run那样进行繁琐的修改了。
顺带一提,类似Instant Run的完整替换资源的方案,在替换AssetManager这一步,也可以采用我们这种方式进行替换,省时省力又省心。
总结
总结一下,相比于目前市面上的资源修复方式,我们提出的资源修复的优势在于:
- 不侵入打包,直接对比新旧资源即可产生补丁资源包。(对比修改aapt方式的实现)
- 不必下发完整包,补丁包中只包含有变动的资源。(对比Instanat Run、Amigo等方式的实现)
- 不需要在运行时合成完整包。不占用运行时计算和内存资源。(对比Tinker的实现)
唯一有个需要注意的地方就是,因为对新的资源的引用是在新代码中,所有资源修复是需要代码修复的支持的。也因此所有资源修复方案必然是附带代码修复的。而之前提到过,本方案在进行代码修复前,会对资源引用处进行修正。而修正就是需要找到旧的资源id,换成新的id。查找旧id时是直接对int值进行替换,所以会找到0x7f??????这样的需要替换id。但是,如果有开发者使用到了0x7f??????这样的数字,而它并非资源id,可是却和需要替换的id数值相同,这就会导致这个数字被错误地替换。
但这种情况是极为罕见的,因为很少会有人用到这样特殊的数字,并且还需要碰巧这数字和资源id相等才行。即使出现,开发者也可以用拼接的方式绕过这类数字的产生。所以基本可以不用担心这种情况,只是需要注意它的存在。
这套资源修复方案目前已经完全集成进阿里云移动热修复(Sophix),值得一提的是,结合Sophix提供的代码热替换机制,资源也可以做到补丁下发即时生效,无需重启APP!如果对代码热替换的技术的实现细节有兴趣,可以看这篇文章,其中实现了兼容性极好的Java方法的Native热替换。
另外,不同于阿里Hotfix1.X版本笨拙的命令行操作,新的补丁工具实现了图形界面,使用起来更加方便快捷。
最后,展示一下这个工具的界面。轻松一键,即可完美生成补丁。
原创文章,转载请注明出处。手淘公众号文章链接:https://mp.weixin.qq.com/s/7f81xxRjqHu3Nu9xDrqShw