android app图标_Android | 资源冲突覆盖的一些思考

啥是资源冲突覆盖,就是两个不同的文件,有着相同的文件名,在打包apk后引起的系列问题。

本文将从情景、解决思路、延伸,三个方面展开。

先简单介绍下背景,App在线上跑了将近7年(历史悠久~),从早期的导购社区,到社区电商,再到社区、电商和直播三驾马车齐驱,也就是三大业务团队。

0eff67f5d3f5e8adeab425563ab05828.png
1 情景

UI不合预期问题

首先,我们建一个壳工程app,建两个业务工程,分别是电商业务biz_shopping和直播业务biz_live,如下,

fa454b97829b38d52c1f44dfad021e42.png

接着在电商工程建一个页面layout/activity_shopping.xml,

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

    <TextViewandroid:id="@+id/textView"android:layout_width="match_parent"android:layout_height="wrap_content"android:text="我是电商页面"android:textSize="30dp" />

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

其中图标资源drawable/icon_goods如下,

f0ec5ace11c0a1268eca54c11e9a5ed7.png

然后有一天,直播团队在直播工程建了一个页面layout/activity_live.xml,

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

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

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


然后他们引入了一些素材,假设是直播带货相关业务,所以引入了一个商品图标drawable/icon_goods如下,

1f3332d6c33e580922a60631aea5d528.png

可以发现,这个图标和电商工程的图标名字相同,但是内容不同,接着运行壳工程,分别打开电商页面和直播页面,

83f20d35584ff2ab3fea3cbd8a5e51bc.png

由于同名的图标只会保留一份,导致电商页面无法按预期展示我是商城icon,而展示成了我是直播icon,相似的,像string资源也一样。

电商工程values/strings.xml,

<resources>
    <string name="buy">电商页买买买string>
resources>

直播工程values/strings.xml,

<resources>
    <string name="buy">直播页买买买string>
resources>


打包后也只会保留一份name为buy的字符串,造成另一方的UI不合预期。

48fc774be0416ea673d9f2391c393bbb.gif

那么UI不合预期问题会带来哪些影响呢?

假设这个版本两个团队的功能改动都在热页面(核心页面,在QA测试范围内),那么这个问题是能在各部门集成后的回归测试环节发现的;那如果电商这个页面是冷页面(年久失修,链路深,QA不会去测),那问题就可能会带到线上,直到用户反馈才能把问题暴露出来。

findViewById问题

首先在电商工程新建页面layout/activity_goods_list.xml,里面有一个list,id为shopping_goods_list,

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

    <ListViewandroid: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">

    <ListViewandroid: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页面报了空指针,

7de1ba4a578dc4f982248a6e106d005f.png

电商团队开始慌了,为什么受伤的总是我?

1441d3b62f5c43d253da239513b39ccd.png

显然,这个问题如果发生在冷页面,是极有可能带到线上,直到个别用户进到冷页面发生crash触发报警,开发团队才会发现问题,P1故障警告!(当然,crash问题比UI问题严重多了,会有QA自动化覆盖页面来避免,这里暂不讨论)

2 解决思路

首先我们会想到的就是,给每个团队的工程文件加上前缀约束不就行了嘛?又或者人为约束靠不住的话,加个Android的resourcePrefix资源前缀限定,

//resourcePrefix资源前缀限定,只能限定布局文件名和value资源的key,并不能限定图片资源的文件名
android {
    //给电商工程加上前缀约束shopping_
    resourcePrefix "shopping_"
}

android {
    //给直播工程加上前缀约束live_
    resourcePrefix "live_"
}

但开头提到过,项目在线上跑了多年,历史包袱贼重,一个App已经有了三四百个子工程,这时再来批量改名,即便使用脚本,也是需要一定的人力投入且有风险的,因为任一图标文件、字符串资源都可能正被多处引用着,再者,有些基础能力组件(如登录),还可能被其他App(如商家版)引用着。

因此,无论从人力投入、还是引入的风险来看,ROI都是不划算的。

那能不能先把目标降低,只做基本的扫描检测?比如通过gradle构建项目的时候来搞点事情?

