深入理解Android熱修復技術原理之資源熱修復技術

一、普遍的實現方式

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

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

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

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

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

二、資源文件的格式

整個 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.jpg,無法查看。不過可以用反編譯工具,看到它的真實值是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這一步,也可以采用我們這種方式進行替換,省時省力又省心。

六、本章小結

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

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

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

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

以上就是深入理解Android熱修復技術原理之資源熱修復技術的詳細內容,更多關於Android資源熱修復的資料請關註WalkonNet其它相關文章!

推薦閱讀: