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.xmlweb

<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不會去測),那問題就可能會帶到線上,直到用戶反饋才能把問題暴露出來。

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

參考資料


本文使用 mdnice 排版

相關文章
相關標籤/搜索