关于Retrofit+Kotlin Flow协程网络请求的封装(全新版)

关于Retrofit+Kotlin Flow协程网络请求的封装(全新版)

上一篇文章讲了 Retrofit + RxJava3 + LiveData 的 Java 版网络请求封装:

https://blog.csdn.net/fzkf9225/article/details/150964127

本文源码地址:

https://github.com/fzkf9225/mvvm-componnent-master

核心文件:core-network/src/main/java/io/coderf/arklab/core/network/NetworkRepository.kt

前言

上一篇文章咱用 Java 把 Retrofit + RxJava3 的网络请求封装讲了一遍,很多小伙伴留言问 Kotlin Flow 协程版本什么时候安排。这不就来了嘛。

不过这篇不是简单地把 Observable 换成 Flow,咱的仓库里其实经历了三代演进

版本 文件 技术栈 状态
第一代 RepositoryImpl.java RxJava3(Observable/Flowable/Single) @Deprecated,仅作兼容
第二代 FlowRepositoryImpl.kt Kotlin Flow(过渡版) @Deprecated,引导迁移
第三代 NetworkRepository.kt Kotlin Flow(全新版) 当前推荐

本文以第三代全新版为主角,但会顺带讲清楚前两代的痛点,让大家理解为什么要这么改,而不是"为改而改"。

需要实现的功能

先列需求,再写代码,跟上一篇思路一样:

  1. 统一入口 :一个 request 方法搞定所有请求,不要再搞一堆重载
  2. 统一 Loading:自动显示/隐藏加载弹窗,支持动态省略号
  3. 超时控制:支持单请求超时,不依赖 OkHttp 全局超时
  4. 通用重试:网络抖动自动重试,指数退避,别把服务器打死
  5. 鉴权刷新重试 :Token 过期自动刷新并重试原请求,Single-Flight 防并发(上一篇 RxJava 版用 share() 实现,Flow 版对应实现见前文:Kotlin Flow 协程版实现刷新 Token 竞态问题
  6. 取消语义:协程取消了就老老实实取消,不重试、不弹错误提示
  7. 统一错误模型 :把各种 Throwable 收敛成一种类型,UI 层不用再写一堆 instanceof
  8. 永不抛异常:错误也通过正常通道发射,消费侧不用 try-catch
  9. 可独立单测:不依赖 Activity/Fragment/ViewModel

三个版本的演进

第一代:RepositoryImpl.java(RxJava)

老版本长这样,357 行代码,ObservableFlowableSingle 三种类型各来一套重载:

java 复制代码
// Observable 一套
sendRequest(Observable<T>, ApiRequestOptions, MutableLiveData<T>, Consumer<Throwable>)
sendRequest(Observable<T>, ApiRequestOptions, MutableStateFlow<T>, Consumer<Throwable>)
sendRequest(Observable<T>, ApiRequestOptions, Consumer<T>, Consumer<Throwable>)
sendRequest(Observable<T>, ApiRequestOptions, MutableLiveData<T>)
...
// Flowable 一套(同样的重载再来一遍)
// Single 一套(同样的重载再来一遍)

痛点很明显:

  1. 重载爆炸:3 种响应类型 × 3 种回调方式 × 带不带参数,30+ 个方法,IDE 里找半天
  2. 样板复制 :每个重载里 subscribeOn(io)observeOn(main)doOnSubscribe 显示 Loading、doFinally 隐藏 Loading,复制粘贴 N 遍
  3. Disposable 手动管理addDisposable 到处调,漏了就内存泄漏
  4. 错误处理散落new ErrorConsumer(...) 到处都是

第二代:FlowRepositoryImpl.kt(过渡版)

过渡版把请求体改成了 suspend () -> T,用 flow {} 包一层,链式串起 Loading、重试、错误处理:

kotlin 复制代码
fun <T> sendRequest(
    request: suspend () -> T,
    apiRequestOptions: ApiRequestOptions = ApiRequestOptions.getDefault()
): Flow<T> {
    return flow {
        val result = withContext(Dispatchers.IO) { request() }
        emit(result)
    }
        .onStart { /* 显示 Loading */ }
        .onCompletion { /* 隐藏 Loading */ }
        .retryWhen { cause, attempt -> handleRetry(cause, attempt) }
        .flowOn(Dispatchers.Main)
        .catch { throwable -> handleFlowError(throwable, apiRequestOptions) }
}

方向对了,但遗留问题不少:

  1. 自带 CoroutineScopeprivate val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob()),自己开作用域,违反协程结构化并发理念,还得手动 clear() 取消
  2. 错误被吞catch 里把错误交给 UI 展示后就没下文了,返回的 Flow<T> 里消费侧完全感知不到失败,想写个失败埋点都难
  3. 便捷方法冗余sendFlowsendToLiveDatasendWithCallbacksafeRequestsendRequestWithTimeoutsendPagingRequest......又走上了第一代重载爆炸的老路

