第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)。

三种Retrofit串行请求实现流程图 串行请求核心流程:请求1 → 请求2 → 请求3 OkHttp同步 Thread + execute() 阻塞当前线程 需手动管理循环 RxJava concatMap Observable链式调用 Schedulers.io()非阻塞 Disposable管理取消 Kotlin协程 suspend挂起函数 withContext(IO)非阻塞 结构化并发取消 串行结果:按顺序拿到UserInfo → Config → HomeData 任一环节失败则整个串行终止(需降级处理) 选择哪种方式取决于团队技术栈、性能要求及错误处理复杂度

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避免一个子协程失败导致全部取消。