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 对象与回调引用链

采集以下 三组数据 可以完整还原泄漏现场:

  1. Call 状态快照:通过 call.isCanceled()、call.isExecuted() 判断 Call 是否仍在存活队列中。
  2. 线程堆栈:抓取主线程与 OkHttp 分派线程的 stack trace,确认回调正在哪个线程上排队。
  3. 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 创建到完成的完整路径,以及最常见的泄漏断裂点:

Retrofit Call 生命周期与泄漏风险点 Retrofit Call 生命周期 — 异步 vs 同步 创建 Call 对象 enqueue 异步 execute 同步 onResponse / onFailure 完成 / 取消 ⚠️ 泄漏风险点 enqueue execute 同步返回 Response / Exception 🔒 泄漏本质:异步回调队列中的 Callback 持有外部强引用,导致组件无法被 GC 回收 当页面销毁时 enqueue 的回调尚未执行,Call 未被 cancel,回调链上的引用就无法断开。

从上图可以看出,异步路径(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 复测 — 验证泄漏消除

使用以下 三步验证法 确认修复生效:

  1. GC 强制回收:反复进入/退出页面 5 次,手动触发 System.gc(),观察内存是否回落到基线。
  2. LeakCanary 零告警:运行自动化 Monkey 测试 15 分钟,确保 LeakCanary 堆栈中不再出现该组件。
  3. 回调空安全校验:在 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 必须在子线程执行,且超时时间 ≤ 页面预期存活时间。