商城 html模板-Android | 资源冲突覆盖的一些思考

什么是资源冲突覆盖,即两个文件名相同的不同文件在打包apk后导致一系列问题。

本文将从场景、解决思路、扩展三个方面入手。

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

场景

UI 不符合预期

首先我们搭建一个shell项目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如下商城 html模板

然后有一天,直播团队在直播项目中建了一个页面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如下,

可以发现,这个图标与电商项目的图标同名,但内容不同。 然后运行shell项目,分别打开电商页面和直播页面。

由于只保留了一份同名图标,所以电商页面无法按预期显示“我是商城”图标,而是显示“我是直播”图标,同样,字符串资源也是一样。

电商项目values/strings.xml,

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


实时项目值/strings.xml,

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



打包后只会保留一个名为buy的字符串,导致对方的UI出现意外。

那么UI不符合预期会有什么影响呢?

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

findViewById问题

首先在电商项目中新建一个页面layout/activity_goods_list.xml,其中包含一个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,导致空指针。 运行shell项目如下,发现直播列表页面是好的,但是电商列表页面报空指针,

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

显然,如果这个问题出现在冷页上,那么很有可能会被带到线上。 直到部分用户进入冷页面、崩溃触发警报时,开发团队才会发现问题。 P1故障警告! (当然,崩溃问题比UI问题严重得多,会有QA自动化覆盖页面来避免,这里不讨论)

解决方案

我们首先会想到的是,给每个团队的项目文件添加前缀约束还不够吗? 或者如果人为约束不可靠的话,添加一个Android的resourcePrefix资源前缀限制,

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

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


但正如开头所说,该项目已上线运行多年,历史包袱沉重。 一个应用程序已经有三四百个子项目。 这时候如果要批量更改名称,即使使用脚本,也需要一定的人力输入,存在风险。 因为任何图标文件和字符串资源都可能被多个地方引用,而且一些基础能力组件(如登录)也可能被其他应用(如商户版本)引用。

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

能不能先降低目标,只做基本的扫描和检测? 比如通过gradle构建项目时做一些事情?

开源项目 CheckResourceConflict

查了一些资料,还真找到了一个开源项目CheckResourceConflict,我们来看看他们是怎么做的。

首先依赖插件

classpath 'com.orzangleli:checkresourceconflict:0.0.2'

然后使用app/build.gradle中的插件

apply plugin: 'CheckResourcePrefixPlugin'

同步,然后运行插件

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

项目分析

首先插件要求项目的Android Gradle Plugin版本不低于3.3,对应的gradle版本不低于4.10.1,因为新版本有一个接口BaseVariantImpl.allRawAndroidResources.files商城 html模板,可以获取编译时的所有资源文件。 附Android gradle插件与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.
     *
     * 

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都会带来很多问题,一些接口没有了,一些旧的插件需要重新改造等等,真的很伤害开发者! 不过Hardy构建的demo使用的是Android gradle 4.0.0,没有问题。

获取资源文件后:

Map<String, Resource> mResourceMap
Map<StringList> 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)
}

大致流程如下

此时可能存在一个问题,那就是项目太老了,而且很多插件使用的gradle版本很低。 gradle升级后这些插件没用了怎么办?

在熟悉了内部持续集成系统(ci平台+Jenkins)后,Hardy想到了迷你主客的想法,这是shell项目的阉割版。 他自己搭建了一个迷你主客,只引入编译或实现依赖,忽略所有旧插件,升级gradle版本。 虽然迷你主机和访客无法运行,但它们可以编译资源并运行 CheckResourceConflict 插件。 总体思路如下

当然,如果你人手足够的话,可以直接修改旧插件,升级gradle版本。 毕竟高版本的gradle支持增量编译,构建速度提升了不少~

延长

冗余资源

既然可以检测到同名不同内容的文件造成的冲突覆盖,那么您有没有想过同内容不同名称造成的冗余问题呢? 例如,电商项目和直播项目都有相同的图标,但由于名称不同,会被打包成两个文件,以增加包大小。

解决方案1:使用GitHub - AndResGuard,例如

1. classpath 'com.tencent.mm:AndResGuard-gradle-plugin:1.2.18'

2. apply plugin: 'AndResGuard'

3. andResGuard {
    // 打开这个开关会合并所有哈希值相同的资源,但请不要过度依赖这个功能去除去冗余资源
    mergeDuplicatedRes = true
}


同步一下,然后在直播项目中复制一份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查看,可以发现直播图标只剩下一个图标了。

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) {
        //遍历这个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。

解决方案 2:使用 android-chunk-utils。 详情请参见美团-Android应用包瘦身优化实践。 思路与方案一基本相同,都是重写resources.arsc。

#%E9%87%8D%E5%A4%8D%E8%B5%84%E6%BA%90%E4%BC%98%E5%8C%96

最后推荐一下我做的网站,玩Android:wanandroid.com,里面有详细的知识体系,好用的工具,还有这个公众号的文章合集。 欢迎体验、收藏!