第3章 Android端Retrofit串行请求基础实现:OkHttp同步调用、RxJava concatMap、Kotlin协程串行
3.1 为什么需要三种串行实现方式?
在Android首屏加载场景中,多个接口必须按顺序执行(例如:先获取用户 → 再请求配置 → 最后加载首页数据)。串行请求的三种主流实现——OkHttp同步调用、RxJava concatMap、Kotlin协程串行——分别代表阻塞式、链式响应式、挂起式异步编程范式。理解它们的适用边界、线程模型和性能影响,是首屏优化的重要前提。
3.2 串行请求原理与首屏性能影响
- 串行原理:每次发送一个请求,只有前一个请求完成(成功或失败)后,才发起下一个请求,保证依赖顺序。
- 首屏影响:每个串行环节都会增加首屏总耗时(
RTT₁ + RTT₂ + …),而非并行时的最大RTT。但串行避免了资源冲突和回调地狱,适合强依赖场景。 - 关键指标:串行发起时间间隔,线程阻塞时间,数据转换开销。
| 实现方式 | 线程模型 | 是否阻塞调用线程 | 取消/中断支持 |
|---|---|---|---|
| OkHttp同步 | 当前线程(需子线程) | 是 | 手动关闭连接 |
| RxJava concatMap | 调度器控制(Schedulers.io) | 否(异步) | Disposable.dispose() |
| Kotlin协程 | 协程调度器(Dispatchers.IO) | 否(挂起非阻塞) | Job.cancel() / withTimeout |
3.3 OkHttp同步调用
原理
调用Call.execute()会阻塞当前线程直到响应返回。必须在非UI线程调用,否则引发NetworkOnMainThreadException或ANR。
基础实现代码
// Retrofit接口
interface ApiService {
@GET("user/info")
suspend fun getUserInfo(): UserInfo
@GET("config")
suspend fun getConfig(): AppConfig
@GET("home/data")
suspend fun getHomeData(): HomeData
}
// 使用OkHttp同步调用(子线程中)
fun fetchAllSync() {
Thread {
try {
val userInfo = api.getUserInfo().execute().body()!!
val config = api.getConfig().execute().body()!!
val homeData = api.getHomeData().execute().body()!!
// 组合数据更新UI
} catch (e: Exception) {
Log.e("Sync", "串行失败", e)
}
}.start()
}
调试方法
- 打印线程名:
Thread.currentThread().name确认在IO线程。 - 添加请求拦截器记录完整请求-响应时间戳。
- 使用
StrictMode检测主线程网络操作。
常见错误与避坑
- 在主线程调用:导致ANR,应始终使用
HandlerThread或ExecutorService。 - 未关闭Response:OkHttp的
Response.body().string()后需手动关闭body或使用try-with-resources。 - 异常中断串行:某个请求失败后后续不再执行,需在catch中处理降级逻辑。
3.4 RxJava concatMap实现
原理
concatMap保证每个上游事件产生的Observable按顺序串行发射,不会像flatMap混淆顺序。每个请求返回Single<Response>,通过concatMap串联。
基础实现代码
// Retrofit接口返回Observable
@GET("user/info")
fun getUserInfo(): Observable<Response<UserInfo>>
@GET("config")
fun getConfig(): Observable<Response<AppConfig>>
@GET("home/data")
fun getHomeData(): Observable<Response<HomeData>>
// 串行调用
api.getUserInfo()
.subscribeOn(Schedulers.io())
.observeOn(Schedulers.io())
.concatMap { userResponse ->
if (userResponse.isSuccessful) {
// 保存userInfo,继续请求
api.getConfig()
} else {
Observable.error(new Exception("用户信息失败"))
}
}
.concatMap { configResponse ->
if (configResponse.isSuccessful) {
api.getHomeData()
} else {
Observable.error(new Exception("配置失败"))
}
}
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ homeResponse ->
// 更新UI
}, { throwable ->
Log.e("RxSerial", "串行异常", throwable)
})
concatMap vs flatMap
| 操作 | 顺序保证 | 并发度 | 适用场景 |
|---|---|---|---|
| concatMap | 严格顺序 | 1 | 串行依赖请求 |
| flatMap | 无序 | 无限制 | 并行无依赖 |
调试方法
- 启用RxJava插件
RxJavaPlugins.setErrorHandler()捕获未处理异常。 - 使用
.doOnNext()打印每个阶段数据。 - 检查
Disposable是否在Activity/Fragment销毁时正确清理。
常见错误与避坑
- 在concatMap中切换调度器导致顺序错乱:保持所有请求在同一个Scheduler中。
- 内存泄漏:未在onDestroy中
disposable.dispose()。 - 未处理onError中断:错误发生后整个链终止,需要
onErrorReturn或retry。
3.5 Kotlin协程串行实现
原理
挂起函数suspend天然支持顺序调用,每个await()或直接调用将挂起当前协程直到结果返回,不会阻塞线程。配合Dispatchers.IO执行网络请求。
基础实现代码
// Retrofit接口定义为suspend
@GET("user/info")
suspend fun getUserInfo(): UserInfo
@GET("config")
suspend fun getConfig(): AppConfig
@GET("home/data")
suspend fun getHomeData(): HomeData
// 在ViewModel或LifecycleScope中调用
viewModelScope.launch {
try {
val userInfo = withContext(Dispatchers.IO) { api.getUserInfo() }
val config = withContext(Dispatchers.IO) { api.getConfig() }
val homeData = withContext(Dispatchers.IO) { api.getHomeData() }
// 更新UI(已在主线程)
} catch (e: Exception) {
Log.e("CoroutineSerial", "串行失败", e)
}
}
调试方法
- 使用
CoroutineName上下文为协程命名:launch(CoroutineName("serial-fetch"))。 - 在
withContext前后打印当前线程与协程上下文。 - 利用Android Studio的Coroutine Debugger查看挂起状态。
常见错误与避坑
- 在UI线程直接调用suspend函数:Retrofit默认在主线程执行挂起,应使用
withContext(Dispatchers.IO)或通过await()指定调度器。 - 未捕获异常导致协程取消:顶层try-catch必须覆盖整个串行块。
- 不合理使用GlobalScope:应使用结构化并发(viewModelScope或lifecycleScope)。
3.6 三种实现对比与选择建议
| 维度 | OkHttp同步 | RxJava concatMap | Kotlin协程 |
|---|---|---|---|
| 代码可读性 | 低(线程管理显式) | 中(链式调用) | 高(顺序代码) |
| 线程开销 | 1:1阻塞线程 | 复用IO线程池 | 挂起无阻塞 |
| 错误处理 | try-catch逐层 | onError统一处理 | try-catch自然结构 |
| 取消机制 | 需手动中断连接 | Disposable | Job.cancel() |
| 推荐场景 | 简单后台任务 | 已有RxJava项目 | 新项目/协程生态 |
3.7 性能优化策略
- 超时设置:为每个请求设置短超时(如5s),避免串行因单个请求卡死。
- 连接复用:OkHttp默认开启Keep-Alive,减少三次握手。
- 缓存中间结果:对不常变的数据(如配置)使用内存缓存,降低串行耗时。
- 提前取消:当用户离开页面时取消未完成的串行请求,释放资源。
3.8 实际案例复盘:首屏串行加载用户信息、配置、首页
背景:某社交App首屏需要依次获取用户资料、个性化配置、首页Feed列表。原实现使用回调嵌套(Callback Hell),维护困难且ANR频发。
改造方案:采用Kotlin协程串行,使用viewModelScope.launch + Dispatchers.IO,每个请求前检查协程是否取消。改造后首屏加载时间从2.8s降至2.1s(主要节省了线程切换和回调开销),ANR率下降90%。
关键优化点:配置接口允许10分钟本地缓存,无需每次串行调用;用户信息接口增加了readTimeout(3, TimeUnit.SECONDS)。
3.9 常见错误与避坑总结
| 错误类型 | OkHttp同步 | RxJava concatMap | Kotlin协程 | 通用解决方案 |
|---|---|---|---|---|
| 线程阻塞 | 主线程直接调用→ANR | 在UI线程subscribe→阻塞UI | 未指定IO调度器→主线程挂起 | 确保网络在IO线程执行 |
| 内存泄漏 | Thread泄漏 | Disposable未释放 | 协程作用域未绑定生命周期 | 使用LifecycleObserver/ViewModelScope |
| 异常处理不当 | catch中未回退 | onError后链断裂 | try块未包裹全部请求 | 在串行头部统一try-catch,提供默认值 |
| 请求取消 | 无法优雅取消 | 调用dispose() | 取消协程后需检查isActive | 实现Cancelable接口或使用协程取消令牌 |
最佳实践提示:在首屏串行加载中,优先使用Kotlin协程+结构化并发,配合
withTimeout设置单请求超时,并利用supervisorScope避免一个子协程失败导致全部取消。