共计 8919 个字符,预计需要花费 23 分钟才能阅读完成。
啥是资源冲突覆盖,就是两个不同的文件,有着相同的文件名,在打包 apk 后引起的系列问题。本文将从情景、解决思路、延伸,三个方面展开。
先简单介绍下背景,App 在线上跑了将近 7 年(历史悠久~),从早期的导购社区,到社区电商,再到社区、电商和直播三驾马车齐驱,也就是三大业务团队。
情景
UI 不合预期问题
首先,我们建一个 壳工程 app
,建两个业务工程,分别是 电商业务 biz_shopping
和 直播业务 biz_live
,如下,
接着在电商工程建一个页面layout/activity_shopping.xml
,
<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
如下,
然后有一天,直播团队在直播工程建了一个页面layout/activity_live.xml
,
<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
如下,
可以发现,这个图标和电商工程的图标名字相同,但是内容不同,接着运行壳工程,分别打开电商页面和直播页面,
由于同名的图标只会保留一份,导致电商页面无法按预期展示 我是商城 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>
两个工程的 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
查了些资料,还真发现了一个开源项目 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
。
参考资料
- 掘金 – Android 组件资源覆盖冲突解决方案
- 美团 – Android App 包瘦身优化实践
- GitHub – AndResGuard
- 简书 – AndResGuard 源码阅读
- 简书 – Android 冗余 or 重复资源处理
- 掘金 – 深度探索 Gradle 自动化构建技术(五、Gradle 插件架构实现原理剖析 — 下)
- Google – 资源合并