第三代:NetworkRepository.kt(全新版)

全新版就一句话的设计目标:

单一入口、类型安全、错误收敛、可测试。

开始撸码

第一步:核心组件

在动手写 NetworkRepository 之前,先把积木备齐。全部放在 core-base 模块的 io.coderf.arklab.core.request 包下。

1. RequestOptions ------ 统一请求选项

替代旧版 ApiRequestOptions,一个 data class 装下所有可配置项:

kotlin 复制代码
data class RequestOptions(
    val showLoading: Boolean = true,
    val loadingMessage: String = "正在加载,请稍后...",
    val enableDynamicEllipsis: Boolean = true,
    /** null 表示使用全局默认重试策略 */
    val retryPolicy: RetryPolicy? = null,
    /** null 表示不额外设置超时(沿用 OkHttp / 调用方) */
    val timeoutMs: Long? = null,
    /** 是否把业务错误交给 RequestUi 展示(Toast / onErrorCode 等) */
    val deliverErrorToUi: Boolean = true,
    /**
     * 是否启用 TokenRefresher 鉴权刷新重试。
     * 登录 / 验证码 / 刷新 token 自身等请求应设为 false,避免递归。
     */
    val enableAuthRetry: Boolean = true
) {
    companion object {
        @JvmStatic fun defaults() = RequestOptions()
        @JvmStatic fun silent() = RequestOptions(showLoading = false, deliverErrorToUi = false)
        @JvmStatic fun noAuthRetry() = RequestOptions(enableAuthRetry = false)
    }
}

几个预置方法很实用:

  • defaults():常规请求
  • silent():静默请求,比如埋点上报,不弹窗、不提示
  • noAuthRetry():登录、验证码这类请求,本身就没鉴权,强制关掉刷新重试,防止刷新失败时递归刷自己
2. RetryPolicy ------ 指数退避重试策略
kotlin 复制代码
data class RetryPolicy(
    val maxRetries: Long = 2,
    val initialDelayMs: Long = 500,
    val maxDelayMs: Long = 5_000,
    val factor: Double = 2.0
) {
    companion object {
        @JvmField val Default = RetryPolicy()
        @JvmField val None = RetryPolicy(maxRetries = 0)
    }
}

默认策略:最多重试 2 次,延迟 500ms → 1s 指数递增,封顶 5s。

3. RequestResult ------ 统一请求结果

老版本要么发 Disposable 要么发 Observable,错误还得靠 ErrorConsumer 二次回调。全新版用密封类把结果收敛成两路:

kotlin 复制代码
sealed class RequestResult<out T> {
    data class Success<T>(val data: T) : RequestResult<T>()
    data class Error(val error: AppError) : RequestResult<Nothing>()

    inline fun onSuccess(block: (T) -> Unit): RequestResult<T> {
        if (this is Success) block(data)
        return this
    }

    inline fun onError(block: (AppError) -> Unit): RequestResult<T> {
        if (this is Error) block(error)
        return this
    }
}

ViewModel 里消费起来就是 result.onSuccess { ... }.onError { ... },链式清爽。

4. AppError ------ 统一错误模型

把各种 Throwable 收敛成一种密封类,UI 层只跟 AppError 打交道:

