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

該系列文章: 

深入探索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相等才行。即使出現,開發者也可以用拼接的方式繞過這類數字的產生。所以基本可以不用擔心這種情況,只是需要注意它的存在。