aapt与aapt2--资源id固定和PUBLIC标记

07-14

前言

整片文章是围绕 tinkerTinkerResourceIdTask 里的知识点进行扩展的。

  1. aaptaapt2 的差异(运行环境和运行结果);
  2. 资源 id 的固定;
  3. 进行 PUBLIC 的标记;

aapt 运行环境为 gradle:2.2.0gradle-wrapper:3.4.1

aapt2 运行环境为 gradle:3.3.2gradle-wrapper:5.6.2

android-aapt-sample 项目是我自己的实验样例。有 aaptaapt2 两个分支,分别对应其实现。

AAPT概述

Android Studio 3.0 开始,google 默认开启了 aapt2 作为资源编译的编译器,aapt2 的出现,为资源的增量编译提供了支持。当然使用过程中也会遇到一些问题,我们可以通过在 gradle.properties 中配置 android.enableAapt2=false 来关闭 aapt2

资源

Android 天生为兼容各种各样不同的设备做了相当多的工作,比如屏幕大小、国际化、键盘、像素密度等等,我们能为各种各样特定的场景下使用特定的资源做兼容而不用改动一行代码,假设我们为各种各样不同的场景适配了不同的资源,如何能快速的应用上这些资源呢?Android 为我们提供了 R 这个类,指定了一个资源的索引(id),然后我们只需要告诉系统在不同的业务场景下,使用对应的资源就好了,至于具体是指定资源里面的哪一个具体文件,由系统根据开发者的配置决定。

在这种场景下,假设我们给定的 idx 值,那么当下业务需要使用这个资源的时候,手机的状态就是 y 值,有了(x,y),在一个表里面就能迅速的定位到资源文件的具体路径了。这个表就是 resources.arsc,它是从 aapt 编译出来的。

其实二进制的资源(比如图片)是不需要编译的,只不过这个“编译”的行为,是为了生成 resources.arsc 以及对 xml 文件进行二进制化等操作,resources.arsc 是上面说的表,xml 的二进制化是为了系统读取上性能更好。AssetManager 在我们调用 R 相关的 id 的时候,就会在这个表里面找到对应的文件,读取出来。

Gradle 在编译资源的过程中,就是调用的这些aapt2命令,传的参数也在这个文档里都介绍了,只不过对开发者隐藏起了调用细节。

aapt2 主要分两步,一步叫 compile,一步叫 link

创建一个空工程:只写了两个xml,分别是 AndroidManifest.xmlactivity_main.xml

Compile

1
2
mkdir compiled
aapt2 compile src/main/res/layout/activity_main.xml -o compiled/

compiled 文件夹中,生成了layout_activity_main.xml.flat 这个文件,它是 aapt2 特有的,aapt 没有(aapt拷贝的是源文件),aapt2 用它能进行增量编译。如果我们有很多的文件的话,需要依次调用 compile 才行,其实这里也可以使用 –dir 参数,只不过这个参数就没有增量编译的效果了。也就是说,当传递整个目录时,即使只有一个资源发生了变化,AAPT2也会重新编译目录中的所有文件。

link 的工作量比 compile 要多一点,此处的输入是多个flat 的文件 和 AndroidManifest.xml,外部资源,输出是只包含资源的 apkR.java。命令如下:

1
2
3
4
5
aapt2 link -o out.apk \
-I $ANDROID_HOME/platforms/android-28/android.jar \
compiled/layout_activity_main.xml.flat \
--java src/main/java \
--manifest src/main/AndroidManifest.xml
  • 第二行 -Iimport 外部资源,此处主要是 android 命名空间下定义的一些属性,我们平常使用的@android:xxx都是放在这个jar 里面,其实我们也可以提供自己的资源供别人链接;
  • 第三行是输入的 flat 文件,如果有多个,直接在后面拼接即可;
  • 第四行是 R.java 生成的目录;
  • 第五行是指定 AndroidManifest.xml;

Link完成后会生成out.apkR.javaout.apk中包含了一个resources.arsc文件。只带资源文件的可以用后缀名.ap_

查看编译后的资源

除了是用 Android Studio 去查看 resources.arsc,还可以直接使用 aapt2 dump apk 信息的方式来查看资源相关的ID 和状态:

1
aapt2 dump out.apk

输出的结果如下:

