深入探索Android熱修復技術原理讀書筆記 —— 資源熱修復技術

huansky發表於2021-05-11

該系列文章: 

深入探索Android熱修復技術原理讀書筆記 —— 熱修復技術介紹

深入探索Android熱修復技術原理讀書筆記 —— 程式碼熱修復技術

普遍的實現方式

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

目前市面上的很多資源熱修復方案基本上都是參考了 Instant Run的實現。 

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

  1. 構造一個新的 AssetManager,並通過反射呼叫 addAssetPath,把這個完 整的新資源包加入到 AssetManager 中。這樣就得到了一個含有所有新資源 AssetManager。

  2. 找到所有之前引用到原有 AssetManager 的地方,通過反射,把引用處替換 AssetManager。

一個 Android 程式只包含一個 ResTable, ResTable 的成員變數 mPackageGroups 就是所有解析過的資源包的集合。任何一個資源包中都含有 resources.arsc,它記錄了所有資源的 id 分配情況以及資源中的所有字串。這些資訊是以二進位制方式存儲的。底層的 AssetManager 做的事就是解析這個檔案,然後把相關資訊儲存到 mPackageGroups 裡面。

2 資原始檔的格式

整個 resources.arse 檔案,實際上是由一個個 ResChunk (以下簡稱 chunk) 拼接起來的。從檔案頭開始,每個 chunk 的頭部都是一個 ResChunk_header 構,它指示了這個 chunk 的大小和資料型別。

通過ResChunk_header中的type成員,可以知道這個chunk是什麼型別, 從而就可以知道應該如何解析這個chunko

解析完一個 chunk 後,從這個 chunk + size 的位置開始,就可以得到下一個 chunk 起始位置,這樣就可以依次讀取完整個檔案的資料內容。

一般來說,一個 resources.arsc 裡面包含若干個package,不過預設情況下, 由打包工具 aapt 打出來的包只有一個 package。這個 package 裡包含了 app 中的 所有資源資訊。

資源資訊主要是指每個資源的名稱以及它對應的編號。我們知道,Android 中的每個資源,都有它唯一的編號。編號是一個 32 位數字,用十六進位制來表示就是0xPPTTEEEE。PP 為 package id, TT type id, EEEE entry id。

它們代表什麼?在 resources.arse 裡是以怎樣的方式記錄的呢?

  • 對於 package id,每個 package 對應的是型別為 RES_TABLE_PACKAG E_ 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 drawablex 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 了。

 

因此,這個 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 上加入補丁包,是沒有任何作用的,補丁中的資源無法生效。

而在 Android 4.4 及以下版本,addAssetPath 只是把補丁包的路徑新增到 mAssetPath中,而真正解析的資源包的邏輯是在app第一次執行 AssetManager::getResTable 的時候。

而在執行到載入補丁程式碼的時候,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 發生改變的資源。x 表示刪除了的資源。

4.1 新增的資源及其導致 id 偏移

可以看到,新的資源包與舊資源包相比,新增了 holo_grey 和 dropdn_item2 資源,新增的資源被加入到 patch 中。並分配了 0x66 開頭的資源 id。

而新增的兩個資源導致了在它們所屬的 type 中跟在它們之後的資源 id 發生了 位移。比如 holojight, id 0x7f020002 變為 0x7f020003, abc_dialog 0x7f030004 變為 0x7f030003。新資源插入的位置是隨機的,這與每次 aapt 打包 時解析 xml 的順序有關。發生位移的資源不會加入 patch,但是在 patch 的程式碼中會調整 id 的引用處。

比如說在程式碼裡,我們是這麼寫的

imageView.setImageResource(R.drawable.holo_light);

這個 R.drawable.holojight 是一個 int 值,它的值是 aapt 指定的,對於開發者 透明,即使點進去,也會直接跳到對應 res/drawable/holo_light.png,無法檢視。不過可以用反編譯工具,看到它的真實值是 0x7f020002。所以這行程式碼其實等價於:

imageView.setImageResource(0x7f020002);

而當打出了一個新包後,對開發者而言,holojight 的圖片內容沒變,程式碼引用處也沒變。但是新包裡面,同樣是這句話,由於新資源的插入導致的 id 改變,對於 R.drawable.holojight 的引用已經變成了:

imageView.setImageResource(0x7f020003);

但實際上這種情況並不屬於資源改變,更不屬於程式碼的改變,所以我們在對比新舊程式碼之前,會把新包裡面的這行程式碼修正回原來的 id。

