向工程腐化开炮|资源治理

作者:刘天宇(谦风)

系列文章回顾《向工程腐化开炮 | proguard治理》《向工程腐化开炮 | manifest治理》《向工程腐化开炮:Java代码治理》。本文为系列文章第四篇,聚焦于Android 资源,这一细分领域。对工程腐化,直接开炮!

准确的说,本文主角是Android资源,而java资源归属到java代码治理范畴,并在《向工程腐化开炮:Java代码治理》一文中给出了应对方案。

Android资源从定义和使用方式来看,可以分为Resource和Asset两个大类。前者提供受控的结构化访问方式,每个资源均有唯一id标识,以及多种配置限定符来支持多语言、多设备、多特性等能力;后者提供原始且相对自由的目录和文件访问。Resource类型是绝大部分资源使用场景下的最佳选择,本文主要聚焦的即是这种类型资源,对冲突、无用、缺失类引用、硬编码文本,这几种腐化情况,开展工具研发,以及治理实践。

基础知识

本章先简要介绍一些基础知识,方便大家对Android资源有一个“框架性”的清晰认知,为理解第二章治理实践内容打下基础。此外,也尝试以独特视角,来讲解一些有趣的技术点。

1.1 资源分类

对于Resource资源,按照使用场景,官方文档已经给出了划分和具体说明。本节以资源编译后,对应R内部类的类型,给出一个分类:

以上24种资源,均可以通过R.<type>.<name>形式在java代码中引用,其中一些还可以通过@<type>/<name>形式在manifest和资源中引用。对上述分类中「是否独立文件」、「是否位于resources.arsc」两个维度进行一些解读:

  • 是否独立文件。一个资源如果对应一个完整的独立文件,这种属于File-Base Resource,在最终apk的res目录下也会存在一份对应文件;否则,属于Value-Base Resource,在apk中没有独立文件与之对应,其值(如果有)存储在resources.arsc中。其中color类型比较特殊,单一color资源是Value-Base,但是颜色状态列表(ColorStateList)属于File-Base。此外,是否独立文件,是从资源编译后这一视角来看的,在定义资源时,Android提供了一种内嵌xml资源的形式,可以把多个独立文件类型资源,写在一个xml文件中,在此不展开讨论;
  • 是否位于resources.arsc。绝大部分资源,在R$<type>类中field的取值,都是0x7fxxxxxx,并且在resources.arsc中都有一条对应记录。对于File-Base资源,记录值是file的相对路径,对于Value-Base资源,记录值就是资源值本身。需要注意的是,styleable类型资源比较特殊,仅存在于R$styleable类中,其field取值也并不是0x7fxxxxxxx格式,而是整型或整型数组,并且在resources.arsc中并不存在。

通过一个styleable定义示例,来帮助我们理解上述知识:

# 资源定义于 res/value/attrs.xml

<resources>

<declare-styleable name="DeclareStyleable1" >

<attr name="attr_enum" format="enum">

<enum name="attrEnum1" value="1"/>

<enum name="attrEnum2" value="2"/>

</attr>

<attr name="attr_integer" format="integer"/>

<attr name="android:padding" format="dimension"/>

</declare-styleable>

</resources>

在apk编译过程中,生成以下R.java代码:

# R.java文件中,生成以下代码

public static final class id {

public static final int attrEnum1=0x7f060000;

public static final int attrEnum2=0x7f060001;

}

public static final class attr {

public static final int attr_enum=0x7f020000;

public static final int attr_integer=0x7f020001;

}

public static final class styleable {

public static final int[] DeclareStyleable1 = {0x010100d5, 0x7f020000, 0x7f020001};

public static final int DeclareStyleable1_android_padding=0;

public static final int DeclareStyleable1_attr_enum=1;

public static final int DeclareStyleable1_attr_integer=2;

}

最后,在resources.arsc中,生成以下记录:

# resources.arsc中,生成记录

type | id | name | value

id 0x7f060000 attrEnum1 None

id 0x7f060001 attrEnum2 None

attr 0x7f020000 attr_enum 1,2

attr 0x7f020001 attr_integer 0