kotlin 复制代码
sealed class AppError(
    open val message: String,
    open val cause: Throwable? = null
) {
    data class Network(...)    // 网络错误
    data class Business(...)   // 业务错误(code/msg)
    data class Timeout(...)    // 超时
    data object Cancelled : AppError(...)  // 已取消
    data class Unknown(...)    // 兜底

    companion object {
        fun from(throwable: Throwable): AppError = when (throwable) {
            is AppErrorThrowable -> throwable.appError
            is BaseException -> fromBaseException(throwable)
            is retrofit2.HttpException -> Business(...)
            is kotlinx.coroutines.TimeoutCancellationException -> Timeout(...)
            is java.util.concurrent.CancellationException -> Cancelled
            is java.net.SocketTimeoutException, is java.net.SocketException -> Timeout(...)
            is java.io.IOException -> Network(...)
            else -> Unknown(...)
        }
    }
}

from() 是核心映射器:超时异常归 Timeout、IOException 归 Network、HttpException 归 Business、协程取消归 Cancelled,一套映射全部覆盖。

5. TokenRefresher ------ 鉴权刷新契约

对应旧版的 RetryService / FlowRetryService,由业务模块(比如 user 模块)提供实现:

kotlin 复制代码
interface TokenRefresher {
    /** 是否因登录过期 / 401 等进入刷新并重试原请求 */
    suspend fun shouldRefresh(throwable: Throwable): Boolean

    /** 执行刷新;并发调用必须复用同一次网络请求(Single-Flight) */
    suspend fun refresh()

    /** 是否属于鉴权类失败(避免鉴权失败后再走通用指数退避重试) */
    fun isAuthFailure(throwable: Throwable): Boolean = false
}

注意 refresh() 的注释:并发调用必须复用同一次网络请求 。这就是 Single-Flight,RxJava 版用 share() 多播实现,Flow 协程版用 CompletableDeferred 多路 await 实现,防止并发 401 时重复消费 refresh_token,具体竞态分析见之前那篇文章。

6. RequestUi ------ 请求过程 UI 回调

Loading 和错误展示的唯一体系入口,由 BaseViewModel 统一注入:

kotlin 复制代码
interface RequestUi {
    fun showLoading(message: String, enableDynamicEllipsis: Boolean)
    fun hideLoading()
    fun refreshLoading(message: String)
    fun showError(error: AppError)
}

object NoOpRequestUi : RequestUi { ... }  // 无 UI 场景(后台任务、单测)

第二步:NetworkRepository 契约

kotlin 复制代码
interface NetworkRepository {
    fun <T> request(
        options: RequestOptions = RequestOptions.defaults(),
        block: suspend () -> T
    ): Flow<RequestResult<T>>
}

就一个方法。请求体是 suspend () -> T,返回 Flow<RequestResult<T>>。响应体 code/msg/data 的自动拆包仍然由 Retrofit 的 BaseConverterFactory 完成,block 拿到的已经是成功的 data,跟旧栈行为一致。

第三步:DefaultNetworkRepository 实现

这是本文的核心,完整实现如下(关键逻辑已逐段拆解在后面):