imageView.setImageResource(0x7f020002);

然後再進行後續程式碼的對比。這樣後續程式碼對比時就不會檢測到發生了改變。

4.2 內容發生改變的資源

而對於內容發生改變的資源(型別為 layout 的 activity_main,這可能是我們修 改了 activity_main.xml 的檔案內容。還有型別為 string 的 no,可能是我們修改了這個字串的值),它們都會被加入到 patch 中,並重新編號為新 id。而相應的程式碼,也會發生改變,比如,

setContentView(R.layout.activity_main); 

實際上也就是

setContentView(0x7f030000);

在生成對比新舊程式碼之前,我們會把新包裡面的這行程式碼變為

setContentView(0x6 6020000);

這樣,在進行程式碼對比時,會使得這行程式碼所在函式被檢測到發生了改變。於是相應的程式碼修復會在執行時發生,這樣就引用到了正確的新內容資源。

4.3 刪除了的資源

對於刪除的資源,不會影響補丁包。

這很好理解,既然資源被刪除了,就說明新的程式碼中也不會用到它,那資源放在那裡沒人用,就相當於不存在了。

4.4 對於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和系統載入資源的程式碼。

更優雅地替換 AssetManager

對於 Android L 以後的版本,直接在原有 AssetManager 上應用 patch 就行 了。並且由於用的是原來的 AssetManager,所以原先大量的反射修改替換操作就 完全不需要了,大大提高了載入補丁的效率。

但之前提到過,在 Android KK 和以下版本,addAssetPath 是不會載入資源 的,必須重新構造一個新的 AssetManager 並加入 patch,再換掉原來的。那麼我們不就又要和 Instant Run —樣,做一大堆相容版本和反射替換的工作了嗎?

對於這種情況,我們也找到了更優雅的方式,不需要再如此地大費周章。

明顯,這個是用來銷燬 AssetManager 並釋放資源的函式,我們來看看它具體做了什麼吧。

可以看到,首先,它析構了 native 層的 AssetManager,然後把 java 層的 AssetManager native 層的 AssetManager 的引用設為空。

native 層的 AssetManager 解構函式會析構它的所有成員,這樣就會釋放之前載入了的資源。

而現在,java 層的 AssetManager 已經成為了空殼。我們就可以呼叫它的 init 方法,對它重新進行初始化了!

這同樣是個native方法,

這樣,在執行 init 的時候,會在 native 層建立一個沒有新增過資源,並且 mResources 沒有初始化的的 AssetManager。然後我們再對它進行 addAssetPath,之後由於 mResource 沒有初始化過,就可以正常走到解析 mResources 邏輯,載入所有此時 add 進去的資源了

由於我們是直接對原有的 AssetManager 進行析構和重構,所有原先對 AssetManager 物件的引用是沒有發生改變的,這樣,就不需要像 Instant Run 樣進行繁瑣的修改了。

順帶一提,類似 Instant Run 的完整替換資源的方案,在替換 AssetManager 這一步,也可以採用我們這種方式進行替換,省時省力又省心。

6本章小結

總結一下,相比於目前市面上的資源修復方式,我們提出的資源修復的優勢在於:

  • 不侵入打包,直接對比新舊資源即可產生補丁資源包。(對比修改 aapt 方式的 實現)
  • 不必下發完整包,補丁包中只包含有變動的資源。(對比 Instanat Run,Amigo 等方式的實現)
  • 不需要在執行時合成完整包。不佔用執行時計算和記憶體資源。(對比 Tinker  實現)

唯一有個需要注意的地方就是,因為對新的資源的引用是在新程式碼中,所有資源修復是需要程式碼修復的支援的。也因此所有資源修復方案必然是附帶程式碼修復的。而 之前提到過,本方案在進行程式碼修復前,會對資源引用處進行修正。而修正就是需要 找到舊的資源id,換成新的id。查詢舊 id 時是直接對 int 值進行替換,所以會找到 0x7f ?????? 這樣的需要替換 id。但是,如果有開發者使用到了 0x7f ?????? 這樣的數字,而它並非資源id,可是卻和需要替換的id數值相同,這就會導致這個數字 被錯誤地替換。

但這種情況是極為罕見的,因為很少會有人用到這樣特殊的數字,並且還需要碰巧這數字和資源id相等才行。即使出現,開發者也可以用拼接的方式繞過這類數字的產生。所以基本可以不用擔心這種情況,只是需要注意它的存在。

 

 

 

 

 

相關文章