AGP 8.0 的 R8 全模式,我迁移时遇到的坑
AGP 8.0 的 R8 全模式,我迁移时遇到的坑
上个月把项目的主干分支从 AGP 7.4.2 切到 8.0.0,Gradle wrapper 从 7.5 升到 8.0,compileSdk 也顺势提到了 33。Sync 过程比预想中顺利,Kotlin 1.8.10 编译通过,Lint 没报阻塞性错误,release 打包也成功了。结果 APK 装到手机上,启动即崩溃,logcat 里抛的是 java.lang.ClassNotFoundException: com.internal.network.bridge.BridgeAdapterImpl。这个类在项目里已经存在了四年,通过 Class.forName 做动态加载,在 AGP 7.4 下从未出过问题。排查了一整天后,我发现问题不在业务代码,而在 R8 全模式的行为变更。
运行时崩溃:反射链断裂
Class.forName 在 AGP 7.4 的 R8 兼容模式下是一张安全牌。只要字符串是常量,R8 通常会保留对应的类,即便没有任何直接引用。但 AGP 8.0 把 android.enableR8.fullMode 的默认值改成了 true,R8 版本也来到了 4.0.x,优化策略变得激进得多。
我们项目中有一个网络适配层,为了解耦主工程与某个商业 SDK,用了这样的逻辑:
val clazz = Class.forName("com.internal.network.bridge.BridgeAdapterImpl")
val instance = clazz.getDeclaredConstructor(Context::class.java).newInstance(context)R8 全模式下的可达性分析不再把 Class.forName 的字符串常量视为强引用。它认为 BridgeAdapterImpl 没有任何非反射的直接调用,于是将整个类标记为 dead code,直接从 dex 里移除。运行时反射加载,直接就炸了。
更麻烦的是,这个模式并不总是崩溃。如果反射字符串拼接过,比如:
val prefix = "com.internal.network.bridge"
val name = "${prefix}.BridgeAdapterImpl"
val clazz = Class.forName(name)R8 在编译期就放弃了对字符串的 trace,类会被保留;反倒是老老实实写完整字符串常量的时候,R8 觉得自己分析明白了,大胆删掉。这种“有时候删、有时候留”的行为最让人头疼,本地 debug 包通常不开 minify,问题全积在 release 包里。
我最初在 proguard-rules.pro 里加了一行 -keep class com.internal.network.bridge.BridgeAdapterImpl,以为万事大吉。结果跑起来又抛 NoSuchMethodException。R8 全模式下,只保留类名不够,构造方法如果只在反射里出现,会被优化掉。最终得写成:
-keep class com.internal.network.bridge.BridgeAdapterImpl {
<init>(android.content.Context);
void connect();
}项目中类似的反射点有二三十处,分布在五个模块里。逐条梳理规则花了一整天。
隐性问题:ServiceLoader 的静默失效
比 ClassNotFoundException 更隐蔽的是 ServiceLoader。我们在基础库中定义了一个日志接口 LoggerFactory,各业务模块通过 META-INF/services/xxx.LoggerFactory 注册自己的实现。加载代码很简单:
val loader = ServiceLoader.load(LoggerFactory::class.java)
val factory = loader.toList().firstOrNull()AGP 7.4 下,只要 META-INF/services 文件存在,R8 会自动保留对应的实现类。AGP 8.0 全模式里,R8 仍然保留服务配置文件本身,但它不再无条件信任文件里的类名。我们的 FileLoggerFactory 和 ConsoleLoggerFactory 被判定为不可达,直接从 dex 中移除。ServiceLoader.load 返回空列表,没有异常,没有 crash,只是日志模块没初始化,整个 App 的日志全丢了。线上如果只看 crash 率,这个问题根本发现不了。
我查了下 R8 的 release note,4.0 之后确实对 service provider 的处理做了调整,倾向于要求显式 keep 规则。修复方案是在 consumer-rules 里给实现类加 @Keep 注解,或者显式写 -keep 规则。但一个第三方老旧 SDK 内部也用了 ServiceLoader 加载自己的插件,我没法改它的源码,只能在主工程的 proguard-rules.pro 里把那个 SDK 的 internal package 全部 keep 住,代价是 APK 大了几百 KB。
编译期就报错:Missing class 的连锁反应
R8 全模式不仅运行时挖坑,编译期也更严格。升级后第一次 CI 构建,R8 阶段直接报错:
ERROR: Missing class com.google.protobuf.GeneratedMessageV3
(referenced from: com.xxx.yyy.internal.ProtoReportBuilder)项目本身没有直接依赖 protobuf,但依赖库 A 的 consumer ProGuard 规则里 -keep 了一个类,而这个类引用了 protobuf 的类。依赖库 B 在编译路径里提供了 protobuf 的传递依赖,但 AGP 8.0 的 R8 全模式下,这个传递依赖被 Gradle 的依赖解析逻辑给优化掉了,导致 R8 在 whole-program analysis 时找不到类定义。
在 AGP 7.4 的兼容模式下,R8 对这种缺失的引用通常只报 warning,构建还能继续。8.0 全模式把它升级成了 fatal error。解决方式倒不复杂,在主工程加一行 -dontwarn com.google.protobuf.**,但问题是我们有十几个传递依赖都有类似的 phantom 引用,逐个 suppress 很烦人。更根本的解法是给这些依赖库升级版本,但某些库半年没发版,最后只能先把 -dontwarn 列表写长。
还有一个坑是 consumer ProGuard 规则的合并顺序。AGP 8.0 调整了 consumerProguardFiles 的合并行为,以前某个模块的 -keep class * extends android.app.Application 能保住 Application 子类,现在和其他模块的规则合并后,R8 全模式似乎对重复 keep 的类做了更激进的成员优化,导致 Application 的 attachBaseContext 被内联后出了异常。最后把模块里的过度 keep 规则删掉,只在主工程保留精确规则,才恢复正常。
脱糖、Lambda 与 Jacoco 的夹击
R8 全模式对脱糖代码的处理也更激进。我们项目的 minSdk 是 26,按理说 Java 8 的 Lambda 已经原生支持,不需要额外脱糖。但某些 Kotlin 编译器生成的 synthetic 方法,或者方法引用生成的 invokedynamic,在 R8 4.0 下会被更积极地内联。
我们有一个图片加载模块,用 Lambda 封装了反射调用:
provider.load {
Class.forName("com.xxx.module.ImageLoaderImpl")
}R8 全模式下,这个 Lambda 被内联后,ImageLoaderImpl 的引用路径变得非常短,R8 认为它只出现在一个 reflection-only 的上下文中,且外层调用方是 inline 的,于是干脆把目标类也移除了。这导致了一个只在特定用户路径下才会触发的崩溃,本地很难复现。
测试覆盖率也出了问题。AGP 8.0 下,testCoverageEnabled = true 的 debug 构建,如果同时开启 R8 全模式(虽然 debug 通常不开 minify,但我们为了在 CI 上跑接近 release 的构建,给 debug 也开了 isMinifyEnabled = true),Jacoco 注入的探针类会被 R8 判定为无用代码,直接优化掉。跑完测试,覆盖率报告大片空白,方法覆盖率为 0,明明单元测试都通过了。
查了半天,发现是 R8 把 Jacoco 的 onMethodExit 探针内联后移除了。修复方案是给 debug 构建单独配一套 ProGuard 规则,显式保留 Jacoco 的 runtime:
-keep class org.jacoco.** { *; }
-dontwarn org.jacoco.**但这就带来了规则维护的复杂度,debug 和 release 的规则集不再完全通用。
构建性能:时间与内存的代价
说完 bug,说说构建。R8 全模式对 APK 体积和运行时性能确实有益处,但编译代价是真实存在的。
我们项目约 120 个模块,Kotlin 和 Java 混合代码约 200 万行。CI 跑在 GitHub Actions 的 ubuntu-latest 上,4 核 16G。
AGP 7.4.2 + 兼容模式,release 包构建时间稳定在 14 分钟左右。
AGP 8.0.0 + R8 全模式,release 包构建时间涨到了 17 分钟。
R8 的优化阶段本身从 3 分钟涨到了 5 分钟。更关键的是内存峰值:从原来的 6GB 堆内存涨到了 9GB。之前 CI 配的 org.gradle.jvmargs=-Xmx8g,AGP 8.0 之后频繁触发 GC,有一次构建甚至跑了 28 分钟,差点超时。最后把 CI 的 Gradle Daemon 堆内存调到了 12G,才把构建时间稳定回 17 分钟。
Google 的文档提到 R8 全模式应该更快,因为不需要做兼容模式的桥接逻辑。但那是针对中小型项目。模块数一多,依赖图复杂,R8 的 whole-program analysis 的额外开销就摊不平了。我还尝试在 gradle.properties 里加 android.enableR8.fullMode=false 切回兼容模式,构建时间确实能回到 14 分钟,但 APK 大了 1.8MB,运行时某些冷启动场景慢了约 5-8%。这个取舍最终取决于团队更看重 CI 时间还是包体积。
调试 R8 的笨办法
排查这些问题的过程中,有几个方法帮了大忙。
第一是看 build/outputs/mapping/release/ 目录下的产物。在 proguard-rules.pro 里显式开启:
-printseeds build/outputs/mapping/release/seeds.txt
-printusage build/outputs/mapping/release/usage.txt
-printmapping build/outputs/mapping/release/mapping.txtusage.txt 直接列出被 R8 判定为 dead code 的类和方法。我就是在里面看到 BridgeAdapterImpl 和 FileLoggerFactory 被打上了删除标记。seeds.txt 则用来验证 keep 规则是否真正生效,有时候你以为 -keep 成功了,但 seeds.txt 里并没有,说明规则写错了。
第二是 AGP 8.0 的 android.enableR8.fullMode 可以在 gradle.properties 里显式关闭,作为对照实验。虽然不建议长期关,但快速验证某个 bug 是否由全模式引起非常有效。
第三,@Keep 注解的使用要心里有数。AndroidX 的 @androidx.annotation.Keep 在 R8 全模式下仍然有效,但它主要保住类名和方法/字段的存在性,不保证方法体不被优化。对于反射调用来说,只要签名还在,一般就够用了。不过如果一个类被 @Keep 了,但方法参数里带了一个自定义类型,那个自定义类型没有被 keep,运行时还是可能触发 NoClassDefFoundError。R8 全模式下,这种级联删除更容易出现。
第四,对于第三方 SDK 的反射问题,与其在主工程里写一大票 keep 规则,不如给 SDK 提 issue,让它们在自己的 consumer-rules.pro 里补全规则。但现实中,很多 SDK 厂商对 AGP 8.0 的适配慢半拍,只能先自己扛着。
最后,Class.forName 的字符串常量