关于 R 的一切

date
May 6, 2020
slug
everything-about-R
status
Published
tags
gradle
summary
关于 R 的一切
type
Post

R 之初体验

R 文件可能是很多 Android 开发者既熟悉又陌生的存在。它无处不在,所有使用到资源的地方都离不开它。它又有些陌生,google 已经把它封装的很完美了,以至于很多开发者并不知道它是怎么工作的。那么我们今天就来揭开它神秘的面纱。
notion image
这是一个资源的 id,用 32 位的 int 表示。格式为 PPTTNNNN。 前 8 位 PP(Package) 表示资源所属包类型,0x7f 表示应用 Apk 包资源,0x01 表示系统包资源。 中间 8 位 TT(Type) 代表资源 id 的类型
0x02:drawable 0x03:layout 0x04:values 0x05:xml 0x06:raw 0x07:color 0x08:menu
最后 16 位表示资源在此类型里面的编号。
notion image
有了 id 之后,就可以去 resource.arsc 里面去查找到真正的资源,将 id 作为 key,就可以查到此 id 对应的资源。

R 文件的存在形式

在平常的开发流程中,我们大概可以将「project」分为三种。
  • AAR
  • Module (com.android.library)
  • Application (com.android.application)
其中 module 和 aar 对于 App 来说是一样的。
为了方便演示,我们构造了以下的工程
- app
    - lib1
        - lib2
            - androidx.recyclerview:recyclerview:1.1.0
app 是 application 工程,依赖 lib1,lib1 依赖 lib2,lib2 依赖了 recyclerview。其中我们在 app、lib1、lib2 分别放置了 string 资源
- app <string name="string_from_app">string_from_app</string>
- lib1 <string name="string_from_lib1">string from lib1</string>
- lib2 <string name="string_from_lib2">string from lib2</string>

Apk

首先我们来看一下最终生成的 apk 里面的 R
notion image
我们发现会生成所有「library」和 「appliaction」 的 R 文件,并且放在不同的包名下。我们再来看一下每个包的 AndroidManifest.xml
notion image
notion image
notion image
notion image
有没有发现什么呢? 每个模块最后都是按照 AndroidManifest.xml 里面定义的 package 来决定要生成的 R 文件的包名。

AAR

我们拆开 recyclerview-1.0.0.aar
notion image
找了一圈,除了一个 R.txt,并没有在其它地方找到 R 相关的踪迹,classes.jar 里面也没有生成的 R.class
有同学就可能有疑问了,既然 classes.jar 里面没有 R.class,那么在开发的时候,我们是怎么引用到 aar 里面的资源的呢?
首先我们明确一点,所有的 R 都是在生成 apk 的时候由 aapt 完成。为什么要这样做呢?试想如果 R 文件在 aar 打包阶段就已经生成了的话,那么很大概率会导致 id 之间的冲突。比如 recyclerview.aar 用了 0x7f020001,appcompat.aar 也有可能用了 0x7f020001 这个 id,在合并的时候,resource.arsc 只能将这个 id 映射到一个资源,这样就会出现错乱。 所以 AGP 做了一件事,所有 R 文件的生成都在 apk 生成的时候交与 aapt 完成。在开发期间对于 R 文件的引用都给一个临时的 classpath: R.java,这里面包含了编译时期所需要的 R 文件,这样编译就不会出错。并且在运行时会扔掉这些临时的 R.java,真正的 R.java 是 aapt 去生成的。
所以我们总结一下:
  • module/aar 里面临时生成的 R.java 只是为了「make compiler happy」,在编译流程中扮演着「compileOnly」的角色。
  • 在生成 apk 的时候,aapt 会根据 app 里面的资源,生成真正的 R.java 到 apk 中,运行的时候代码就会获取到 aapt 生成的 id。
这里有一个问题,我们仔细观察一下 app 和 module 里面对 R.id 引用出的地方。
notion image
App 的代码
notion image
App 生成的字节码
notion image

