2. Retrofit 回调生命周期:Call 对象、enqueue/execute、回调线程模型
2.1 现象识别 — 回调泄漏的运行时表现
回调泄漏不会直接 Crash,而是通过以下 三个异常特征 暴露出来:
- 页面无法回收:反复进入/退出同一个 Activity/Fragment 后,堆内存持续上涨,GC 无法将其标记为可回收。
- LeakCanary 报警:泄漏链路末端指向
Callback.onResponse()或Callback.onFailure(),且中间节点持有 Activity/Fragment 引用。 - 回调在页面销毁后仍然触发:日志中看到
onResponse出现在onDestroy()之后,且内部操作了已销毁的 UI 组件。
🔍 判断规则:如果在 Activity.onDestroy() 之后仍有该 Activity 的 Callback 实例被执行,即为“生命周期错位泄漏”。
2.2 数据采集 — 捕获 Call 对象与回调引用链
采集以下 三组数据 可以完整还原泄漏现场:
- Call 状态快照:通过
call.isCanceled()、call.isExecuted()判断 Call 是否仍在存活队列中。 - 线程堆栈:抓取主线程与 OkHttp 分派线程的 stack trace,确认回调正在哪个线程上排队。
- GC 根引用链:使用 MAT 或 LeakCanary 的
HeapAnalyzer找到Callback → 外部对象的强引用路径。
| 采集手段 | 目标数据 | 泄漏指示 |
|---|---|---|
call.isCanceled() |
Call 是否已被取消 | false 且页面已销毁 → 泄漏 |
| Dispatcher.runningCallsCount() | 正在运行的异步 Call 数量 | 数量 > 0 且对应页面已销毁 → 泄漏 |
| MAT dominator_tree | Activity 实例的 GC 根路径 | 路径中包含 Callback → 确认泄漏 |
💡 采集技巧:在 Activity.onDestroy() 中主动打印
call.toString() + " isCanceled=" + call.isCanceled(),可在日志中快速定位未取消的 Call。
2.3 原因定位 — 生命周期链断裂点
根本原因在于 Call 对象的生命周期超越了组件的生命周期。下图展示了从 Call 创建到完成的完整路径,以及最常见的泄漏断裂点:
从上图可以看出,异步路径(enqueue) 是泄漏的高发区:回调被放入 OkHttp 的 Dispatcher 队列后,即使页面已销毁,回调仍会在网络响应到达时被触发。而 同步路径(execute) 的泄漏风险相对较低,但若在后台线程长时间阻塞,同样会导致持有外部引用的线程对象无法释放。
2.3.1 线程模型导致的泄漏陷阱
- 异步回调(enqueue):回调在 OkHttp 的
Dispatcher线程池中执行,默认最大并发 64;回调返回前会通过Platform切换到主线程。若主线程回调持有 Activity 引用,泄漏即产生。 - 同步回调(execute):在调用线程中阻塞执行,不会触发回调接口,但若在子线程中直接使用 execute 并传入外部对象引用,同样会造成跨线程引用泄漏。
- 线程切换时机:OkHttp 在
Callback.onResponse()返回后,将响应通过ExecutorCallbackCall提交到主线程的Handler。如果页面在响应到达前已销毁,这条消息仍会执行。
2.4 修复动作 — 正确管理 Call 生命周期
修复的核心原则是:组件销毁时,必须取消与之关联的所有 Call。提供三种标准方案:
| 方案 | 实现方式 | 适用场景 |
|---|---|---|
| 手动取消 | 在 onDestroy() 中调用 call.cancel() |
单个 Call,对象生命周期清晰 |
| 弱引用回调 | 使用 WeakReference<Activity> 包裹回调内的外部引用 |
遗留代码改造,无法统一管理 Call |
| Lifecycle 绑定 | 通过 LifecycleObserver 或 ViewModel 自动取消 |
推荐方案,适用于 MVVM 架构 |
⚠️ 手动取消的常见错误:在
onStop() 中取消,在 onStart() 中重新 enqueue,会导致回调多次注册。正确做法是在 onDestroy() 中一次性取消,不再重新创建。
修复代码示例(手动取消 + 弱引用):
// 方案一:在 onDestroy 中取消 Call
@Override
protected void onDestroy() {
super.onDestroy();
if (userCall != null && !userCall.isCanceled()) {
userCall.cancel();
Log.d("Lifecycle", "Call 已取消,防止泄漏");
}
}
// 方案二:使用 WeakReference 包裹回调
private static class WeakCallback implements Callback<User> {
final WeakReference<UserActivity> activityRef;
WeakCallback(UserActivity activity) {
this.activityRef = new WeakReference<>(activity);
}
@Override
public void onResponse(Call<User> call, Response<User> response) {
UserActivity activity = activityRef.get();
if (activity != null && !activity.isFinishing()) {
activity.updateUI(response.body());
}
}
@Override
public void onFailure(Call<User> call, Throwable t) {
UserActivity activity = activityRef.get();
if (activity != null && !activity.isFinishing()) {
activity.showError(t.getMessage());
}
}
}
2.5 复测 — 验证泄漏消除
使用以下 三步验证法 确认修复生效:
- GC 强制回收:反复进入/退出页面 5 次,手动触发
System.gc(),观察内存是否回落到基线。 - LeakCanary 零告警:运行自动化 Monkey 测试 15 分钟,确保 LeakCanary 堆栈中不再出现该组件。
- 回调空安全校验:在
onResponse入口添加if (getActivity() == null || getActivity().isFinishing()) return;,确认日志中无空指针异常。
💡 复测工具:使用
adb shell dumpsys meminfo <package> 观察 Activity 和 ViewRootImpl 实例数,不应持续增长。
2.6 预防 — 架构级防护
从架构层面统一管理回调生命周期,彻底消除人为疏忽:
- ViewModel + LiveData:网络请求在 ViewModel 中发起,通过 LiveData 向 UI 层传递结果,ViewModel 的生命周期天然独立于 Activity。
- LifecycleObserver 自动取消:在
onCreate中注册一个LifecycleObserver,在onDestroy事件中遍历并取消所有关联的 Call。 - 统一封装 CallManager:创建一个
CallManager单例,在发送请求时注册 Call,在页面销毁时批量取消。
✅ 预防规则清单:
1. 每个 Call 必须绑定一个生命周期所有者(Activity / Fragment / ViewModel)。
2. 禁止在匿名内部类中直接引用外部 Activity。
3. 回调方法内第一行检查宿主是否存活。
4. 同步 execute 必须在子线程执行,且超时时间 ≤ 页面预期存活时间。
1. 每个 Call 必须绑定一个生命周期所有者(Activity / Fragment / ViewModel)。
2. 禁止在匿名内部类中直接引用外部 Activity。
3. 回调方法内第一行检查宿主是否存活。
4. 同步 execute 必须在子线程执行,且超时时间 ≤ 页面预期存活时间。