Android | 資源衝突覆蓋的一些思考

啥是資源衝突覆蓋,就是兩個不一樣的文件,有着相同的文件名,在打包apk後引發的系列問題。本文將從情景、解決思路、延伸,三個方面展開。html

先簡單介紹下背景,App在線上跑了將近7年(歷史悠久~),從早期的導購社區,到社區電商,再到社區、電商和直播三駕馬車齊驅,也就是三大業務團隊。java

情景

UI不合預期問題

首先,咱們建一個殼工程app,建兩個業務工程,分別是電商業務biz_shopping直播業務biz_live,以下,android

接着在電商工程建一個頁面layout/activity_shopping.xmlgit

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android">

    <TextView
        android:id="@+id/textView"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="我是電商頁面"
        android:textSize="30dp" />

    <ImageView
        android:id="@+id/imageView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:srcCompat="@drawable/icon_goods" />
</LinearLayout>

其中圖標資源drawable/icon_goods以下,github

而後有一天,直播團隊在直播工程建了一個頁面layout/activity_live.xmljson

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android">

    <TextView
        android:id="@+id/textView"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="我是直播頁面"
        android:textSize="30dp" />

    <ImageView
        android:id="@+id/imageView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:srcCompat="@drawable/icon_goods" />
</LinearLayout>

而後他們引入了一些素材,假設是直播帶貨相關業務,因此引入了一個商品圖標drawable/icon_goods以下,api

能夠發現,這個圖標和電商工程的圖標名字相同,可是內容不一樣,接着運行殼工程,分別打開電商頁面和直播頁面,瀏覽器

因爲同名的圖標只會保留一份,致使電商頁面沒法按預期展現我是商城icon,而展現成了我是直播icon緩存

類似的,像string資源也同樣。架構

電商工程values/strings.xml

<resources>
    <string name="buy">電商頁買買買</string>
</resources>

直播工程values/strings.xml

<resources>
    <string name="buy">直播頁買買買</string>
</resources>

打包後也只會保留一份name爲buy的字符串,形成另外一方的UI不合預期。

那麼UI不合預期問題會帶來哪些影響呢?

假設這個版本兩個團隊的功能改動都在熱頁面(核心頁面,在QA測試範圍內),那麼這個問題是能在各部門集成後的迴歸測試環節發現的;那若是電商這個頁面是冷頁面(年久失修,鏈路深,QA不會去測),那問題就可能會帶到線上,直到用戶反饋才能把問題暴露出來。

findViewById問題

首先在電商工程新建頁面layout/activity_goods_list.xml,裏面有一個list,id爲shopping_goods_list

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android">

    <ListView
        android:id="@+id/shopping_goods_list"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content" />

</LinearLayout>

接着,直播團隊要在直播間帶貨,也建了一個名字相同的頁面layout/activity_goods_list.xml,裏面也有一個list,可是id不一樣,爲live_goods_list

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android">

    <ListView
        android:id="@+id/live_goods_list"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content" />

</LinearLayout>

兩個工程的ActivityfindViewById時分別用本身的id,因爲打包只會保留一份activity_goods_list.xml,必有其中一方,會在Activity裏,findViewById獲得的ListView爲null,引起空指針,運行殼工程以下,

發現直播list頁面是好的,可是電商list頁面報了空指針,

電商團隊開始慌了,爲何受傷的老是我?

顯然,這個問題若是發生在冷頁面,是極有可能帶到線上,直到個別用戶進到冷頁面發生crash觸發報警,開發團隊纔會發現問題,P1故障警告!(固然,crash問題比UI問題嚴重多了,會有QA自動化覆蓋頁面來避免,這裏暫不討論)

解決思路

首先咱們會想到的就是,給每一個團隊的工程文件加上前綴約束不就好了嘛?又或者人爲約束靠不住的話,加個Android的resourcePrefix資源前綴限定,

//resourcePrefix資源前綴限定,只能限定佈局文件名和value資源的key,並不能限定圖片資源的文件名
android {
    //給電商工程加上前綴約束shopping_
    resourcePrefix "shopping_"
}

android {
    //給直播工程加上前綴約束live_
    resourcePrefix "live_"
}

但開頭提到過,項目在線上跑了多年,歷史包袱賊重,一個App已經有了三四百個子工程,這時再來批量更名,即使使用腳本,也是須要必定的人力投入且有風險的,由於任一圖標文件、字符串資源均可能正被多處引用着,再者,有些基礎能力組件(如登陸),還可能被其餘App(如商家版)引用着。

所以,不管從人力投入、仍是引入的風險來看,ROI都是不划算的。

那能不能先把目標下降,只作基本的掃描檢測?好比經過gradle構建項目的時候來搞點事情?

開源項目CheckResourceConflict

查了些資料,還真發現了一個開源項目CheckResourceConflict,來看看人家是怎麼作的。

首先依賴插件,

classpath 'com.orzangleli:checkresourceconflict:0.0.2'

