資源修復技術詳解
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<size_t>(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<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 = 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<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 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數組的處理
————————
至此