kotlin 复制代码
open class DefaultNetworkRepository(
    requestUi: RequestUi = NoOpRequestUi,
    private val tokenRefresher: TokenRefresher? = null,
    private val boundApiService: ApiRetrofitService? = null
) : NetworkRepository, RequestUiHost {

    @Volatile
    private var activeRequestUi: RequestUi = requestUi

    override fun setRequestUi(ui: RequestUi?) {
        activeRequestUi = ui ?: NoOpRequestUi
    }

    override fun <T> request(
        options: RequestOptions,
        block: suspend () -> T
    ): Flow<RequestResult<T>> {
        val policy = options.retryPolicy ?: RetryPolicy.Default

        return flow {
            val timeout = options.timeoutMs
            val data = if (timeout != null && timeout > 0) {
                withTimeout(timeout.milliseconds) { block() }
            } else {
                block()
            }
            @Suppress("UNCHECKED_CAST")
            emit(RequestResult.Success(data) as RequestResult<T>)
        }
            .retryWhen { cause, attempt ->
                // ① 协程取消:不重试
                if (cause is CancellationException) return@retryWhen false

                // ② 鉴权刷新重试
                val refresher = resolveTokenRefresher()
                if (options.enableAuthRetry && refresher != null) {
                    if (refresher.shouldRefresh(cause)) {
                        return@retryWhen try {
                            refresher.refresh()
                            true
                        } catch (_: Exception) {
                            false
                        }
                    }
                    if (refresher.isAuthFailure(cause)) {
                        return@retryWhen false
                    }
                }

                // ③ 通用指数退避重试
                if (attempt >= policy.maxRetries) return@retryWhen false
                val delayMs = (policy.initialDelayMs * policy.factor.pow(attempt.toDouble()))
                    .toLong()
                    .coerceAtMost(policy.maxDelayMs)
                delay(delayMs.milliseconds)
                true
            }
            .onStart {
                if (options.showLoading) {
                    withContext(Dispatchers.Main.immediate) {
                        activeRequestUi.showLoading(options.loadingMessage, options.enableDynamicEllipsis)
                    }
                }
            }
            .onCompletion {
                if (options.showLoading) {
                    withContext(Dispatchers.Main.immediate) {
                        activeRequestUi.hideLoading()
                    }
                }
            }
            .catch { throwable ->
                val error = AppError.from(throwable)
                if (options.deliverErrorToUi && error !is AppError.Cancelled) {
                    withContext(Dispatchers.Main.immediate) {
                        activeRequestUi.showError(error)
                    }
                }
                emit(RequestResult.Error(error))
            }
            .flowOn(Dispatchers.IO)
    }
}

逐段拆解

1. flow 构建与超时
kotlin 复制代码
flow {
    val data = if (timeout != null && timeout > 0) {
        withTimeout(timeout.milliseconds) { block() }
    } else {
        block()
    }
    emit(RequestResult.Success(data))
}

block 就是业务方的 suspend 请求,比如 api.getUserInfoSuspend()。超时用 withTimeout,不传 timeoutMs 就沿用 OkHttp 的超时配置。超时抛出的 TimeoutCancellationException 会被后面的 catch 转成 AppError.Timeout

2. retryWhen 三段式重试

这是全篇的重头戏,按优先级分三段:

第一段:取消不重试

kotlin 复制代码
if (cause is CancellationException) return@retryWhen false

协程取消(比如 ViewModel 销毁触发 viewModelScope 取消)时,直接放行,不重试、不等待。这是跟 RxJava 版最大的体验差异:协程取消是结构化并发的天然能力,retryWhen 里的 delay 也会被立即打断。

第二段:鉴权刷新重试

kotlin 复制代码
val refresher = resolveTokenRefresher()
if (options.enableAuthRetry && refresher != null) {
    if (refresher.shouldRefresh(cause)) {
        return@retryWhen try {
            refresher.refresh()
            true   // 刷新成功 → 重试原请求
        } catch (_: Exception) {
            false  // 刷新失败 → 终止,走 catch 给 UI 展示错误
        }
    }
    if (refresher.isAuthFailure(cause)) {
        return@retryWhen false  // 鉴权类失败 → 不落入通用重试,避免傻等
    }
}
  • shouldRefresh:业务方判断是不是 401/登录过期,实现内部自带重试计数,超过上限返回 false
  • refresh():执行刷新,Single-Flight 保证并发只发一次网络请求
  • 刷新成功返回 true 重试原请求;刷新失败返回 false,错误走 catch 交给 UI
  • isAuthFailure:鉴权类失败直接终止,不要再落入第三段通用退避,登录过期了重试网络请求毫无意义

第三段:通用指数退避

kotlin 复制代码
if (attempt >= policy.maxRetries) return@retryWhen false
val delayMs = (policy.initialDelayMs * policy.factor.pow(attempt.toDouble()))
    .toLong()
    .coerceAtMost(policy.maxDelayMs)
delay(delayMs.milliseconds)
true