而後在app/build.gradle使用插件,

apply plugin: 'CheckResourcePrefixPlugin'

sync一下,而後運行插件,

運行後,生成html報告,能夠在瀏覽器中查看,可見,衝突的圖標、佈局文件、字符串資源都被列出來了。

項目分析

首先插件要求項目的Android Gradle Plugin版本爲不低於3.3,對應的gradle版本不低於4.10.1,由於新版本有一個接口BaseVariantImpl.allRawAndroidResources.files能夠在編譯期間獲取到全部的資源文件,附上一張Android gradle plugin和gradle的版本對照

而後看到項目核心類,

class CheckResourcePrefixPlugin implements Plugin<Project> {
    @Override
    void apply(Project project) {
        project.afterEvaluate {
            variants.forEach { variant ->
                variant as BaseVariantImpl
                //任務名字
                def thisTaskName = "checkResource${variant.name.capitalize()}"
                //建立任務
                def thisTask = project.task(thisTaskName)
                //給任務指定一個group
                thisTask.group = "check"
                //在Execution階段,獲取資源文件
                thisTask.doLast {
                    def files = variant.allRawAndroidResources.files
                }
            }
        }
    }
}

點擊allRawAndroidResources進去看看,

public interface BaseVariant {
    /**
     * Returns file collection containing all raw Android resources, including the ones from
     * transitive dependencies.
     *
     * <p><strong>This is an incubating API, and it can be changed or removed without
     * notice.</strong>
     */
    //返回包含全部原始Android資源的文件集合,包括來自傳遞依賴項的資源
    //這是一個正在孵化的API,能夠更改或刪除它,恕不另行通知
    @Incubating
    @NonNull
    FileCollection getAllRawAndroidResources();
}

嗯,符合Android gradle一向的擁抱變化的做風,

@Incubating的接口咱們隨時能夠改,通不通知,文檔裏更不更新,咱們看心情 --「Android gradle團隊」

開個玩笑啦,不過每當升級gradle都確實會帶來一堆問題,什麼接口沒了,一些老的插件又要改造之類的,真是苦了開發者啊!不過,哈迪建的demo用的是Android gradle 4.0.0,也還沒啥問題。

拿到資源文件後,

Map<String, Resource> mResourceMap
Map<String, List<Resource>> mConflictResourceMap

//在Execution階段,獲取資源文件
thisTask.doLast {
    def files = variant.allRawAndroidResources.files

    //遍歷Set<File>,將value資源、file資源存進mResourceMap,發生衝突的資源則存進mConflictResourceMap
    files.forEach { file -> traverseResources(file)
    }

    //用mConflictResourceMap,生成資源對象樹,而後轉成json字符串
    //把json字符串塞給html模板,生成報表
}

下面看看是怎麼判斷文件衝突的,

void recordResource(Resource resource) {
    //獲取資源id,
    //value資源id:"value@" + lastDirectory + "/" + resName
    //file資源id:"file@" + lastDirectory + "/" + fileName
    def uniqueId = resource.getUniqueId()
    if (mResourceMap.containsKey(uniqueId)) {
        Resource oldOne = mResourceMap.get(uniqueId)
        //若是id相同,可是內容不一樣,則發生衝突(內容比較:value資源比較字符值;file資源比較md5)
        if (oldOne != null && !oldOne.compare(resource)) {
            List<Resource> resources = mConflictResourceMap.get(uniqueId)
            if (resources == null) {
                resources = new ArrayList<Resource>()
                resources.add(oldOne)
            }
            //把衝突的幾個資源存進list,方便對照
            resources.add(resource)
            //存進衝突map
            mConflictResourceMap.put(uniqueId, resources)
        }
    }
    //存進總map
    mResourceMap.put(uniqueId, resource)
}

大體流程以下,

到這裏,可能會有一個問題,就是項目太老,不少插件用的gradle版本很低,gradle一升級這些插件就廢怎麼辦?哈迪大體熟悉了一下內部的持續集成體系(ci平臺+Jenkins)後,想到了一個迷你主客的思路,就是殼工程的閹割版,自建一個迷你主客,只引入compileimplementation的依賴,忽略全部老插件,將gradle版本升高,迷你主客雖跑不起來,可是能夠進行資源編譯和運行CheckResourceConflict插件,大體思路以下,

固然啦,若是有足夠人力投入,直接魔改一發老插件,把gradle版本升起來就好了,畢竟高版本的gradle支持增量編譯,構建速度提高了很多~

延伸

冗餘資源

既然能夠檢測出名字相同但內容不一樣的文件引發的衝突覆蓋,那有沒有想過,內容相同但名字不一樣引發的冗餘問題呢?好比,電商工程和直播工程都有一個相同的圖標,但因爲命名不同,打包時就會打包進兩份文件增大包體積。

方案一:使用GitHub - AndResGuard,如

1. classpath 'com.tencent.mm:AndResGuard-gradle-plugin:1.2.18'