一个styleable定义,最终会生成一连串产物,由此可见,Android资源的处理逻辑,相对还是比较复杂的。在这个例子中,还有几个有意思的技术点,值得拿来讲一讲:

  • 一个attr,name使用android:xxxx,在R.java和resources.arsc中不会生成对应内容,因此在语意可复用时,使用系统提供的attr,可以节省一点包大小空间;
  • 如果多个styleable或者style,定义了同名attr,实际只会生成一个attr资源,相当于提高了复用度;
  • attrEnum1、attrEnum2这种id类型资源,如果其它类型资源(例如layout)中也有同名定义,那么实际也只会生成一个id资源,同样也提高了复用度。

好了,对于资源分类,就到此为止,如果对于资源编译、R.java、resources.arsc等还不够了解,也没有关系,后面小节或许会给出答案。

1.2 资源引用

资源在定义后,就需要从另外的地方对其进行引用。从引用确定性这个维度来看,可以分为直接和间接(动态)两种;从引用元素为度来看,可以分为java代码、manifest、资源三种:

图注:资源引用方式

其中,间接(动态)引用,提供动态化的资源引用方式,可以在运行时,根据上下文条件,决定引用哪个资源,灵活度很高。但是,这种资源引用方式,相对于直接引用,需要额外进行由资源名称查找资源id的处理,因此性能略差,谨慎使用。

1.3 资源编译

接下来,看看资源的编译过程:

图注:资源编译过程

