熱修復原理學習(6)資源熱修復技術

1. 普遍的實現方式

Android資源熱修復是指在App不重新安裝的情況下,利用下發的補丁包直接更新App中的資源。

目前市面上很多資源熱修復方案基本上都參考了Instant Run的實現。關於Instant Run如何進行資源替換的,請看這篇熱修復原理學習 第3.2節。

簡單來說,Instant Run中的資源熱修復分爲兩步:

  1. 構造一個新的AssetManager,並通過反射調用 addAssetPath(),把這個完整的新資源包加入到AssetManager中。這樣就得到了一個含有所有新資源的AssetManager
  2. 找到之前引用原有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_greydropdn_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的方式進行替換。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章