Module 的代码
notion image
Module 生成的字节码。
我们发现,在 App 里面的代码发生了「内联」,但是在 module 里面的代码并没有被内联,而是通过运行时查找变量的方式去获取。 结合上面的「R生成过程」,来想一下为什么 module 里面的 id 不被内联而 app 里面的 id 会被内联呢? 答案已经很清楚了。module/aar 在编译的时候,AGP 会为它们提供一个临时的 R.java 来帮助他们编译通过,我们知道,如果一个常量被标记成 static final,那么 java 编译器会在编译的时候将他们内联到代码里,来减少一次变量的内存寻址。AGP 为 module/aar 提供的 R.java 里面的 R.id 不是 final 的,因为如果设计成了 final,R.id 就会被内联到代码中去,那在运行的时候,module 里面的代码就不会去寻找 aapt 生成的 R.id,而是使用在编译时期 AGP 为它提供的假的 R.id,这个 id 肯定是不能用的,不然就会导致 resource not found。

R 文件的生成

在编译的中间产物中,R 大概有这么几种存在形式
「此 project」代表当前在编译的 module 「本地资源」代表当前在编译的 module 里面声明的资源
  • R.java(java 文件,给此 projet 做 compileOnly 用)
  • R.txt(记录了此 project 的所有资源列表,并生成了 id,最后会根据这个值生成对应的 R.java)
  • R-def.txt(记录了此 project 的本地资源,不包括依赖)
  • package-aware-r.txt(记录了此 project 的所有资源,没有生成 id)
大概的生成逻辑是
notion image
这是一个 module 生成 R.java 的过程。 首先,当前 module 会搜集所有它的依赖,并且拿到它的 R.txt。比如 lib1 依赖 lib2,lib2 依赖 recyclerview-1.0.0.aar,那么 lib1 会拿到这俩个 R.txt。其中
  • lib2 的 R.txt 是经历了上图的过程已经生成好的了
  • AAR 的 R.txt 是 AGP 在 transform 的时候从 aar 里面解压出来的
