aapt2资源链接链
一份 res/values/strings.xml 和一份 res/layout/main.xml,为什么不能直接复制进 APK?关键不只是“XML 要转成二进制”,而是这些文件中的名字、配置限定符和引用必须先进入同一张资源表,再获得稳定且唯一的 ID,最后才有资格成为 resources.arsc、二进制 XML、R.java 和 R.txt。aapt2 compile 与 aapt2 link 正是在这个边界上分工。
本文面向已经读过 lunch目标与编译变体、模块化编译 和 APK签名工具链 的读者。需要知道 Android 资源目录、APK/ZIP 和基本构建依赖;不要求预先理解 protobuf、resources.arsc 二进制字段或运行时 AssetManager。本文只追踪 Android 17 固定源码中的编译和链接链,不展开 Gradle Plugin 调度、R8 资源 shrink、运行时资源选择算法或 RRO 安装激活。
读完后,读者应能从一个 res/ 路径定位它进入哪一种 compile 分支,解释 .flat 内保存的是资源表还是文件载荷,沿 TableMerger → IdAssigner → ReferenceLinker → TableFlattener 复述 link 主线,并能根据错误判断问题属于路径、合并、ID、引用还是写出阶段。
1. 两阶段边界
frameworks/base/tools/aapt2/Main.cpp 把 compile、link、dump、optimize 和 convert 注册为并列子命令。它们共享诊断和文件格式代码,却不是一个隐藏的单阶段编译器。
源码文件:frameworks/base/tools/aapt2/Main.cpp
源码文件:platform/frameworks/base/tools/aapt2/Main.cpp
MainCommand main_command(&printer, &diagnostics);
main_command.AddOptionalSubcommand(util::make_unique<CompileCommand>(&diagnostics));
main_command.AddOptionalSubcommand(util::make_unique<LinkCommand>(&diagnostics));
main_command.AddOptionalSubcommand(util::make_unique<DumpCommand>(&printer, &diagnostics));
main_command.AddOptionalSubcommand(util::make_unique<OptimizeCommand>());
main_command.AddOptionalSubcommand(util::make_unique<ConvertCommand>());compile 的 owner 是一次 CompileCommand:它理解输入路径、解析单个 values/XML/PNG,并为每个输入写中间容器。此时 CompileContext 的 package name 为空,package ID 为 0,也不提供 NameMangler 和 external symbols。换言之,compile 可以知道“这是 layout/main”,但不知道它最终属于哪个应用包,也不能为它承诺 0x7f... ID。
link 的 owner 是一次 LinkCommand:它先从 manifest 确定 compilation package,加载依赖符号,把普通输入和 overlay 合入 final_table_,然后分配 ID、闭合引用并写 APK。最终的 R 类、文本符号和 keep rules 都在这个阶段生成。
这张图的生效屏障是 link。单个 .flat 写出只证明该文件可以被解析成中间表示;只有 link 完成后,资源名字、ID、引用和输出包才形成一致闭包。APK 后续还要经过签名,aapt2 写出的 package-res.apk 不是签名身份的 owner。
| 阶段 | 已知状态 | 未知状态 | 直接消费者 |
|---|---|---|---|
| compile | 路径类型、配置、资源名、文件内容 | 最终 package、最终 ID、全部外部符号 | link 或中间产物检查工具 |
| link | manifest package、依赖表、overlay、稳定 ID | 运行时设备配置和安装策略 | APK 打包、Java 编译、代码 shrinker |
| runtime | 已安装资源表和设备配置 | 构建期中间容器 | AssetManager/Resources |
2. 路径分派
2.1 路径解析
compile 首先把路径当成资源语义,而不是普通文件名。ExtractResourcePathData() 取倒数第二段作为 type[-config],取最后一段作为资源名和扩展名;.9.png 必须整体识别,不能按最后一个点拆成普通 PNG。配置串通过 ConfigDescription::Parse() 校验,非法限定符在读取文件内容前就失败。
源码文件:frameworks/base/tools/aapt2/cmd/Compile.cpp
相关函数/类型:ExtractResourcePathData
// tools/aapt2/cmd/Compile.cpp
size_t dash_pos = dir.find('-');
if (dash_pos != std::string::npos) {
config_str = dir_str.substr(dash_pos + 1, dir.size() - (dash_pos + 1));
if (!ConfigDescription::Parse(config_str, &config)) {
*out_error = "invalid configuration '" + std::string(config_str) + "'";
return {};
}
dir_str = dir_str.substr(0, dash_pos);
}
const std::string kNinePng = ".9.png";
if (filename.size() > kNinePng.size() &&
std::equal(kNinePng.rbegin(), kNinePng.rend(), filename.rbegin())) {
name = name.substr(0, filename.size() - kNinePng.size());
extension = "9.png";
}中间文件名也由这份结构化结果生成:资源目录、原始 config、名字、feature flag 和扩展名依次编码,最后追加 .flat。values XML 会在进入生成函数前把扩展名改成 arsc,因此典型输出是 values_strings.arsc.flat,而布局 XML 是 layout_main.xml.flat。这不是随意命名约定;Soong 必须复刻同一算法,才能提前声明 Ninja outputs。
源码文件:frameworks/base/tools/aapt2/cmd/Compile.cpp
static std::string BuildIntermediateContainerFilename(const ResourcePathData& data) {
std::stringstream name;
name << data.resource_dir;
if (!data.config_str.empty()) name << "-" << data.config_str;
name << "_" << data.name;
if (!data.flag_name.empty()) name << ".(" << data.flag_name << ")";
if (!data.extension.empty()) name << "." << data.extension;
name << ".flat";
return name.str();
}2.2 类型选择
路径解析完成后,Compile() 根据资源目录和扩展名选择函数。values XML 进入 CompileTable;除 raw 外的 XML 进入 CompileXml;PNG 在未关闭 crunch 时进入 CompilePng,.9.png 无条件进入 PNG 分支;其他文件走 CompileFile。因此“所有 XML 都生成同一种 flat”是错误模型。
源码文件:frameworks/base/tools/aapt2/cmd/Compile.cpp
if (path_data.resource_dir == "values" && path_data.extension == "xml") {
compile_func = &CompileTable;
path_data.extension = "arsc";
} else if (const ResourceType* type = ParseResourceType(path_data.resource_dir)) {
if (*type != ResourceType::kRaw) {
if (*type == ResourceType::kXml || path_data.extension == "xml") {
compile_func = &CompileXml;
} else if ((!options.no_png_crunch && path_data.extension == "png") ||
path_data.extension == "9.png") {
compile_func = &CompilePng;
}
}
}CompileCommand::Action() 还控制输入集合:--dir 与 --zip 互斥,普通参数先排序,目录、ZIP 和单文件分别包装为不同 IFileCollection。单个文件编译失败时,循环会继续处理剩余输入并记录 error = true;命令结尾只要出现过错误就返回非零。这解释了为何一次分片 action 可能留下部分 .flat,但 Ninja 仍把整个 action 判为失败。
3. flat容器
.flat 不是旧稿中可以凭经验画出的“固定头 + 字符串池 + data block”。Android 17 的真实定义在 format/Container.cpp:文件头是 magic、version 和 entry count;每个 entry 再携带 type 与长度,并按 4 字节对齐。entry 只有两类:资源表 kResTable 和资源文件 kResFile。
源码文件:frameworks/base/tools/aapt2/format/Container.cpp
constexpr const static uint32_t kContainerFormatMagic = 0x54504141u;
constexpr const static uint32_t kContainerFormatVersion = 1u;
constexpr const static size_t kPaddingAlignment = 4u;
ContainerWriter::ContainerWriter(ZeroCopyOutputStream* out, size_t entry_count) {
CodedOutputStream coded_out(out);
coded_out.WriteLittleEndian32(kContainerFormatMagic);
coded_out.WriteLittleEndian32(kContainerFormatVersion);
coded_out.WriteLittleEndian32(static_cast<uint32_t>(entry_count));
}3.1 values表
CompileTable() 用 ResourceParser 把一个 values XML 解析进局部 ResourceTable,再调用 SerializeTableToPb() 生成 pb::ResourceTable,最后以一个 kResTable entry 写入容器。这个局部表可以含多个 string、style、attr 和配置值,但仍没有最终应用 ID。
源码文件:frameworks/base/tools/aapt2/cmd/Compile.cpp
ResourceTable table;
ResourceParser res_parser(context->GetDiagnostics(), &table, path_data.source,
path_data.config, parser_options);
if (!res_parser.Parse(&xml_parser)) {
return false;
}
ContainerWriter container_writer(©ing_adaptor, 1u);
pb::ResourceTable pb_table;
SerializeTableToPb(table, &pb_table, context->GetDiagnostics());
if (!container_writer.AddResTableEntry(pb_table)) {
return false;
}3.2 文件载荷
布局 XML、普通文件和 PNG 使用 kResFile。entry 先写 protobuf CompiledFile header,再写真实 payload。XML payload 是序列化后的 pb::XmlNode;普通文件 payload 是原始内容;PNG payload 是过滤或重新编码后的图像。header 保存资源名、config、source、类型和 exported symbols,link 据此把文件引用挂入资源表。
源码文件:frameworks/base/tools/aapt2/format/Container.cpp
bool ContainerWriter::AddResFileEntry(const pb::internal::CompiledFile& file,
KnownSizeInputStream* in) {
coded_out.WriteLittleEndian32(kResFile);
coded_out.WriteLittleEndian64(entry_size);
coded_out.WriteLittleEndian32(header_size);
coded_out.WriteLittleEndian64(data_size);
file.SerializeToCodedStream(&coded_out);
WritePadding(header_padding, &coded_out);
io::Copy(out_, in);
WritePadding(data_padding, &coded_out);
return !coded_out.HadError();
}NinePatch 是一个特殊失败边界。CompilePng() 必须解析 1 像素边框并写入 chunk;普通 PNG 若 crunch 后更大,可以保留过滤后的原图,但 .9.png 不能这样退回,因为边框语义必须被转换。读图、NinePatch 创建、写图或输出容器任何一步失败,当前输入返回 false。
4. 合并语义
link 从 manifest 取得 compilation package 后创建 TableMerger,普通 input 用 override=false,-R overlay 用 override=true。.flat 由 ContainerReader 逐 entry 读取:kResTable 反序列化后合表,kResFile 恢复 CompiledFile 并调用 MergeFile()。原始 .xml 或 .png 被直接传给 link 时会明确报“必须先编译为 .flat”。
4.1 普通输入
普通输入可以向最终表新增资源,但同一 package/type/entry/config 的两个普通值不能悄悄覆盖。ResourceTable 的所有权层级决定冲突粒度:package 拥有 types,type 拥有 entries,entry 拥有按 configuration/product 区分的 values。
源码文件:frameworks/base/tools/aapt2/ResourceTable.h
相关函数/类型:ResourceTablePackage
// tools/aapt2/ResourceTable.h
class ResourceTablePackage {
public:
std::string name;
std::vector<std::unique_ptr<ResourceTableType>> types;
};
class ResourceTableType {
public:
const ResourceNamedType named_type;
std::vector<std::unique_ptr<ResourceEntry>> entries;
};
class ResourceEntry {
public:
std::string name;
std::optional<ResourceId> id;
Visibility visibility;
std::vector<std::unique_ptr<ResourceConfigValue>> values;
};“同名”必须说清层级。string/title 与 layout/title 不冲突,因为 type 不同;values-zh/string/title 与默认 string/title 共存,因为 config 不同;同一 config 下两个普通定义才进入冲突判断。最终 owner 是 LinkCommand::final_table_,不是任一输入 .flat。
4.2 overlay
overlay 允许替换已有资源的同 config 值,但默认不允许创造一个基表不存在的新名字。允许新增有两个入口:资源声明带 <add-resource> 对应的 allow_new,或 link 开启 --auto-add-overlay。源码把这个条件直接传给合并实现:
源码文件:frameworks/base/tools/aapt2/link/TableMerger.cpp
return MergeImpl(src, table, overlay,
options_.auto_add_overlay || !overlay);这里 !overlay 表示普通输入天然允许新增;overlay 则必须额外获准。即使允许覆盖,visibility、public ID、staged ID 和 overlayable actor/policy 仍需一致。styleable 总是合并成员;style 默认合并 item,只有专门的 override-style 选项才整体替换。这些差异说明 overlay 不是“后来的文件覆盖前面的 ZIP entry”。
4.3 静态库改名
静态资源库不是简单并表。默认路径保留 library package,并由 MergeAndMangle() 把冲突名字改写到 compilation package 的命名空间;兼容选项 --no-static-lib-packages 才会清空库 package,把它当成本地资源,并把原 package 加入额外 R 类集合。
源码文件:frameworks/base/tools/aapt2/cmd/Link.cpp
if (options_.no_static_lib_packages) {
options_.extra_java_packages.insert(pkg->name);
pkg->name = "";
result = table_merger_->Merge(source, table, override);
} else {
result = table_merger_->MergeAndMangle(source, pkg->name, table);
}因此看到 com.lib$entry 一类 mangled name 时,应回查静态库 package 和 NameManglerPolicy,而不是把 $ 当作非法资源字符。
5. ID分配
5.1 三段编号
资源 ID 的三段可写为 0xPPTTEEEE:PP 是 package,TT 是 type,EEEE 是 entry。IdAssigner 的输入已经是合并后的表和 link context,所以它能使用确定的 package ID。算法先遍历所有 entry 预留已有 ID、staged ID 和 stable ID,再第二次遍历为无 ID 资源取下一个空位。
源码文件:frameworks/base/tools/aapt2/link/ReferenceLinker.cpp
bool IdAssigner::Consume(IAaptContext* context, ResourceTable* table) {
IdAssignerContext assigned_ids(context->GetCompilationPackage(),
context->GetPackageId());
for (auto& package : table->packages) {
for (auto& type : package->types) {
for (auto& entry : type->entries) {
ResourceName name(package->name, type->named_type, entry->name);
if (entry->id && !assigned_ids.ReserveId(name, *entry->id,
entry->visibility,
context->GetDiagnostics())) {
return false;
}
}
}
}
// The implementation then reserves stable-map entries that are absent from
// the current table, and performs a second pass for entries without IDs.
}上面省略的两段在同一函数中紧接着发生:stable map 中暂时不存在的资源也要预留,随后才为无 ID entry 分配空位。这里用文字保留控制流,避免把省略代码误读成可直接编译的完整函数。
NextIdFinder 使用按 ID 排序的预留表,分配时跳过已占用位置,因此会填补可用间隙,而不是永远在最大 ID 后追加。一个 type 的 entry ID 是 16 位,耗尽 65536 个位置就失败;不同资源复用同一 entry ID、不同 type 复用同一 type ID、package 段不一致也都会诊断失败。
5.2 稳定ID
--stable-ids 的意义不是“把所有 ID 固定写死”,而是把给定名字到 ID 的映射加入预留集合。当前表中存在的名字直接取得指定 ID;映射中已经删除的旧名字仍占住槽位,防止新资源复用它。--emit-ids 则在分配完成后遍历 final_table_ 写出本次映射。
这两个选项形成恢复闭环:先用上一版映射约束本次 link,再输出新映射供下一版使用。若映射把两个名字指向同一 ID,或指定了错误 package/type,构建应失败,不能通过重新排序资源规避。
6. 引用闭合
ID 分配后,link 把 final_table_ 放到 external symbol source 的最前面,然后运行 ReferenceLinker。它负责把表中 @string/name、?attr/name 等引用解析为本地或依赖符号,并验证类型、visibility 和缺失资源。默认值缺失的资源还会先被 NoDefaultResourceRemover 移除,使后续引用直接暴露为失败。
源码文件:frameworks/base/tools/aapt2/cmd/Link.cpp
context_->GetExternalSymbols()->PrependSource(
util::make_unique<ResourceTableSymbolSource>(&final_table_));
if (!options_.no_resource_removal &&
!NoDefaultResourceRemover{}.Consume(context_, &final_table_)) {
return 1;
}
ReferenceLinker linker;
if (!options_.merge_only && !linker.Consume(context_, &final_table_)) {
context_->GetDiagnostics()->Error(
android::DiagMessage() << "failed linking references");
return 1;
}文件 XML 与 manifest 走 XmlReferenceLinker。资源表引用必须先闭合,因为 XML 中的属性和资源引用最终要写入已分配 ID。manifest 还有额外顺序:先由 ManifestFixer 修改并验证,再在最终表上链接,然后才写进 APK。merge_only 是条件边界,它允许跳过常规引用链接,不能用该模式的成功推断普通应用 link 一定成功。
顺序不能任意交换:没有合并后的表就无法发现冲突,没有 package/type/entry ID 就无法把 XML 引用写成最终值,没有闭合引用就不应序列化 APK。
7. 产物写出
7.1 资源表
WriteApk() 先用 ResourceFileFlattener 写文件资源,再用 FlattenTable() 写资源表。binary 输出使用 TableFlattener 生成 resources.arsc;proto 输出使用 protobuf serializer,服务于需要 proto resource table/XML 的消费者。两种格式是 link 输出选择,不是 compile 阶段 .flat 的同义词。
文件资源 flatten 失败会报告 failed linking file resources,资源表 flatten 失败会报告 failed to write resource table。archive writer 创建失败、manifest 写入失败、输出流 flush 失败都会使命令返回非零。写出是链尾消费者,也是前面所有状态真正对 APK 生效的时机。
7.2 R与keep规则
GenerateJavaClasses() 从已经分配 ID 的 final_table_ 生成 R 源码;--output-text-symbols 通过同一表生成 R.txt;--emit-ids 输出名字到 ID 的稳定映射。它们是同一资源表的不同投影,若彼此不一致应先查 link 输入和 package 选项。
--proguard 的输出尤其容易被误解。ProguardRules.cpp 遍历 manifest、layout、menu、navigation 等 XML,收集由标签或属性字符串引用的 class/method,再写出 -keep、-if 等规则:
源码文件:frameworks/base/tools/aapt2/java/ProguardRules.cpp
相关函数/类型:WriteKeepSet
void WriteKeepSet(const KeepSet& keep_set, OutputStream* out,
bool minimal_keep, bool no_location_reference) {
Printer printer(out);
for (const auto& entry : keep_set.manifest_class_set_) {
printer.Print("-keep class ").Print(entry.first)
.Println(" { <init>(); }");
}
for (const auto& entry : keep_set.conditional_class_set_) {
printer.Print("-keep class ").Print(entry.first.name)
.Print(" { <init>(");
printer.Print(minimal_keep ? entry.first.signature : "...");
printer.Println("); }");
}
}aapt2 到这里就结束了。它生成供代码 shrinker 消费的规则,不执行 R8,也不能据此声称未引用资源已经从 APK 删除。资源 shrink 的 reachability、资源重写和最终删除属于后续构建阶段,必须另用对应源码和产物实验验证。
8. Soong接入
8.1 编译分片
build/soong/java/aapt2.go 把资源路径按 100 个一组分片。原因不是 aapt2 需要 100 个文件才能运行,而是 Ninja action 启动有固定成本;一文件一 action 会让 framework 大资源目录付出大量调度开销。代价是分片内任一文件改变时,该 shard 会整体重编译。
源码文件:build/soong/java/aapt2.go
const AAPT2_SHARD_SIZE = 100
var aapt2CompileRule = pctx.AndroidStaticRule("aapt2Compile",
blueprint.RuleParams{
Command: `${config.Aapt2Cmd} compile -o $outDir $cFlags $in`,
}, "outDir", "cFlags")
shards := android.ShardPaths(paths, AAPT2_SHARD_SIZE)
for _, shard := range shards {
outPaths := pathsToAapt2Paths(ctx, shard)
ctx.Build(pctx, android.BuildParams{
Rule: aapt2CompileRule,
Inputs: shard,
Outputs: outPaths,
})
}aapt2 CLI 只接收输出目录,不返回每个输出路径给 Soong,所以 pathToAapt2Path() 必须预测文件名。这正是 compile 命名规则不能随便修改的消费者证据。Soong 最后排序返回 outputs,使后续构建图稳定。
8.2 链接输入
普通 compiled resources 被写进 aapt2/res.list,以 @list 传给 link;compiled overlays 写进 aapt2/overlay.list,通过 -R @list 传入。列表文件本身和其中每个 .flat 都加入依赖,确保任何输入变化会重跑 link。
源码文件:build/soong/java/aapt2.go
相关函数/类型:aapt2Link
if len(compiledRes) > 0 {
resFileList := android.PathForModuleOut(ctx, "aapt2", "res.list")
// 生成列表规则后:
deps = append(deps, compiledRes...)
deps = append(deps, resFileList)
inFlags = append(inFlags, "@"+resFileList.String())
}
if len(compiledOverlay) > 0 {
overlayFileList := android.PathForModuleOut(ctx, "aapt2", "overlay.list")
deps = append(deps, compiledOverlay...)
deps = append(deps, overlayFileList)
inFlags = append(inFlags, "-R", "@"+overlayFileList.String())
}link rule 固定请求 package-res.apk、Proguard options 和 R.txt。需要 Java 源时,Soong 先删除临时生成目录,运行 aapt2 link --java,再用 soong_zip 打成 jar,成功后删除临时目录。这里的清理 owner 是 Soong rule,不是 aapt2。
源码文件:build/soong/java/aapt2.go
相关函数/类型:aapt2LinkAndGenRule
Command2: blueprint.NewCommand(
android.Rm, ` -rf $aapt2GenDir && `,
`${config.Aapt2Cmd} link -o $out $flags --proguard $proguardOptions `,
`--output-text-symbols ${rTxt} $inFlags --java $aapt2GenDir && `,
android.SoongZip, ` -write_if_changed -jar -o $aapt2GenJar `,
`-C $aapt2GenDir -D $aapt2GenDir && `,
android.Rm, ` -rf $aapt2GenDir`,
)中间任何命令失败,&& 会阻止后续步骤。若 aapt2 失败,末尾清理不会执行,但下一次 action 开头会删除旧生成目录后恢复;若打 jar 失败,也由下一次 action 的首个 rm -rf 清理。这是构建级恢复,不是事务回滚。
9. 故障边界
9.1 失败路径
| 现象 | 失败 owner | 首查源码 | 不能直接推断 |
|---|---|---|---|
invalid configuration | compile 路径解析 | ExtractResourcePathData | 文件 XML 内容有错 |
uncompiled XML file | link 输入读取 | MergeFile | XML parser 不支持该标签 |
| overlay 不能新增 | TableMerger | MergeImpl、allow_new | overlay 顺序一定错误 |
failed assigning IDs | IdAssigner | stable/public/staged ID 预留 | R.java 生成器有错 |
failed linking references | ReferenceLinker | final table 与依赖符号 | .flat 容器损坏 |
failed to write resource table | flattener/writer | output format、archive writer | 前面合并一定成功且正确 |
compile 会聚合一批输入错误,link 多数阶段在关键不变量失败后立即返回 1。两者都没有“跳过错误资源继续生成可安装 APK”的降级承诺。--merge-only、legacy mode、auto-add overlay 等选项会改变检查边界,诊断时必须先固定完整命令行。
9.2 中断恢复
aapt2 是单次命令,没有持久 session、取消回调或事务日志。进程被中断时,已经写出的 .flat 或部分 archive 文件不会由工具统一回滚。Ninja/Soong 以声明的 outputs 和 action 退出码判断该步骤是否完成;恢复方式是修正输入后重跑失败 action,而不是手工把某个 .flat 标成成功。
Soong 的 Java 临时目录具有显式“运行前清理、成功后清理”;普通 .flat 分片和 package-res.apk 则依赖构建系统重新执行。对手工命令,最稳妥的恢复是删除本次输出目录或目标 APK 后重新运行,避免把上一次成功产物与本次失败残留混合。
10. 边界实验
10.1 ID测试
compile/IdAssigner_test.cpp::AssignIdsWithReservedIds 构造一张包含预留 type/entry ID 的表:id/foo 固定为 0x01010000,两个 attr 固定在 0x01040000 和 0x01040006。action 是运行 IdAssigner::Consume();断言是新 type 填入 0x0102...、0x0103...、0x0105...,同一 attr type 的新 entry 填入 0x01040001 和 0x01040002。
源码文件:frameworks/base/tools/aapt2/compile/IdAssigner_test.cpp
相关函数/类型:AssignIdsWithReservedIds
EXPECT_EQ(0x01020000, Find("android:dimen/two")->id);
EXPECT_EQ(0x01030000, Find("android:integer/three")->id);
EXPECT_EQ(0x01050000, Find("android:string/five")->id);
EXPECT_EQ(0x01040001, Find("android:attr/bar")->id);
EXPECT_EQ(0x01040002, Find("android:attr/baz")->id);它证明算法会跳过预留位并填补间隙。它不证明不同构建输入顺序会自动保持相同 ID;跨版本稳定性仍依赖 stable/public ID 输入。相邻测试还断言重复 entry ID、重复 type ID 和 entry 数耗尽必须失败。
10.2 overlay测试
link/TableMerger_test.cpp::FailToMergeNewResourceWithoutAutoAddOverlay 的 arrange 是空基表和只含 bool/foo=true 的 overlay,auto_add_overlay=false;action 是先合基表,再以 overlay=true 合第二张表;assert 是第二次 Merge() 返回 false。
相对测试 OverrideResourceWithOverlay 先定义 bool/foo=true,overlay 定义同名 false,断言合并成功且最终二进制值为 0。两者合起来证明“覆盖已有资源”和“新增资源”是不同分支。它们没有证明设备运行时 RRO policy,因为这里测试的是构建期 TableMerger。
10.3 构建图验证
在固定的 Android 17 checkout 中,可以只读验证源码入口:
git -C frameworks/base show android-17.0.0_r1:tools/aapt2/cmd/Compile.cpp
git -C frameworks/base show android-17.0.0_r1:tools/aapt2/cmd/Link.cpp
git -C frameworks/base show android-17.0.0_r1:tools/aapt2/compile/IdAssigner.cpp
git -C frameworks/base show android-17.0.0_r1:tools/aapt2/format/Container.cpp
git -C build/soong show android-17.0.0_r1:java/aapt2.go有完整 AOSP 构建输出时,再检查某模块的中间目录:
find out/soong/.intermediates -path '*/aapt2/res.list' -o \
-path '*/aapt2/overlay.list' -o -name 'package-res.apk' | head
unzip -l out/soong/.intermediates/<module>/<variant>/package-res.apk
aapt2 dump resources out/soong/.intermediates/<module>/<variant>/package-res.apk输入必须固定到一个模块和 variant。应断言 res.list/overlay.list 中的 .flat 与 Ninja action 依赖一致,APK 中存在资源表,并用 dump 核对名字、ID 和 config。该实验能证明实际产物,不证明未选中的产品 overlay、split APK 或 Gradle 工程走同一组 flags。
11. 边界收束
遇到资源问题时,先按阶段归属,而不是从 R.java 倒猜全部链路:路径或限定符错误查 compile;.flat 读取和同 config 冲突查 merge;public/stable ID 冲突查 IdAssigner;资源名存在但引用失败查 symbol source 与 ReferenceLinker;APK 内资源表缺失或格式错误查 flattener 与 archive writer;Soong 中输入顺序和增量问题查 shard、res.list、overlay.list 与声明 outputs。
本文不能外推四件事:.flat 是公开稳定格式;aapt2 自己执行 R8 或资源 shrink;构建期 overlay 合并等同于设备 RRO 激活;link 成功就代表最终 APK 已签名并可安装。它们分别属于工具内部格式、后续 shrinker、运行时 overlay 管理和 APK 签名/安装边界。
