Skip to content

这是我在排查 release 包闪退时整理的笔记。打 prodRelease 包后装真机,进去就闪退——但奇怪的是,项目里两个 Retrofit 接口(AuthService 和 DictApi)写法完全一样,AuthService 用的页面正常,DictApi 用的页面崩溃。最后定位到 R8 混淆把 DictApi 接口收缩删掉了。这篇把完整的排查链路和根因讲清楚。

一、现象:同样的写法,一个崩一个不崩

两个 Retrofit 接口定义几乎一样:

kotlin
// 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()

kotlin
// 都是 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,多半是 ClassCastExceptionNoSuchMethodError。真正的问题在 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() 返回的动态代理被这个检查点判为类型不符 → 运行时直接崩

R8 收缩的启发式:两条引用链的差异

本质结论:同样都是 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 实测
AuthServicelambda 包装(模拟 DictApi)预期被收缩0 条目——没被收缩,seeds 完整保留 ❌假设错(它有真实入口链,与 DictApi 的"死代码"本质不同)
DictApi纯 apiCall 包装复现被收缩dictApi 字段被删) ✅
LoginApi纯 apiCall 包装可能中招被收缩
UserApiapiCall + 直接调用预期保留也被收缩了 ❌假设错

先回答一个更本质的问题: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 -> bsubmitContact -> a(方法都在)

结论:改调用方式无法让 AuthService 崩——它有一条结构性保留根AuthBlockedScreen(@AndroidEntryPoint 入口)→ AuthViewModel → AuthRepository.verifyLicense 方法 → authService 字段,R8 从入口做方法级可达性分析,这条链是授权启动流程、必然可达 → 接口方法稳定保留。所以 AuthService 不是"碰巧没中招",而是结构性安全;DictApi 是结构性危险(接口方法零调用)。

决定性验证(控制变量实验):为确认"接口方法调用链"就是 R8 判定的唯一依据,做了第三次实验——把 DictRepository.preloadCommonDicts(空方法)改成真实调用 getDictOptions,其他条件不变(同样无 keep 规则):

指标v2(无调用)v3(加了真实调用)
DictApi 在 usage(被删列表)被删0 条目(不删了)
dictApi 字段被删保留
seeds 里 DictApi0 条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.09.1.0
Gradle9.1.09.3.1
minify
Retrofit keep 规则无(本次才补)

关键差异是 AGP 版本。查 AGP 9.0 官方发布说明,明确写了 R8 变更:

  1. "R8 is getting more aggressive at minifying your code"——R8 收缩更激进,新 flags 默认开启
  2. "Stop propagating keep info to companion methods"——接口方法的 keep 规则不再传播到合成 companion 方法(旧版会传播,等于间接保护了接口)

而 Retrofit 自带的 consumer 规则是:

proguard
-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 规则语法):

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 规范

经验沉淀成规范,新项目直接套:

  1. Retrofit 接口全部加 keep 规则(上面那一行是通用模板,所有项目通用)
  2. 新增接口不逐个加规则——interface * { @retrofit2.http.* <methods>; } 是通配的,自动覆盖
  3. 排查套路:release 闪退 → 先看 Logcat 崩溃类 → 去 usage.txt 搜该类 → 若在"被收缩"列表 → 补 keep
  4. 不要信"以前没崩":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)。

决策顺序(从优到劣)

  1. 确定不用 → 断开注入(治本,最干净)
  2. 预留扩展点 → keep 规则兜底(一行规则,代价最小)
  3. 只加 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 标准语法(静态核对)。