OkHttp请求与GATT回调泄漏混合场景教程 - 第4节

同步请求 vs 异步请求:你到底该选哪个?

说实话,很多人刚接触 OkHttp 时,最容易搞混的就是 execute() 和 enqueue()。一个叫同步,一个叫异步,字面意思好理解,但一旦跟 GATT 回调、主线程、内存泄漏搅在一起,就变得特别坑。我见过不少项目,就因为选错了调用方式,导致 ANR 或者回调泄漏,查一整天都定位不到原因。

这一节我就把自己踩过的坑、总结的规律都摊开来聊。你想想看,如果你正在做一款蓝牙设备交互 App,一边发 OkHttp 请求,一边接收 BLE GATT 回调——这两个东西的线程模型一旦冲突,崩溃就是分分钟的事。

execute():同步请求,堵你没商量

execute() 是 OkHttp 提供的同步请求方法。意思是说,你调用它,当前线程就得等着,直到服务器返回数据或者超时。说白了,它就是一条“单行道”,不堵到头绝不让你过。

核心特点:

  • 阻塞当前线程,直到响应到达。
  • 必须在子线程里调用——主线程直接调用就是 ANR 炸弹。
  • 返回 Response 对象,你需要手动关闭 Response Body。
OkHttpClient client = new OkHttpClient();
Request request = new Request.Builder()
    .url("https://api.example.com/data")
    .build();

// 千万不能在主线程执行!
new Thread(() -> {
    try {
        Response response = client.newCall(request).execute();
        // 注意:Response.body() 需要及时关闭
        String body = response.body().string();
        response.close();
    } catch (IOException e) {
        e.printStackTrace();
    }
}).start();

我在项目里遇到过这么一件事:当时要在一个蓝牙设备连接成功后,立刻通过 HTTP 上报设备信息。我在 GATT 连接成功的回调里直接调用了 execute(),结果那个回调是在 Binder 线程池里跑的,一阻塞就把整个蓝牙协议栈给卡死了。后来一看日志,发现 GATT 回调的线程也被阻塞了,设备一直连不上——这就是典型的线程交叉死锁。

enqueue():异步请求,回调交给你

enqueue() 就不一样了。它把请求丢进 OkHttp 内部的线程池,然后立即返回。请求完成后,回调会通过一个线程池里的工作线程通知你。它不会阻塞调用方,所以你在主线程调都没问题。

核心特点:

  • 非阻塞,立即返回。
  • 通过 Callback 接口接收结果:onFailure() 和 onResponse()。
  • 回调运行在 OkHttp 的工作线程,不是主线程。
client.newCall(request).enqueue(new Callback() {
    @Override
    public void onFailure(Call call, IOException e) {
        // 网络失败在这里处理
        Log.e("OkHttp", "请求失败", e);
    }

    @Override
    public void onResponse(Call call, Response response) throws IOException {
        // 注意:这是在子线程!不能直接更新 UI
        String body = response.body().string();
        // 如果你要用结果更新 UI,一定要 post 到主线程
        runOnUiThread(() -> textView.setText(body));
        response.close();
    }
});

⚠️ 警告:回调线程陷阱

很多新手直接 onResponse() 里更新 UI,然后 crash。记住:onResponse() 不是主线程!你需要 Handler 或 runOnUiThread() 切一下。另外,Response Body 必须关闭,最好用 try-finally 或 Kotlin 的 use 块,否则内存泄漏等着你。

同步 vs 异步——一个表格说清楚

对比维度execute() (同步)enqueue() (异步)
阻塞调用线程是否
能否在主线程调用❌ 必 ANR✅ 安全
返回方式直接返回 ResponseCallback 回调
回调线程调用线程本身OkHttp 工作线程
响应体关闭必须手动 close()必须在 onResponse 中 close()
适用场景后台 Service、WorkManager 等独立线程UI 触发、短时请求

💡 我的小习惯:

如果是需要在 GATT 回调里发请求,我基本都用 enqueue(),再配合一个 Handler 切到业务线程。因为 GATT 回调本身线程不可控,再同步阻塞下去,很容易出诡异问题。我之前吃过一次亏,GATT 回调 + execute 导致设备断开,后来排查了半天才发现是线程死锁——从那以后,凡是在系统回调里发网络请求,一律异步处理。

Response 处理:别以为拿到了就可以

拿到 Response 不等于万事大吉。OkHttp 的 Response 设计上是一个“流式”对象,它的 Body 只能被消费一次。如果你连续调用两次 body().string(),第二次就会返回空字符串。嗯,这个问题我在 Code Review 里提醒过很多次。

必须关闭 Response.Body

OkHttp 的 Response Body 底层是网络流,如果不手动关闭,连接池里的连接就无法复用,甚至导致文件描述符泄漏。我个人习惯用 try-finally 或者 try-with-resources(Java 7+)来确保关闭。

