APK包資源精簡 立減1M
Apk包大小是Android優化的一項重要指標,本文主要從資源方面著手,提出一些優化的新思路。
無用資源精簡
項目隨著開發迭代,會遺留大量的無用代碼和資源,今天主要說一下無用資源如何精簡。資源精簡最重要的是無用資源的檢索,常規檢索方式有lint的unused resource,gradle開啟shrinkResources。但用lint僅僅檢測出了十幾個,效果不明顯,開啟shrinkResources后,對包大小的影響也在幾K級別。Google把shrinkResources集成到了打包流程中,為什么很多無用資源都檢索不出來呢,帶著這些疑問,我決定仔細研究一下原理。
開啟shrinkResources后,打包過程會新增task transformClassesWithShrinkResFor{variant},gradle1.5之后只需要注冊一個tranform就會產生一個對應的task,查看源碼發現對應的tranfrom在com.android.build.gradle.internal.transforms.ShrinkResourcesTransform,此類中調用com.android.build.gradle.tasks.ResourceUsageAnalyzer的analyze方法進行分析無用資源。
public void analyze() throws IOException, ParserConfigurationException, SAXException {
gatherResourceValues(mResourceClassDir);
recordMapping(mProguardMapping);
recordClassUsages(mClasses);
recordManifestUsages(mMergedManifest);
recordResources(mMergedResourceDir);
keepPossiblyReferencedResources();
dumpReferences();
mModel.processToolsAttributes();
mUnused = mModel.findUnused();
}
該方法首先會根據R文件來生成資源表,然后利用asm遍歷所有的class分析class中用到的R,然后將資源表中相應資源標記為可達,接著分析Mainfest,Res文件找出資源文件中引用的其它資源。最終調用keepPossiblyReferencedResources,此方法是標記可能會引用的資源,項目中調用了getIdentifier,參數是通配符,資源名稱只要匹配就會標記為可用。
查看編譯后的文件build/outputs/mapping/release/resources.txt,shrinkResources相關的日志都會在此文件中,有大量資源因為keepPossiblyReferencedResources被標記為可達。
從原理上分析,這種查找無用資源的方式是可行的,只是需要稍微改動。默認情況下shrinkResources是安全模式,可能會被使用的資源也被標記為了可達;關閉安全模式,開啟嚴格模式,只有真正通過代碼或是資源文件引用的資源才被標記為可達。混淆配置添加-dontshrink -dontoptimize,系統是分析混淆后的類,如果一個類被壓縮掉了,它引用的資源就會被標志為不可達,這時候如果僅僅刪除資源,后續就編譯通不過了。
在res目錄中添加keep.xml,設置嚴格模式。
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools"
tools:shrinkMode="strict" />
經過上述配置改動后,重新編譯查看輸出文件,可以看到大量的無用資源。打包過程是將其替換為了一個同名的空文件,但我們可以解析這個文件,找到無用資源,用腳本批量刪除。
頭條app客戶端原始15M,通過腳本批量刪除了600+資源,包大小減小0.47M。不同項目效果不同。
重復資源精簡
Android開發推崇根據業務拆分多個模塊,模塊間為了防止資源覆蓋,會給每一個模塊的資源加一個前綴,同樣的資源就會在apk包中出現多次。閱讀微信資源混淆源碼時發現,它將每個資源Chunk中的資源路徑替換為了一個較短的路徑。那么對于重復資源,僅僅保留一份,修改arsc文件,重定向Chunk對應的資源路徑,就可以解決重復資源問題。
打包過程中ProcessAndroidResources這個Task會生成資源文件/build/intermediates/res/resources-release.ap_,該文件是一個zip文件,解壓后包括AndroidManifest.xml,res,resources.arsc幾部分。res目錄中的文件即是最終要打入到apk中的,resources.arsc即為最終的arsc文件。
解壓ap_文件,遍歷res目錄中的文件,根據每個文件的md5值,找出重復的文件。最終發現主要有兩種重復的情況,一種是文件名相同,但在不同的dpi目錄下;一種是內容相同,名字不同。刪除重復文件,保留一份,然后利用ChunkUtil這個庫來修改arsc文件,ChunkUtil是一個arsc編輯庫。
重復資源處理,作為一個gradle插件,后續會開源給大家作為參考。
重復資源處理后,資源映射如下所示,每個資源代碼一個chunk,假如以下3個chunk中的資源相同,則處理后它們會指向相同的路徑。
經過打包期間刪除重復資源,共刪除了300+資源,包大小減小了0.28M,不同的項目效果不同。
重復資源處理與微信資源混淆沖突
項目中如果使用了微信資源混淆,打包失敗。如果你的項目中沒使用微信資源混淆,就沒必要看后面的問題了。
根據錯誤堆棧可以定位到微信資源混淆出現的位置。閱讀微信資源混淆源碼,發現映射關系如下:從res/drawable/icon.png到r/a/c.png是一一映射的。每個資源路徑可變的有兩部分,一是資源類型如drawable,color;另一個是資源名稱。映射過程有以下規則,同類型資源會映射到相同目錄中,資源id相同也即是同名資源映射后的資源名相同。如下圖中,資源1和2是名字相同,映射后的名字都是c.png,資源2和資源3資源類型相同,映射后的資源都在r/b目錄下。
這時候將重復資源處理和微信混淆流程串聯起來如下所示:
資源1和資源2映射后的目標路徑相同。微信資源混淆會遍歷每個chunk,把每個chunk中的資源從原始目錄copy到目標目錄并重新命名為映射后的文件,copy前check目標文件是否存在,如果存在會出現上述錯誤。
微信資源混淆的目標路徑映射規則是根據id值映射的,不是根據原始路徑。因此我們需要改變默認的映射規則,如果原始路徑相同,則映射到相同的目標路徑,并且不做后續的copy工作。修改了映射邏輯后,資源3最終映射的路徑也變為了r/a/c.png。
重新打包,發現還是出現上述錯誤,只是出錯的資源不同。這時候如果有第四個資源,和前面3個資源內容不相同。資源類型和資源1相同,所以映射成了r/a/目錄,名稱和資源3相同,所以最終映射成了r/a/c.png,又導致了上述目標地址重復的問題。這種情況需要對路徑進行remapping。
對微信資源處理的邏輯全在com.tencent.mm.androlib.res.decoder.ARSCDecoder的readValue函數中。
private void readValue(boolean flags, int specNamesId) throws IOException, AndrolibException {
/* size */
mIn.skipCheckShort((short) 8);
/* zero */
mIn.skipCheckByte((byte) 0);
byte type = mIn.readByte();
int data = mIn.readInt();
//這里面有幾個限制,一對于string ,id, array我們是知道肯定不用改的,第二看要那個type是否對應有文件路徑
if (mPkg.isCanProguard() && flags && type == TypedValue.TYPE_STRING && mShouldProguardForType && mShouldProguardTypeSet.contains(mType.getName())) {
if (mTableStringsProguard.get(data) == null) {
String raw = mTableStrings.get(data).toString();
if (StringUtil.isBlank(raw)) return;
// 相同原始路徑映射到相同目標地址
String oldResult = mRawToResult.get(raw);
if(oldResult != null){
mTableStringsProguard.put(data, oldResult);
return;
}
String proguard = mPkg.getSpecRepplace(mResId);
//這個要寫死這個,因為resources.arsc里面就是用這個
int secondSlash = raw.lastIndexOf("/");
if (secondSlash == -1) {
throw new AndrolibException(
String.format("can not find \\ or raw string in res path=%s", raw)
);
}
String newFilePath = raw.substring(0, secondSlash);
if (!mApkDecoder.getConfig().mKeepRoot) {
newFilePath = mOldFileName.get(raw.substring(0, secondSlash));
}
if (newFilePath == null) {
System.err.printf("can not found new res path, raw=%s\n", raw);
return;
}
//同理這里不能用File.separator,因為resources.arsc里面就是用這個
String result = newFilePath + "/" + proguard;
int firstDot = raw.indexOf(".");
if (firstDot != -1) {
result += raw.substring(firstDot);
}
String compatibaleraw = new String(raw);
String compatibaleresult = new String(result);
//為了適配window要做一次轉換
if (!File.separator.contains("/")) {
compatibaleresult = compatibaleresult.replace("/", File.separator);
compatibaleraw = compatibaleraw.replace("/", File.separator);
}
File resRawFile = new File(mApkDecoder.getOutTempDir().getAbsolutePath() + File.separator + compatibaleraw);
File resDestFile = new File(mApkDecoder.getOutDir().getAbsolutePath() + File.separator + compatibaleresult);
// 相同地址remapping
if(resDestFile.exists()){
// re proguard
result = newFilePath + "/" + mProguardBuilder.getReplaceString();
firstDot = raw.indexOf(".");
if (firstDot != -1) {
result += raw.substring(firstDot);
}
compatibaleresult = new String(result);
if (!File.separator.contains("/")) {
compatibaleresult = compatibaleresult.replace("/", File.separator);
}
resDestFile = new File(mApkDecoder.getOutDir().getAbsolutePath() + File.separator + compatibaleresult);
}
//這里用的是linux的分隔符
HashMap<String, Integer> compressData = mApkDecoder.getCompressData();
if (compressData.containsKey(raw)) {
compressData.put(result, compressData.get(raw));
} else {
System.err.printf("can not find the compress dataresFile=%s\n", raw);
}
if (!resRawFile.exists()) {
System.err.printf("can not find res file, you delete it? path: resFile=%s\n", resRawFile.getAbsolutePath());
return;
} else {
if (resDestFile.exists()) {
printRawToResult();
throw new AndrolibException(
String.format("res dest file is already found: destFile=%s", resDestFile.getAbsolutePath())
);
}
FileOperation.copyFileUsingStream(resRawFile, resDestFile);
//already copied
mApkDecoder.removeCopiedResFile(resRawFile.toPath());
mTableStringsProguard.put(data, result);
mRawToResult.put(raw,result);
}
}
}
}
Map<String,String> mRawToResult = new HashMap<>();
void printRawToResult(){
Set<String> keySets = mRawToResult.keySet();
for(String s: keySets){
System.out.println("printRawToResult raw " + s + " dest " + mRawToResult.get(s));
}
}
來自:https://techblog.toutiao.com/2017/09/20/untitled-11/