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 | ✅ 安全 |
| 返回方式 | 直接返回 Response | Callback 回调 |
| 回调线程 | 调用线程本身 | 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 回调线程和主线程的关系。
混合场景实战:GATT 回调里怎么选?
最后我们落到实际场景里。假设你在一个蓝牙 GATT 回调(比如 onServicesDiscovered)里需要发起一个 OkHttp 请求。这时你的选择会直接影响系统稳定性。
我的建议流程:
- 选择 enqueue():GATT 回调本身运行在系统线程池,绝不能阻塞。
- 在 Callback 里关闭 Response:使用 try-finally 确保释放。
- 切到你自己的业务线程:因为 onResponse 跑在 OkHttp 线程,别做重活。
- 更新 UI 用 Handler 切回主线程:或者用 LiveData /协程。
嗯,这里要注意:如果你在 GATT 回调里用了 execute(),那么该回调线程会被一直占用,导致蓝牙协议栈无法继续处理其他事件,比如断开连接或发现特征值——后果就是设备连接超时,用户体验极差。我曾经在一个 IoT 项目里就因为这个原因,导致设备配对成功率从 98% 降到了 70%,最后发现是 execute() 在 GATT 回调里堵住了线程池。改成 enqueue() 后立刻恢复。
⚠️ 关键点总结:
- 任何时候,如果在系统回调(广播、GATT、ContentObserver)里需要发网络请求,优先用异步。
- 如果你必须在后台线程发同步请求,确保这个线程是你自己创建的、可控的线程池,而不是系统线程。
- Response 对象不是安全的,读完立即关,别拖延。
说实话,这些细节看起来没什么,但一旦遇上多线程混合场景,一个不小心就会踩坑。掌握了 execute 和 enqueue 的根本差异,以及 Response 的生命周期管理,你在 OkHttp 上的功力就算是扎实了。