1. 普遍的實現方式
Android資源熱修復是指在App不重新安裝的情況下,利用下發的補丁包直接更新App中的資源。
目前市面上很多資源熱修復方案基本上都參考了Instant Run
的實現。關於Instant Run如何進行資源替換的,請看這篇熱修復原理學習 第3.2節。
簡單來說,Instant Run中的資源熱修復分爲兩步:
- 構造一個新的AssetManager,並通過反射調用
addAssetPath()
,把這個完整的新資源包加入到AssetManager中。這樣就得到了一個含有所有新資源的AssetManager。 - 找到之前引用原有AssetManager的地方,通過反射,將AssetManager類型的mAssets字段引用全部替換爲新創建的 AssetManager。
這其中的重點,自然是 AssetManager.addAssetPath()
這個函數,Java層的AssetManager只是個包裝,所以這個方法真正的實現,是位於Native層中。
執行addAssetPath就是解析這個資源格式,然後構造出底層數據的過程。整個解析資源的調用鏈是:
public final int addAssetPath(String path)
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;
Vector<Header*> mHeaders; //表示所有resources.arsc原始數據,這就等同於所有通過addAssetPath加載進來的路徑的資源ID信息
Vector<PackageGroup*> mPackageGroups //資源包的實體,包含所有加載進來的package id所對應的資源
uint8_t mPackageMap[256]; //索引表,表示0~255的package id,每個元組分別存放該ID所屬PackageGroup在mPackageGroups中的index
uint8_t mNextPackageId;
}
一個Android進程只包含一個ResTable,ResTable的成員變量mPackageGroups
就是所有解析過的資源包的集合。
任何一個資源包中都含有resources.arsc
,它記錄了所有資源的ID分配情況,以及資源中的所有字符串。這些信息是以二進制數的方式存儲的。底層的AssetManager做的事就是解析這個資源文件,然後把相關信息存儲到 mPackageGroups裏面。
2. 資源文件的格式
整個resources.arsc文件,實際上是由一個個ResChunk
(以下簡稱chunk)拼接起來的。從頭文件開始,每個chunk的頭部都是一個ResChunk_header
結構,他指示了這個chunk的大小和數據類型。
struct ResChunk_header
{
uint16_t type;
uint16_t headerSize;
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結構體,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的id就是1,drawable就是2,以此類推。 - 對於entry id,每個entry表示一個資源項,資源項是按照排列的先後順序自動被標記編號的。也就是說,一個type裏按位置出現的第一個資源項其entry id爲0,依次類推。因此我們因此我們是無法直接指定entry id的,只能夠根據排布順序決定。資源項之間是緊密排布的,沒有空隙,但是可以指定資源項爲 ResTable_type::NO_ENTRY來填入一個空資源。
舉個例子:
我們隨便找個帶資源APK,用AAPT解析一下,看到其中的一行是:
spc resource 0x7f040019 com.rikkathworld.hotfix:layout/activity_main flags=0x00000000
表示 activity_main這個資源的編號是 0x7f040019,其中package id是 0x7f,資源類型ID是0x04(即layout類型),而0x04類型的第0x0019個資源項就是activity_main這個資源。
3. 運行時資源的解析
默認由Android SDK編出來的APK,是由APPT工具進行打包的,其資源包的package id就是 0x7f
系統的資源包,也就是framework-res.jar,package id爲0x01
在走到App的第一行代碼之前,系統就已經幫我們構造好一個已經添加了安裝包資源的AssetManager了。
// ResourcesManager.java
protected @Nullable AssetManager createAssetManager(@NonNull final ResourcesKey key) {
AssetManager assets = new AssetManager();
if (key.mResDir != null) { // 1
if (assets.addAssetPath(key.mResDir) == 0) { // 2
Log.e(TAG, "failed to add asset path " + key.mResDir);
return null;
}
}
....
return assets;
}
註釋1:if語句 mResDir,指的就是安裝包APK。
註釋2:用新建的AssetManager調用 addAssetPath()
去解析這個APK下的資源
因此這個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中。
但是在獲取這個資源的時候呢?
在獲取某個Type的資源的時候,它會從前往後遍歷,也就是說先得到原有安裝包裏的資源,除非後面的資源的config比前面更詳細纔會覆蓋。而針對於同一個config而言,補丁中的資源就永遠無法生效了。所以在Android L以上的版本,在原有的AssetManager上加入補丁包,是沒有任何作用的,補丁中的資源無法生效。
而在Android4.4及以下的版本,addAssetPath只是把補丁包的路徑添加到了 mAssetPath中,而真正解析的資源包的邏輯是在App第一次執行AssetManager::getResTbale()
的時候:
//Android4.4_r1 frameworks/base/libs/androidfw/AssetManager.cpp
const ResTable* AssetManager::getResTable(bool required) const
{
// mResources已經存在,直接返回不再往下走
//如果已經執行過一次完整的該方法,以後都會直接return,所以補丁包來了也解析不了。
ResTable* rt = mResources;
if (rt) {
return rt;
}
....
const size_t N = mAssetPaths.size();
for (size_t i=0; i<N; i++) {
//真正解析package的地方
....
}
return rt;
}
而在執行到加載補丁代碼的時候,getResTable已經執行過了無數次。這是因爲就算我們之前沒有做過任何資源相關的操作,Android framework裏的代碼也會多次調用到那裏。所以,以後即使是 addAssetPath,也只是添加到了mAssetPath,並不會發生解析。因而補丁包裏面的資源是完全不生效的!(注意看代碼中的註釋)
所以像Instant Run這種方案,一定需要一個全新的AssetManager,再加入完整的新資源包,替換到原有的AssetManager。
4. 另闢蹊徑的資源修復方案
一個好的資源修復方案是怎樣的呢?
首先,補丁包要足夠的小,像直接下發完整的補丁包肯定是不行的,很佔用空間
而像有些方案,是先進行bsdiff,對資源包做差量處理,然後下發差量包,在運行時合成完整包再進行加載。這樣確實減小了包的體積,卻在運行時多了合成的操作,耗費了運行時間和內存。合成後的包也是完整的包,仍舊會佔用磁盤空間。
而如果不採用類似Instant Run的方案,市面上許多實現方案是自己修改APPT,在打包時將補丁包資源進行重新編號。這樣就會涉及修改Android SDK工具包,既不利於集成也無法很好地對將來的APPT版本進行升級。
Sophix的方案,簡單來說,是構造了一個package id爲0x66的資源包,這個包裏只包含改變了的資源項,直接在原有的AssetManager中的addAssetPath這個包就可以了。
真的這麼簡單?
是的,由於補丁包的package id爲0x66,不與目前已經加載的0x7f衝突,因此直接加入已有的AssetManager中就可以直接使用了。補丁包裏面的資源,只包含原有包裏面沒有而新的包有的新增資源,以及原有內容發生了改變的資源。
面對資源改變包含的 增加、減少、修改這三種情況,我們分別是如何處理的呢?
- 對於新增資源,直接加入補丁包,然後新代碼裏直接引用就可以了,沒什麼好說的
- 對於減少資源,我們只要不使用它就行了,因此不用考慮這種情況,它也不影響補丁包
- 對於修改資源,比如替換了一張圖片之類的情況。我們把它視爲新增資源,在打入補丁的時候,代碼在引用處也會做相應修改,也就是直接把原來使用舊資源ID的地方變爲新ID。
用下圖來說明補丁包的情況:
圖中綠線表示新增資源,紅線表示內容發生修改的資源,黑線表示內容沒有變化,但是ID發生改變的資源,×表示刪除的資源。
4.1 新增的資源及其導致的ID偏移
可以看到,新的資源包與舊資源包相比,新增了 holo_grey
和dropdn_item2
資源,新增的資源被加入到了補丁包中,並分配了0x66開頭的資源id。
而新增的兩個資源導致了在它們所屬的type中跟在它們之後的資源id發生了位移。比如holo_light,id由0x7f020002變爲0x7f020003,而abc_dialog由 0x7f030004變爲了 0x7f030003。新資源插入的位置是隨機的,這與每次APPT打包時解析XML的順序有關。發生位移的資源不會加入到補丁包中,但是在補丁包的代碼中會調整ID的引用處。
比如說在代碼裏,我們是這麼寫的:
imageView.setImageResource(R.drawable.holo_light);
這個R.drawable.holo_light是一個int值,它的值時AAPT指定的,對於開發者透明,即使點進去,也會直接跳到對應 res/drawable/holo_light.png,無法查看這int。不過可以通過反編譯工具,看到它的真實值時0x7f020002,所以這行代碼等價於:
imageView.setImageResource(0x7f020002);
而當打出了一個新包後,對開發者而言,holo_light的圖片內容沒有改變,代碼引用處也沒有改變,但是新包裏面,同樣是這句話,由於新資源的插入導致ID改變,對於R.drawable.holo_light的引用已經變成了:
imageView.setImageResource(0x7f020003);
但實際上這種情況並不屬於資源改變,更不屬於代碼的改變,所以我們在對比新舊代碼之前,會把新包裏面的這行代碼修正回原來的資源ID。
imageView.setImageResource(0x7f020002);
然後進行後續代碼的對比。這樣後續代碼對比時就不會被檢測到發生了改變。
4.2 內容發生改變的資源
而對於內容發生改變的資源(類型爲layout的activity_main,這可能是我們修改了activity_main.xml的文件內容。還有類型爲string的no,可能是我們修改了這個字符串的值),他們都會被加入到補丁包中,並重新編號爲新ID。
而相應的代碼,也會發生改變,比如:
setContentView(R.layout.activity_main);
//實際上就是下面的
setContentView(0x7f030000);
在生成對比新舊代碼之前,我們會把新包裏面的這行代碼變爲:
setContentView(0x66020000);
這樣,在進行代碼對比時,會使得這行代碼所在函數被檢測到發生了改變。於是相應的代碼修復會在運行時發生,這樣就引用到了正確的新內容資源。
4.3 刪除了的資源
對於刪除的資源,不會影響補丁包。
這很好理解,既然資源被刪除了,就說明新的代碼中也不會用到它,那資源放在那裏沒人用,就相當於不存在了。
4.4 對於type的影響
可以看到,對於type0x01的所有資源項都沒有發生改變,所以整個type0x01資源都沒有加入到補丁包中。這也使得後面的type的id都往前移了一位。因此Type String Pool中的字符串也要進行修正,這樣才能使得0x01的type指向drawable,而不是原來的attr。
所以我們可以看到,所謂簡單,指的是運行時應用補丁變得簡單了。
而真正複雜的地方在於構造補丁。我們需要把新舊兩個資源包解開,分別解析其中的resources.arsc文件,對比新舊的不同,並將他們重新打成帶有新package id的新資源包。這裏補丁包是指定的package id只要不是0x7f和0x01就行,可以是仍以0x7f以下的數字,我們默認把它指定爲0x66。
構造這樣的補丁資源包,需要對整個resources.arsc的結構十分了解,要對二進制數形式的一個一個chunk進行解析分類,然後再把補丁信息一個一個重新組裝成二進制數形式的chunk。這裏面很多工作與AAPT做的類似,實際上開發打包工具的時候也是產考了很多AAPT和系統加載資源的代碼。
5. 更優雅地替換AssetManager
對於Android L以後的版本,直接在原有AssetManager上應用補丁就行了,並且由於用的是原來的AssetManager,所以原先大量的反射修改替換操作就完全不需要了,大大提高了補丁加載的效率。
但之前提到過,在Android KK和以下的版本,addAssetPath是不會加載資源的,必須重新構造一個新的AssetManager並加入補丁包中,再換掉原來的。那麼我們不就又要和Instant Run一樣,做一大堆兼容版本和反射替換的工作了嗎?
對於這種情況,我們也找到了更優雅的方式,不需要如此的大費周章。
在AssetManager源碼裏面,有一個有趣的東西:
// AssetManager.java
.....
private native final void destroy();
.....
明顯,這個是用來AssetManager並釋放資源的函數,我們來看看它具體做了什麼把。
static void android_content_AssetManager_destroy(JNIEnv* env, jobject clazz)
{
AssetManager* am = (AssetManager*) (env->GetIntField(clazz, gAssetManagerOffsets.mObject));
if (am != null) {
delete am;
env->SetIntField(clazz, gAssetManagerOffsets, 0);
}
}
可以看到,首先,它析構了native層的AssetManager,然後把Java層的AssetManager對native層的AssetManager的引用設爲空。
AssetManager::~AssetManager(void)
{
int count = android_atomic_dec(&gCount);
delete mConfig;
delete mResources;
delete[] mLocal;
delete[] mVendor;
}
native層的AssetManager析構函數會析構它的所有成員,這樣就會釋放之前的加載了的資源。
而現在,Java層的AssetManager已經成爲了空客,我們就可以調用它的init方法,對它重新進行初始化了!
public final class AssetManager {
...
private native final void init(boolean isSystem);
}
它同樣是一個native方法:
static void android_content_AssetManager_init(JNIEnv* env, jobject clazz)
{
AssetManager* am = new AssetManager();
if(am == NULL) {
...
}
am->addDefaultAssets();
env->SetIntField(clazz, gAssetManagerOffsets.mObject, (jint)am);
}
這樣,在執行init的時候,會在native層創建一個沒有添加過資源,並且mResources沒有初始化的AssetManager。然後我們再對它進行addAssetPath,之後由於mResources沒有初始化過,就可以正常走到解析mResources的邏輯,加載所有此時添加進去的資源了。
//Android4.4 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;
}
所以我們要想辦法在加載資源前,調用當前進程的AssetManager進行destroy,然後添加補丁包路徑後,再調用AssetManager的init方法,這個方案的實現代碼如下:
....
Method initMeth = assetManagerMethod("init"); //通過反射拿到 init方法
Method destroyMeth = assetManagerMethod("destroy"); //通過反射拿到destroy方法
Method addAssetPathMeth = assetManagerMethod("addAssetPath", String.class); //通過反射拿到addAssetPath
destroyMeth.invoke(am); // 析構 AssetManager
initMeth.invoke(am); // 重新構造 AssetManager
assetManagerField("mStringBlocks").set(am, null); // 通過反射拿到mStringBlocks字段並置空
for (String path : loadedPaths) { // 重新添加原有 AssetManager 中加載過的資源路徑
addAssetPathMeth.invoke(am, path);
}
addAssetPathMeth.invoke(am, patchPath); // 添加 patch 資源路徑
assetManagerMethod("ensureStringBlocks").invoke(am); // 重新對 mStringBlocks 賦值
這裏需要的是 mStringBlocks
這個字段。它記錄了之前加載過所有的資源包的String Pool,因此很多時候訪問字符串是通過它來找到的,如果不盡興重新構造,在後面用到它的時候就會導致崩潰。
由於我們是直接對原有的AssetManager進行析構和重構,所有原先對AssetManager對象的引用是沒有發生改變的,這樣,就不需要向Instant Run那樣進行繁瑣的修改了。
順帶一提,類似Instant Run的完整替換資源的方案,在替換AssetMIanager的這一步,也可以採用Sophix的方式進行替換。