从一行 D8 错误到构建系统修复:incremental desugar 与 Java nestmate 排查实录
这篇文章用一个真实的 Android 构建失败做例子,讲清楚如何从一行错误开始,一步一步定位到构建系统里的根因,并设计一个可验证的修复方案。
我们只保留最关键的一句错误日志:
1
Caused by: com.android.tools.r8.utils.AbortException: Class MainActivity$2 requires its nest host MainActivity to be on program or class path.
这句话已经足够开始排查。它告诉我们三个线索:
- 报错来自
com.android.tools.r8,也就是 Android 的 R8/D8 工具链。 - 出问题的 class 是
MainActivity$2。 - D8 需要它的
nest host,也就是MainActivity,但当前输入里没看到。
下面从这句话开始拆。
第一步:先读懂错误本身
D8 是 Android 的 dex 编译器。普通 Java 编译器 javac 生成的是 .class 文件,但 Android Runtime 运行的是 .dex 文件。D8 的工作就是把 .class 或 .jar 转成 .dex。
举个例子:
1
2
3
4
5
MainActivity.java
-> javac
-> MainActivity.class
-> D8
-> classes.dex
MainActivity$2 是 Java 编译后的内部类名字。在这个例子里,sample-app/src/main/java/com/example/customlog/MainActivity.java 里有一个 CustomLogServiceConnection 匿名内部类,它是 MainActivity 里的第二个匿名内部类,所以 javac 生成的 class 名字就是 MainActivity$2.class。
简化后的源码是这样:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package com.example.customlog;
class MainActivity extends Activity {
private CustomLogClient customLogClient;
private final CustomLogServiceConnection customLogConnection =
new CustomLogServiceConnection() {
@Override
public void onCustomLogServiceConnected(
ComponentName name, CustomLogClient client) {
customLogClient = client;
}
@Override
public void onServiceDisconnected(ComponentName name) {
customLogClient = null;
}
};
}
编译后对应的 class 路径是:
1
2
com/example/customlog/MainActivity.class
com/example/customlog/MainActivity$2.class
nest host 是 Java 11 classfile 里的概念。为了把这句话讲清楚,需要同时解释 nest、nest group、nestmate、NestHost 和 NestMembers。
nest / nest group 指一组逻辑上属于同一个外部类的 class。nest host 是这组 class 的宿主,通常就是外部类;nest member 是这组里的成员,通常是内部类、匿名内部类、局部类。属于同一个 nest group 的这些 class 互相叫 nestmates。
在 classfile 里,这个关系由两个属性表达:
NestHost写在 member class 上,表示“我的 host 是谁”。NestMembers写在 host class 上,表示“我的 group 里有哪些 members”。
举个例子:
1
2
3
4
MainActivity.class // nest host
MainActivity$1.class // nest member
MainActivity$2.class // nest member
MainActivity$LaunchInfo.class
对应到 classfile metadata,就是:
1
2
3
4
5
MainActivity$2.class
NestHost: MainActivity
MainActivity.class
NestMembers: MainActivity$1, MainActivity$2, MainActivity$LaunchInfo, ...
这些 class 组成同一个 nest group,所以 MainActivity.class、MainActivity$1.class、MainActivity$2.class 互相都是 nestmates。
当 MainActivity$2.class 声明自己的 nest host 是 MainActivity 时,D8 在处理它时需要能看到 MainActivity.class。如果只给 D8 一个孤立的 MainActivity$2.class,D8 就不知道它所属的 nest 是否完整,也无法安全处理 nest-based access desugaring。
program or class path 也要拆开看。
program path 是 D8 本轮真正要编译成 dex 的输入。
class path 是 D8 只用来查类型、查方法、辅助分析的依赖输入。
举个例子:
1
2
3
4
5
6
7
program input:
tmp_extract_dir/com/example/customlog/MainActivity$2.class
classpath:
android.jar
custom_log_runtime/classes.jar
other dependency jars
D8 的意思是:我至少要在本轮 program input 或 classpath 里看到 MainActivity.class,但现在没看到。
所以第一步可以得到一个初步判断:
1
2
这不是 Java 语法错误,也不是 MainActivity$2.class 文件不存在。
更像是 D8 本轮拿到的输入集合不完整。
第二步:定位这是哪个模块和构建任务
看到 MainActivity 不要马上把它当成当前打开的 Activity。Android 工程里经常有多个 module、多个 sample、多个 test app 都叫 MainActivity。要先按完整包名找源头。
错误里的 class 实际是:
1
com/example/customlog/MainActivity$2.class
对应源码是:
1
sample-app/src/main/java/com/example/customlog/MainActivity.java
也就是说,它属于 sample-app 这个 Android module,而不是工程里其他同名 Activity。
在标准 Android/Gradle 工程里,可以先构建对应 module:
1
./gradlew :sample-app:assembleDebug --info
如果日志里能看到更细的 dex 任务,也可以单独关注失败的 dex/final dex 阶段。不同 Android Gradle Plugin 版本任务名会不同,常见名字包括:
1
2
3
:sample-app:dexBuilderDebug
:sample-app:mergeDexDebug
:sample-app:mergeExtDexDebug
final dex 表示 APK 级别的最终 dex 阶段。一个 APK 会依赖很多 Java library、第三方依赖 jar 和生成代码,final dex 要把 APK 最终需要的 Java 字节码整合成 dex 输出。
它和普通 Java 编译不是一层。
例如:
1
2
3
4
5
6
MainActivity.java
-> javac 生成 .class
-> 打成 app_classes.jar 或放入 javac/classes 输出目录
-> D8 生成中间 dex
-> final dex 整合 APK 所有 dex/class 输入
-> APK
所以第二步要确认:失败发生在 dex/final dex 阶段,不是 javac,也不是资源编译。
第三步:判断为什么是 final dex 失败
如果只是普通的 Java 编译失败,错误通常会出现在 javac 阶段,比如找不到符号、类型不匹配、语法错误。
但这次错误来自 D8,而且提到了 nest host 和 program or class path,说明 Java 源码已经编译成 class 了,失败发生在 class 转 dex 或 desugar 过程中。
desugar 是把较新的 Java 语言特性或 classfile 特性改写成旧 Android Runtime 可以理解的形式。
直接用上面的 CustomLog 例子。MainActivity$2.class 对应 CustomLogServiceConnection 这个匿名内部类,它会写入外部类 MainActivity 的 private 字段 customLogClient:
1
2
3
4
5
6
7
8
9
10
11
12
class MainActivity {
private CustomLogClient customLogClient;
private final CustomLogServiceConnection customLogConnection =
new CustomLogServiceConnection() {
@Override
public void onCustomLogServiceConnected(
ComponentName name, CustomLogClient client) {
customLogClient = client;
}
};
}
javac 生成:
1
2
MainActivity.class
MainActivity$2.class
在较新的 Java classfile 语义里,MainActivity$2.class 通过 nestmate 关系写入 MainActivity.customLogClient。旧 Android Runtime 不理解这种访问方式,所以 D8 会做 desugar,把它改写成旧 runtime 能理解的访问形式:
1
2
3
4
5
6
7
desugar 前:
MainActivity$2.onCustomLogServiceConnected()
通过 nestmate 语义写入 MainActivity.customLogClient
desugar 后:
MainActivity$2.onCustomLogServiceConnected()
调用 D8 生成的辅助访问方法,再写入 customLogClient
本问题里相关的是 nest-based access desugaring。Java nestmate 允许同一个 nest 里的类更自然地访问彼此的 private 成员,但老 Android runtime 不理解这种 classfile 语义,所以 D8 需要把它改写成可运行的形式。
final dex 有两类常见路径。
第一类是 dex merge,也就是合并已经生成好的 dex 文件。
举例:
1
2
3
4
5
library_a.dex
library_b.dex
library_c.dex
-> merge
-> final classes.dex
第二类是 re-desugar,也就是 final dex 阶段重新拿 .class 输入做 desugar 和 dex。
举例:
1
2
3
processed_classpath 中的一堆 .jar/.class
-> D8 --desugar
-> final dex
本次失败走的是第二类。典型参数长这样:
1
2
3
--class-inputs-filearg=@FileArg(...:processed_classpath)
--desugar
--desugar-dependencies ...app_final_dex.desugardeps
这里的 --class-inputs-filearg 表示 D8 这次吃的是 class 输入,不是现成 dex。
--desugar-dependencies 引出了下一个关键文件:.desugardeps。
第四步:理解 .desugardeps 如何把依赖变化连到 MainActivity$2
.desugardeps 是构建系统里的 D8 wrapper 写出来的 desugar 依赖文件。它记录某个 class 在 desugar 时依赖了哪些其他 class。
格式像这样:
1
2
A.class
<- B.class
意思是:
1
如果 B.class 变化了,A.class 需要被重新评估并重新 desugar。
这次关键链路类似:
1
2
3
com/example/customlog/MainActivity$2.class
<- com/example/customlog/runtime/CustomLogServiceConnection.class
<- com/example/customlog/MainActivity.class
CustomLogServiceConnection.class 不是 app 源码目录里直接展开的 .class 文件,而是在生成出来的依赖 jar 里。
举例查找方式:
1
2
3
zipinfo -1 \
app/build/intermediates/transforms/custom_log_runtime/classes.jar \
| rg 'com/example/customlog/runtime/CustomLogServiceConnection.class'
如果一次 CustomLog 依赖更新导致这个 jar entry 变化,incremental D8 就会查 .desugardeps,发现 MainActivity$2.class 受影响,于是决定重新处理它。
incremental D8 是增量 D8。为了加快构建,它不会每次都把所有 class 全部重新 dex,而是根据上一次构建记录,只处理变化的 class 和受影响的 class。
举个例子:
1
2
3
4
5
6
全量 D8:
处理 10000 个 class
增量 D8:
发现只有 B.class 变化
查依赖后只处理 A.class 和 B.class
增量构建本身没问题。问题在于,这次被选中的 A.class 是 MainActivity$2.class,它不是独立 class,而是 nest group 的一员。
第五步:发现真正的问题是“只抽了一个 member”
incremental D8 不是直接把整个 app 的 Java class 输出都交给 D8。它会先从 jar 或 classes 目录里抽取需要重新处理的 class 到临时目录。
错误里的路径已经暴露了这一点:
1
tmp_extract_dir/com/example/customlog/MainActivity$2.class
这说明构建脚本先抽出了 MainActivity$2.class,然后把它交给 D8。
问题是,原始 jar 里其实有完整的一组 class:
1
2
3
4
5
6
7
8
com/example/customlog/MainActivity.class
com/example/customlog/MainActivity$1.class
com/example/customlog/MainActivity$2.class
com/example/customlog/MainActivity$3.class
com/example/customlog/MainActivity$4.class
com/example/customlog/MainActivity$5.class
com/example/customlog/MainActivity$LaunchInfo.class
com/example/customlog/MainActivity$PinInfo.class
但增量抽取时只拿了其中一个:
1
com/example/customlog/MainActivity$2.class
于是 D8 看到的世界变成:
1
2
3
4
我有 MainActivity$2.class。
它说自己的 nest host 是 MainActivity。
但是我看不到 MainActivity.class。
所以我不能继续。
这就是错误日志的真实含义。
第六步:用 javap 确认 NestHost
现在要验证一件事:MainActivity$2.class 声明了自己的 nest host 是 MainActivity。
要证明它,可以用 javap -v。
javap 是 JDK 自带的 class 文件查看工具。-v 表示输出详细 classfile metadata。
示例命令:
1
2
3
4
third_party/jdk/current/bin/javap \
-classpath app/build/intermediates/javac/debug/classes.jar \
-v 'com.example.customlog.MainActivity$2' \
| rg -n 'NestHost|InnerClasses|EnclosingMethod|com/example/customlog/MainActivity' -A4 -B2
关键输出应该包含:
1
NestHost: class com/example/customlog/MainActivity
这一步很重要,因为它把排查从猜测变成证据:
1
2
MainActivity$2.class 不是普通孤立 class。
它的 classfile metadata 明确要求 MainActivity 作为 nest host。
再举一个简单例子:
1
2
3
4
5
6
7
8
9
10
11
12
class Outer {
private int value;
Runnable create() {
return new Runnable() {
@Override
public void run() {
System.out.println(value);
}
};
}
}
javac 生成:
1
2
Outer.class
Outer$1.class
Outer$1.class 访问了 Outer 的 private 字段 value。在 Java nestmate 语义下,它们属于同一个 nest,D8 处理 Outer$1.class 时就需要知道 Outer.class 的存在。
第七步:解释为什么本地复现不稳定
这个问题不是稳定的 clean build 错误。它依赖增量状态。
clean build 时,构建系统通常会全量抽取所有 class:
1
2
3
4
MainActivity.class
MainActivity$1.class
MainActivity$2.class
...
这样 D8 能看到完整 nest group,自然不会报错。
no-op build 时,target 已经 up-to-date,D8 根本不会重新跑。
真正容易复现的是 CI builder 这种状态:
1
2
3
4
5
6
1. builder 已经有上一次成功构建留下的输出。
2. builder 有旧的 `.desugardeps` 和 `.md5.stamp`。
3. 某次依赖更新改动了 CustomLog runtime jar。
4. md5/subpath tracking 发现 CustomLog 某个 class entry 变化。
5. incremental D8 只抽受影响 class。
6. 受影响 class 刚好是 Java nest member。
.md5.stamp 是构建系统记录上一次输入状态的文件。它可以帮助判断某个输入文件或 jar entry 是否变化。
举个例子:
1
2
3
4
5
6
7
8
上次:
custom_log_runtime/classes.jar 中 CustomLogServiceConnection.class 的 checksum = abc
这次:
checksum = def
结论:
这个 jar entry 变化了。
如果没有旧 stamp 或旧 .desugardeps,本地直接跑会走全量路径,也就不复现。
第八步:设计复现
复现可以分三层,从简单到接近 CI。
复现 A:直接跑目标
1
./gradlew :sample-app:assembleDebug --info
这个方法最简单,但不稳定。它取决于你的输出目录是否刚好处在问题状态。
复现 B:做一个 D8 微复现
目标是证明“只给 D8 一个 nest member 会失败”。
步骤:
1
2
3
4
5
1. 从 javac jar 里只抽 MainActivity$2.class。
2. 用 D8 或构建系统里的 D8 wrapper 跑 desugar。
3. 观察它报同样的 nest host 错误。
4. 再抽完整 MainActivity*.class group。
5. 重新跑 D8,观察通过。
示例:
1
2
3
4
mkdir -p /tmp/d8_repro/only_member
cd /tmp/d8_repro/only_member
jar xf /path/to/project/app/build/intermediates/javac/debug/classes.jar \
'com/example/customlog/MainActivity$2.class'
如果这时只把这个目录作为 D8 program input,并启用 desugar,就会得到类似错误:
1
Class MainActivity$2 requires its nest host MainActivity to be on program or class path.
再抽完整组:
1
2
3
4
5
6
7
8
9
mkdir -p /tmp/d8_repro/full_group
cd /tmp/d8_repro/full_group
jar xf /path/to/project/app/build/intermediates/javac/debug/classes.jar \
com/example/customlog/MainActivity.class \
'com/example/customlog/MainActivity$1.class' \
'com/example/customlog/MainActivity$2.class' \
'com/example/customlog/MainActivity$3.class' \
'com/example/customlog/MainActivity$4.class' \
'com/example/customlog/MainActivity$5.class'
这时 D8 能继续处理。这个微复现证明:问题不是 D8 不能处理 MainActivity$2,而是不能处理孤立的 MainActivity$2。
复现 C:模拟 CI 的依赖变化
更接近 CI 的方式是临时修改 generated dependency jar 里的 CustomLogServiceConnection.class,让 incremental D8 认为 classpath 变了。
思路:
1
2
3
4
1. 保留已有 final dex 输出、`.md5.stamp` 和 `.desugardeps`。
2. 修改 build/.../custom_log_runtime/classes.jar 里的 CustomLogServiceConnection.class。
3. 重新跑对应 app module 的 assemble 或 dex task。
4. 观察 D8 是否只抽 MainActivity$2.class 并报错。
这个复现方式要注意:修改的是 build output,只能本地临时使用,复现后要恢复,不能提交。
第九步:排除几个看起来合理但不根治的修法
修法一:改 MainActivity.java
例如把匿名内部类改成具名类,避免生成 MainActivity$2.class。
这可以绕开当前错误,但不是根治。
原因是:
1
2
3
任何 Java nest member 都会在同样的增量抽取缺陷下触发同类问题。
当前只是 MainActivity$2 被 CustomLog 依赖变化牵出来。
换一个 target、换一个内部类,问题仍然存在。
修法二:只把 MainActivity.class 加进来
这比只抽 MainActivity$2.class 好,但仍然不够。
D8 的 nest-based access desugaring 需要看到同一个 nest group 的多个 members。
举例:
1
2
3
4
MainActivity.class
MainActivity$1.class
MainActivity$2.class
MainActivity$3.class
如果只给:
1
2
MainActivity.class
MainActivity$2.class
仍会缺少其他 nest member 信息。
修法三:对 .desugardeps 求传递闭包
传递闭包的意思是:从一个 class 出发,把依赖图里能递归走到的 class 都加入。
举例:
1
2
3
A <- B
B <- C
C <- D
如果 B 变了,不只拉 A,还继续拉 C、D 或相关节点。
这个办法理论上能拉进更多 class,但风险是范围变得很大,甚至接近全量 dex,影响 incremental D8 的性能。更重要的是,.desugardeps 是 desugar 依赖图,不是 Java nest group 的语义模型。用它做大范围闭包,有点绕远。
第十步:根修应该修哪里
根因在构建脚本的 incremental class extraction。
也就是:当它决定抽取 MainActivity$2.class 时,应该意识到这是 MainActivity 这个 nest group 的一员,于是把同组 class 一起抽出来。
可以利用 javac 的命名约定:
1
2
3
4
Outer.class
Outer$1.class
Outer$Inner.class
Outer$Inner$Deep.class
这些都共享同一个 prefix:
1
Outer
带 package 时也是一样:
1
2
3
pkg/Outer.class -> pkg/Outer
pkg/Outer$1.class -> pkg/Outer
pkg/Outer$Inner.class -> pkg/Outer
所以修复思路是:
1
2
3
1. 先根据 changed/required class 计算 nest prefix。
2. 遍历 jar entry 时,凡是同 prefix 的 .class 都抽出来。
3. 这样 D8 看到的就是完整或足够完整的 nest group。
推荐代码形态类似:
1
2
3
4
5
6
7
def _ClassFileNestPrefix(class_path):
base = class_path[:-len('.class')]
slash = base.rfind('/')
dollar = base.find('$', slash + 1)
if dollar != -1:
base = base[:dollar]
return base
这个 helper 的例子:
1
2
3
4
5
_ClassFileNestPrefix('pkg/Top.class') -> 'pkg/Top'
_ClassFileNestPrefix('pkg/Outer$Inner.class') -> 'pkg/Outer'
_ClassFileNestPrefix('pkg/Outer$Inner$Deep.class')-> 'pkg/Outer'
_ClassFileNestPrefix('Outer$1.class') -> 'Outer'
_ClassFileNestPrefix('weird$pkg/Top.class') -> 'weird$pkg/Top'
注意最后一个例子。$ 如果出现在 package path 里,不应该被当成内部类分隔符。只看最后一个 / 后面的 simple class name。
然后在抽取逻辑里做扩展:
1
2
3
4
5
6
7
8
9
10
11
changed_class_set = (set(changes.IterChangedSubpaths(jar))
| required_classes_set)
nest_prefixes = {
_ClassFileNestPrefix(path)
for path in changed_class_set
if _IsClassFile(path)
}
predicate = lambda path: _IsClassFile(path) and (
_ClassFileNestPrefix(path) in nest_prefixes)
举个完整例子:
1
2
3
4
5
6
7
8
9
10
11
12
changed_class_set:
pkg/Outer$1.class
nest_prefixes:
pkg/Outer
jar entries:
pkg/Outer.class -> 抽出
pkg/Outer$1.class -> 抽出
pkg/Outer$2.class -> 抽出
pkg/Other.class -> 跳过
pkg/Outer.txt -> 跳过
这样修复的是根因:D8 的输入集合不再只包含一个孤立 nest member。
第十一步:给修复加测试
测试不要只测 helper,最好测真实抽取行为。
第一类测试:prefix 计算。
1
2
3
4
5
6
7
8
9
10
def testClassFileNestPrefix(self):
cases = {
'pkg/Top.class': 'pkg/Top',
'pkg/Outer$Inner.class': 'pkg/Outer',
'pkg/Outer$Inner$Deep.class': 'pkg/Outer',
'Outer$1.class': 'Outer',
'weird$pkg/Top.class': 'weird$pkg/Top',
}
for path, expected in cases.items():
self.assertEqual(dex._ClassFileNestPrefix(path), expected, msg=path)
第二类测试:抽取行为。
构造一个临时 jar:
1
2
3
4
5
pkg/Outer.class
pkg/Outer$1.class
pkg/Outer$2.class
pkg/Other.class
pkg/Outer.txt
模拟 required_classes_set 只有:
1
pkg/Outer$1.class
期望抽出来的是:
1
2
3
pkg/Outer.class
pkg/Outer$1.class
pkg/Outer$2.class
不应该抽:
1
2
pkg/Other.class
pkg/Outer.txt
这个测试直接证明修复覆盖了真实问题:只要一个 nest member 被要求重 dex,同组 nestmates 会一起进入 D8 输入。
第十二步:验证修复
先跑 Python 语法检查:
1
python3 -m py_compile build/android/gyp/dex.py build/android/gyp/dex_test.py
再跑单元测试:
1
python3 build/android/gyp/dex_test.py
预期类似:
1
2
3
Ran 3 tests in 0.012s
OK
最后做接近 CI 的验证:模拟 generated dependency jar 变化,然后跑 final dex:
1
./gradlew :sample-app:assembleDebug --info
如果修复正确,即使仍然走:
1
2
3
--class-inputs-filearg
--desugar
--desugar-dependencies
也应该通过。
验证时要特别注意一点:不要只证明“通过了”,还要证明“是在原来的 re-desugar 路径上通过了”。如果只是把 minSdk 调高让它走 dex merge,那是绕过,不是证明 root fix。
最终链路图
把前面的排查压成一条完整链路,就是:
1
2
3
4
5
6
7
8
CustomLogServiceConnection.class 变化
-> `.desugardeps` 记录 MainActivity$2.class 依赖它
-> incremental D8 判定 MainActivity$2.class 需要重新 desugar
-> 构建脚本只从 jar 里抽出 MainActivity$2.class
-> MainActivity$2.class 的 classfile metadata 声明 NestHost = MainActivity
-> D8 本轮输入里没有 MainActivity.class 和同组 nestmates
-> D8 无法完成 nest-based access desugaring
-> 报 requires its nest host
所以这篇文章要解决的不是 D8 完全不能处理 nestmate,而是构建系统在 incremental extraction 阶段给 D8 的输入不完整。只要把同一个 nest group 的 class 一起抽出来,D8 就能继续处理。
读完你应该能回答的问题
读完这篇文章,应该能回答下面几个问题:
- 为什么
MainActivity$2.class不能被孤立地交给 D8? NestHost、NestMembers、nest group、nestmates分别表示什么关系?.desugardeps在这次问题里起什么作用?- 为什么 clean build 不稳定复现,而 CI 的增量构建状态更容易复现?
- 为什么改
MainActivity.java或调高 minSdk 都只是绕过,不是根修? - 为什么按 javac 的
Outer$Member.class命名规则补齐同组 class 是更合适的修复?
官方参考资料
这些链接可以作为文中概念的官方出处:
- Java VM Spec: The
NestHostAttribute - Java VM Spec: The
NestMembersAttribute - OpenJDK JEP 181: Nest-Based Access Control
- Android Developers: D8 dexer
- Android Developers: Java 8+ API desugaring support
- Oracle JDK Tools:
javapcommand
结论
这次问题可以用一条链路总结:
1
2
3
4
5
6
CustomLog classpath jar 变化
-> `.desugardeps` 找到受影响的 MainActivity$2.class
-> incremental D8 只抽出 MainActivity$2.class
-> MainActivity$2.class 声明 NestHost = MainActivity
-> D8 看不到 MainActivity.class 和同组 nestmates
-> nest-based access desugaring 报错
最好的修复不是改 MainActivity.java,也不是单纯清理 CI 输出,而是在 incremental D8 的 class extraction 阶段补齐 Java nest group。
文章开头那一行错误,其实已经给了方向:
1
requires its nest host
看到这几个词,就应该想到:
1
2
不是某个 class 不存在这么简单,
而是一个 Java nest member 被孤立地交给了 D8。
排查这类问题的关键,是从 D8 报错反推输入集合,再从输入集合反推增量抽取逻辑,最后把修复放在构建系统保证输入完整性的地方。