1
2
3
4
5
Binary APK
Package name=com.geminiwen.hello id=7f
type layout id=01 entryCount=1
resource 0x7f010000 layout/activity_main
() (file) res/layout/activity_main.xml type=XML

可以看到layout/activity_main 对应的 ID0x7f010000

资源共享

android.jar 只是一个编译用的桩,真正执行的时候,Android OS 提供了一个运行时的库(framework.jar)。android.jar很像一个 apk,只不过它存在的是 class 文件,然后存在一个 AndroidManifest.xmlresources.arsc。这就意味着我们也可以对它用aapt2 dump,执行如下命令:

1
aapt2 dump $ANDROID_HOME/platforms/android-28/android.jar > test.out

得到很多类似如下的输出:

1
2
3
4
5
6
7
8
resource 0x010a0000 anim/fade_in PUBLIC
() (file) res/anim/fade_in.xml type=XML
resource 0x010a0001 anim/fade_out PUBLIC
() (file) res/anim/fade_out.xml type=XML
resource 0x010a0002 anim/slide_in_left PUBLIC
() (file) res/anim/slide_in_left.xml type=XML
resource 0x010a0003 anim/slide_out_right PUBLIC
() (file) res/anim/slide_out_right.xml type=XML

它多了一些PUBLIC的字段,一个 apk 文件里面的资源,如果被加上这个标记的话,就能被其他 apk 所引用,引用方式是@包名:类型/名字,例如:@android:color/red

如果我们想要提供我们的资源,那么首先为我们的资源打上 PUBLIC 的标记,然后在 xml 中引用你的包名,比如:`@com.gemini.app:color/red就能引用到你定义的color/red` 了,如果你不指定包名,默认是自己。

至于AAPT2 如何生成 PUBLIC,感兴趣的可以接着阅读本文。

ids.xml概述

ids.xml:为应用的相关资源提供唯一的资源idid是为了获得xml中的对象需要的参数,也就是 Object = findViewById(R.id.id_name); 中的id_name

这些值可以在代码中用android.R.id引用到。
若在ids.xml中定义了ID,则在layout中可如下定义@id/price_edit,否则@+id/price_edit

优点

  1. 命名方便,我们可以把一些特定的控件先命好名,在使用的时候直接引用id即可,省去了一个命名环节。
  2. 优化编译效率:
    • 添加id后会在R.java中生成;
    • 使用ids.xml统一管理,一次性编译即可多次使用.
      但使用"@+id/btn_next"的形式,每次文件保存(Ctrl+s)后R.java都会重新检测,如果存在该id则不生成,如果不存在就需要添加该id。故编译效率降低。

ids.xml文件内容:

1
2
3
4
5
<?xml version="1.0" encoding="utf-8"?>
<resources>
<item name="forecast_list" type="id"/>
<!-- <item name="app_name" type="string" />-->
</resources>

也许有人很好奇上面有一行被注释的代码,打开注释会发现编译会报一下错误:

1
2
Execution failed for task ':app:mergeDebugResources'.
> [string/app_name] /Users/tanzx/AndroidStudioProjects/AaptDemo/app/src/main/res/values/strings.xml [string/app_name] /Users/tanzx/AndroidStudioProjects/AaptDemo/app/src/main/res/values/ids.xml: Error: Duplicate resources

因为app_name对于的资源已经在value中被声明了。

public.xml概述

官方相关的说明官网:选择要设为公开的资源

原文翻译:库中的所有资源在默认情况下均处于公开状态。如需将所有资源隐式设为私有,您必须至少将一个特定属性定义为公开。资源包括您项目的 res/ 目录中的所有文件,例如图像。为了防止库的用户访问仅供内部使用的资源,您应该通过声明一个或多个公开资源的方式来使用这种自动私有标识机制。或者,您也可以通过添加空的 <public /> 标记将所有资源设为私有,此标记不会将任何资源设为公开,而是会将一切(所有资源)都设为私有。

通过将属性隐式设为私有,您不仅可以防止库的用户从内部库资源获得代码补全建议,还可以重命名或移除私有资源,而不会破坏库的客户端。系统会从代码补全中过滤掉私有资源,并且 Lint 会在您尝试引用私有资源时发出警告。

