资源修复技术详解
接上篇:深入探索Android热修复技术原理 (手淘技术团队)
Android资源的热修复,就是在app不重新安装的情况下,利用下发的补丁包直接更新app中的资源。
目前市面上的很多热修复方案都参考了Instant run的实现。
下面来简单看一下instant run方案是怎么做资源热修复的。
Instant Run中的资源修复分为两步,
- 构造新的AssetManager,并通过反射调用addAssetPath,把这个完整的新资源包加入到AssetManager中。这样就得到了一个含有所有新资源的AssetManager。
- 找到所有之前引用到原有AssetManager的地方,通过反射,把引用处替换为AssetManager。
一个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 allow 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 trunk.
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。
运行时资源解析
默认由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安装包资源。
为什么资源无法像dex一样addPath修改原有的AssetManager
如果此时直接在原有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->typeSpecFlag = (const uint32_t*) (((const uint8_t*)typeSpec) + dtohs(typeSpec->head.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->type[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(realEntryIndex) >= typeSpec->entryCount) {
ALOGW("For resource 0x%08x, entry index(%d) is beyond type entryCount(%d)",
Res_MARKID(packageGroup->id-1, typeIndex, entryIndex), entryIndex, static_cast(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 = thisSpec->package;
actualTypeIndex = realTypeIndex;
// If no config
if (config == NULL) {
break;
}
}
}
}
在获取某个Type的资源时,会从前往后遍历,也就是说先得到原有安装包里的资源,除非后面的资源config比前面的更详细才会发生覆盖。而对于同一个config而言,补丁中资源就永远无法生效了。所以在Android L以上的版本,在原有AssetManager上加入 补丁包 ,是没有任何作用的,补丁中的资源无法生效。
而在Android4.4及以下版本,addAssetPath只是把补丁包的路径添加到了mAssetPath中,而真正解析的资源包的逻辑是在app第一次执行AssetManager::getResTable的时候。
@android-4.4.4_r2/frameworks/base/libs/andridfw/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 (mResource != NULL) {
return mResource;
}
if (required) {
LOG_FATAL_IF(mAssetPathPaths.size() == 0, "No assets added to AssetManager");
}
if (mCacheMode != CACHE_OFF && !mCacheValid) {
const_cast(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 find resources.arsc");
}
if (!rt) {
mResources = rt = new ResTable();
}
return rt;
}
而在执行到加载补丁代码的时候,getResTable已经执行过了无数次了。
这是因为就算我们之前没做过任何资源相关操作,Android Framework里的代码也会多次调用到这里。
所以,以后即使是addAssetPath,也只是添加到了mAssetPath,并不会发生解析。所以补丁包里面的资源是完全不生效的。
所以,像instant run这种方案,一定需要一个全新的AssetManager时,然后再加入完整的新资源包,替换掉原有的AssetManager。
一个好的资源修复方案
从前面分析可以得出一个好的资源修复方案需要满足:
- 资源包足够小
- 不侵入打包流程
简单来说,Sophix提出的方案满足了上面的要求:
- 构造一个package id为0x66的资源包,这个包里面只包含改变了的资源项,
- 在原有AssetManager中addAssetPath这个包。
没错!就是这么简单。
由于补丁包的package id为0x66,不与目前已经0x7f冲突,因此直接加入到已有的AssetManager中就可以直接使用了。
补丁包里的资源,只包含原有包里面没有而新包里面有的新增资源以及原有内容发生了改变的资源。
更加优雅的替换AssetManager
对于Android L以后的版本,直接在原有AssetManager上应用patch就行了。
并且由于用的是原来的AssetManager,所以原先大量的反射替换操作就完全不需要了,大大提高了补丁加载生效的效率。
但之前提到过Android 4.4和以下版本,addAssetPath是不会加载资源的,必须重新构造一个新的AssetManager并加入patch,再替换掉原来的。
** 那么如何省掉版本兼容和反射替换的工作呢? **
其实在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(void) {
int count = android_atomic_dec(&gCount);
// ALOGI("Destroying AssetManager inn %p #%d\n", this, count);
delete mConfig;
delete mResource;
// don't have a String class yet, so make sure we clean up
delete[] mLocate;
delete[] mVendor;
}
native层的AssetManager析构函数会析构它的所有成员,这样机会释放之前加载了的资源。
而现在,java层的AssetManager已经成了空壳。我们可以调用它的init方法,对它重新初始化了!
@frameworks/base/core/java/android/content/res/AssetManger.java
public final class AssetManager {
... ...
private native final void init();
... ...
}
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层创建一个没有添加过资源并且mResource没有初始化的AssetManager。然后我们再对它进行addAssetPath,之后由于mResoure没有初始化过,就可以正常走到解析mResource的逻辑,加载所有add进去的资源了!
核心实现代码如下:
... ...
// 反射关键方法
Method initMeth = assetManagerMethod("init");
Method destroy = assetManagerMethod("destroy");
Method addAssetPathMeth = assetManager("addAssetManager", String.class);
// 析构AssetManager
destroyMeth.invoke();
// 重新构造AssetManager
initMeth.invoke();
// 置空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赋值,mStringBlocks记录了之前加载过的所有资源包的String Pool,
// 因此很多时候访问字符串是通过它来找到的,如果不进行重新构造,在后面使用的时候会导致崩溃
assetManagerMethod("ensureStringBlocks").invoke(am);
... ...
由于我们直接是对原有的AssetManager进行析构和重构,所有原先对AssetManager对象的引用时没有发生变化的,这样就不需要想Instant Run那样进行繁琐的修改了。
So库修复技术详解
so库加载原理
Java Api提供了两个接口来加载so库:
- System.loadLibrary(String libName):传进去的参数:so库名称。表示so库文件位于apk压缩文件中的libs目录,最后复制到apk安装目录下。
- System.load(String pathName):传进去的参数:so库在磁盘中的完整路径。记载一个自定义外部so文件。
上述两个方式加载一个so库,实际上最后都调用nativeLoad
这个native方法去加载so库,这个方法的参数是so库在磁盘中的完整路径名。
public class MainActivity extends Activity {
static {
System.loadLibrary("jnitest");
}
public static native String stringFromJNI();
public static native void test();
}
// 静态注册stringFromJNI本地方法
extern "c" jstring Java_com_taobao_jni_MainActivity_stringFromJNI(JNIEnv* env, jclass clazz) {
std::string hello = "jni stringFrom JNI old.... ";
return env->NewStringUTF(hello.c_str());
}
// 动态注册test方法
void test(JNIEnv* env, jclass clazz) {
LOGD("jni test old.... ");
}
JNINativeMethod nativeMethods[] = {
{"test", "()V", (void *)test}
};
#define JNIREG_CLASS "com/taobao/jni/MainActivity"
JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved) {
LOGD("old JNI_OnLoad");
....
jclass clz = env->FindClass(JNIREG_CLASS);
if (env->RegisterNatives(clz, nativeMethods, sizeof(nativeMethods)/sizeof(nativeMethod[0])) != JNI_OK) {
return JNI_ERR;
}
return JNI_VERSION_1_4;
}
我们知道JNI编程中,动态注册的native方法必须实现JNI_OnLoad方法,同时实现一个JNINativeMethod[]数组,静态注册的native方法必须是Java+类完整路径+方法名的格式。
总结下:
- 动态注册的native方法映射通过加载so库过程中调用JNI_OnLoad方法调用完成。
- 静态注册的native方法映射是在该native方法第一次执行的时候才完成映射。
动态注册native方法实时生效
我们知道动态注册的native方法调用一次JNI_OnLoad都会重新完成一次映射,所以我们需要先加载原来的so库,然后再加载补丁so库,就能完成Java层native方法到native层patch后的新方法映射,这样就完成了动态注册native方法的patch实时修复。
实测发现:
ART虚拟机下这样做是可以做到实时生效的,但是Dalvik下做不到实时生效。
实际上Dalvik下第二次load补丁so库,执行的仍然是原来的so库的JNI_OnLoad方法,所以Dalvik下做不到实时生效。
既然拿到的是so库的JNI_OnLoad方法,那么我们首先怀疑一下两个函数是否有问题:
- dlopen():返回给我们一个动态链接库的句柄
- dlsym():通过dlopen得到的动态链接库句柄,来查找一个symbol
源码在/bionic/linker/dlfcn.cpp
文件,方法调用链为:dlopen->do_dlopen->find_library->find_library_internal
static soinfo* find_library_internal(const char* name) {
soinf* si = find_loaded_library(name);
if (si != NULL) { // so已经加载过
if (si->flags & FLAG_LINKED) {
return si; // 直接返回该so库的句柄
}
DL_ERROR("OOPS: recursive link to \"%s\" ", si->name);
return NULL;
}
TRACE("[ '%S' has not been loaded yes. Locating...] ", name);
si = load_library(name); // so库从未加载过,load_library执行加载
if (si == NULL) {
return NULL;
}
return si;
}
find_loaded_library
方法判断那么表示的so库是否已经被加载过,如果加载过直接返回之前的句柄,否则就调用load_library
尝试加载so库。
static soinfo* find_loaded_library(const char* name) {
soinfo* si;
const char* bname;
// TODO: don't use basename only for determining libraries
// http://code.google.com/p/android/issues/detail?id=6670
bname = strrchr(name, '/');
bname = bname ? bname + 1 : name;
for (si = solist; si != NULL; si = si->next) {
if (!strcmp(bname, si->name)) {
return si;
}
}
return NULL;
}
看代码注释,也知道其实这是Dalvik虚拟机下的一个bug,这里是通过base那么去做查找,传进来的name实际上是so库所在磁盘的完整路径。
比如此时修复后的so库路径为/data/data/com.taobao.jni/files/libnative-lib.so
。但是此时通过bname:libnative-lib.so
作为key去查找,因此补丁包和原包文件命名一致的话,就会发生新库永远无法生效。
因此在Dalvik虚拟机下,尝试对补丁包so进行重命名,确保bname是全局唯一的,才能做到Dalvik环境下的动态注册的native方法实时生效。
静态注册native方法实时生效
前面说过静态注册的native方法的摄影是在native方法第一次执行的时候就完成了映射,所以如果native方法在加载补丁so库之前已经执行过,那么是否这种时候的静态注册的native方法一定无法修复吗?
幸运的是,系统JNI API提供了注册的接口。
static jint UnregisterNatives(JNIEnv* env, jclass jclazz) {
ClassObject* clazz = (ClassObject*) dvmDecodeIndirectRef(ts.self(), jclazz);
dvmUnregisterJNINativeMethods(clazz);
return JNI_OK;
}
/*
* Un-register all JNI native methods from a class
*/
void dvmUnregisterJNINativeMethods(ClassObject* clazz) {
unregisterJNINativeMethods(clazz->directMethods, clazz->directionMethodCount);
unregisterJNINativeMethods(clazz->virtualMethods, clazz->virtualMethodCount)
}
static void unregisterJNINativeMethods(Method* methods, size_t count) {
while(count != 0) {
count--;
Method* meth = &methods[count];
if (!dvmIsNativeMethod(meth)) {
continue;
}
if (dvmIsAbstractMethod(meth)) { /* avoid abstract method stubs */
continue;
}
dvmSetNativeFunc(meth, dvmResolveNativeMethod, NULL); // meth->nativeFunc重新指向dvmResolveNativeMethod
}
}
UnregisterNatives
函数会啊jclazz
所在类的所有native方法都重新指向为dvmResolveNativeMethod
,所以调用UnregisterNatives
之后不管是静态注册还是动态注册native
方法、之前是否执行过,在加载补丁so的时候都会重新去做映射。
所以我们只需要调用:
static void patchNativeMethod(JNIEnv *env, jclass clz) {
env->UnregisterNatives(clz);
}
这里有一个难点,因为native方法是在so库,所以补丁工具很难检测出到底是哪个Java类需要解除native方法的注册。 这个问题暂且放下。
假设我们现在可以知道哪个具体的Java类需要解除注册native方法,然后load补丁库,再次执行该native方法,按照道理来说是可以让native方法实时生效,但是这里有个坑。
问题现象:
在上面动态注册native方法补丁实时生效的部分说过so库需要重命名,测试发现重命名后的so文件时而生效时而不生效
首先,静态注册的native方法之前从未执行过的话或者调用了UnregisterJNINativeMethods
方法解除注册,那么该方法将指向dvmResolveNativeMethod(meth->nativeFunc = dvmesolveNativeMethod
),那么真正运行该方法的时候,实际上执行的是dvmResolveNative方法。
此函数主要是完成Java层native方法和native层方法的映射逻辑。
void dvmResolveNativeMethod(const u4* args, JValue* pResult, const Method* method, Thread* self) {
void* func = lookupSharedLibMethod(method);
... ...
if (func != NULL) {
// 调用lookupSharedLibMethod方法,拿到so库文件对应的native方法函数指针。
dvmUseJNIBridage((Method*) method, func);
(*method->nativeFunc)(args, pResult, method, self);
return;
}
... ...
dvmThrowUnstatisfiedLinkError("Native method not found", method);
}
static void* lookupSharedLibMethod(const Method* method) {
return (void*) dvmHashForeach(gDvm.nativeLibs, findMethodInLib, (void*) method);
}
int dvmHashForeach(HashTable* pHashTable, HashForeachFunc func, void* arg) {
int i, val, tableSize;
tableSize = pHashTable->tableSize;
for (i = 0; i < tableSize; i++) {
HashEntry* pEnt = &pHashTable->pEntries[i];
if (pEnt->data != NULL && pEnt->data != HASH_TOMBSTONE) {
val = (*func)(pEnt->data, arg);
if (val != 0) {
return val;
}
}
}
return 0;
}
gDvm.nativeLibs
是一个全局变量,它是一个HashTable,存放着整个虚拟机加载so库的SharedLib结构指针。
然后该变量作为参数传递给dvmHashForeach
函数进行HashTable遍历。
执行findMethodInLib
函数看是否找到对应的native函数指针,如果第一个就找到,就直接return。
这个结构很重要,在虚拟机中大量使用到了HashTable这个数据结构,实现源码在dalvik/vm/Hash.h
和 dalvik/vm/Hash.cpp
文件。
简单说下Java的HashTable和这里的HashTable的异同点:
共同点:两者实际上都是数组实现,都是对key进行hash计算后跟hashtable的长度进行取模作为bucket。
不同点:Dalvik虚拟机下的HashTable实现要比Java中的实现简单一些。
Java中的HashTable的put操作要处理hash冲突的情况,一般情况下会在冲突节点上新增一个链表处理冲突,然后get实现会遍历链表
Dalvik下的HashTable的put操作只是简单的把指针下移到下一个空间点。get实现首先根据hash值算出bucket位置,然后比较是否一致,不一致的话,指针下移,HashTable的遍历实现就是数组遍历
由于HashTable的实现方法以及dvmHashForeach
的遍历实现,so注册位置跟文件命名hash后的bucket值有关,如果顺序靠前,那么生效的永远是最前面的,而后面一直无法生效。
可见so库实时生效方案,对于静态注册的native方法有一定的局限性,不能满足通用性。
so库冷部署重启生效实现方案
为了更好的兼容通用性,我们尝试通过冷部署重新生效的角度分析下补丁so库的修复方案。
1、接口替换方案
提供接口替换System默认加载so库接口
SoPatchManager.loadLibrary(String libName) -> 代替 System.loadLibrary(String libName)
自定义loadLibrary加载策略如下:
- 如果存在则加载补丁so库
- 如果不存在,那么调用
System.loadLibrary
加载安装apk目录下的so库
虽然此方案实现简单,但无法修改第三方包的so库。
2、反射注入方案
前面介绍过System.loadLibrary("native-lib")
,调用native层的时候参数就会包装成/data/app-lib/com.taobao.jni-2/libnative-lib.so
,so库会在DexPathList.nativeLibraryDirectories/nativeLibraryPathElements
变量所表示的目录下搜索
这个方式有点像DexElements数组的处理
————————
至此