普通网络错误(IOException、超时等)走这里:延迟 = 500ms × 2^n,封顶 5s,默认最多重试 2 次。attempt 从 0 开始,attempt >= maxRetries 即停止。

3. onStart / onCompletion ------ Loading
kotlin 复制代码
.onStart {
    if (options.showLoading) {
        withContext(Dispatchers.Main.immediate) {
            activeRequestUi.showLoading(options.loadingMessage, options.enableDynamicEllipsis)
        }
    }
}
.onCompletion {
    if (options.showLoading) {
        withContext(Dispatchers.Main.immediate) {
            activeRequestUi.hideLoading()
        }
    }
}

因为整个上游链最终被 flowOn(Dispatchers.IO) 切到了 IO 线程,所以 UI 操作内部用 withContext(Dispatchers.Main.immediate) 切回主线程。immediate 的好处:如果已经在主线程就不做多余的线程跳转。

4. catch ------ 错误收敛
kotlin 复制代码
.catch { throwable ->
    val error = AppError.from(throwable)
    if (options.deliverErrorToUi && error !is AppError.Cancelled) {
        withContext(Dispatchers.Main.immediate) {
            activeRequestUi.showError(error)
        }
    }
    emit(RequestResult.Error(error))
}

注意两点:

  1. AppError.from() 统一映射后,先按 deliverErrorToUi 决定要不要交给 UI 展示
  2. 不管交不交 UI,都会 emit(RequestResult.Error(error)) ------ 错误通过正常通道下发给消费侧,这是第二代"错误被 catch 吞掉"问题的修复

取消(AppError.Cancelled)也走 Error 通道,但不做 UI 提示,消费侧可以拿它做状态复位。

5. flowOn(Dispatchers.IO) ------ 线程模型

flowOn 放在链尾,影响它之前的所有上游操作符:flow {} 里的请求体、retryWhen 里的 delay、onStart/onCompletion/catch 全部跑在 IO 线程,UI 操作在内部手动切主线程。下游(collect 端)则运行在调用方上下文viewModelScope 下就是主线程。

6. TokenRefresher 解析顺序

跟旧栈对齐,按 ApiRetrofit / ApiService 实例作用域,不是 App 进程全局

kotlin 复制代码
protected fun resolveTokenRefresher(): TokenRefresher? {
    tokenRefresher?.let { return it }                       // ① 构造参数(局部覆盖)
    val builder = boundApiService?.retrofit?.builder ?: return null
    builder.tokenRefresher?.let { return it }               // ② Builder 上 set 的 TokenRefresher
    val flowRetry = builder.flowRetryService
    return flowRetry as? TokenRefresher                     // ③ FlowRetryService 实现类本身
}

这意味着:主站 ApiService 在 Module 里 setTokenRefresher 后,只有绑定该实例的 Repository 才会刷 Token;另起一个不配置的第三方 ApiService(比如文件上传域名),完全不受影响。解析优先级:Repository 构造参数 > boundApiService 的 Builder 配置 > 无则不鉴权重试。

最终效果

仓库层

kotlin 复制代码
class UserRepositoryImpl(
    private val apiService: UserApiService,
) : BaseNetworkRepository(
    boundApiService = apiService   // 绑定 ApiService,自动解析它 Builder 上的 TokenRefresher
),
    UserProfileRepository {

    override fun refreshUserInfo(): Flow<RequestResult<UserInfo>> {
        return request(
            RequestOptions.builder()
                .showLoading(false)   // 静默刷新,不弹窗
                .build()
        ) {
            apiService.getUserInfoSuspend()
        }
    }
}

就一个 request(options) { suspend 请求 },没有线程切换、没有 Loading 代码、没有 try-catch,框架全包了。

ViewModel 层

kotlin 复制代码
viewModelScope.launch {
    userRepository.refreshUserInfo()
        .collect { result ->
            result.onSuccess { user -> /* 更新 UI */ }
                  .onError { error -> /* 一般已由 RequestUi 展示,这里做状态复位/埋点 */ }
        }
}

RequestResult 密封类两路消费,永不抛异常,ViewModel 里不需要任何 try-catch。

分页场景

