啥是资源冲突覆盖,就是两个不一样的文件,有着相同的文件名,在打包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 排版