在构建库时,Android Gradle 插件会获取公开资源定义,并将其提取到 public.txt 文件中,然后系统会将此文件打包到 AAR 文件中。

实测结果也仅仅是不回代码自动不全,编译器报红。如果进行lint检查,编译都没有警告~!

现在大部分的解释为:文件RES/value/public.xml用于将固定资源 ID 分配给 Android 资源。

stackoverfloew:What is the use of the res/values/public.xml file on Android?

public.xml文件内容:

1
2
3
4
5
6
<?xml version="1.0" encoding="utf-8"?>
<resources>
<public name="forecast_list" id="0x7f040001" type="id" />
<public name="app_name" id="0x7f070002" type="string" />
<public name="string3" id="0x7f070003" type="string" />
</resources>

资源id固定

资源id的固定在热修复和插件化中极其重要。在热修复中,构建patch时,需要保持patch包的资源id和基准包的资源id一致;在插件化中,如果插件需要引用宿主的资源,则需要将宿主的资源id进行固定,因此,资源id的固定在这两种场景下是尤为重要的。

Android Gradle Plugin 3.0.0中,默认开启了aapt2,原先aapt的资源固定方式public.xml也将失效,必须寻找一种新的资源固定的方式,而不是简单的禁用掉aapt2,因此本文来探讨一下aapt和aapt2分别如何进行资源id的固定。

aapt进行id的固定

项目环境配置(PS:吐槽一下aapt已经被aapt2代替了,aapt相关资料几乎没有,环境搭建太费劲了~!)

com.android.tools.build:gradle:2.2.0

distributionUrl=https\://services.gradle.org/distributions/gradle-3.4.1-all.zip

compileSdkVersion 24

buildToolsVersion '24.0.0'

先在value文件下按照上面的ids.xmlpublic.xml的内容以及文件名,生成对应的文件。

直接编译结果

在这里插入图片描述

通过直接编译之后的R文件的内容,可以看到我们想要的设置的资源id并没有按照我们预期的生成。

public.xml文件拷贝到build/intermediates/res/merged对应的目录

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
afterEvaluate {
for (variant in android.applicationVariants) {
def scope = variant.getVariantData().getScope()
String mergeTaskName = scope.getMergeResourcesTask().name
def mergeTask = tasks.getByName(mergeTaskName)
mergeTask.doLast {
copy {
int i=0
from(android.sourceSets.main.res.srcDirs) {
include 'values/public.xml'
rename 'public.xml', (i++ == 0? "public.xml": "public_${i}.xml")
}
into(mergeTask.outputDir)
}
}
}
}

在这里插入图片描述

这次我们可以直接看到资源id按照我们需要生成了。

这是为什么呢?

  1. android gradle插件1.3以下版本可以直接将public.xml放在源码res目录参与编译;

  2. android gradle插件1.3+版本在执行mergeResource任务时忽略了public.xml,所以merge完成后的build目录下的res目录下没有public.xml相关的内容。所以需要在编译时通过脚本将public.xml插入到merge完成后的build目录下的res目录下。之所以这样做可行,是因为aapt本身是支持public.xml的,只是gradle插件在对资源做预处(merge)时对public.xml做了过滤。

aapt2进行id的固定

aapt2编译(将资源文件编译为二进制格式)后,发现merge的资源都已经经过了预编译,产生了flat文件,这时候将public.xml文件拷贝至该目录就会产生编译错误。

但在aapt2链接阶段中,我们查看相关的链接选项

选项 说明
--emit-ids path 在给定的路径下生成一个文件,该文件包含资源类型的名称及其 ID 映射的列表。它适合与 --stable-ids 搭配使用。
--stable-ids outputfilename.ext 使用通过 --emit-ids 生成的文件,该文件包含资源类型的名称以及为其分配的 ID 的列表。此选项可以让已分配的 ID 保持稳定,即使您在链接时删除了资源或添加了新资源也是如此。