分页有专门的 NetworkPagingRepository 基类,默认不弹 Loading,错误自动转异常给 Paging 展示:

kotlin 复制代码
abstract class NetworkPagingRepository<T : Any, Q : PagingQuery>(
    requestUi: RequestUi = NoOpRequestUi,
    tokenRefresher: TokenRefresher? = null,
    boundApiService: ApiRetrofitService? = null
) : BaseNetworkRepository(requestUi, tokenRefresher, boundApiService) {

    protected open val pagingRequestOptions: RequestOptions =
        RequestOptions.builder().showLoading(false).build()

    protected abstract suspend fun fetchPage(page: Int, pageSize: Int, query: Q): List<T>

    open fun loadPage(page: Int, pageSize: Int, query: Q): Flow<List<T>> {
        return request(pagingRequestOptions) { fetchPage(page, pageSize, query) }
            .map { result ->
                when (result) {
                    is RequestResult.Success -> result.data
                    is RequestResult.Error -> throw AppErrorThrowable(result.error)
                }
            }
    }
}

与 RxJava 版用法对照

场景 RxJava 老版本 Flow 全新版
发起请求 sendRequest(api.xxx(), options, liveData) request(options) { api.xxxSuspend() }
成功回调 LiveData / Consumer Flow<RequestResult<T>> collect
错误处理 ErrorConsumer 统一展示 AppError.from + RequestUi.showError
Token 刷新竞态 Observable.share() 多播 CompletableDeferred Single-Flight
线程切换 subscribeOn / observeOn 散落各处 链尾一个 flowOn(IO)
取消 Disposable.dispose() 手动管理 结构化并发,随作用域自动取消
测试 依赖 BaseRepository 构造注入 NoOpRequestUi,纯逻辑可单测

写在最后

三代演进下来,核心思路就是一句话:把散落的横切关注点(Loading、错误、重试、鉴权、线程)收敛到一条 Flow 管道里

  • 第一代 RxJava 版:功能最全,但重载爆炸、样板复制、取消靠手动
  • 第二代过渡版:Flow 化迈出了第一步,但自带作用域、错误被吞,不够纯粹
  • 第三代全新版:单一入口 + 密封类结果 + 统一错误模型 + Single-Flight 鉴权刷新,配合 BaseNetworkRepository 还能无缝接入旧框架的 BaseViewModel 装配体系

老项目迁移建议:新写的仓库直接继承 BaseNetworkRepository(它兼容旧 IRepository 体系,ViewModel 不用动),老仓库逐步替换,框架内部已经标好了 @Deprecated 指路。

下一篇预告:Token 刷新 Single-Flight 在 Flow 协程里的完整竞态分析,感兴趣的老铁先点个关注,源码已同步到 GitHub:

https://github.com/fzkf9225/mvvm-componnent-master

相关推荐
事圆则缓5 天前
从suspend字节码到 Retrofit 协程桥:挂起函数识别与恢复
android·retrofit
事圆则缓9 天前
OkHttp + Retrofit 协作原理
okhttp·retrofit
2601_9623815810 天前
Python爬虫requests框架详解!零基础学会网络请求,抓取全网数据
爬虫·python·网络请求·数据抓取·requests框架
极小狐11 天前
用 Flow 编排 Agent 智能体:极狐GitLab Duo 自定义工作流实战
ai·gitlab·agent·devops·flow·duo
felixking1 个月前
C++20 协程
开发语言·c++·协程
linweidong1 个月前
钉钉Java面经及参考答案
nacos·协程·垃圾收集器·jvm调优·spring ioc·脏读·mysql索引
消失的旧时光-19431 个月前
第一篇:Ktor Client 到底是什么?从 Retrofit 迁移理解 Ktor 网络请求架构
网络·架构·retrofit·ktor·dsl
阿pin1 个月前
Android随笔-kotlin Flow
android·kotlin·flow
XiaoLeisj1 个月前
Kotlin Flow 常用操作符:数据变换 map、filter、onEach,时间控制 debounce、sample,终端聚合 reduce、fold
android·kotlin·android jetpack·协程·响应式编程·flow