天天看點

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

該系列文章: 

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

深入探索Android熱修複技術原理讀書筆記 —— 代碼熱修複技術

1 普遍的實作方式

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 的大小和資料類型。

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

通過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 這個資源。

3 運作時資源的解析

預設由 Android SDK 編出來的 apk,是由 aapt 具進行打包的,其資源包的 package id 就是 0x7f。

系統的資源包,也就是 framework-res.jar, package id 為 0x01。

在走到 app 的第一行代碼之前,系統就已經幫我們構造好一個已經添加了安裝包資源的 AssetManager 了。

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

是以,這個 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。

4 另辟蹊徑的資源修複方案

而一個好的資源熱修複方案是怎樣的呢?

首先,更新檔包要足夠小,像直接下發完整的更新檔包肯定是不行的,很占用空間。

而像有些方案,是先進行 bsdiff,對資源包做差量,然後下發差量包,在運作時 合成完整包再加載。這樣确實減小了包的體積,但是卻在運作時多了合成的操作,耗費了運作時間和記憶體。合成後的包也是完整的包,仍舊會占用磁盤空間。

而如果不采用類似 Instant Run 的方案,市面上許多實作,是自己修改aapt, 在打包時将更新檔包資源進行重新編号。這樣就會涉及到修改 Android SDK 工具包, 即不利于內建也無法很好地對将來的aapt 版本進行更新。

針對以上幾個問題,一個好的資源熱修複方案,既要保證更新檔包足夠小,不在 運作時占用很多資源,又要不侵入打包流程。我們提出了一個目前市面上未曾實作 的方案。

簡單來說,我們構造了一個 package id 為 0x66 的資源包,這個包裡隻包含改變了的資源項,然後直接在原有 AssetManager 中 addAssetPath 這個包。然後就可以了。真的這麼簡單?

沒錯!由于更新檔包的 package id 為 0x66,不與目前已經加載的 0x7f 沖突,因 此直接加入到已有的 AssetManager 中就可以直接使用了。更新檔包裡面的資源,隻包含原有包裡面沒有而新的包裡面有的新增資源,以及原有内容發生了改變的資源。

而資源的改變包含增加、減少' 修改這三種情況,我們分别是如何處理的呢?

  • 對于新增資源,直接加入更新檔包,然後新代碼裡直接引用就可以了,沒什麼好說的。
  • 對于減少資源,我們隻要不使用它就行了,是以不用考慮這種情況,它也不影響更新檔包。
  • 對于修改資源,比如替換了一張圖檔之類的情況。我們把它視為新增資源, 在打入更新檔的時候,代碼在引用處也會做相應修改,也就是直接把原來使用舊資源 id 的地方變為新 id。

用一張圖來說明更新檔包的情況,是這樣的:

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

圖中綠線表示新增資源。紅線表示内容發生修改的資源。黑線表示内容沒有變 化,但是 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和系統加載資源的代碼。

5 更優雅地替換 AssetManager

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

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

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

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

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

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

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

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

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

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

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

這同樣是個native方法,

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

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

樹林美麗、幽暗而深邃,但我有諾言尚待實作,還要奔行百裡方可沉睡。 -- 羅伯特·弗羅斯特