发现--emit-ids--stable-ids命令搭配可以实现id的固定。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
android {
aaptOptions {
File publicTxtFile = project.rootProject.file('public.txt')
//public文件存在,则应用,不存在则生成
if (publicTxtFile.exists()) {
project.logger.error "${publicTxtFile} exists, apply it."
//aapt2添加--stable-ids参数应用
aaptOptions.additionalParameters("--stable-ids", "${publicTxtFile}")
} else {
project.logger.error "${publicTxtFile} not exists, generate it."
//aapt2添加--emit-ids参数生成
aaptOptions.additionalParameters("--emit-ids", "${publicTxtFile}")
}
}
}
  1. 第一次编译,先通过--emit-ids在项目的根目录生成public.txt;
  2. 再将public.txt里面对于的id改为自己想要固定的id;
  3. 再次编译,通过--stable-ids和根目录下的public.txt进行资源id的固定;

--emit-ids编译结果

在这里插入图片描述

修改public.txt文件内容再次编译

在这里插入图片描述

R.txt转public.txt

我们一般正常打包生成的中间产物是build/intermediates/symbols/debug/R.txt,需要将其转化为public.txt

R.txt格式(int type name id)或者(int[] styleable name {id,id,xxxx}

public.txt格式(applicationId:type/name = id

所以在转化过程中需要过滤掉R.txt文件中的styleable类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
android {
aaptOptions {
File rFile = project.rootProject.file('R.txt')
List<String> sortedLines = new ArrayList<>()
// 一行一行读取
rFile.eachLine {line ->
//rLines.add(line)
String[] test = line.split(" ")
String type = test[1]
String name = test[2]
String idValue = test[3]
if ("styleable" != type) {
sortedLines.add("${applicationId}:${type}/${name} = ${idValue}")
}
}
Collections.sort(sortedLines)
File publicTxtFile = project.rootProject.file('public.txt')
if (!publicTxtFile.exists()) {
publicTxtFile.createNewFile()
sortedLines?.each {
publicTxtFile.append("${it}\n")
}
}
}
}

PUBLIC标记

AAPT概述这部分我们讲过如果一个 apk 文件里面的资源,如果被加上PUBLIC标记的话,就能被其他 apk 所引用,引用方式是@包名:类型/名字,例如:@android:color/red

阅读上面《aapt进行id的固定》到《aapt2进行id的固定》这两部分,我们知道aaptaapt2进行id固定的方法是不相同的。

其实如果我们用aapt2 dump build/intermediates/res/resources-debug.ap_命令查看生成资源的相关信息。

aapt通过public.xml进行id固定的资源信息有PUBLIC标记:

在这里插入图片描述

二使用上面aapt2进行id固定的方式是没有下图中的PUBLIC标记的。

原因还是aaptaapt2的差异造成的,aapt2public.txt不等于aaptpublic.xml,在aapt2中如果要添加PUBLIC标记,其实还是得另寻其他途径。

回顾思考

回顾

  1. aapt 进行资源 id 固定和 PUBLIC 标价,是将public.xml 复制到 ${mergeResourceTask.outputDir};
  2. aapt2 相比于 aapt ,做了增量编译的优化。AAPT2 会解析该文件并生成一个扩展名为 .flat 的中间二进制文件。

在这里插入图片描述

思考

能否使用aapt2自己将public.xml编译为public.arsc.flat,并像 aapt 操作一样将其复制到 ${mergeResourceTask.outputDir};

动手实践

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
android {
//将public.txt转化为public.xml,并对public.xml进行aapt2的编译将结果复制到${ergeResourceTask.outputDir}
//下面大部分代码是copy自tinker的源码
applicationVariants.all { def variant ->
def mergeResourceTask = project.tasks.findByName("merge${variant.getName().capitalize()}Resources")
if (mergeResourceTask) {
mergeResourceTask.doLast {
//目标转换文件,注意public.xml上级目录必须带values目录,否则aapt2执行时会报非法文件路径
File publicXmlFile = new File(project.buildDir, "intermediates/res/public/${variant.getDirName()}/values/public.xml")
//转换public.txt文件为publicXml文件,最后一个参数true标识固定资源id
convertPublicTxtToPublicXml(project.rootProject.file('public.txt'), publicXmlFile, false)
def variantData = variant.getMetaClass().getProperty(variant, 'variantData')
def variantScope = variantData.getScope()
def globalScope = variantScope.getGlobalScope()
def androidBuilder = globalScope.getAndroidBuilder()
def targetInfo = androidBuilder.getTargetInfo()
def mBuildToolInfo = targetInfo.getBuildTools()
Map<BuildToolInfo.PathId, String> mPaths = mBuildToolInfo.getMetaClass().getProperty(mBuildToolInfo, "mPaths") as Map<BuildToolInfo.PathId, String>
//通过aapt2 compile命令自己生成public.arsc.flat并输出到${mergeResourceTask.outputDir}
project.exec(new Action<ExecSpec>() {
@Override
void execute(ExecSpec execSpec) {
execSpec.executable "${mPaths.get(BuildToolInfo.PathId.AAPT2)}"
execSpec.args("compile")
execSpec.args("--legacy")
execSpec.args("-o")
execSpec.args("${mergeResourceTask.outputDir}")
execSpec.args("${publicXmlFile}")
}
})
}
}
}
}

public.txt文件转化为public.xml文件.

  • public.txt中存在styleable类型资源,public.xml中不存在,因此转换过程中如果遇到styleable类型,需要忽略;
  • vector矢量图资源如果存在内部资源,也需要忽略,在aapt2中,它的名字是以$开头,然后是主资源名,紧跟着__数字递增索引,这些资源外部是无法引用到的,只需要固定id,不需要添加PUBLIC标记,并且$符号在public.xml中是非法的,因此忽略它即可;
  • 由于aapt2有资源id的固定方式,因此转换过程中可直接丢掉id,简单声明即可(PS:这里通过withId参数控制是否需要固定id);
  • aapt2编译的public.xml文件的上级目录必须是values文件夹,否则编译过程会报非法路径;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
/**
* 转换publicTxt为publicXml
* copy tinker:com.tencent.tinker.build.gradle.task.TinkerResourceIdTask#convertPublicTxtToPublicXml
*/
@SuppressWarnings("GrMethodMayBeStatic")
void convertPublicTxtToPublicXml(File publicTxtFile, File publicXmlFile, boolean withId) {
if (publicTxtFile == null || publicXmlFile == null || !publicTxtFile.exists() || !publicTxtFile.isFile()) {
throw new GradleException("publicTxtFile ${publicTxtFile} is not exist or not a file")
}

GFileUtils.deleteQuietly(publicXmlFile)
GFileUtils.mkdirs(publicXmlFile.getParentFile())
GFileUtils.touch(publicXmlFile)

project.logger.info "convert publicTxtFile ${publicTxtFile} to publicXmlFile ${publicXmlFile}"

publicXmlFile.append("<!-- AUTO-GENERATED FILE. DO NOT MODIFY -->")
publicXmlFile.append("\n")
publicXmlFile.append("<resources>")
publicXmlFile.append("\n")
Pattern linePattern = Pattern.compile(".*?:(.*?)/(.*?)\\s+=\\s+(.*?)")

publicTxtFile.eachLine {def line ->
Matcher matcher = linePattern.matcher(line)
if (matcher.matches() && matcher.groupCount() == 3) {
String resType = matcher.group(1)
String resName = matcher.group(2)
if (resName.startsWith('$')) {
project.logger.info "ignore to public res ${resName} because it's a nested resource"
} else if (resType.equalsIgnoreCase("styleable")) {
project.logger.info "ignore to public res ${resName} because it's a styleable resource"
} else {
if (withId) {
publicXmlFile.append("\t<public type=\"${resType}\" name=\"${resName}\" id=\"${matcher.group(3)}\" />\n")
} else {
publicXmlFile.append("\t<public type=\"${resType}\" name=\"${resName}\" />\n")
}

}
}
}
publicXmlFile.append("</resources>")
}

以上思考和动手实践的过程,我们不仅解决了aapt2进行PUBLIC标记的问题,还找到了一种新的aapt2进行id固定的方法。

可能遇到的报错:

1
no signature of method com.android.build.gradle.internal.variant.applicationvariantdata.getscope() is applicable for argument types: () values: []

解决方法为修改gradle 版本为 gradle:3.3.2gradle-wrapper:5.6.2 ,毕竟 tinker 也不支持最新版的 gradle .

参考:

Github:tinker

android public.xml 用法

Android-Gradle笔记

aapt2 适配之资源 id 固定

文章到这里就全部讲述完啦,若有其他需要交流的可以留言哦~!~!

想阅读作者的更多文章,可以查看我的公共号:

振兴书城
坚持原创技术分享,您的支持将鼓励我继续创作!