这是我在排查 release 包闪退时整理的笔记。打 prodRelease 包后装真机,进去就闪退——但奇怪的是,项目里两个 Retrofit 接口(AuthService 和 DictApi)写法完全一样,AuthService 用的页面正常,DictApi 用的页面崩溃。最后定位到 R8 混淆把 DictApi 接口收缩删掉了。这篇把完整的排查链路和根因讲清楚。
一、现象:同样的写法,一个崩一个不崩
两个 Retrofit 接口定义几乎一样:
// AuthService —— 正常
interface AuthService {
@POST("license/verify")
suspend fun verifyLicense(@Body req: LicenseReq): ApiResult<LicenseInfo>
@POST("contact/submit")
suspend fun submitContact(@Body req: ContactReq): ApiResult<Unit>
}
// DictApi —— 闪退
interface DictApi {
@GET("dict/data")
suspend fun getDictData(@Query("name") dictName: String): ApiResult<List<DictData>>
}使用方式也一样,都是 retrofit.create():
// 都是 Hilt 提供 + Retrofit 动态代理创建
@Provides @Singleton
fun provideAuthService(retrofit: Retrofit): AuthService = retrofit.create(AuthService::class.java)
@Provides @Singleton
fun provideDictApi(retrofit: Retrofit): DictApi = retrofit.create(DictApi::class.java)但真机上:AuthService 相关页面正常,DictApi 相关页面闪退。
二、定位:R8 的 usage.txt / seeds.txt 是破案关键
闪退直接看 Logcat 的 FATAL EXCEPTION,多半是 ClassCastException 或 NoSuchMethodError。真正的问题在 R8 优化报告——构建产物 build/outputs/mapping/{variant}/ 下有几个关键文件:
| 文件 | 内容 | 作用 |
|---|---|---|
usage.txt | 被 R8 收缩删除的类/方法 | 看"谁被删了" |
seeds.txt | 被保留的入口 | 看"谁保住了" |
mapping.txt | 混淆前后名字映射 | 崩溃堆栈还原 |
打开 usage.txt 搜 DictApi,发现接口类 DictApi 本身出现在被收缩列表里:
com.xxx.data.remote.DictApi ← 接口被 R8 收缩删除了!
com.xxx.data.remote.DictApi$$InternalSyntheticThrowCCEIfNotNull$72而 AuthService 在 usage.txt 里只有 Hilt 工厂条目,接口本身没有出现——说明它没被收缩。
三、根因:R8 收缩是"启发式"的,不是"无脑全删"
R8 的收缩(shrink)阶段会做全程序分析:从入口(main/Activity/Provider 等)出发,凡是被静态证明"被使用"的类和方法就保留,证明不了的就被删。
问题来了:retrofit.create() 用的是动态代理——接口是通过反射/代理在运行时被调用的,R8 的静态分析看不穿这层代理。所以"接口到底有没有被用"R8 只能靠猜:
- AuthService:消费链从入口真实可达——
AuthBlockedScreen(@AndroidEntryPoint)→ AuthViewModel → AuthRepository.verifyLicense → authService,R8 能看到方法被调用 → 保留 ✅ - DictApi:接口方法零调用(唯一调用点在
DictRepository.getDictOptions,而它是死代码——LoginViewModel/AppViewModel只调空方法preloadCommonDicts)→ R8 判定接口"未被真实使用" → 接口类被删 → 但 DictApi 是 Hilt 构造注入依赖(LoginViewModel→DictRepository→DictApi),启动注入时工厂 cast 失败 → CCE 闪退 ❌
R8 还有个细节:对被判定"类型可能不匹配"的路径,会插入类型检查点 InternalSyntheticThrowCCEIfNotNull(字面意思:cast 结果非空就抛 ClassCastException)。retrofit.create() 返回的动态代理被这个检查点判为类型不符 → 运行时直接崩。
本质结论:同样都是 retrofit.create(),但引用方式不同,R8 的静态判定结果就不同。AuthService 这次没中招,不代表它安全——换个调用方式可能就崩。所以正确姿势是"所有 Retrofit 接口统一 keep",而不是"哪个崩修哪个"。
四、实验验证:AuthService 包进 apiCall 会崩吗?
为了验证"引用链一变 AuthService 也会中招",做了个对照实验:把 AuthService 的调用改成和 DictApi 完全同构的 lambda 包装(authApiCall { authService.verifyLicense(req) }),同时移除全部 keep 规则,打 prodRelease 包看 R8 报告:
实验条件:无任何 keep 规则 + AuthService 改为 lambda 包装(与 DictApi 引用链同构)。
结果(usage.txt / seeds.txt):
| 接口 | 调用形态 | 实验前假设 | usage.txt 实测 |
|---|---|---|---|
| AuthService | lambda 包装(模拟 DictApi) | 预期被收缩 | 0 条目——没被收缩,seeds 完整保留 ❌假设错(它有真实入口链,与 DictApi 的"死代码"本质不同) |
| DictApi | 纯 apiCall 包装 | 复现 | 被收缩(dictApi 字段被删) ✅ |
| LoginApi | 纯 apiCall 包装 | 可能中招 | 被收缩 ✅ |
| UserApi | apiCall + 直接调用 | 预期保留 | 也被收缩了 ❌假设错 |
先回答一个更本质的问题:DictApi 到底有没有被调用? 查证全项目调用点——getDictOptions / getDictData 零消费方(LoginViewModel/AppViewModel 只调 preloadCommonDicts,而它是空方法,不打真实字典调用)!所以从"方法调用"层面,DictApi 确实没人调用(这个判断完全正确)。
那崩溃从哪来?——Hilt 构造注入:虽然接口方法没人调,但 DictApi 是 Hilt 构造注入的依赖:
LoginViewModel(@HiltViewModel)
→ 构造注入 DictRepository
→ 构造注入 DictApi(字段)
→ Hilt 工厂 DictApi_Factory.get() 需要 cast 出 DictApi 实例即使 getDictData 从没被调用,Hilt 在创建 LoginViewModel(登录页/启动流程)时也要实例化整个注入链——DictApi 工厂 cast 时,接口类已被 R8 删除 → ClassCastException,启动即闪退。
所以 R8 收缩 DictApi 是正确的死代码判定(方法零调用 → 接口类"未被真实使用" → 删),风险在于:接口类虽然方法没人调,但作为 Hilt 注入依赖类型被引用——R8 不把 Hilt 工厂的 cast 当作"使用",删了接口类,运行时注入就崩。模板和新项目都会中招(注入链都在),这就是必须加 keep 规则的原因。
那为什么 AuthService 包装后依然保留? usage.txt 给出了精确答案:
# AuthRepository(保留)——只有合成方法被删,authService 字段保留
com.cxtech.eam.core.auth.repository.AuthRepository:
private final synthetic java.lang.Object authApiCall(...) ← 只有合成桥接方法被删
# 字段 authService 没出现在 usage 里 = 被判定"被读取" → 接口保留
# DictRepository(保留)——但 dictApi 字段被删!
com.cxtech.eam.core.data.system.repository.DictRepository:
private final com.cxtech.eam.core.data.system.remote.DictApi dictApi ← 字段被收缩删除R8 是"字段级"判定的:authService 字段被 R8 判定"被读取"(lambda 内联后 invoke-interface 可见)→ 接口保留;dictApi 字段被判定"未被读取"→ 字段删除 → 接口唯一的静态引用消失 → 接口收缩。
这回答了"为什么那么多 API 只有 DictApi 崩":
- 修复前(只有 Retrofit 自带
-keepclassmembers规则,只保方法不保接口类):有直接调用点的接口(AuthService/DeptApi/OssUploadApi 的api.xxx()直接调)→ R8 看到字段被读取 → 保留;纯 apiCall 包装的接口(DictApi/LoginApi)→ 字段判定未读取 → 收缩 - DictApi 在启动早期被使用 → 先崩;LoginApi 同款中招,但 App 在用到登录页前就崩在 DictApi,没机会暴露
- 所以"只有 DictApi 崩"是表象——纯包装接口普遍中招,只是 DictApi 先触发
实验的另一个教训:UserApi 即使有 api.getUserInfo() 直接调用(UserRepository:122),在无任何 keep 规则时接口方法还是被移除——说明"直接调用点"也不是绝对保护,只有 keep 规则是可靠的。这再次印证:统一 keep 而不是赌引用链。
追问:能不能让 AuthService 也崩? 继续实验——把 authApiCall 改成非 inline(lambda 对象化,完全复刻 DictApi 的失败形态)再打包,结果:
| 检查项 | 结果 |
|---|---|
AuthRepository$verifyLicense$1(lambda 合成类) | ❌ 被移除(lambda 对象化成功) |
| AuthService 接口(usage.txt) | 0 条目——依然保留 |
| AuthService(seeds.txt) | 3 条——完整保留 |
| mapping 里 AuthService 方法 | verifyLicense -> b、submitContact -> a(方法都在) |
结论:改调用方式无法让 AuthService 崩——它有一条结构性保留根:AuthBlockedScreen(@AndroidEntryPoint 入口)→ AuthViewModel → AuthRepository.verifyLicense 方法 → authService 字段,R8 从入口做方法级可达性分析,这条链是授权启动流程、必然可达 → 接口方法稳定保留。所以 AuthService 不是"碰巧没中招",而是结构性安全;DictApi 是结构性危险(接口方法零调用)。
决定性验证(控制变量实验):为确认"接口方法调用链"就是 R8 判定的唯一依据,做了第三次实验——把 DictRepository.preloadCommonDicts(空方法)改成真实调用 getDictOptions,其他条件不变(同样无 keep 规则):
| 指标 | v2(无调用) | v3(加了真实调用) |
|---|---|---|
| DictApi 在 usage(被删列表) | 被删 | 0 条目(不删了) |
dictApi 字段 | 被删 | 保留 |
| seeds 里 DictApi | 0 条 | 2 条(保留) |
一个变量的改动(空方法 → 真实调用)就让 DictApi 从"被删"变"保留"——铁证:R8 删不删 Retrofit 接口,唯一决定因素就是接口方法有没有真实可达的调用链。这也修正了表述:不是"Hilt 入口"问题(AuthService/DictApi 的 Hilt 注入机制完全一样),Hilt 组件里的 cast 只是崩溃触发点(接口被删后 cast 失败),不是 R8 的保留依据。
这对排查的启发:R8 收缩不是"看接口写法",而是看消费链从入口是否可达——这解释了"为什么只有 DictApi 崩"的完整因果。也再次说明:不要赌"哪个接口安全"(今天是 AuthService,换个人写个新的纯包装接口就中招),统一 keep 规则兜底。
五、更深的根因:AGP 9.x 的 R8 行为变化
排查到这里,有个问题值得追问:为什么另一个项目(EAM)同样的写法、同样没 keep 规则,上线却一直正常? 查版本对比:
| EAM(不崩) | 本项目模板(崩) | |
|---|---|---|
| AGP 版本 | 8.13.0 | 9.1.0 |
| Gradle | 9.1.0 | 9.3.1 |
| minify | 开 | 开 |
| Retrofit keep 规则 | 无 | 无(本次才补) |
关键差异是 AGP 版本。查 AGP 9.0 官方发布说明,明确写了 R8 变更:
- "R8 is getting more aggressive at minifying your code"——R8 收缩更激进,新 flags 默认开启
- "Stop propagating keep info to companion methods"——接口方法的 keep 规则不再传播到合成 companion 方法(旧版会传播,等于间接保护了接口)
而 Retrofit 自带的 consumer 规则是:
-keepclassmembers,allowshrinking,allowobfuscation interface * {@retrofit2.http.* <methods>;}注意它用的是 -keepclassmembers(只保方法,不保接口类本身)——在 AGP 8.x 的保守 R8 下"碰巧够用",但 AGP 9.x 的激进 R8 下就不够了,接口类照样被收缩。
完整因果链:
模板从 EAM 复制 proguard 规则(EAM 8.13 的保守 R8 下"没规则也活着")
→ 模板升级到 AGP 9.1(R8 激进化)
→ 新项目从模板初始化 = AGP 9.1
→ 第一次打 release → DictApi 接口被收缩 → 闪退所以不是"EAM 配置对",是 EAM 的 AGP 版本老、R8 不激进,侥幸没踩。模板升到 AGP 9.1 时这个隐性缺陷就暴露了——这也是为什么这次要统一 keep 规则而不是"哪个崩修哪个":无论 AGP 8 还是 9,规则都能兜住。
六、解决:一行 keep 规则 + 显式包 keep
在 proguard-rules.pro 里加(R8 兼容 ProGuard 规则语法):
# 核心:所有含 Retrofit 注解方法的接口,允许混淆名但绝不收缩删除
-keep,allowobfuscation interface * { @retrofit2.http.* <methods>; }
# 兜底:remote 包下接口显式 keep(双保险)
-keep,allowobfuscation interface com.xxx.data.remote.** { *; }规则解读:
-keep:不收缩(shrink)也不优化(optimize)匹配项allowobfuscation:允许混淆类名(保留混淆能力,只是不删)——Retrofit 接口本来就不怕改名(反射按方法注解找),但怕被删@retrofit2.http.* <methods>:匹配所有带 Retrofit HTTP 注解(@GET/@POST/@PUT...)的方法
七、验证:三个证据证明修复生效
重新编译 prodRelease(27MB 打出成功),对比修复前后:
| 验证点 | 修复前 | 修复后 |
|---|---|---|
usage.txt 里 DictApi$$InternalSyntheticThrowCCEIfNotNull | 存在(检查点) | 消失 ✅ |
seeds.txt 里 DictApi.getDictData | 缺失 | 完整保留 ✅ |
| 真机安装 | 闪退 | 正常(不再崩) ✅ |
八、预防:Retrofit 接口的 keep 规范
经验沉淀成规范,新项目直接套:
- Retrofit 接口全部加 keep 规则(上面那一行是通用模板,所有项目通用)
- 新增接口不逐个加规则——
interface * { @retrofit2.http.* <methods>; }是通配的,自动覆盖 - 排查套路:release 闪退 → 先看 Logcat 崩溃类 → 去 usage.txt 搜该类 → 若在"被收缩"列表 → 补 keep
- 不要信"以前没崩":R8 是启发式的,代码一改(包个高阶函数、加个泛型)引用链就变,之前安全的接口随时可能中招
九、更优解:能移除的断开注入,keep 只是兜底
keep 规则是兜底(保护"会用到但 R8 静态看不穿"的接口),但治本方案是移除不用的依赖——把引用注入断开,R8 删死代码就无副作用了:
两种手段的定位
| 手段 | 定位 | 适用场景 |
|---|---|---|
| 断开注入(治本) | 从注入链上摘掉不用的依赖:ViewModel 构造参数移除 + DataModule 删 provider | 确定不用的功能——删了注入后 R8 收缩该接口无副作用,不需要 keep |
| keep 规则(兜底) | 一行通配保护所有 Retrofit 接口类 | 预留扩展点/不确定会不会用——模板场景(字典链是模板卖点,业务方可能激活) |
实战案例(新项目移除 Dict 注入)
新项目(AKS 出库校验)确认不用模板的字典功能,只断开注入、保留代码文件:
改(断开注入):
AppViewModel / LoginViewModel 构造参数移除 dictRepository + 调用点删除
DataModule 删 provideDictApi / provideDictRepository
保留(代码文件不动):
DictApi.kt / DictRepository.kt / DictData.kt ← 将来要用还能恢复效果:Hilt 不再生成 DictApi 的 cast 工厂 → R8 删 DictApi(死代码判定)无副作用 → 不崩、也不需要为它 keep;keep 规则继续兜底其他在用接口(AuthService/UserApi/DeptApi/OssUploadApi/LoginApi)。
决策顺序(从优到劣)
- 确定不用 → 断开注入(治本,最干净)
- 预留扩展点 → keep 规则兜底(一行规则,代价最小)
只加 keep 不清理死代码(能用但长期积累垃圾,且"哪个崩修哪个"不可靠)
核心思想:keep 是防御(告诉 R8"这个将来要用"),移除是进攻(告诉 R8"这个确实不要")——能确定不用的,进攻优先。
小结
- R8 收缩是静态启发式:
retrofit.create()的动态代理它看不穿,接口可能被误删 - 不是哪个崩修哪个——引用方式一变,没崩的接口也可能崩,Retrofit 接口要统一 keep
- 破案靠 usage.txt / seeds.txt:被收缩的类、被保留的入口一目了然
- 通用规则一行搞定:
-keep,allowobfuscation interface * { @retrofit2.http.* <methods>; }(比 Retrofit 自带的-keepclassmembers更稳——后者只保方法不保接口类,AGP 9 激进 R8 下不够) - 治本优先于兜底:确定不用的接口直接断开注入(ViewModel 构造参数 + DataModule provider 移除),R8 删死代码无副作用;keep 规则是给"预留扩展点/可能用到"的接口兜底(见第九节)
想了解 R8 整个处理流程(压缩/优化/混淆/收缩四件套)和 mapping 还原崩溃堆栈,之后可以写一篇 R8 全流程详解。
验证说明:本文的 R8 收缩行为、usage/seeds 报告数据来自真实生产 release 构建实测(修复前后对比验证);keep 规则为 ProGuard/R8 标准语法(静态核对)。
