作者 | 張祖橋
前言
目前,安卓端對于包體積的優(yōu)化方案已經(jīng)多如過江之鯽,我們系列的上一篇文章介紹了 Class 字節(jié)碼的優(yōu)化,本期我們將關(guān)注點聚焦到資源文件上,從資源二進制文件的全新角度,拓展出包體積優(yōu)化的新思路。
在資源文件優(yōu)化方面,通常的優(yōu)化手段多集中在圖片/文件壓縮、資源文件名稱混淆、離線下載資源文件等方面,而我們的新思路基于對于常規(guī)思路的深度分析及思考。
一開始,我們是從資源文件名稱混淆入手優(yōu)化,業(yè)界對于資源文件名稱混淆方案,最為熟知的開源項目當(dāng)屬 AndResGuard,該項目優(yōu)化目標(biāo)為資源文件目錄 res 內(nèi)的文件,其優(yōu)化點如下:
- 對重復(fù)的資源文件,以計算 md5 值的方式來判斷是否重復(fù)并只保留一份;
- 對資源文件名稱進行縮短,即名稱混淆;
- 對 APK 中的內(nèi)容采取 7zip 壓縮優(yōu)化;
按照此項目進行優(yōu)化,總體收益可以達到非常可觀的 MB 級別。但完成此項目的優(yōu)化后,資源文件的進一步優(yōu)化便達到瓶頸。
為了在此基礎(chǔ)上更好的實現(xiàn)優(yōu)化資源大小,我們需要了解資源文件目錄 res 所包含的文件類型及其大小的分布情況。以抖音為例,下表是對其包含的子文件夾名稱、文件數(shù)量、將文件夾 zip 壓縮后大小的梳理,以文件數(shù)量降序排序:
從上表,可以看到:
- drawable-xxhdpi-v4 目錄下文件數(shù)量最多有 6000+,壓縮后文件大小約為 19.5MB。
- drawable 目錄下文件數(shù)量排第三,有 4388 個,壓縮后 4.6MB,同時包含圖片和.xml 文件。
- 文件數(shù)量排第二和第四的都是 layout 目錄下的布局文件,分別有 5970,2985 個,其文件夾壓縮后大小分別為 12.2MB,8.5MB。布局文件總數(shù)近 9K,文件大小約 20.7MB
可見,layout 目錄下的布局文件大小已經(jīng)和圖片文件不相上下。而這部分如此大的文件,除了有文件名稱的混淆優(yōu)化之外,是否還有其他優(yōu)化方式?或者其文件名稱混淆是否徹底?
此外,APK 解壓后的 resources.arsc 文件有 7.3MB 之大。其中包含了 app 所有資源文件名稱和資源字符串值,其中是否也存在冗余字符串?
對于 layout 布局文件,從近萬份之多的文件數(shù)量及 20+MB 的文件體積來看,即存在值得探究的必要。我們通過對資源文件的二進制文件格式的解析,并從文件內(nèi)容被使用的角度分析,發(fā)現(xiàn)存在可以刪除的冗余內(nèi)容。在反復(fù)嘗試并解決了各種穩(wěn)定性和打包兼容問題后,最終研發(fā)出了一套針對 Android ARSC/XML 文件格式的包體積優(yōu)化方案,目前已經(jīng)落地抖音,實現(xiàn) 2MB 以上的收益。
接下來,本文將深入講解該方案的實現(xiàn)細節(jié)。
APK 資源格式優(yōu)化
我們的核心思路是,以資源路徑縮短為優(yōu)化出發(fā)點,在最終的 APK 文件里,從resources.arsc與 layout 布局文件的二進制文件格式著手,查看其內(nèi)容結(jié)構(gòu),尋找可以刪除的未使用字符串,優(yōu)化文件名稱或者文件里的字符串池。主要分為下面兩個優(yōu)化點。
資源路徑縮短
資源格式修改
接入 AndResGuard 后,資源文件 res 目錄 -> r,其中的子文件夾和文件名也都被混淆,即:
res/anim/abc_fade_in.xml -> r/a/a.xml
res/anim/abc_fade_in.xml -> r/a/a.xml這是為了減少資源文件路徑,從而減少包體積,自然聯(lián)想到,是否還能進一步減少資源文件路徑呢?顯然,如果能將所有文件都放在 r 目錄下,將中間的子文件夾去掉,則可以進一步減少資源文件路徑和 zip 節(jié)點數(shù)量,一定還有包體收益;順便可以將文件名的后綴去掉,也可以減少文件路徑,即:
r/a/a.xml -> r/a
r/a/b.png -> r/b
由于修改資源文件名稱需要修改resources.arsc文件,這里對resources.arsc 的文件格式分析下:
可以看到,其中包含有 3 個字符串池。
假如我們有一個資源文件abc_fade_in.xml在 res/anim 目錄下,其在resources.arsc文件中 3 個字符串池里的信息如下:
- 全局字符串池(字符串池 1):主要包含完整文件路徑名,即res/anim/abc_fade_in.xml
- 類型字符串池(字符串池 2):資源種類名(包括存儲 res 目錄下子文件目錄名),即anim
- 鍵字符串池(字符串池 3):文件名,即: abc_fade_in
可以看到,與資源文件名相關(guān)的地方有兩處,分別在全局字符串池保存著完整文件路徑名,鍵字符串池保存著文件名,為了將資源路徑縮短,需要同時修改這兩處,即在全局字符串池中修改res/anim/abc_fade_in.xml -> r/f ,在鍵字符串池修改 abc_fade_in -> f。在resources.arsc文件中需要修改的兩處字符串池,如下圖箭頭所示:
然而,在完成資源路徑縮短后,卻發(fā)現(xiàn)包體積反而變大了 160K+ !
鍵常量池裁剪
我們知道,文件名稱混淆,其混淆名稱來源于符合文件名稱規(guī)范的混淆字符串集合,其中的字符串都是唯一不重合的,所以,字符串集合數(shù)量越大,其最長字符串的長度也會越大。
在資源路徑未縮短情況,不同子目錄文件夾下,其使用的文件名稱每次都可以從混淆字符串集合中重新選取,使得其名稱在鍵字符串池中始終保持最短;
其對應(yīng)的文件名字符串集合為:[a,b,c,d,e]
而在縮短的情況下,由于所有文件都包含在一個文件夾r下,其使用的文件名稱只能來自同一個混淆字符串集合,使其名稱在鍵字符串池中會逐漸變長,同時也會使得路徑字符串跟著變長,導(dǎo)致其整體結(jié)果反而變大!如下圖所示:
其對應(yīng)的文件名字符串集合為:[a,b,c,d,e,f,g,h,i,j]
因此,當(dāng)所有文件都包含在一個文件夾r下時,無法使得不同子目錄下的文件名得以復(fù)用,所以雖然路徑縮短,會使得全局字符串池變小,但鍵字符串池反而會變大。這是因為鍵名默認(rèn)需要和文件名保持一致。
猜想:resources.arsc文件中,鍵名是否需要和文件名保持一致,更或者,鍵名本身是否有存在的必要?
其實,在經(jīng)過編譯后,資源文件被使用的地方會被替換成特定的 id 值,比如:
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
// => setContentView(0x7f0b001c); // 替換為id值
}
}
由此可知,資源文件名稱必然與整型 id 值有著一一對應(yīng)的關(guān)系,這種一一映射的關(guān)系可以聯(lián)想到:是否只需要根據(jù)整型 id 值,就可以找到相應(yīng)的文件路徑名稱?因為這個過程里完全不涉及鍵字符串的引用。
基于這個想法,我們將鍵字符串池全部替換為單一值"_", 發(fā)現(xiàn) APK 運行正常。顯然,去掉鍵字符串池,似乎并不會影響 APK 運行期間根據(jù)整型 id 值去查找文件路徑。
那么,鍵字符串池中的字符串的作用是什么呢?翻看源碼發(fā)現(xiàn),只有使用類似“資源文件反射”的方式調(diào)用,才會獲取鍵字符串池中的字符串值,比如:
// MainActivity.java
// 此處返回值為"_", 因為鍵字符串池已經(jīng)全部替換為 "_"
String entryName = getResources().getResourceEntryName(R.layout.activity_main);
// 此處返回的id值為0, 因為找不到名為 "abc_fade_in",類型為"anim"的資源
int id = getResources().getIdentifier("abc_fade_in", "anim", "cn.pkg");
當(dāng)前項目中,一般沒有使用上述“資源文件反射”獲取資源名稱的使用方式,所以鍵字符串池可以全部替換為單一值"_";目前已知的必須要以這種方式使用資源文件的方式,大多是插件等不在一個宿主項目下的情況,如果需要,可以對這部分字符串名稱進行保留,配置白名單即可。
下圖是resources.arsc文件中鍵字符串池的格式和內(nèi)容示意圖:
- 偏移數(shù)組(標(biāo)記 1),數(shù)組的值為指向鍵字符串池(標(biāo)記 2)中每個字符串的偏移值
- 由于需要將鍵字符串池中所有字符串替換為單一值"_",那么,鍵字符串池中就只有一個"_"字符串,偏移數(shù)組也將只有一個元素,其指向鍵字符串池中"_"字符串的起始偏移值 0。
最后,還需將resources.arsc文件中,鍵字符串對應(yīng)的偏移數(shù)組的索引值,所有被調(diào)用的地方,全部替換為字符串"_"對應(yīng)的偏移數(shù)組的索引值 0,這樣原有文件名字符串都會換為"_",鍵字符串池就只剩"_"字符串了。
崩潰和兼容性問題
在項目具體實施灰度中出現(xiàn)了崩潰,發(fā)現(xiàn)在 drawable 目錄下的 xml 圖片文件有對其后綴的檢查,如下圖:
frameworks/base/core/java/android/content/res/ResourcesImpl.java
//創(chuàng)建drawable
private Drawable loadDrawableForCookie(@NonNull Resources wrapper, @NonNull TypedValue value, int id, int density) {
if (file.endsWith(".xml")) { //對xml文件解析并創(chuàng)建drawable
final String typeName = getResourceTypeName(id);
if (typeName != null && typeName.equals("color")) {
dr = loadColorOrXmlDrawable(wrapper, value, id, density, file);
} else {
dr = loadXmlDrawable(wrapper, value, id, density, file);
}
} else { //對.png等其他圖片解析并創(chuàng)建drawable
final InputStream is = mAssets.openNonAsset(value.assetCookie, file, AssetManager.ACCESS_STREAMING);
final AssetInputStream ais = (AssetInputStream) is;
dr = decodeImageDrawable(ais, wrapper, value);
}
}
因此,我們對 drawable 目錄下的 .xml 后綴不做去除。
上線后,有反饋 6.x 上部分手機啟動慢的現(xiàn)象,經(jīng)排查發(fā)現(xiàn)是其中圖片文件名稱后綴刪除優(yōu)化,導(dǎo)致的在部分 rom 上 app 啟動慢。排除掉這些兼容性問題,最后,我們僅保留路徑縮短和鍵常量池裁剪優(yōu)化,而不做文件名后綴去除,即:r/a/a.xml -> r/a.xml,此部分資源路徑壓縮優(yōu)化收益 300K+。
layout 優(yōu)化
我們知道,layout 目錄下的布局文件所占包體積很大,從之前的分析可知,resources.arsc文件中有好幾個字符串池,有的字符串池并沒使用可以刪除,而 layout 布局文件與resources.arsc文件的二進制文件格式一致,其中也有字符串池,是否也存在類似的優(yōu)化點呢?對此,有必要對布局文件的文件格式和內(nèi)容探究一波,隨意打開一個布局文件,其源代碼和二進制文件格式內(nèi)容如下:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:enabled="true"
android:gravity="center"
android:background="@color/colorAccent"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
從布局文件文件格式上,可以看到,布局文件有一個字符串池 strPool 和一個數(shù)組 resMap,為了闡述其作用,假如布局文件中有一個屬性"layout_width",其在布局文件中包含的信息如下:
- 字符串偏移數(shù)組(標(biāo)記 1),指向字符串池(標(biāo)記 2),用于從字符串池中獲取標(biāo)簽(如:"LinearLayout")或?qū)傩宰址?如:"layout_width")
- 字符串池(標(biāo)記 2),布局文件中的唯一字符串池,保存布局文件中標(biāo)簽或?qū)傩宰址?,即?layout_width"
- 屬性數(shù)組Resids(標(biāo)記 3),包含當(dāng)前文件所有屬性的整型 id 值,屬性"layout_width"的 id 值:10100F4h。從數(shù)組中整型 id 值提示的屬性名(類似:attr_layout_width(10100F4h)),可以看到其屬性名與字符串池中名稱一一對應(yīng)。
我們知道 layout_width 本身是一個attr屬性,查看系統(tǒng)源碼中的 public.xml,可以看到:
其整型 id 值與上面 layout 文件中的值,即 attr_layout_width 后面的整型 id 值完全一致,都是0x010100f4。系統(tǒng)屬性的 id 值是固定的,而且,一個布局文件的屬性由字符串名稱或整型 id 值來唯一標(biāo)識,那么,這里是否只需要 id 就可以標(biāo)識屬性,而屬性的字符串名可以刪除?
猜想:每個屬性都有字符串名和整型 id 值,為了性能,在解析布局文件中每個節(jié)點的屬性時,是根據(jù)整型 id 值而不是字符串名來唯一標(biāo)識,并據(jù)此拿到該屬性的值即可。
為了驗證我們的猜想,簡單修改字符串池中的一個屬性字符串:layout_width -> llyout_width,驗證可以運行成功。由前面的敘述可知 layout 目錄下文件有近 9K 個,影響范圍很廣,如果可行其收益預(yù)計會很大,同時也更需要謹(jǐn)慎。
通過翻看源碼發(fā)現(xiàn),每個屬性(attr)包含一個對應(yīng)的整型 id 值,在parseXml()解析布局文件得到標(biāo)簽后,獲取其屬性值時果然會直接根據(jù)整型 id 值來獲取。這里屬于比較底層的代碼,因為與性能相關(guān),一般 rom 廠商似乎不會改到這里,其兼容性可能不會受影響。
源碼中解析布局文件,標(biāo)識屬性并獲取屬性值的代碼如下:
frameworks/base/core/jni/android_util_AssetManager.cpp
// 通過屬性整型id值獲取屬性值
static jboolean android_content_AssetManager_applyStyle( ) {
while (ix < NX && curIdent > curXmlAttr) {
ix++;
curXmlAttr = xmlParser->getAttributeNameResID(ix); //獲取屬性id值
}
if (ix < NX && curIdent == curXmlAttr) { //通過id值來標(biāo)識屬性
block = kXmlBlock;
xmlParser->getAttributeValue(ix, &value); //獲取屬性值
}
}
uint32_t ResXMLParser::getAttributeNameResID(size_t idx) const {
int32_t id = getAttributeNameID(idx);
// mTree.mResIds 就是 Resids數(shù)組;返回值即屬性id值
if (id >= 0 && (size_t)id < mTree.mNumResIds) {
return dtohl(mTree.mResIds[id]);
}
return 0;
}
具體實現(xiàn)上,該思路一共有三個要點,總體收益 1.9MB+ ,詳見如下分析。
屬性字符串名稱修改
首先,我們將所有能與 Resids 數(shù)組一一對應(yīng)的字符串全部替換為""字符串,對所有 layout 目錄下的文件進行處理,得到收益 1.1MB+ 。
將此優(yōu)化進行推廣,對資源目錄 res 下所有以 “.xml” 后綴的文件進行優(yōu)化,得到附加收益 180K+。這部分收益不大的原因是其他目錄下的“.xml” 后綴文件,多為 drawable 或者 anim 目錄下的文件,這類文件中 Resids數(shù)組沒有或者包含的屬性不太多。
偏移數(shù)組修改
觀察 layout 目錄下布局文件中字符串池格式,發(fā)現(xiàn)包含偏移數(shù)組和字符串池,其中,每個節(jié)點從字符串池中讀取字符串是根據(jù)偏移數(shù)組獲取。所以,這里可以只修改偏移數(shù)組,將其指向同一個字符串值,從而將空字符串合并為一個,減少字符串池,節(jié)省空間,如下圖:
上圖中,左圖,是將與 Resids 數(shù)組一一對應(yīng)的字符串全部替換為""字符串了,其偏移數(shù)組會指向 5 個""字符串;右圖,是修改偏移數(shù)組,將其值修改為指向第一個""字符串,同時刪除冗余的 4 個""字符串,此方案得到收益 300K+。
命名空間去除
在解析布局文件獲取屬性值的時候,我們發(fā)現(xiàn)屬性的命名空間字符串很長,例如:"http://schemas.android.com/apk/res/android"。而且每個布局文件都至少存在一個命名空間字符串,其出現(xiàn)相當(dāng)頻繁。我們猜想,在獲取屬性值時,是否也沒解析屬性的命名空間字符串呢?而在前面我們說過,屬性值的獲取只需要屬性id值來標(biāo)識,沒用到命名空間字符串。將命名空間字符串替換為空串后,發(fā)現(xiàn)的確沒有問題,此優(yōu)化得到收益500K+。
最終優(yōu)化形式如下:
- 標(biāo)記 1--屬性字符串名稱裁剪,將字符串池中每一個字符串替換成""空字符串;
- 標(biāo)記 2--偏移數(shù)組修改,將字符串池中所有""空字符串合并為一個;
- 標(biāo)記 3--命名空間去除,將字符串池中命名空間字符串替換成""空串。
App Bundle 兼容
以上優(yōu)化都屬于通用方案,都能在國內(nèi) App 上接入使用。然而,目前在海外 Google Play 商店上,App 均采用的是 App Bundle 文件格式(即 AAB),其中resources.arsc文件和 layout 目錄下布局文件的格式已和上述格式不同,谷歌在其之前二進制文件格式的基礎(chǔ)上,使用了 protobuf 格式,以增強對該文件格式中內(nèi)容的可擴展性和魯棒性。
在 AAB 文件 split 成多個 APK 的時候,會有 protobuf 格式到二進制 xml 格式的轉(zhuǎn)換,而這個轉(zhuǎn)換過程在 Google Play 上,我們無法更改。所以只能針對 AAB 文件格式中的資源文件格式進行優(yōu)化了,通過 App Bundle 解析布局文件中的屬性并不復(fù)雜,這里不再詳述,針對上述優(yōu)化方案在 AAB 文件上的移植結(jié)果如下:
資源路徑縮短:
- 無法實現(xiàn),因為resources.arsc文件中常量池裁剪無法實現(xiàn), 即全部替換為同一個字符串"_"會轉(zhuǎn)換失敗,原因在于執(zhí)行 protobuf 格式到二進制 xml 格式的轉(zhuǎn)換中,會判斷當(dāng)前的鍵字符串是否重復(fù),如果是,那么會直接返回,無法解析通過。
frameworks/base/tools/aapt2/format/proto/ProtoDeserialize.cpp
//讀取protobuf格式的資源文件
static bool DeserializePackageFromPb( ) {
for (const pb::ConfigValue& pb_config_value : pb_entry.config_value()) {
//FindOrCreateValue搜尋已存在的或者創(chuàng)建新的ResourceConfigValue,搜尋時會判斷鍵字符串是否已存在
ResourceConfigValue* config_value = entry->FindOrCreateValue(config, pb_config.product());
if (config_value->value != nullptr) {//發(fā)現(xiàn)已存在config_value,返回錯誤
*out_error = "duplicate configuration in resource table";
return false;
}
}
}
layout 優(yōu)化:
- 屬性字符串名稱裁剪:可以實現(xiàn),取得收益 400K+;
- 偏移數(shù)組修改:無法實現(xiàn),因為最后轉(zhuǎn)換 protobuf 格式到二進制 xml 格式,這一步是在 Google Play 本地的 aapt2 命令環(huán)境中實現(xiàn),無法修改;
- 命名空間去除:可以實現(xiàn),取得收益 200K+
因此,我們的優(yōu)化方案最終在某海外 App 上總體可以取得 600K+收益。在完成對 AAB 文件的優(yōu)化后,通過 split 后獲取其中的 base-master.apk,查看其中的 layout 布局文件,收益示意圖如下:
標(biāo)記 1--屬性字符串名稱裁剪,命名空間去除已優(yōu)化;
標(biāo)記 2--偏移數(shù)組修改,無法優(yōu)化,因此還是存在多個""字符串,而不像 APK 里面可以合并為一個。
總結(jié)
可見,在資源文件優(yōu)化方面,還是可以另辟蹊徑,有不少通用優(yōu)化可以做,總結(jié)起來,主要工作還是在對無用字符串的搜尋和確認(rèn)上。通常,編譯后的二進制文件中,字符串的作用有:
- 代碼執(zhí)行需要。這類字符串是必須的,但可以考慮是否可以精簡,即混淆;
- 調(diào)試輔助功能。這類字符串不一定必須,可以去除,如果需要保留,可以做相應(yīng)的 keep 功能;
- 文件格式設(shè)計者當(dāng)初為了格式完備性引入,已拓展后續(xù)功能。這類字符串可能是與性能相違背的,如未使用可以直接去除;
后兩個點便是搜尋冗余字符串和優(yōu)化包大小的重點方向。