这样这个 module 就拿到了所有的依赖的资源。然后 AGP 会独处当前 module 的「本地资源」,结合刚刚拿到的所有依赖的 R.txt,生成 package-aware-r.txt. 它的格式是这样的
com.example.lib1
layout activity_in_lib2
string string_from_lib1
string string_from_lib2
第一行表示了它的 package name,是从 AndroidManifest.xml 里面取的,下面的几行表示这个 module 中所有的资源,包括自己的和依赖的别人的。 然后 AGP 就会根据 package-aware-r.txt 生成 R.txt,那 R.txt 里面的内容和 package-aware-r.txt 有什么不同呢?
int layout activity_in_lib2 0x7f0e0001
int string string_from_lib1 0x7f140001
int string string_from_lib2 0x7f140002
我们可以看到 R.txt 已经很接近我们的 R.java 的内容了。在从 package-aware-r.txt 拿到所有的资源后,AGP 为资源分配了「临时的 id」。
notion image
具体的分配逻辑如上,可以看到它维护了一个 "map",每个资源的 id 都是从 0x7fxx0000 开始递增的。当然这里的分配逻辑没什么用,完全可以乱分配,反正最后也用不着。
最后一步就是通过 R.txt 生成 R.java 啦,AGP 会根据 R.txt 里面的资源及其 id 生成最后的 R.java,作为 classpath 供编译时使用。 这样一通操作下来,「此 project」也生成了 R.txt,当它的上层依赖在编译的时候,就可以拿到它的 R.txt 作为依赖,生成自己的 R.txt,重复上面的步骤。
另: 其实在 AGP 最新版本已经没有 R.java 了,取而代之的是 R.jar,R.jar 会把所有生成的 R.java 打成一个 jar 包,作为 classpath 来编译。可是这样做有什么好处呢?
看看 Jake 大神的说法:
notion image
如果你的工程是纯 kotlin 工程,那 AGP 就不用启动一个 javac 去编译 R.java,这样会大幅提升编译的速度。(可见 Google 也在很努力的优化 AGP 了,高版本的 AGP 往往能带来很多性能上的优化。

AAPT 生成 R

终于来到了激动人心的时刻了,前面 AGP 生成了这么多 R.java 最后都要被丢掉,统统交给 aapt 去生成我们运行时需要的 R.java 了。 AGP 的高版本已经默认开启 aapt2 了,这里我们就直接看 aapt2 相关的代码。 首先 aapt2 其实是 Android SDK 里面的一个命令,用 c++ 编写。你可以运行一下 aapt2,看它的 readme。你也可以在 aapt2中找到它的说明。
$ANDROID_HOME/build-tools/29.0.2/aapt2

AGP

AGP 是通过 AaptV2CommandBuilder 来生成 aapt 的具体命令。 在 aapt2 中,为了实现增量编译,aapt2 将原来的编译拆成了 compile 和 link。aapt2 先将资源文件编译成 .flat 的中间产物,然后通过 link 将 .flat 中间产物编译成最终的产物。 AGP 对于 Module 模块调用的 link 命令如下:
notion image
传入了 android.jar、AndroidManifest.xml、merge & compile 后的资源产物等等。 android.jar 是提供给代码去链接 Android 自身的资源,比如你使用了 @android:color/red 这个资源,就会从 android.jar 中去取。 --non-final-ids 表示不要为 module 生成的 R.java 使用 final 字段,这个我们上面讨论过了。 对应的,application 生成的 aapt link 命令是这样的
notion image
为 Application 生成的命令中就没有 --non-final-ids。还传入了一个 --java的参数,表示生成的 R.java 的存放路径,这里就是我们的 R 的最终存放路径了。

aapt 生成 R

调用 aapt2 命令之后就要开始执行 link 了,这里的代码比较多,就不一一啰嗦了。 我们抽一个 id 生成的逻辑来讲。
notion image
通过注释我们可以大概了解到:正常的话,id 是从 0 开始生成,每用一个会往后 +1。比如 string 是从 0x7f030000 开始,下一个 string 就是 0x7f030001。
如果你看过 aapt2 的命令,还会发现 aapt2 有个有意思的功能:「固定 id」
notion image
--emit-ids 会输出一个 resource name -> id 的映射表。 --stable-ids 可以输入一个 resource name -> 映射表,来决定这次生成的映射关系。
notion image
当有 --stable-ids 的输入时,aapt link 会解析这个文件,将映射表提前存入 stable_id_map 中。
notion image
在构造 IdAssigner 的时候,将这个 map 传进去,IdAssigner 在遇到在 map 中存在的 resource 时,就会直接分配 map 表里面存的 id。其它的 resource 在分配的时候将会 "Fill the Gap",找到空缺的 id 分配给它。
  • -stable-ids 在「热修复」、「插件化」中有很大的用处,我们知道,如果新增了一个资源,按照原来的分配逻辑,是会在原来的 id 里面插入一个新的 id 的。比如原来是
int string string_from_lib1 0x7f140001
int string string_from_lib2 0x7f140002
int string string_from_lib3 0x7f140003
这个时候,如果不固定 id,在 lib1 和 lib2 中间插入一个 lib4,它将会变成如下的样子
int string string_from_lib1 0x7f140001
int string string_from_lib4 0x7f140002
int string string_from_lib2 0x7f140003
int string string_from_lib3 0x7f140004
这就导致原来的 lib2 和 lib3 都发生了变动。 但是如果我们固定了 id,那生成的 id 可能就是以下这样
int string string_from_lib1 0x7f140001
int string string_from_lib4 0x7f140004
int string string_from_lib2 0x7f140002
int string string_from_lib3 0x7f140003

R 文件相关的优化

其实在细品了 R 文件生成的流程之后,我们发现其实在很多方向上 R 文件有优化的空间。
比如,我们可以在编译完成之后将 module 里面对于 R 的引用换成「内联」的,这样就可以少了一次内存寻址,也可以删掉被内联后的 R.class,减少了包体积又做了性能优化。
比如,我们可以在编译的时候通过固定 id 来减少增删改资源带来的大量 id 变动,导致 R.java 被“连根拔起”,带来下游依赖它的 java/kotlin 文件重新编译。
这里就不再一一展开叙述。

总结

题目起的有点大,其实关于 R 这还只是冰山一角。但是光光看完这一部分代码后,就有点醍醐灌顶的感觉。 R 的设计很精妙,开发者完全不用关心怎么去粘合代码和资源,代码里只用 R.xxxx 一把梭,就可以很轻易地访问到想要的资源。当然这背后需要 IDE、agp、aapt 的配合。 如有错误或者不懂的地方,欢迎交流。

© Guang Feng 2022 - 2024