关于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(全新版) | 当前推荐 |
本文以第三代全新版为主角,但会顺带讲清楚前两代的痛点,让大家理解为什么要这么改,而不是"为改而改"。
需要实现的功能
先列需求,再写代码,跟上一篇思路一样:
- 统一入口 :一个
request方法搞定所有请求,不要再搞一堆重载 - 统一 Loading:自动显示/隐藏加载弹窗,支持动态省略号
- 超时控制:支持单请求超时,不依赖 OkHttp 全局超时
- 通用重试:网络抖动自动重试,指数退避,别把服务器打死
- 鉴权刷新重试 :Token 过期自动刷新并重试原请求,Single-Flight 防并发(上一篇 RxJava 版用
share()实现,Flow 版对应实现见前文:Kotlin Flow 协程版实现刷新 Token 竞态问题) - 取消语义:协程取消了就老老实实取消,不重试、不弹错误提示
- 统一错误模型 :把各种 Throwable 收敛成一种类型,UI 层不用再写一堆
instanceof - 永不抛异常:错误也通过正常通道发射,消费侧不用 try-catch
- 可独立单测:不依赖 Activity/Fragment/ViewModel
三个版本的演进
第一代:RepositoryImpl.java(RxJava)
老版本长这样,357 行代码,Observable、Flowable、Single 三种类型各来一套重载:
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 一套(同样的重载再来一遍)
痛点很明显:
- 重载爆炸:3 种响应类型 × 3 种回调方式 × 带不带参数,30+ 个方法,IDE 里找半天
- 样板复制 :每个重载里
subscribeOn(io)、observeOn(main)、doOnSubscribe显示 Loading、doFinally隐藏 Loading,复制粘贴 N 遍 - Disposable 手动管理 :
addDisposable到处调,漏了就内存泄漏 - 错误处理散落 :
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) }
}
方向对了,但遗留问题不少:
- 自带 CoroutineScope :
private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob()),自己开作用域,违反协程结构化并发理念,还得手动clear()取消 - 错误被吞 :
catch里把错误交给 UI 展示后就没下文了,返回的Flow<T>里消费侧完全感知不到失败,想写个失败埋点都难 - 便捷方法冗余 :
sendFlow、sendToLiveData、sendWithCallback、safeRequest、sendRequestWithTimeout、sendPagingRequest......又走上了第一代重载爆炸的老路
第三代: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/登录过期,实现内部自带重试计数,超过上限返回 falserefresh():执行刷新,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))
}
注意两点:
AppError.from()统一映射后,先按deliverErrorToUi决定要不要交给 UI 展示- 不管交不交 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: