深度剖析 | 阿里热修复如何精简优化补丁资源?
阿里妹導(dǎo)讀:Sophix 是阿里推出的史上首個(gè)非侵入式移動(dòng)熱更新解決方案,自去年推出已有一年的時(shí)間了。這一年來(lái),阿里集團(tuán)內(nèi)外成千上萬(wàn)的app踴躍接入。由于接入簡(jiǎn)便,操作流暢,功能可靠,資源占用極小,Sophix得到了廣大開(kāi)發(fā)者的好評(píng),網(wǎng)上也出現(xiàn)了大量開(kāi)發(fā)者親身實(shí)踐的接入文章。
今天,我們選取了其中一個(gè)改進(jìn)點(diǎn)——資源補(bǔ)丁的精簡(jiǎn)優(yōu)化,來(lái)詳細(xì)介紹一下 Sophix 背后的技術(shù)。
這一年,關(guān)于Sophix熱修復(fù)我們陸續(xù)做了很多優(yōu)化和改進(jìn),包括:
- 兼容最新Android版本至Android P dp3
- JIT混合編譯的兼容
- 第三方加固的全面兼容
- 新增穩(wěn)健接入方式
- 三星低版本特殊機(jī)型的兼容
- 補(bǔ)丁工具加速與初始化檢查
- 資源補(bǔ)丁深度優(yōu)化
- 其他穩(wěn)定性和性能的改進(jìn)
Sophix熱修復(fù)中的資源修復(fù)我們?cè)凇渡钊胩剿鰽ndroid熱修復(fù)技術(shù)原理》(在阿里技術(shù)公眾號(hào),回復(fù)“熱修復(fù)”,即可免費(fèi)下載)書(shū)中已經(jīng)有過(guò)介紹,主要思想就是將新增和修改的資源打包到補(bǔ)丁資源包中,以0x66的包名來(lái)重新編排這些資源。對(duì)比其他熱修復(fù)需要替換完整資源包,Sophix的增量的資源補(bǔ)丁方案能做到資源補(bǔ)丁最小化,并且運(yùn)行時(shí)無(wú)需合成完整資源,實(shí)現(xiàn)了性能與空間的最優(yōu)化。
在此基礎(chǔ)上,我們繼續(xù)改進(jìn)了資源補(bǔ)丁,對(duì)resources.arsc中的字符串池進(jìn)行裁剪,在不損耗運(yùn)行時(shí)性能的情況下讓補(bǔ)丁包大小精簡(jiǎn)到了極致。
resources.arsc結(jié)構(gòu)
resources.arsc文件集結(jié)了所有帶id的資源項(xiàng),其粗略概貌可以由以下這張圖展現(xiàn):
這里我們不需要太關(guān)注細(xì)節(jié),只大致說(shuō)明一下。每個(gè)arsc文件的開(kāi)頭是一個(gè)類型為RES_TABLE_TYPE的ResTable_header結(jié)構(gòu)頭,它指定了這個(gè)arsc文件所包含的其他結(jié)構(gòu),一般來(lái)說(shuō),只有一個(gè)全局字符串池和其他包資源塊,通常情況下(Android Studio默認(rèn)編譯出來(lái)的)也僅有一個(gè)包,包id為0x7f,也就是說(shuō)該包下的所有資源編號(hào)都是0x7fXXXXXX。
我們發(fā)現(xiàn),每個(gè)包中還有兩個(gè)字符串池,分別是類型字符串池和資源項(xiàng)字符串池,這兩個(gè)字符串池和全局字符串池又有怎樣的關(guān)系呢?
類型字符串池只表示類型對(duì)應(yīng)的名稱,像layout、string、color、integer等這些字符串,在arsc中只有一個(gè)類型id(比如0、1、2、3等)來(lái)表示他們。下面還有例子會(huì)詳細(xì)解釋。類型字符串池是比較獨(dú)立的,而且所占空間很小,與其他結(jié)構(gòu)也沒(méi)有太大關(guān)聯(lián)。
而資源項(xiàng)字符串池中存儲(chǔ)的是鍵字符串,與全局字符串池中存儲(chǔ)的是值字符串相對(duì)應(yīng)。這里的鍵和值就是我們通常理解中鍵值對(duì)(Key-Value)的鍵和值。之所以值字符串放在全局,應(yīng)該是Android在設(shè)計(jì)之初打算在一個(gè)resources.arsc中的各個(gè)包中進(jìn)行資源值的復(fù)用,然而由于目前默認(rèn)只有一個(gè)0x7f包,自然也沒(méi)有復(fù)用這一說(shuō)了。
只看這個(gè)結(jié)構(gòu)會(huì)比較抽象,我們舉個(gè)例子,對(duì)于以下這個(gè)字符串資源:
假設(shè)這個(gè)資源在編譯進(jìn)arsc之后,對(duì)應(yīng)的id為0x7f010000
此時(shí)arsc中0x7f包中類型字符串池是
0x7f包中鍵字符串池是
arsc文件中的全局值字符串池是
那么,在解析這個(gè)資源項(xiàng)的時(shí)候,由于它的包id為0x7f,就會(huì)找到這個(gè)0x7f包中來(lái)解析,類型id為0x01,表示類型字符串池的第0x01個(gè)字符串,也就是這里的string類型,剩下的0x0000,表示該類型的第0個(gè)資源項(xiàng)。
我們從第0個(gè)資源項(xiàng)中解析出它是一個(gè)字符串類型的資源(這里省略解析過(guò)程),并且得到他的key值為0x1,value的值為0x3。而從前面列出的信息中可以看到,鍵字符串池第1個(gè)字符串為app_name,值字符串池的第3個(gè)字符串為MyDemo。由此就可以得到這個(gè)<string name="app_name">MyDemo</string>資源的完整信息了。
這里我們可以看出,一個(gè)資源中占空間最大的正是字符串池,其他結(jié)構(gòu)只是一些索引數(shù)字,所占空間很小,因此如果能對(duì)字符串池進(jìn)行精簡(jiǎn),將節(jié)省很多空間。
字符串池的構(gòu)造
首先,我們得先弄清字符串池的結(jié)構(gòu)是怎樣的,它的關(guān)鍵入口是ResStringPool_header這個(gè)結(jié)構(gòu)頭,系統(tǒng)會(huì)以通過(guò)這個(gè)結(jié)構(gòu)頭解析出完整的字符串池。
接下來(lái)我們從StringPool解析過(guò)程的系統(tǒng)源碼入手,探尋其具體的構(gòu)造。核心解析邏輯在ResStringPool::setTo,簡(jiǎn)單起見(jiàn),以下代碼去掉了與主流程無(wú)關(guān)的檢查代碼:
這里很清楚地展示了解析的過(guò)程,對(duì)ResStringPool的各個(gè)字段進(jìn)行賦值。
其中有幾個(gè)比較重要的字段:
- mEntries:字符串偏移數(shù)組指針
- mStringPoolSize:字符串個(gè)數(shù)
- mStrings:字符串塊的起始地址
- mEntryStyles:樣式偏移數(shù)組指針
- mStylePoolSize:樣式個(gè)數(shù)
- mStyles:所有樣式的存儲(chǔ)的起始地址
mEntries與mEntryStyles保存是都是每個(gè)字符串在字符串塊中的偏移,字符串塊就是所有字符串的集合,以\0分割開(kāi),通過(guò)偏移可以獲得具體的某個(gè)字符串值,這個(gè)過(guò)程體現(xiàn)在另一個(gè)ResStringPool::stringAt函數(shù):
這里需要注意的一點(diǎn)是,字符串池中的字符串可以以UTF8或者UTF16編碼來(lái)存儲(chǔ),不同編碼中的保存偏移的方式有所不同。這里僅看UTF16的情況,參數(shù)idx表示我們要獲取的第幾個(gè)字符串,mEntries[idx/sizeof(uint16_t)可以獲得第idx個(gè)字符串在字符串池中的偏移off,然后由mStrings+off就可以獲得這個(gè)字符串實(shí)體的起始位置,接著就可以由decodeLength方法得到真正的字符串值。
style即表示字符串的樣式,后面我們會(huì)詳細(xì)講到。
通過(guò)這個(gè)解析過(guò)程,我們可以得到這張結(jié)構(gòu)圖,其很好地體現(xiàn)出字符串池的構(gòu)造:
精簡(jiǎn)思路
我們的資源補(bǔ)丁方案中,補(bǔ)丁中只包含新增和修改的資源,而生成補(bǔ)丁需要一個(gè)新包APK和一個(gè)舊包APK,毫無(wú)疑問(wèn),這兩種加入補(bǔ)丁包的資源實(shí)際上都是屬于生成補(bǔ)丁時(shí)的新包中的資源,因此直接拿新包APK中resources.arsc的完整字符串池就可以作為補(bǔ)丁的字符串池,我們最早的資源補(bǔ)丁就是直接采用這種方式。這么做有一個(gè)好處,就是新增和修改的資源用到的字符串索引完全不需要修改,就可以正常獲取到字符串池的具體值。但是,由于字符串池是從完整的新包中直接拿過(guò)來(lái)的,因此,里面非新增和修改的資源所用的字符串也直接包含在了其中,而這些字符串對(duì)于補(bǔ)丁,是多余的。因此,我們需要精簡(jiǎn)去除的,正是這些無(wú)用的字符串。
具體來(lái)說(shuō),主要分為三個(gè)步驟:
確定要留下的字符串
需要留下的字符串,無(wú)疑就是補(bǔ)丁資源中使用的字符串,而補(bǔ)丁資源中使用的字符串,就是我們通過(guò)比較新包和舊包,得到的新增和修改的資源所用到的字符串。具體來(lái)說(shuō),我們已經(jīng)通過(guò)比較得到了一個(gè)映射表,里面記錄了所有新包資源到補(bǔ)丁資源的id映射關(guān)系,如下所示:
這里需要處理兩個(gè)字符串池,全局的值字符串池0x7f和包中的鍵字符串池,其中的無(wú)用的字符串和樣式都需要去掉。
對(duì)于0x7f包中的鍵字符串,我們需要收集表中所有資源的鍵,也就是這些資源項(xiàng)的名稱,得到一個(gè)字符串索引值的列表,這個(gè)時(shí)候得到的列表,由于是新包字符串池的索引,因此是零散分布的。
我們可以直接為每個(gè)收集到的鍵的字符串索引重新指定一個(gè)索引值,由此得到一張新包索引到補(bǔ)丁包索引的映射表:
對(duì)于全局值字符串池的處理也是類似,不同地方在于,我們需要進(jìn)一步解析每個(gè)資源項(xiàng),得到其對(duì)應(yīng)的具體字符串值,仍然是以這個(gè)資源為例:
我們需要找到的,就是app_name在0x7f包鍵字符串的索引,以及MyDemo在全局值字符串中的索引。
另外,我們還需要處理樣式。樣式是字符串的特殊格式,比如下面的這個(gè)資源
這里的Demo字符串就擁有加粗的樣式,而某個(gè)字符串對(duì)應(yīng)的樣式的在樣式表中的索引值與這個(gè)字符串在字符串池中的索引值是一樣的。aapt在編譯的時(shí)候也會(huì)將帶有樣式的資源全部放到字符串池的最前面。比如有五個(gè)字符串具有樣式,這五個(gè)字符串就會(huì)被默認(rèn)放到字符串池的前五個(gè),而樣式表也只有五個(gè)樣式,分別對(duì)應(yīng)了這前五個(gè)字符串。而從第六個(gè)字符串以后,就沒(méi)有樣式了。
所以,這里我們還需要調(diào)整樣式表,把收集到的字符串所對(duì)應(yīng)的樣式也一同移動(dòng)到對(duì)應(yīng)位置。此外,樣式字符串,也就是例子中的b字符串實(shí)際上也是保存在字符串池中的,因此,當(dāng)使用到某個(gè)樣式的時(shí)候,還需要將該樣式的字符串索引添加到我們的索引映射表中并重新編排。
重新編排與調(diào)整偏移值
我們用一張示意圖來(lái)描述這個(gè)編排過(guò)程:
其中深色offset entry的表示補(bǔ)丁中實(shí)際有效的字符串所對(duì)應(yīng)的偏移值,可以看到,其中的新包中entries按照前面安排的映射關(guān)系移動(dòng)到了補(bǔ)丁entries的相應(yīng)位置,并且entries的偏移值也根據(jù)新排布的字符串位置進(jìn)行了調(diào)整。下方的字符串塊strings和樣式塊styles的內(nèi)容也只保留有效部分,這樣,所有有效字符串緊貼在了一起,并去除了新包中其他無(wú)用的資源,大幅節(jié)省了空間。
最后需要重新構(gòu)造字符串的頭部ResStringPool_header結(jié)構(gòu),使得其中的各個(gè)字段(stringCount、styleCount、stringsStart、stylesStart等)填入正確的值。
這樣,一個(gè)有效的補(bǔ)丁字符串池就完整構(gòu)建好了。這個(gè)重排的過(guò)程對(duì)于鍵值兩種字符串池是完全相同的。
修正資源引用處
字符串池構(gòu)建完畢了以后,還需要對(duì)資源中使用到這些字符串的地方進(jìn)行重新索引。顯然,只需要根據(jù)這個(gè)映射表:
把原來(lái)的老索引值修正為新索引值就行了。具體來(lái)說(shuō),就是將資源文件結(jié)構(gòu)中的ResTable_entry(代表資源項(xiàng))和Res_value(代表具體資源的值)中,類型為ResStringPool_ref的字段的index值修正過(guò)來(lái)即可。
由于我們壓縮優(yōu)化的是resources.arsc中的字符串池,因此需要完整地遍歷每個(gè)補(bǔ)丁資源項(xiàng),把相應(yīng)的index做替換。而xml中的資源不需要相應(yīng)修改,因?yàn)閤ml中使用到的只有arsc里面的資源id,感知不到id對(duì)應(yīng)的字符串是什么,所以只要在arsc中處理好,xml自然就能找到id所持有的正確的字符串。
總結(jié)
通過(guò)這三個(gè)步驟,便實(shí)現(xiàn)了字符串池的精簡(jiǎn)。當(dāng)然處理過(guò)程中還有有很多零碎的問(wèn)題,比如引用類型資源的處理、Map資源項(xiàng)和字符串池各個(gè)塊的拼接等等,這些都需要十分細(xì)致地處理好,否則都會(huì)導(dǎo)致運(yùn)行時(shí)解析格式失敗而崩潰。本文沒(méi)有述及這些繁瑣的問(wèn)題,也是為了不因?yàn)樗鼈兌鴶_亂了主要處理邏輯,當(dāng)搞定了主干后,回頭再收拾這些細(xì)枝末節(jié)就顯得游刃有余了。
精簡(jiǎn)后效果是很明顯的,不過(guò)具體還是取決于原始APK中資源字符串的數(shù)量以及補(bǔ)丁資源中實(shí)際有效的字符串的數(shù)量,如果資源字符串較多的話會(huì)有非常顯著的優(yōu)化。我們遇到最極端的一個(gè)例子是,精簡(jiǎn)之前帶資源的補(bǔ)丁有4M大小,而精簡(jiǎn)之后直接變?yōu)?3K!由此可見(jiàn)一斑。
目前Sophix最新版本打包工具的高級(jí)選項(xiàng)中已默認(rèn)開(kāi)啟這個(gè)優(yōu)化資源補(bǔ)丁選項(xiàng),立刻使用就能為你的資源熱修復(fù)補(bǔ)丁瘦身。
當(dāng)然,還有一些其他選項(xiàng)開(kāi)關(guān),是為了打包的靈活性而設(shè)置的,其中有些強(qiáng)烈建議打開(kāi)的選項(xiàng)我們已經(jīng)默認(rèn)開(kāi)啟了。
Sophix熱修復(fù)中還有許多技術(shù)優(yōu)化點(diǎn),我們也在去年7月推出了《深入探索Android熱修復(fù)技術(shù)原理》免費(fèi)電子書(shū),詳細(xì)講解了代碼、資源、動(dòng)態(tài)庫(kù)的熱修復(fù)實(shí)現(xiàn)(在阿里技術(shù)公眾號(hào),回復(fù)“熱修復(fù)”,即可下載)。值此一周年之際,我們與電子工業(yè)出版社合作,計(jì)劃在近期出版該書(shū)的印刷紙質(zhì)版,并新增了一些篇章,以方便大家翻閱,敬請(qǐng)期待。
最后,手淘基礎(chǔ)平臺(tái)部EMAS平臺(tái)誠(chéng)招Android高級(jí)開(kāi)發(fā)工程師/專家,歡迎各位優(yōu)秀靠譜的小伙伴加入,
查看職位詳情:https://job.alibaba.com/zhaopin/position_detail.htm?positionId=46817
或者發(fā)送簡(jiǎn)歷至xiaolin.gxl@alibaba-inc.com。
每天一篇技術(shù)文章,
看不過(guò)癮?
關(guān)注“阿里巴巴機(jī)器智能”微信公眾號(hào)
發(fā)現(xiàn)更多AI干貨。
總結(jié)
以上是生活随笔為你收集整理的深度剖析 | 阿里热修复如何精简优化补丁资源?的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問(wèn)題。
- 上一篇: 调度算法为何被阿里如此重视?
- 下一篇: 为什么做技术 PM 这么难?