向工程腐化开炮|资源治理
作者:刘天宇(谦风)
系列文章回顾《向工程腐化开炮 | 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。绝大部分资源,在 Rstyleable 类中,其 field 取值也并不是 0x7fxxxxxxx 格式,而是整型或整型数组,并且在 resources.arsc 中并不存在。
通过一个 styleable 定义示例,来帮助我们理解上述知识:
在 apk 编译过程中,生成以下 R.java 代码:
最后,在 resources.arsc 中,生成以下记录:
一个 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_wsc/androids/473798), 这里重点关注资源编译产物,以及与其它处理逻辑的关系:
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 数量的最大值。举个例子:
上述一共生成 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、外部依赖模块):
接下来,对各个资源“腐化”项的治理实践,逐一讲解。
2.1 冲突资源
冲突资源,是指来自不同模块的同名资源,其对应配置下的内容值不一致。在资源编译过程中,同名资源只会保留一份,选择哪个资源可以认为是“随机的”(实际和模块声明顺序有关),这会导致每次构建出来的 apk,对应资源值可能会发生变化。冲突资源,会给运行时带来不确定性风险,轻则文本内容、尺寸大小、UI 颜色发生非预期变化,重则导致异常产生。
在优酷历次迭代中,曾经发生多次冲突资源导致的线上崩溃,为了解决这个顽疾,首先研发了冲突资源检测工具,示例结果如下:
在上述检测结果中,当同名资源在同一配置下,超过两个模块包含此资源值时,才可能发生冲突,因此也才会进行资源特征值计算,否则会显示为 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 版本、各工具链版本等兼容性,补齐更多类型资源间的引用分析,添加额外模块归属信息,最终沉淀为此无用资源检测功能。
图注:无用资源分析
无用资源检测,分析结果示例:
此外,资源的直接引用关系,也可以输出到分析结果中:
无用资源,考虑到存在间接(动态)引用导致误检的问题,因此并没有进一步形成卡口,而是作为包大小分析结果中,一个可瘦身项来呈现。2020 年 6 月功能上线时,共有 1.7 万个无用资源,目前已经降至 0.9 万个,存量清理效果显著。
无用资源治理情况
2.3 缺失类引用
layout 中可以声明自定义 view 节点,如果这个自定义 view 对应类,最终不在 apk 的 dex 文件中,由于资源编译的特性,上述情况并不会引发 apk 构建过程失败,但是在 app 运行时,一旦“加载“此 layout 就会引发异常。上述这种情况,我们称之为资源的缺失类引用。
资源缺失类引用检测,列出了问题资源,及其所属模块,以及缺失的引用类。示例结果如下:
与此同时,提供资源名称颗粒度的忽略名单配置,暂时排除一些二、三方模块内的问题资源。更近一步,提供选项,当检测结果不通过时,终止构建过程,形成卡口机制。此项功能,近期刚上线对应卡口,尚未有触发卡口拦截案例出现,存量 30 个问题资源,已分发到对应研发团队。
事实上,layout 中的每一个自定义 view 节点,AAPT 在进行处理时,都会生成一条 keep 规则,这会成为一条无用 keep 规则,在「向工程腐化开炮:proguard 治理」一文中,提到了这种情况。在此,把示例再展示下:
虽然无用 keep 规则卡口,已经完全包含资源缺失类引用问题,但是二者管控的维度并不一致,因此仍然将资源缺失类引用,作为独立能力提供。
2.4 硬编码文本
硬编码文本,是指直接在资源中编写的字符串文本。隐私合规检测机构,会检测 apk 中的一些敏感文本,做为隐私合规问题的重点怀疑 &验证点,例如「发票抬头」、「身份证」等,其中一部分就是来自于资源中的硬编码文本(另外可能的来源是 java 代码、so)。硬编码文本,存在以下缺点:
易冗余。多处资源使用同一文本时,会导致存在多份此文本;
不灵活。当线上版本出现问题时(例如各类运营活动),难以动态修改;
低安全。一些敏感信息,如果以明文硬编码文本形式存在,非常容易被获取,并用于不正当用途。
对于这类问题,开发了对应检测能力,可以自定义正则表达式,对上述资源中硬编码文本进行匹配。检测结果中,按照模块、资源进行逐级聚合。支持以下类型资源中的字符串文本:
以所有中文字符检测为例:
目前在优酷,隐私合规相关的一些敏感文本,是一个正在进行的探索方向,由于目前没有明确规则,因此还没有实际落地使用。在日常研发过程,对于需要查找特定硬编码文本的场景,已经能够起到很好的辅助提效作用。
2.5 治理全景
至此,对于 Android 资源,进行了较全面有效的防腐化能力建设和治理。最后,给出一份全景图:
图注:资源治理全景
还能做些什么
Android 资源,并不会像 java 代码那样多变和复杂,前面这些治理项,已经基本覆盖绝大部分资源腐化场景,但是 Android 资源在日常研发过程中,非常容易被忽视:一个字符串、一个颜色/尺寸值、一个属性值,一个布局文件,好像每一个都“微不足道”,即使重复定义、即使忘了清理,看起来也没多大影响。而这,正是资源腐化的可怕之处:单个资源过于“微小”,开发者的专业意识稍有松懈,就成了漏网之鱼。
能够进行批量的清理,固然值得称赞,但是在日常研发的点滴间,能够时刻坚守工匠精神,降低“腐化”代码产生,更难能可贵。“千丈之堤,以蝼蚁之穴溃;百尺之室,以突隙之烟焚”(韩非子·喻老),与诸君共勉。
【参考文档】
【google】应用资源:https://developer.android.com/guide/topics/resources/providing-resources
【google】AAPT2:https://developer.android.com/studio/command-line/aapt2
关注【阿里巴巴移动技术】微信公众号,每周 3 篇移动技术实践 &干货给你思考!
版权声明: 本文为 InfoQ 作者【阿里巴巴移动技术】的原创文章。
原文链接:【http://xie.infoq.cn/article/b400a6a606f0e76d39333dd42】。文章转载请联系作者。
评论