开源项目CheckResourceConflict

https://github.com/hust201010701/CheckResourceConflict

查了些资料,还真发现了一个开源项目CheckResourceConflict,来看看人家是怎么做的。

首先依赖插件

classpath 'com.orzangleli:checkresourceconflict:0.0.2'

然后在app/build.gradle使用插件

apply plugin: 'CheckResourcePrefixPlugin'

sync一下,然后运行插件

e59bafd3c8b5978a5ffe7b9ad83b0c1f.png

运行后,生成html报告,可以在浏览器中查看,可见,冲突的图标、布局文件、字符串资源都被列出来了。

6bc3ddd73bc1b823050f8ffda5624d79.png

项目分析

首先插件要求项目的Android Gradle Plugin版本为不低于3.3,对应的gradle版本不低于4.10.1,因为新版本有一个接口BaseVariantImpl.allRawAndroidResources.files可以在编译期间获取到所有的资源文件,附上一张Android gradle plugin和gradle的版本对照

85f795a6990feadee0e0b361332615ed.png

然后看到项目核心类

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

This is an incubating API, and it can be changed or removed without
     * notice.

     */


    //返回包含所有原始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> mConflictResourceMap//在Execution阶段,获取资源文件
thisTask.doLast {
    def files = variant.allRawAndroidResources.files//遍历Set,将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 resources = mConflictResourceMap.get(uniqueId)if (resources == null) {
                resources = new ArrayList()
                resources.add(oldOne)
            }//把冲突的几个资源存进list,方便对照
            resources.add(resource)//存进冲突map
            mConflictResourceMap.put(uniqueId, resources)
        }
    }//存进总map
    mResourceMap.put(uniqueId, resource)
}

大致流程如下

b021a4479225572272f58882b9103dde.png

到这里,可能会有一个问题,就是项目太老,很多插件用的gradle版本很低,gradle一升级这些插件就废怎么办?

哈迪大致熟悉了一下内部的持续集成体系(ci平台+Jenkins)后,想到了一个迷你主客的思路,就是壳工程的阉割版,自建一个迷你主客,只引入compile或implementation的依赖,忽略所有老插件,将gradle版本升高,迷你主客虽跑不起来,但是可以进行资源编译和运行CheckResourceConflict插件,大致思路如下

71f94c1ec6db2090f9b148fbb064208c.png

当然啦,如果有足够人力投入,直接魔改一发老插件,把gradle版本升起来就行了,毕竟高版本的gradle支持增量编译,构建速度提升了不少~

3 延伸

冗余资源

既然可以检测出名字相同但内容不同的文件引起的冲突覆盖,那有没有想过,内容相同但名字不同引起的冗余问题呢?比如,电商工程和直播工程都有一个相同的图标,但由于命名不一样,打包时就会打包进两份文件增大包体积。

方案一:使用GitHub - AndResGuard,如

https://github.com/shwenzhang/AndResGuard/blob/master/README.zh-cn.md

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,即完全一样的图标文件用了不同的名字,导致资源冗余,然后运行

e9f724539eabef3fec2712a121a9ad17.png

在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 mergeDuplicatedResInfoList =
        mMergeDuplicatedResInfoData.get(resRawFile.length());if (mergeDuplicatedResInfoList != null) {//遍历这个listfor (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_goodsreturn 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。

https://github.com/madisp/android-chunk-utils

https://tech.meituan.com/2017/04/07/android-shrink-overall-solution.html#%E9%87%8D%E5%A4%8D%E8%B5%84%E6%BA%90%E4%BC%98%E5%8C%96

最后推荐一下我做的网站,玩Android: wanandroid.com ,包含详尽的知识体系、好用的工具,还有本公众号文章合集,欢迎体验和收藏!

推荐阅读:

大佬们,一波RxJava 3.0来袭,请做好准备~ 直面底层:“吹上天”的协程,带你深入源码分析 关于Android 抓包 与 反抓包

5ebcb37d72f54dd5ae61c978d1d82663.png

扫一扫 关注我的公众号

如果你想要跟大家分享你的文章,欢迎投稿~

┏(^0^)┛明天见!

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值