啥是資源衝突覆蓋,就是兩個不一樣的文件,有着相同的文件名,在打包apk後引發的系列問題。本文將從情景、解決思路、延伸,三個方面展開。html
先簡單介紹下背景,App在線上跑了將近7年(歷史悠久~),從早期的導購社區,到社區電商,再到社區、電商和直播三駕馬車齊驅,也就是三大業務團隊。java
首先,咱們建一個殼工程app
,建兩個業務工程,分別是電商業務biz_shopping
和直播業務biz_live
,以下,android
接着在電商工程建一個頁面layout/activity_shopping.xml
,git
<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.xml
,web
<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
以下,json
能夠發現,這個圖標和電商工程的圖標名字相同,可是內容不一樣,接着運行殼工程,分別打開電商頁面和直播頁面,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不會去測),那問題就可能會帶到線上,直到用戶反饋才能把問題暴露出來。
首先在電商工程新建頁面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> 複製代碼
兩個工程的Activity
在findViewById
時分別用本身的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,來看看人家是怎麼作的。
首先依賴插件,
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)後,想到了一個迷你主客
的思路,就是殼工程的閹割版,自建一個迷你主客,只引入compile
或implementation
的依賴,忽略全部老插件,將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
。
本文使用 mdnice 排版