APK包資源精簡 立減1M

moon 7年前發布 | 22K 次閱讀 Gradle 安卓開發 Android開發 移動開發

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/

 

 本文由用戶 moon 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!