2. apply plugin: 'AndResGuard'

3. andResGuard {
    // 打開這個開關會合並全部哈希值相同的資源,但請不要過分依賴這個功能去除去冗餘資源
    mergeDuplicatedRes = true
}

sync一下,而後在直播工程拷貝一份drawable/icon_goods命名爲drawable/icon_goods2,即徹底同樣的圖標文件用了不一樣的名字,致使資源冗餘,而後運行,

app/build/outputs/apk/debug/AndResGuard_app-debug下獲得apk文件和一些映射文件,其中merge_duplicated_res_mapping_app-debug.txt

res filter path mapping:
    //...
    //icon_goods2指向了icon_goods
    res/drawable-xhdpi-v4/icon_goods2.png : res/drawable-xhdpi-v4/cb.png -> res/drawable-xhdpi-v4/icon_goods.png : res/drawable-xhdpi-v4/ca.png (size:8.2KB)
removed: count(8), totalSize(10.5KB)

或者,把app-debug_unsigned.apk拖進Android studio查看,能夠發現我是直播icon這個圖標只剩下一張了。

AndResGuard大體思路:輸入apk文件、解析並改寫resources.arsc、從新打包。

//ARSCDecoder.java

private MergeDuplicatedResInfo mergeDuplicated(File resRawFile, File resDestFile, 
                                       String compatibaleraw, String result){
    MergeDuplicatedResInfo filterInfo = null;
    //大小相同的文件被緩存在同一個list裏,加快查找
    List<MergeDuplicatedResInfo> mergeDuplicatedResInfoList =
        mMergeDuplicatedResInfoData.get(resRawFile.length());
    if (mergeDuplicatedResInfoList != null) {
        //遍歷這個list
        for (MergeDuplicatedResInfo mergeDuplicatedResInfo : mergeDuplicatedResInfoList) {
            if (mergeDuplicatedResInfo.md5 == null) {
                mergeDuplicatedResInfo.md5 = 
                    Md5Util.getMD5Str(new File(mergeDuplicatedResInfo.filePath));
            }
            String resRawFileMd5 = Md5Util.getMD5Str(resRawFile);
            //查找md5值相同的文件
            if (!resRawFileMd5.isEmpty() && resRawFileMd5.equals(mergeDuplicatedResInfo.md5)) {
                filterInfo = mergeDuplicatedResInfo;
                filterInfo.md5 = resRawFileMd5;
                break;
            }
        }
    }
    if (filterInfo != null) {
        //把冗餘文件和替代文件的映射寫入mapping.txt,如icon_goods2指向了icon_goods
        generalFilterResIDMapping(compatibaleraw, result, filterInfo.originalName, 
                                  filterInfo.fileName, resRawFile.length());
        //統計文件數量和大小
        mMergeDuplicatedResCount++;
        mMergeDuplicatedResTotalSize += resRawFile.length();
    } else {
        //尚未相同的文件,new個對象緩存起來就行
        MergeDuplicatedResInfo info = new MergeDuplicatedResInfo.Builder()
            .setFileName(result)
            .setFilePath(resDestFile.getAbsolutePath())
            .setOriginalName(compatibaleraw)
            .create();
        info.fileName = result;
        info.filePath = resDestFile.getAbsolutePath();
        info.originalName = compatibaleraw;
        if (mergeDuplicatedResInfoList == null) {
            mergeDuplicatedResInfoList = new ArrayList<>();
            mMergeDuplicatedResInfoData.put(resRawFile.length(), mergeDuplicatedResInfoList);
        }
        mergeDuplicatedResInfoList.add(info);
    }
    //filterInfo = mergeDuplicatedResInfo,即返回值要麼爲null,要麼爲第一個被發現的icon_goods
    return filterInfo;
}

再看到調用這個方法的地方,

//ARSCDecoder.java

private void readValue(boolean flags, int specNamesId) throws IOException, AndrolibException {
    MergeDuplicatedResInfo filterInfo = null;
    //獲取gradle中的mergeDuplicatedRes配置
    boolean mergeDuplicatedRes = mApkDecoder.getConfig().mMergeDuplicatedRes;
    if (mergeDuplicatedRes) {
        //若是有開啓冗餘資源的過濾,調用mergeDuplicated拿到第一個被發現的icon_goods
        filterInfo = mergeDuplicated(resRawFile, resDestFile, compatibaleraw, result);
        if (filterInfo != null) {
            resDestFile = new File(filterInfo.filePath);
            result = filterInfo.fileName;
        }
    }
    //將目標通通指向第一個被發現的icon_goods
    mTableStringsResguard.put(data, result);
}

具體實現可見ARSCDecoder.mergeDuplicated

方案二:使用android-chunk-utils,詳見美團 - Android App包瘦身優化實踐,思路跟方案一基本一致,都是改寫resources.arsc

參考資料


相關文章
相關標籤/搜索