深度剖析 | 阿里熱修復如何精簡優化補丁資源?
來自專欄我是程序員
這一年,關於Sophix熱修復我們陸續做了很多優化和改進,包括:
兼容最新Android版本至Android P dp3
JIT混合編譯的兼容
第三方加固的全面兼容
新增穩健接入方式
三星低版本特殊機型的兼容
補丁工具加速與初始化檢查
資源補丁深度優化
其他穩定性和性能的改進
Sophix熱修復中的資源修復我們在《深入探索Android熱修復技術原理》(在阿里技術公眾號,回復「熱修復」,即可免費下載)書中已經有過介紹,主要思想就是將新增和修改的資源打包到補丁資源包中,以0x66的包名來重新編排這些資源。對比其他熱修復需要替換完整資源包,Sophix的增量的資源補丁方案能做到資源補丁最小化,並且運行時無需合成完整資源,實現了性能與空間的最優化。
在此基礎上,我們繼續改進了資源補丁,對resources.arsc中的字元串池進行裁剪,在不損耗運行時性能的情況下讓補丁包大小精簡到了極致。
resources.arsc結構
resources.arsc文件集結了所有帶id的資源項,其粗略概貌可以由以下這張圖展現:
這裡我們不需要太關注細節,只大致說明一下。每個arsc文件的開頭是一個類型為RES_TABLE_TYPE的ResTable_header結構頭,它指定了這個arsc文件所包含的其他結構,一般來說,只有一個全局字元串池和其他包資源塊,通常情況下(Android Studio默認編譯出來的)也僅有一個包,包id為0x7f,也就是說該包下的所有資源編號都是0x7fXXXXXX。
我們發現,每個包中還有兩個字元串池,分別是類型字元串池和資源項字元串池,這兩個字元串池和全局字元串池又有怎樣的關係呢?
類型字元串池只表示類型對應的名稱,像layout、string、color、integer等這些字元串,在arsc中只有一個類型id(比如0、1、2、3等)來表示他們。下面還有例子會詳細解釋。類型字元串池是比較獨立的,而且所佔空間很小,與其他結構也沒有太大關聯。
而資源項字元串池中存儲的是鍵字元串,與全局字元串池中存儲的是值字元串相對應。這裡的鍵和值就是我們通常理解中鍵值對(Key-Value)的鍵和值。之所以值字元串放在全局,應該是Android在設計之初打算在一個resources.arsc中的各個包中進行資源值的復用,然而由於目前默認只有一個0x7f包,自然也沒有復用這一說了。
只看這個結構會比較抽象,我們舉個例子,對於以下這個字元串資源:
假設這個資源在編譯進arsc之後,對應的id為0x7f010000
此時arsc中0x7f包中類型字元串池是
0x7f包中鍵字元串池是
arsc文件中的全局值字元串池是
那麼,在解析這個資源項的時候,由於它的包id為0x7f,就會找到這個0x7f包中來解析,類型id為0x01,表示類型字元串池的第0x01個字元串,也就是這裡的string類型,剩下的0x0000,表示該類型的第0個資源項。
我們從第0個資源項中解析出它是一個字元串類型的資源(這裡省略解析過程),並且得到他的key值為0x1,value的值為0x3。而從前面列出的信息中可以看到,鍵字元串池第1個字元串為app_name,值字元串池的第3個字元串為MyDemo。由此就可以得到這個MyDemo資源的完整信息了。
這裡我們可以看出,一個資源中占空間最大的正是字元串池,其他結構只是一些索引數字,所佔空間很小,因此如果能對字元串池進行精簡,將節省很多空間。
字元串池的構造
首先,我們得先弄清字元串池的結構是怎樣的,它的關鍵入口是ResStringPool_header這個結構頭,系統會以通過這個結構頭解析出完整的字元串池。
接下來我們從StringPool解析過程的系統源碼入手,探尋其具體的構造。核心解析邏輯在ResStringPool::setTo,簡單起見,以下代碼去掉了與主流程無關的檢查代碼:
這裡很清楚地展示了解析的過程,對ResStringPool的各個欄位進行賦值。
其中有幾個比較重要的欄位:
mEntries:字元串偏移數組指針
mStringPoolSize:字元串個數
mStrings:字元串塊的起始地址
mEntryStyles:樣式偏移數組指針
mStylePoolSize:樣式個數
mStyles:所有樣式的存儲的起始地址
mEntries與mEntryStyles保存是都是每個字元串在字元串塊中的偏移,字元串塊就是所有字元串的集合,以0分割開,通過偏移可以獲得具體的某個字元串值,這個過程體現在另一個ResStringPool::stringAt函數:
這裡需要注意的一點是,字元串池中的字元串可以以UTF8或者UTF16編碼來存儲,不同編碼中的保存偏移的方式有所不同。這裡僅看UTF16的情況,參數idx表示我們要獲取的第幾個字元串,mEntries[idx/sizeof(uint16_t)可以獲得第idx個字元串在字元串池中的偏移off,然後由mStrings+off就可以獲得這個字元串實體的起始位置,接著就可以由decodeLength方法得到真正的字元串值。
style即表示字元串的樣式,後面我們會詳細講到。
通過這個解析過程,我們可以得到這張結構圖,其很好地體現出字元串池的構造:
精簡思路
我們的資源補丁方案中,補丁中只包含新增和修改的資源,而生成補丁需要一個新包APK和一個舊包APK,毫無疑問,這兩種加入補丁包的資源實際上都是屬於生成補丁時的新包中的資源,因此直接拿新包APK中resources.arsc的完整字元串池就可以作為補丁的字元串池,我們最早的資源補丁就是直接採用這種方式。這麼做有一個好處,就是新增和修改的資源用到的字元串索引完全不需要修改,就可以正常獲取到字元串池的具體值。但是,由於字元串池是從完整的新包中直接拿過來的,因此,裡面非新增和修改的資源所用的字元串也直接包含在了其中,而這些字元串對於補丁,是多餘的。因此,我們需要精簡去除的,正是這些無用的字元串。
具體來說,主要分為三個步驟:
首先,我們需要確定要留下的是哪些字元串。
接著,重新編排留下的有效字元串,使其緊湊對齊,並且重新計算各個字元串相對起始位置的偏移。
最後,修正所有引用字元串的地方,使得補丁資源可以正確地引用到重排過的字元串。
確定要留下的字元串
需要留下的字元串,無疑就是補丁資源中使用的字元串,而補丁資源中使用的字元串,就是我們通過比較新包和舊包,得到的新增和修改的資源所用到的字元串。具體來說,我們已經通過比較得到了一個映射表,裡面記錄了所有新包資源到補丁資源的id映射關係,如下所示:
這裡需要處理兩個字元串池,全局的值字元串池0x7f和包中的鍵字元串池,其中的無用的字元串和樣式都需要去掉。
對於0x7f包中的鍵字元串,我們需要收集表中所有資源的鍵,也就是這些資源項的名稱,得到一個字元串索引值的列表,這個時候得到的列表,由於是新包字元串池的索引,因此是零散分布的。
我們可以直接為每個收集到的鍵的字元串索引重新指定一個索引值,由此得到一張新包索引到補丁包索引的映射表:
對於全局值字元串池的處理也是類似,不同地方在於,我們需要進一步解析每個資源項,得到其對應的具體字元串值,仍然是以這個資源為例:
我們需要找到的,就是app_name在0x7f包鍵字元串的索引,以及MyDemo在全局值字元串中的索引。
另外,我們還需要處理樣式。樣式是字元串的特殊格式,比如下面的這個資源
這裡的Demo字元串就擁有加粗的樣式,而某個字元串對應的樣式的在樣式表中的索引值與這個字元串在字元串池中的索引值是一樣的。aapt在編譯的時候也會將帶有樣式的資源全部放到字元串池的最前面。比如有五個字元串具有樣式,這五個字元串就會被默認放到字元串池的前五個,而樣式表也只有五個樣式,分別對應了這前五個字元串。而從第六個字元串以後,就沒有樣式了。
所以,這裡我們還需要調整樣式表,把收集到的字元串所對應的樣式也一同移動到對應位置。此外,樣式字元串,也就是例子中的b字元串實際上也是保存在字元串池中的,因此,當使用到某個樣式的時候,還需要將該樣式的字元串索引添加到我們的索引映射表中並重新編排。
重新編排與調整偏移值
我們用一張示意圖來描述這個編排過程:
其中深色offset entry的表示補丁中實際有效的字元串所對應的偏移值,可以看到,其中的新包中entries按照前面安排的映射關係移動到了補丁entries的相應位置,並且entries的偏移值也根據新排布的字元串位置進行了調整。下方的字元串塊strings和樣式塊styles的內容也只保留有效部分,這樣,所有有效字元串緊貼在了一起,並去除了新包中其他無用的資源,大幅節省了空間。
最後需要重新構造字元串的頭部ResStringPool_header結構,使得其中的各個欄位(stringCount、styleCount、stringsStart、stylesStart等)填入正確的值。
這樣,一個有效的補丁字元串池就完整構建好了。這個重排的過程對於鍵值兩種字元串池是完全相同的。
修正資源引用處
字元串池構建完畢了以後,還需要對資源中使用到這些字元串的地方進行重新索引。顯然,只需要根據這個映射表:
把原來的老索引值修正為新索引值就行了。具體來說,就是將資源文件結構中的ResTable_entry(代表資源項)和Res_value(代表具體資源的值)中,類型為ResStringPool_ref的欄位的index值修正過來即可。
由於我們壓縮優化的是resources.arsc中的字元串池,因此需要完整地遍歷每個補丁資源項,把相應的index做替換。而xml中的資源不需要相應修改,因為xml中使用到的只有arsc裡面的資源id,感知不到id對應的字元串是什麼,所以只要在arsc中處理好,xml自然就能找到id所持有的正確的字元串。
總結
通過這三個步驟,便實現了字元串池的精簡。當然處理過程中還有有很多零碎的問題,比如引用類型資源的處理、Map資源項和字元串池各個塊的拼接等等,這些都需要十分細緻地處理好,否則都會導致運行時解析格式失敗而崩潰。本文沒有述及這些繁瑣的問題,也是為了不因為它們而擾亂了主要處理邏輯,當搞定了主幹後,回頭再收拾這些細枝末節就顯得遊刃有餘了。
精簡後效果是很明顯的,不過具體還是取決於原始APK中資源字元串的數量以及補丁資源中實際有效的字元串的數量,如果資源字元串較多的話會有非常顯著的優化。我們遇到最極端的一個例子是,精簡之前帶資源的補丁有4M大小,而精簡之後直接變為23K!由此可見一斑。
目前Sophix最新版本打包工具的高級選項中已默認開啟這個優化資源補丁選項,立刻使用就能為你的資源熱修復補丁瘦身。
當然,還有一些其他選項開關,是為了打包的靈活性而設置的,其中有些強烈建議打開的選項我們已經默認開啟了。
Sophix熱修復中還有許多技術優化點,我們也在去年7月推出了《深入探索Android熱修復技術原理》免費電子書,詳細講解了代碼、資源、動態庫的熱修復實現(在阿里技術公眾號,回復「熱修復」,即可下載)。值此一周年之際,我們與電子工業出版社合作,計劃在近期出版該書的印刷紙質版,並新增了一些篇章,以方便大家翻閱,敬請期待。
本文作者: 萬壑
原文鏈接
更多技術乾貨敬請關注云棲社區知乎機構號:阿里云云棲社區 - 知乎
本文來自雲棲社區合作夥伴「阿里技術」,如需轉載請聯繫原作者。推薦閱讀:
※學數學:你會不會反思?20091214
※中國數學界掃地僧:當過服務生,蟄伏三十年拿遍榮譽
※一道概率題的解惑
※Excel函數學習8:SEARCH函數
※三分鐘學會如何在函數計算中使用 puppeteer