// Java 7 之前,手动 finally
Response response = null;
try {
    response = client.newCall(request).execute();
    String body = response.body().string();
    // 处理数据
} finally {
    if (response != null) response.close();
}

// 或者用 try-with-resources(如果 Response 实现了 Closeable)
try (Response response = client.newCall(request).execute()) {
    String body = response.body().string();
    // 自动关闭
}

⚠️ 注意:

如果你使用 enqueue(),回调参数里的 Response 也需要主动关闭。很多初学者在 onResponse() 里直接 return 不关,没问题,但一旦你启动了另一个线程去处理 Body,就必须在完成时关掉。否则你想想,每次请求都泄漏一个连接,App 跑久了肯定崩。

响应体只能读一次的真相

为什么只能读一次?因为 OkHttp 为了效率,Body 是流式读取,底层是一个 InputStream,读完了就没了。如果你想多次读取,可以先把整个内容缓存在内存里。但要注意大文件,别 OOM。

// 一次性读取整个响应体到字符串
String bodyStr = response.body().string();
// 此后不能再调用 response.body().string(),会返回空

// 如果你需要多次解析,可以存 byte[]
byte[] data = response.body().bytes();
// 然后按需处理

onResponse 里的线程切换

记住:onResponse 在 OkHttp 的线程池里运行,这个线程池是共享的。如果你在这个线程里做耗时操作(比如写数据库、解析 JSON 大文件),会阻塞其他请求的回调。我建议的原则是:在回调里只做轻量转换,然后投递到专用线程。

client.newCall(request).enqueue(new Callback() {
    @Override
    public void onResponse(Call call, Response response) throws IOException {
        // 先切到业务线程池
        businessExecutor.execute(() -> {
            try {
                String body = response.body().string();
                // 这里可以安全处理
            } catch (IOException e) {
                e.printStackTrace();
            } finally {
                response.close();
            }
        });
    }
    // ...
});

💡 避坑指南:

我曾经在一个 GATT 回调 + OkHttp 的混合场景里,忘了关闭 Response,结果每次蓝牙设备断开重连后,App 内存就涨一点。后来用 Memory Profiler 一看,OkHttp 的连接池里挂了上百个连接。关了响应体之后,内存稳如老狗。所以,关闭 Response 和关闭数据库 Cursor 一样重要。

流程串联图:同步 vs 异步 + GATT 回调

下面这张图可以帮你把整个流程串起来。红色是同步请求的阻塞路径,绿色是异步请求的非阻塞路径,中间还标注了 GATT 回调线程和主线程的关系。

OkHttp 同步 vs 异步 + GATT 回调线程模型 主线程 (UI) GATT 回调线程 execute() 同步请求 阻塞当前线程 返回 Response enqueue() 异步请求 OkHttp 工作线程 onResponse() 回调 关键提醒 同步请求会阻塞调用它的线程(如 GATT 回调),易造成死锁与泄漏 异步请求不阻塞,但 onResponse 在子线程,需注意线程切换与 Response 关闭 实线 = 同步路径;虚线 = 异步路径(非阻塞返回)

混合场景实战:GATT 回调里怎么选?

最后我们落到实际场景里。假设你在一个蓝牙 GATT 回调(比如 onServicesDiscovered)里需要发起一个 OkHttp 请求。这时你的选择会直接影响系统稳定性。

我的建议流程:

  1. 选择 enqueue():GATT 回调本身运行在系统线程池,绝不能阻塞。
  2. 在 Callback 里关闭 Response:使用 try-finally 确保释放。
  3. 切到你自己的业务线程:因为 onResponse 跑在 OkHttp 线程,别做重活。
  4. 更新 UI 用 Handler 切回主线程:或者用 LiveData /协程。

嗯,这里要注意:如果你在 GATT 回调里用了 execute(),那么该回调线程会被一直占用,导致蓝牙协议栈无法继续处理其他事件,比如断开连接或发现特征值——后果就是设备连接超时,用户体验极差。我曾经在一个 IoT 项目里就因为这个原因,导致设备配对成功率从 98% 降到了 70%,最后发现是 execute() 在 GATT 回调里堵住了线程池。改成 enqueue() 后立刻恢复。

⚠️ 关键点总结:

  • 任何时候,如果在系统回调(广播、GATT、ContentObserver)里需要发网络请求,优先用异步。
  • 如果你必须在后台线程发同步请求,确保这个线程是你自己创建的、可控的线程池,而不是系统线程。
  • Response 对象不是安全的,读完立即关,别拖延。

说实话,这些细节看起来没什么,但一旦遇上多线程混合场景,一个不小心就会踩坑。掌握了 execute 和 enqueue 的根本差异,以及 Response 的生命周期管理,你在 OkHttp 上的功力就算是扎实了。