首先,资源会进行合并,同名资源仅保留一份,同时,manifest也会进行合并。接下来,会以上述二者作为核心输入数据,通过AAPT(2)进行资源编译,具体的编译过程比较复杂,网络上也有比较全面的讲解(可以参考这篇文章:https://www.kancloud.cn/alex_...), 这里重点关注资源编译产物,以及与其它处理逻辑的关系:

  • AndroidManifest.xml文件。其中对资源的引用,会替换为对应资源id,并编译为二进制格式,最终会被打包到apk中。
  • resources.arsc文件。资源符号(索引)表,记录所有资源id与各配置下的资源值,最终也会被打包到apk中。
  • 处理(编译)后的资源文件集合。所有需要编译的独立资源文件(例如layout),均会编译为二进制格式,和不需要编译的资源文件一起,最终被打包到apk中。
  • R.java文件。记录资源类型/名称,与id值的对应关系,用于在java代码中直接引用。每个模块(subproject、flat aar、external aar)都会生成对应的package.R.java文件,最终和其它所有java源文件一起,共同进行javac编译。
  • 资源对应keep规则文件。主要包括layout中view节点对应java类,onClick属性值对应java方法,以及manifest中四大组件对应java类。这些keep规则,会与其它自定义keep规则一起,用于后续的proguard处理。

从上述整个过程来看,资源编译与其它几个核心处理过程,都有紧密联系,因此,了解资源编译过程,对掌握整个apk构建,具有重要价值。

1.4 资源裁剪

google官方的Android Gradle Plugin,提供了资源裁剪功能。核心原理是,计算资源的直接引用关系,以manifest和java代码中的引用,作为根引用,所有不被引用到的资源,均属于无用资源。看起来是一个非常有效的功能,但是由于java代码中存在间接(动态)引用,为了将这部分引用也覆盖到,采用了比较保守的策略:收集java代码中的所有字符串常量,如果资源名称以这些常量开头,则也认为资源有引用。除此之外,还有几个逻辑,用于处理特殊的引用方式。上述处理逻辑,有以下几个问题:

  • 如果通过Resources.getIdentifier动态引用资源时,名称参数完全是一个变量,那么会导致相关资源被误删;
  • 如果java代码常量池中,几乎包含所有单个字符,例如a-z,1-9,那么所有资源均会被认为有引用,导致不会裁剪任何一个资源(优酷就是如此)。

因此,资源裁剪功能,从技术原理上看,无论如何都是一个非确定性算法,必定会存在误裁、漏裁的可能性。对此,google提供了白名单机制,来解决误裁问题,还有严格模式,用于取消对间接(动态)引用的保留逻辑。

对于历史包袱不重的app,尽早开启这项功能,有利于减轻包大小负担。对于代码复杂度高,历史包袱重的大型app(优酷就是如此),应该会存在不少间接(动态)引用,不开启严格模式,几乎无效果,开启严格模式,存量确认&加白名单的成本又极高。对此,优酷的选择是,通过建立独立的无用资源检测功能,结合包大小治理,促进从源头直接删除资源,这样既可以降低资源处理耗时,又可以实现降低包大小效果。对于新增无用资源,则通过包大小卡口,实现非实时(可延迟)清理。

1.5 几个有趣的问题

最后,来讲几个比较有趣,并且不容易被注意到的技术点。

被忽视的一员 - id类型资源

id类型资源,作为唯一标识符,在Android资源体系下,承担“穿针引线”作用。例如最常用的,在layout中定义一个view节点,赋予其一个id名称,这样在java代码中,就可以方便的获取这个view实例,从而进行后续各种操作。再举个例子,在前面styleable示例中,一个enum类型attr包含的每一个枚举值,都会生成一个对应id类型资源。

id类型资源,在编译期的一个重要特性是可以全局复用,这一点在前面styleable示例中,已经讲述过。在app运行时,id类型资源的特性是,局部唯一即可。例如在一个layout中,或者在一个enum类型的attr中,都是如此。讲到这里,有些同学一定能够想到,我们是不是可以利用这两个特性,在保证运行时局部唯一性前提下,仅保留一个最小集合,其它所有定义和引用,均在这个最小集合内选取即可,而这个最小集合的数量,取决于所有局部使用场景中,需要id数量的最大值。举个例子:

# styleable类型资源,定义于 res/value/attrs.xml

<resources>

<declare-styleable name="DeclareStyleable1" >

<attr name="attr_enum" format="enum">

<enum name="attrEnum1" value="1"/>

<enum name="attrEnum2" value="2"/>

</attr>

<attr name="attr_integer" format="integer"/>

<attr name="android:padding" format="dimension"/>

</declare-styleable>

</resources>

# layout类型资源,定义于 res/layout/main.xml

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

android:layout_width="match_parent"

android:layout_height="match_parent">

<TextView

android:id="@+id/main_textview"

android:layout_width="wrap_content"

android:layout_height="wrap_content"

android:textColor="@color/purple_200"

android:text="Hello World!"/>

</LinearLayout>

上述一共生成3个id类型资源:attrEnum1、attrEnum2、main_textview,这两个使用场景中,styleable需要2个id,layout需要一个id,所以最小id集合只需要包含2个id。假设我们可以在资源编译过程中,可以将"@+id/main_textview"修改为"@+id/attrEnum1",就可以减少1个id类型资源。在优酷这样复杂度的app中,共有1.3万多个id类型资源,而所有局部使用场景中,需要id数量的最大值,相信一定不会超过两位数。一个id类型资源在apk中占用的大小(Byte),可以简单认为等于id名称长度,保守估计以平均20Byte来计算,1.3万个id资源,可节省包大小250KB。由于收益并不显著,并没有实际进行开发,作为一个有趣的思考,留给本文读者。

资源与java代码的桥梁 - R类

通过前面讲解,相信读者对R类已经具备一定了解。在这里,我们考虑几个情况。

第一个情况,每个模块(subproject、flat aar、external aar)都会生成对应package.R.java文件,但是这些文件包含内容,都是<app_package>.R类的子集。那么,我们是不是可以,移除所有模块的R类,统一使用app的R类,以此来降低包大小呢?答案是肯定的,事实上优酷在apk构建过程中,会删除所有模块R类,并将java代码中对这些R类的引用,转换为对appR类的引用。通过这种方式,降低了MB量级的apk大小,模块数量越多,收益越明显。

第二个情况,R类内容非常简单,就是记录了资源类型/名称,与资源id值的对应关系,manifest和资源这二者对资源的引用,在编译过程中,已经转换为对应资源id值,那么,如果我们把所有java代码中R.<type>.<name>的引用,也全部替换为对应id值,是不是R类就可以删除了呢?答案是肯定的,在已经完成第一种情况的优化后,这个处理的收益比较有限,因此并没有实际投入研发和使用。但是我们确实可以这么做!

资源百晓生 - resources.arsc

resources.arsc文件,作为资源符号(索引)表,记录所有资源类型、名称、id值,以及各配置下的值。所有Resource类型资源(运行时视角,排除编译期视角的styleable资源)均记录在案,app运行时,无论java代码还是资源,都是拿着资源id值,到resources.arsc中来获取资源值,称之为“资源百晓生”一点都不夸张。这个查找过程非常高效,相当于给定一个key,获取其在一个hashmap中的值。

实际上,通过Resources.getIdentifier这种间接(动态)方式获取资源id值时,也是以资源类型+名称,在resources.arsc中进行反向查找,找到后,再继续通过id值获取资源值。这个查找过程,相当于给定一个值,获取其在一个hashmap中的key。那么有没有什么方式,可以更高效实现这种运行时灵活的引用资源呢?一个比较自然的想法,是通过java反射获取R.<type>.<name>值,那么问题来了,相对于Resources.getIdentifier方式获取,哪种性能更好一些?答案可能并不是简单的二选一,耗时可能与资源数量,以及是否第一次查询同一种类型资源,都有关系,答案就留给读者来思考和验证吧。

治理实践

随着工程模块&功能增加,资源腐化逐步积累:同名资源的冲突情况愈发频繁,导致多次构建apk,资源值无法保障一致性;资源引用关系复杂,代码删除后往往会忘记,或者不敢轻易删除对应资源,导致无用资源持续积累;layout中引用自定义view,但是view的java实现类被删除,app运行时layout被“加载”时会引发java异常;资源中的硬编码文本,带来线上隐私合规风险,或者国家/地区/宗教文化争议问题。上述诸多问题,都是过往优酷与资源“腐化”斗争中,不断遇到的真实问题,通过相关工具建立有效的检测能力,并基于此形成日常研发卡口机制,在确保问题零新增前提下,逐步消化已有存量问题。

在问题定位、排查过程中,快速获取资源来自哪个模块,是一个基本诉求。二、三方模块大量引入,以及app工程模块化程度提高,都使上述信息获取的成本变得越来越高。为此,首先开发了模块包含资源列表功能,可以快速查看,目标资源位于哪个模块(app工程、subproject工程、flat aar、外部依赖模块):

com.youku.android:aln:1.9.49

|-- string/m_mode

|-- layout/pager_last

|-- dimen/h_n_bar_pop_star

|-- asset/config/custom_config.json

com.youku.android:YHP:1.23.511.1

|-- layout/channel_list_footer

|-- layout/f_cover_s_feed_item

|-- drawable-night-xhdpi-v8/ic_ho

接下来,对各个资源“腐化”项的治理实践,逐一讲解。

2.1 冲突资源

冲突资源,是指来自不同模块的同名资源,其对应配置下的内容值不一致。在资源编译过程中,同名资源只会保留一份,选择哪个资源可以认为是“随机的”(实际和模块声明顺序有关),这会导致每次构建出来的apk,对应资源值可能会发生变化。冲突资源,会给运行时带来不确定性风险,轻则文本内容、尺寸大小、UI颜色发生非预期变化,重则导致异常产生。

在优酷历次迭代中,曾经发生多次冲突资源导致的线上崩溃,为了解决这个顽疾,首先研发了冲突资源检测工具,示例结果如下:

[conflict] drawable/al_down_arrow

|-- xhdpi-v4

| |-- md5:cc2ef446bf586b03fd08332a5a75b304 (com.ali.user.sdk:au:4.10.6.18)

| |-- md5:5f9c59ec3fba027c5783120effa12789 (com.ta.android:lo4android:4.10.6.18)

[conflict] string/str_retry

|-- en

| |-- not calculated (com.ali.android.phone:bee-build:10.2.3.358)

|-- default

| |-- 重试 (com.ali.android.phone:photo-build:10.2.3.57)

| |-- 点击重试 (com.ali.android.phone:bee-build:10.2.3.358)

在上述检测结果中,当同名资源在同一配置下,超过两个模块包含此资源值时,才可能发生冲突,因此也才会进行资源特征值计算,否则会显示为not calculated。不同类型资源的特征值计算方式如下:

与此同时,提供资源名称、模块两种不同颗粒度的忽略名单配置,以临时排除一些二、三方模块之间的冲突资源。更近一步,提供选项,当检测结果不通过时,终止构建过程,形成卡口机制。

优酷在2020年,首先研发了第一版冲突资源检测工具,当时存量冲突资源共计600多个,之后联合QA同学进行两轮清理专项,降低到100个以内,2021年初卡口上线后,截至当前已降至40多个(主要来自二、三方模块之间的冲突):

图注:冲突资源治理情况

冲突资源卡口上线至今,累计拦截13次,有效防止冲突资源,引发的线上非预期情况,甚至是app崩溃的严重故障。

2.2 无用资源

前面「资源引用」一节,已经对资源的引用关系,进行了基础知识讲解。总结下,资源可能在如下三个地方进行直接引用:

  • java代码。通过R.resourceType.resourceName方式引用,例如R.string.app_name;或者通过资源id方式,直接引用,例如0x7fxxxxxx;
  • 清单文件AndroidManifest.xml;
  • 其它资源。

以java代码和manifest作为引用根节点,对资源引用关系进行完全展开,最终未被包含到的资源,即为无用资源。对于通过Resources.getIdentifier这种间接(动态)方式引用的资源,不包含在此处的资源引用关系计算过程中,因此,无用资源检测结果,需要确认是否存在这种引用方式。基于google官方AndroidGradlePlugin中的无用资源分析逻辑,全方位增强对工程结构、AndroidGradlePlugin版本、各工具链版本等兼容性,补齐更多类型资源间的引用分析,添加额外模块归属信息,最终沉淀为此无用资源检测功能。

图注:无用资源分析

无用资源检测,分析结果示例:

project:app:1.0

|-- array/planets_array

|-- color/white

|-- drawable/fake_drawable

|-- layout/layout_miss_view

|-- raw/app_resource_raw_chinese_text

|-- string/string_resource_chinese_name

|-- xml/app_resource_xml_chinese_text

project:library-aar-1:1.0

|-- layout/layout_contain_merge

|-- string/library_aar_1_name

此外,资源的直接引用关系,也可以输出到分析结果中:

Resource Reference Graph:

array:planets_array:2130771968 is reachable: false. The references =>

attr:attr_enum:2130837504 is reachable: true. The references =>

referenced by code : [com/example/libraryaar1/CustomImageView (project:library-aar-1:1.0)]

referenced by resource : [layout:layout_use_declare_styleable1:2131099652]

attr:attr_integer:2130837505 is reachable: true. The references =>

referenced by resource : [style:CustomTextStyle:2131361792]

无用资源,考虑到存在间接(动态)引用导致误检的问题,因此并没有进一步形成卡口,而是作为包大小分析结果中,一个可瘦身项来呈现。2020年6月功能上线时,共有1.7万个无用资源,目前已经降至0.9万个,存量清理效果显著。

无用资源治理情况

2.3 缺失类引用

layout中可以声明自定义view节点,如果这个自定义view对应类,最终不在apk的dex文件中,由于资源编译的特性,上述情况并不会引发apk构建过程失败,但是在app运行时,一旦“加载“此layout就会引发异常。上述这种情况,我们称之为资源的缺失类引用。

资源缺失类引用检测,列出了问题资源,及其所属模块,以及缺失的引用类。示例结果如下:

* [ignored] layout-xxxhdpi/layout_include_layout (project:library-aar-1:1.0)

|-- com.example.libraryaar1.NonExistCustomView

* layout/layout_miss_view (project:app:1.0, project:library-aar-1:1.0)

|-- com.example.myapplication.NonExistView2

|-- com.example.myapplication.NonExistView

与此同时,提供资源名称颗粒度的忽略名单配置,暂时排除一些二、三方模块内的问题资源。更近一步,提供选项,当检测结果不通过时,终止构建过程,形成卡口机制。此项功能,近期刚上线对应卡口,尚未有触发卡口拦截案例出现,存量30个问题资源,已分发到对应研发团队。

事实上,layout中的每一个自定义view节点,AAPT在进行处理时,都会生成一条keep规则,这会成为一条无用keep规则,在「向工程腐化开炮:proguard治理」一文中,提到了这种情况。在此,把示例再展示下:

# layout中引用不存在的class,在apk编译过程中,并不会引发构建失败,但依然会生成相对应的keep规则。

# 这个layout一旦在运行时被“加载“,那么会引发Java类找不到的异常。

<?xml version="1.0" encoding="utf-8"?>

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

xmlns:app="http://schemas.android.com/apk/res-auto"

xmlns:tools="http://schemas.android.com/tools"

android:layout_width="match_parent"

android:layout_height="match_parent">

<com.example.myapplication.NonExistView

android:id="@+id/main_textview"

android:layout_width="wrap_content"

android:layout_height="wrap_content"

android:text="Hello World!"/>

</LinearLayout>

# 生成的keep规则为:-keep class com.example.myapplication.NonExistView { <init> ( ... ) ; }

虽然无用keep规则卡口,已经完全包含资源缺失类引用问题,但是二者管控的维度并不一致,因此仍然将资源缺失类引用,作为独立能力提供。

2.4 硬编码文本

硬编码文本,是指直接在资源中编写的字符串文本。隐私合规检测机构,会检测apk中的一些敏感文本,做为隐私合规问题的重点怀疑&验证点,例如「发票抬头」、「身份证」等,其中一部分就是来自于资源中的硬编码文本(另外可能的来源是java代码、so)。硬编码文本,存在以下缺点:

  • 易冗余。多处资源使用同一文本时,会导致存在多份此文本;
  • 不灵活。当线上版本出现问题时(例如各类运营活动),难以动态修改;
  • 低安全。一些敏感信息,如果以明文硬编码文本形式存在,非常容易被获取,并用于不正当用途。

对于这类问题,开发了对应检测能力,可以自定义正则表达式,对上述资源中硬编码文本进行匹配。检测结果中,按照模块、资源进行逐级聚合。支持以下类型资源中的字符串文本:

以所有中文字符检测为例:

project:app:1.0

|-- array/planets_array

| |-- [text] string-array包含的中文item

|-- raw/app_resource_raw_chinese_text

| |-- [text] <files-path name="我是raw类型xml资源文件中,包含的中文文本" path="game-bundles/" />

|-- string/string_resource_chinese_name

| |-- [text] 我是中文string资源

|-- xml/app_resource_xml_chinese_text

| |-- [text] <files-path name="我是xml资源中的中文文本" path="game-bundles/" />

|-- layout/activity_main

| |-- [text] android:text="你好,世界!" />

project:library-aar-1:1.0

|-- asset/library_aar_1_asset_chinese_text

| |-- [text] 我是包含中文文本的asset资源文件.

目前在优酷,隐私合规相关的一些敏感文本,是一个正在进行的探索方向,由于目前没有明确规则,因此还没有实际落地使用。在日常研发过程,对于需要查找特定硬编码文本的场景,已经能够起到很好的辅助提效作用。

2.5 治理全景

至此,对于Android资源,进行了较全面有效的防腐化能力建设和治理。最后,给出一份全景图:

图注:资源治理全景

还能做些什么

Android资源,并不会像java代码那样多变和复杂,前面这些治理项,已经基本覆盖绝大部分资源腐化场景,但是Android资源在日常研发过程中,非常容易被忽视:一个字符串、一个颜色/尺寸值、一个属性值,一个布局文件,好像每一个都“微不足道”,即使重复定义、即使忘了清理,看起来也没多大影响。而这,正是资源腐化的可怕之处:单个资源过于“微小”,开发者的专业意识稍有松懈,就成了漏网之鱼。

能够进行批量的清理,固然值得称赞,但是在日常研发的点滴间,能够时刻坚守工匠精神,降低“腐化”代码产生,更难能可贵。“千丈之堤,以蝼蚁之穴溃;百尺之室,以突隙之烟焚”(韩非子·喻老),与诸君共勉。

【参考文档】

  • 【google】应用资源:https://developer.android.com...
  • 【google】AAPT2:https://developer.android.com...

关注【阿里巴巴移动技术】微信公众号,每周 3 篇移动技术实践&干货给你思考!

以上是 向工程腐化开炮|资源治理 的全部内容, 来源链接: utcz.com/z/267624.html

回到顶部