Android 多层架构下如何优雅地处理 API 响应与异常
原文:《Scalable API Response Handling Across Multi Layered Architectures with Sandwich》------Jaewoong Eum
有删改。

在如今 MVVM 和 MVI 当道的 Android 开发中,多层架构已是标配。数据需要在 Data Source、Repository、Domain (UseCase) 和 ViewModel 之间像接力棒一样层层传递,最终抵达 UI。这种分层设计确实实现了代码解耦,但也带来了一个棘手难题:当 API 响应(特别是各种异常和错误)需要跨越重重层级时,该如何优雅地处理?
绝大多数开发者的解决方案都很"简单粗暴":直接用 try-catch 块包裹 API 调用,一旦出错就返回一个默认值(Fallback)。在小项目里,这种做法或许还能凑合,但随着 API 接口数量的膨胀,它的弊端就会越来越明显:结果充满了歧义,到处都是重复的样板代码,下游层级急需的上下文信息也被丢失了。
试想一下,你在 ViewModel 里拿到一个空列表,却根本分不清这到底是"服务端确实没数据"还是"网络请求挂了";Repository 层无意间"吞掉"了关键的报错细节;而数据源层则是把同样的错误处理代码复制粘贴了几十遍。
本文我们将深入聊一聊在多层架构中处理 Retrofit API 调用时会遇到的这些痛点,剖析为什么传统做法在项目规模扩大后会难以为继。同时,我会向大家介绍 Sandwich ------ 一个类型安全且高度可组合的解决方案,它能帮你把从网络层到 UI 层的响应处理变得异常简单。我们会从最基础的用法讲起,一直到顺序组合、响应合并、全局错误映射这些高级操作,并且结合真实的业务场景,手把手教你如何优雅地解决这些问题。
Retrofit 配合协程的使用现状
先来看最常见的场景,现在绝大多数 Android 项目都在用 Retrofit 配合 Kotlin 协程来处理网络通信。一个典型的 Service 接口定义通常长这样:
kotlin
interface PosterService {
@GET("DisneyPosters.json")
suspend fun fetchPosterList(): List<Poster>
}
接口直接返回了 List<Poster>。Retrofit 贴心地帮我们将 JSON 响应体反序列化,把数据直接喂到嘴边。

风和日丽、一切顺利、请求成功的时候,这套机制运行得简直完美。但问题在于------它完全没有给你提供一套结构化的方式来处理"失败"的情况。
不出意外的话,那就应该是出意外了,服务器返回了非 2xx 的状态码,Retrofit 会抛出一个 HttpException;网络连接出了问题,又会抛出各种 IO 异常。结果就是,捕获这些异常的脏活累活,全得由调用者自己扛。

当我们在 Data Source(数据源)层去调用这个接口时,传统的"标准写法"通常是下面这样:
kotlin
class PosterRemoteDataSource(
private val posterService: PosterService,
) {
suspend fun fetchPosterList(): List<Poster> {
return try {
posterService.fetchPosterList()
} catch (e: HttpException) {
emptyList()
} catch (e: Throwable) {
emptyList()
}
}
}
美国的事我不知道,对于国内开发者而言,要处理奇葩后端返回的各种自定义
status_code,痛苦程度不必多言...
这里的 Data Source 简单粗暴地捕获了所有可能的异常,并返回一个 emptyList() 作为兜底。这导致了一个尴尬的局面:站在调用者的角度看,这个函数仿佛是"永远成功"的,因为它总是能返回一个 List<Poster>。
传统处理方式的几大硬伤
上面的代码看似人畜无害,实际上隐藏了三个随着项目规模扩大而愈发严重的隐患。
1. 结果充满了歧义
无论是因为 HTTP 报错还是网络异常(数据包收发失败),数据源都会简单粗暴地返回一个 emptyList(),下游层级(如 Repository 或 ViewModel)拿到一个空列表时,完全一脸懵逼,根本分不清三种截然不同的情况:
- 请求成功了,服务器确实返回了一个空列表。
- 请求失败了,比如报了个 401 未授权错误。
- 压根没网,连请求都没发出去。
这三种情况最终都是拿到一个空列表,Repository 没法决定是该报错、跳转登录页,还是显示"暂无数据"。响应的上下文(Context)就这样丢失了,一旦在源头丢失,下游逻辑再怎么努力也救不回来。
你可能会想:"那我出错时返回 null 而不是空列表行不行?" 但这只会引入新的歧义:null 到底代表"出错了"还是"没数据"呢?
最后你还是写了一个包装类,但这样写就没问题了吗?你为了解决一个问题,又引入了一套隐式的约定,还得天天记着这个约定。
kotlin
sealed class Resource<T>(
val data: T? = null,
val message: String? = null
) {
class Success<T>(data: T) : Resource<T>(data)
class Error<T>(message: String, data: T? = null) : Resource<T>(data, message)
class Loading<T> : Resource<T>()
}
即使这么写,还是得在每个调用 API 的地方进行 try catch。而且 Resource.Error 只包含一个 message: String,原始的 HTTP 状态码(Status Code)、Headers、以及具体的 Exception 类型全都丢了。
2. 样板代码满天飞
每一个 API 调用都需要裹上一层 try-catch。如果你有 20 个接口方法,你就得写 20 个几乎一模一样的 try-catch 块。每一个都要捕获 HttpException,捕获 Throwable,然后返回某种默认值。徒增维护成本与出错概率------比如在第 20 个方法里如果不小心忘写了某种异常捕获,程序可能直接就挂了。
kotlin
class UserRemoteDataSource(private val userService: UserService) {
suspend fun fetchUser(id: String): User? {
return try {
userService.fetchUser(id)
} catch (e: HttpException) { null }
catch (e: Throwable) { null }
}
suspend fun fetchFollowers(id: String): List<User> {
return try {
userService.fetchFollowers(id)
} catch (e: HttpException) { emptyList() }
catch (e: Throwable) { emptyList() }
}
suspend fun updateProfile(profile: Profile): Boolean {
return try {
userService.updateProfile(profile)
true
} catch (e: HttpException) { false }
catch (e: Throwable) { false }
}
...
}
3. 响应处理上下文丢失
Repository 和 UI 层最终拿到的只是那个"被阉割"后的原始数据类型(List<Poster>, User?, Boolean)。它们完全接触不到 HTTP 状态码、错误响应体或者异常的细节。
如果 ViewModel 需要根据不同的错误类型给用户不同的反馈------比如 401 提示"会话过期",503 提示"系统维护中"------数据源就必须想方设法把这些信息硬塞进返回值里。
这时通常只有三个选择:要么把异常往上抛(但这违背了我们想在数据源捕获异常的初衷),要么给每个 API 都定义一套复杂的 Sealed Class(会让样板代码更多),要么干脆"装傻",把这些关键信息全丢了。
你真正需要的,是一个单一的、标准化的类型。 这个类型应该能完美封装 API 调用的所有结果------不管是成功的数据、错误的细节,还是异常信息------并且能让这些信息在架构的每一层之间自由流动,不丢失任何上下文。
而这,正是 Sandwich 能给你的。
初识 Sandwich
Sandwich 是一个 Kotlin Multiplatform (KMP) 库,它的核心思想就是利用 Sealed Types(密封类型) 为 API 和 I/O 调用构建一套标准化的响应模型。
它提供了一个非常轻量级的 ApiResponse 类型,将"成功"、"错误"和"异常"这三种情况明确地区分为不同的子类型。自带了一套丰富且可组合的扩展函数,让你在架构的各个层级中能干净利落地处理、转换、恢复、校验、过滤以及合并这些响应。
Sandwich 完美支持 Retrofit、Ktor 和 Ktorfit。它不需要使用注解,也不涉及代码生成,不需要引入奇怪的 Gradle 插件,配置简单。
快速上手 (Setup)
首先,在你的模块 build.gradle.kts 中添加依赖:
kotlin
dependencies {
// 根据你使用的 HTTP 客户端选择对应的模块:
implementation("com.github.skydoves:sandwich-retrofit:$version") // for Retrofit
implementation("com.github.skydoves:sandwich-ktor:$version") // for Ktor
implementation("com.github.skydoves:sandwich-ktorfit:$version") // for Ktorfit
}
Retrofit 用户
只需要在构建 Retrofit 实例时,添加一个 ApiResponseCallAdapterFactory:
diff
val retrofit = Retrofit.Builder()
.baseUrl(BASE_URL)
+ .addCallAdapterFactory(ApiResponseCallAdapterFactory.create())
.addConverterFactory(MoshiConverterFactory.create())
.build()
这个 Factory 会自动拦截所有的 Retrofit 调用,并将结果包装成 ApiResponse。它会自动处理成功的响应、HTTP 错误以及网络异常。这意味着你再也不需要手动写 try-catch 块或者自定义拦截器了。
接下来,把你的 Service 接口返回值改成 ApiResponse<T> 即可:
diff
interface PosterService {
@GET("DisneyPosters.json")
- suspend fun fetchPosterList(): List<Poster>
+ suspend fun fetchPosterList(): ApiResponse<List<Poster>>
}
就这么简单,所有的结果------无论是成功拿到数据,还是遇到了 HTTP 错误,亦或是网络异常------都被 ApiResponse 这个返回类型统统"拿捏"了。
Ktor 用户
直接使用 apiResponseOf 这个扩展函数来包裹任意的 Ktor HttpResponse:
kotlin
val apiResponse = apiResponseOf<List<Poster>> {
client.get("DisneyPosters.json")
}
Ktorfit 用户
添加 ApiResponseConverterFactory 即可:
diff
val ktorfit = Ktorfit.Builder()
.baseUrl(BASE_URL)
+ .converterFactories(ApiResponseConverterFactory.create())
.build()
深入理解 ApiResponse
ApiResponse 是一个密封接口(Sealed Interface),它通过三个子类型完美覆盖了 API 调用可能出现的所有结果。
简单来说,ApiResponse 主要由以下三个子类组成:
ApiResponse.SuccessApiResponse.Failure.ErrorApiResponse.Failure.Exception

1. ApiResponse.Success
代表请求成功。 意味着服务器返回了 2xx 范围的状态码,并且响应体(Body)也成功反序列化了。此时可以通过 data 属性直接获取反序列化后的数据对象:
kotlin
val apiResponse = ApiResponse.Success(data = posterList)
val data: List<Poster> = apiResponse.data
如果用的是 Retrofit,ApiResponse.Success 还携带了原始的响应元数据比如 statusCode 和 headers:
kotlin
val statusCode: StatusCode = apiResponse.statusCode
val headers: Headers = apiResponse.headers
当你需要从成功的响应中读取分页 Header、缓存控制指令(Cache Control) 或者 速率限制(Rate Limit)信息时,这些元数据就能派上用场了。
2. ApiResponse.Failure.Error
代表 HTTP 错误响应。 意思是:服务器收到了你的请求,也给你回信了,但状态码不是 2xx。比如常见的 400 Bad Request**、401 Unauthorized 、403 Forbidden 、404 Not Found、**500 Internal Server Error 等等,所有非 2xx 的 HTTP 错误都在这里。
可以通过 payload 拿到完整的响应体:
kotlin
val apiResponse = ApiResponse.Failure.Error(payload = errorBody)
val payload = apiResponse.payload
- 在 Retrofit 中,
payload是原始的okhttp3.Response,可以读取 Error Body、状态码和 Header。 - 在 Ktor 中,
payload则是HttpResponse。
无论你用的是什么 HTTP 客户端,完整的错误上下文都被完好地保留了下来。
3. ApiResponse.Failure.Exception
代表在收到服务器 HTTP 响应之前发生的客户端异常。
Error 意味着网络请求走完了,服务器也回话了(虽然是报错)。Exception 是意味着请求压根就没跑完,甚至可能都没发出去。
常见的原因包括:
- 网络连接失败(比如开了飞行模式,或者没连 WiFi)
- DNS 解析错误(找不到服务器域名)
- 连接超时(服务器没在规定时间内响应)
- SSL/TLS 握手失败(证书有问题)
- JSON 解析错误(响应体格式不对,解析崩了)
kotlin
val apiResponse = ApiResponse.Failure.Exception(throwable = exception)
val throwable: Throwable = apiResponse.throwable
val message: String? = apiResponse.message
如果服务器返回 401(Failure.Error),你应该提示用户"请重新登录";如果是因为没网导致请求失败(Failure.Exception),你应该提示"请检查网络连接"。
优雅地处理 ApiResponse
既然 ApiResponse 已经把所有可能的请求结果都包装起来了,接下来的问题是:怎么处理它们才最优雅?
类似 Kotlin 的 Result 类,Sandwich 提供了一套丝滑的链式扩展函数,可以针对不同的响应类型,挂载对应的 Lambda 代码块:
kotlin
val response = posterService.fetchPosterList()
response.onSuccess {
// 这里的 this 是:ApiResponse.Success<List<Poster>>
// 直接拿 `data` 就能用
val posters: List<Poster> = data
}.onError {
// 这里的 this 是:ApiResponse.Failure.Error
// 可以直接访问 `payload`, `message()`, `statusCode` 等
val message = message()
}.onException {
// 这里的 this 是:ApiResponse.Failure.Exception
// 可以直接访问 `throwable`, `message` 等
val cause = throwable
}
当然,如果你的业务逻辑不需要分得那么细,不在乎到底是服务器报错还是断网了,可以用 onFailure 把 Error 和 Exception 这两种失败情况"一把抓":
kotlin
response.onSuccess {
_posters.value = data
}.onFailure {
// 这里的 this 是:ApiResponse.Failure
// 同时也处理了 Error 和 Exception
_error.value = message()
}
使用 when 表达式进行穷尽式匹配
除了链式调用的"组合拳",也可以回归朴素,利用 Kotlin 的 when 表达式来进行穷尽式匹配,毕竟 ApiResponse 是密封接口,编译器会帮你把关,它会强制要求你必须处理掉 ApiResponse 的每一种子类型。
kotlin
when (response) {
is ApiResponse.Success -> {
val posters = response.data
}
is ApiResponse.Failure.Error -> {
val message = response.message()
}
is ApiResponse.Failure.Exception -> {
val throwable = response.throwable
}
}
基于 ApiResponse 的多层架构实践
引入 ApiResponse 后,整个架构逻辑会发生质的改变。
首先,Data Source(数据源层)变得异常简单,再也不需要写那些繁琐的 try-catch 块,不需要考虑返回什么兜底默认值,甚至不需要任何错误处理逻辑:
kotlin
class PosterRemoteDataSource(
private val posterService: PosterService,
) {
suspend fun fetchPosterList(): ApiResponse<List<Poster>> {
return posterService.fetchPosterList()
}
}
现在的 Data Source 变成了一个纯粹的"透传者"(Pass-through)。所有的错误上下文都被原封不动地保存在了 ApiResponse 里。
接下来是 Repository(仓库层)。它可以在保持 ApiResponse 包装完整性的同时,插入业务逻辑。比如,拦截成功的请求进行缓存:
kotlin
class PosterRepository(
private val remoteDataSource: PosterRemoteDataSource,
private val posterDao: PosterDao,
) {
suspend fun fetchPosterList(): ApiResponse<List<Poster>> {
val response = remoteDataSource.fetchPosterList()
response.onSuccess {
posterDao.insertPosterList(data) // 缓存成功的数据
}
return response
}
}
最后到达 ViewModel。由于拿到了完整的上下文,ViewModel 可以针对每一种场景做出精准的 UI 决策:
kotlin
class PosterViewModel(
private val repository: PosterRepository,
) : ViewModel() {
fun fetchPosters() {
viewModelScope.launch {
val response = repository.fetchPosterList()
response.onSuccess {
_posters.value = data
}.onError {
// 服务器返回了错误(如 400, 500);展示对应的错误提示
_error.value = message()
}.onException {
// 网络不通或客户端异常;建议用户检查网络连接
_error.value = "Please check your internet connection."
}
}
}
}
这就实现了我们梦寐以求的效果:响应对象携带了完整的上下文信息,穿透了架构的每一层。
ViewModel 可以轻松区分"数据为空"(成功的空列表)、"服务器错误"(401, 500 等)以及"网络故障"(没网、超时)。Data Source 在传递过程中没有丢失任何信息,每一层都在各司其职(比如 Repository 做缓存),而且不会剥离掉其他层(比如 UI 层)所需要的关键信息。
直接提取数据
有时候,你可能只是想简单直接地把数据从响应里拿出来:
kotlin
// 如果请求成功则返回数据,否则返回 null
val posters: List<Poster>? = response.getOrNull()
// 如果请求成功则返回数据,否则返回一个默认值
val posters: List<Poster> = response.getOrElse(emptyList())
// 如果请求成功则返回数据,否则直接抛出异常
val posters: List<Poster> = response.getOrThrow()
此外,getOrElse 也支持 Lambda 写法实现惰性求值:
kotlin
val posters: List<Poster> = response.getOrElse {
posterDao.getCachedPosters() // 只有在请求失败时,这行代码才会执行
}
ApiResponse 与协程、Flow 的完美结合
对于那些重度依赖 Kotlin Flow 的响应式架构,Sandwich 贴心地提供了一套挂起(Suspend)版本的扩展函数。
调用一个挂起函数发起网络请求,成功了就再调用另一个挂起函数保存到数据库,这种场景太常见了,但标准的 onSuccess、onError 和 onException 接收的都是普通 Lambda,不能在这些 Lambda 内直接调用挂起函数,我们可以使用 suspendOnSuccess、suspendOnError 和 suspendOnException:
kotlin
fun fetchPosterList() = flow {
val response = posterService.fetchPosterList()
response.suspendOnSuccess {
posterDao.insertPosterList(data) // 挂起函数:保存到数据库
emit(data) // 挂起函数:发射数据到 Flow
}.suspendOnError {
val cached = posterDao.getCachedPosterList() // 挂起函数:读取缓存兜底
emit(cached)
}.suspendOnException {
emit(emptyList())
}
}.flowOn(Dispatchers.IO)
每个处理程序的作用域都是一个挂起 Lambda,可以直接在里面进行数据库插入、额外的 API 调用、文件 I/O 等任何耗时操作,不需要再手动嵌套一层 launch 或 withContext。
kotlin
public suspend inline fun <T> ApiResponse<T>.suspendOnSuccess(
// 注意这里的函数参数是 suspend
crossinline onResult: suspend ApiResponse.Success<T>.() -> Unit,
): ApiResponse<T> {
...
if (this is ApiResponse.Success) {
onResult(this)
}
return this
}
直接转换为 Flow
如果你只想要成功的数据流,想把失败处理当作一种"副作用"(Side Effect)来处理(比如记个日志、弹个 Toast),可使用 toFlow() 扩展:
kotlin
val flow: Flow<List<Poster>> = posterService.fetchPosterList()
.onError {
logger.error("API error: ${message()}")
}.onException {
logger.error("Network error: $message")
}.toFlow()
toFlow() 会创建一个只发射成功数据的 Flow。如果请求失败了,这个 Flow 就什么都不发射(返回 emptyFlow())。当你只希望成功的数据流入状态管理(State),而失败仅仅需要打个日志时,用它就对了。
进阶玩法: 你甚至可以在转换过程中直接对数据进行"加工"。这是 Repository 层最经典的写法------先缓存,再读取:
kotlin
val flow = posterService.fetchPosterList()
.toFlow { posters ->
posters.forEach { it.page = page }
posterDao.insertPosterList(posters) // 先存库
posterDao.getAllPosterList(page) // 再返回从数据库读取的数据
}.flowOn(Dispatchers.IO)
在这个 Lambda 里,你拿到的是成功的数据,返回的是转换后的结果。所有的数据库操作都运行在 Flow 的协程上下文中。
响应的映射与转换
在现实世界的开发中,后端 API 返回的数据格式,往往跟 UI 层需要展示的格式是不一致的。
比如,服务器可能返回一个庞大的 UserAuthResponse,里面包含了各种 Token、元数据(Metadata)等一大坨东西;但你的 ViewModel 可能只需要一个清爽的 LoginInfo,里面只包含用户对象和 Token 字符串。Sandwich 提供了一系列映射扩展(Mapping Extensions),能让开发者在 ApiResponse 内部直接对数据进行"整容",同时既不会打断链式调用,也不会弄丢原本的错误上下文。
mapSuccess
这个函数专门用来转换成功 的数据,把类型 T 变成类型 V。如果原本的响应是失败的(Failure),那它就什么都不做,直接原样透传:
kotlin
val response: ApiResponse<LoginInfo> = authService.requestToken(
UserRequest(authProvider = provider, authIdentifier = id, email = email),
).mapSuccess {
// 这里的 this 就是原本的 UserAuthResponse
LoginInfo(user = user, token = token)
}
这在 Repository 层简直是神器,尤其是当需要把 API 实体模型(DTO)转换成 业务模型(Domain Models)的时候。
Service 层返回的是 ApiResponse<UserAuthResponse>,经过转换后,Repository 层暴露给 ViewModel 的就是干净的 ApiResponse<LoginInfo>。所有的转换逻辑都收拢在了一处,完全不用担心会影响到那些失败的响应。
还有一个常见的用法:从一个列表响应中提取出某一项:
kotlin
val response: ApiResponse<Poster?> = posterService.fetchPosterList()
.mapSuccess { firstOrNull() } // 直接取第一个,取不到就返回 null
mapFailure
顾名思义,它是用来转换失败的数据载荷(payload / throwable):
kotlin
public fun <T> ApiResponse<T>.mapFailure(transformer: Any?.() -> Any?): ApiResponse<T> {
if (this is ApiResponse.Failure.Error) {
return ApiResponse.Failure.Error(payload = transformer.invoke(payload))
} else if (this is ApiResponse.Failure.Exception) {
return ApiResponse.exception(ex = (transformer.invoke(throwable) as? Throwable) ?: throwable)
}
return this
}
flatMap
它能把一个 ApiResponse 转换成一个完全不同的 ApiResponse。
跟只转换 data 数据的 mapSuccess 不同,flatMap 让你能拿到整个响应对象,并且允许你返回任意类型的 ApiResponse。这在处理自定义错误类型时威力巨大------你可以把服务器返回的错误 Body 解析出来,直接映射成你自定义的错误类型:
kotlin
val response = service.fetchMovieList()
.flatMap {
// 如果是 Error,尝试解析错误体
if (this is ApiResponse.Failure.Error) {
val errorBody = (payload as? Response)?.body?.string()
if (errorBody != null) {
val error: ErrorMessage = Json.decodeFromString(errorBody)
// 根据错误码返回自定义的错误类型(继承自 ApiResponse.Failure.Error)
when (error.code) {
10000 -> LimitedRequest
10001 -> WrongArgument
else -> this
}
} else this
} else this
}
经过这个 flatMap 处理后,响应要么是原本成功的 ApiResponse.Success(保持不变),要么就是你自定义的错误类型(比如 LimitedRequest 或 WrongArgument)。
这样一来,下游层级(比如 ViewModel)就可以直接对这些自定义类型进行模式匹配(Pattern Match),不用重复去解析 JSON 错误体了:
kotlin
response.onError {
when (this) {
LimitedRequest -> showRateLimitDialog() // 限流错误,弹窗提示
WrongArgument -> showValidationError() // 参数错误,提示校验失败
else -> showGenericError() // 其他错误,兜底处理
}
}
串行依赖请求的处理
现实世界的开发里,经常会遇到这种"串行"的工作流:后一个 API 调用必须依赖前一个调用的结果。
例如:你需要先去获取一个认证 Token,拿到 Token 后再去拉取用户详情,最后根据用户的名字去查询海报列表。在这个链条中,只要有任何一环掉链子,整个流程就应该立即停止,并抛出那个步骤的错误。
Sandwich 专门为此提供了 then 和 suspendThen 这两个中缀函数(Infix Functions),完美解决了这个问题:
kotlin
val response = service.getUserToken(userId) suspendThen { tokenResponse ->
// 第一步成功了,拿到 token 继续查用户详情
service.getUserDetails(tokenResponse.token)
} suspendThen { userResponse ->
// 第二步也成功了,拿到用户名继续查海报
service.queryPosters(userResponse.user.name)
}
response.onSuccess {
// 只有三步全通了,这里才会执行
_posters.value = data
}.onFailure {
// 任何一步挂了,错误都会传到这里
_error.value = message()
}
逻辑非常清晰:如果 getUserToken 失败了,后面的 getUserDetails 和 queryPosters 压根就不会执行。
任意一步的失败都会直接"穿透"到最后的 onFailure 处理函数。
你甚至可以把 suspendThen 和 mapSuccess 结合起来,打一套"组合拳",直接把最终结果转换成 UI 需要的数据:
kotlin
service.getUserToken(userId) suspendThen { tokenResponse ->
service.getUserDetails(tokenResponse.token)
} suspendThen { userResponse ->
service.queryPosters(userResponse.user.name)
}.mapSuccess { posterResponse ->
// 只取海报列表
posterResponse.posters
}.onSuccess {
posterStateFlow.value = data
}.onFailure {
Log.e("API", message())
}
错误恢复与兜底策略
网络请求这东西,总有翻车的时候。服务器宕机、手机断网、API 莫名其妙报错,这些都是家常便饭。Sandwich 提供了一套强大的恢复扩展(Recovery Extensions),让你能优雅地定义"兜底"行为,同时还能保持响应链的完整性。
recover
这个操作符的作用是:如果响应失败了,它会返回一个包含兜底值的 ApiResponse.Success;如果响应本来就是成功的,那就原样透传,啥也不改:
kotlin
val response : ApiResponse = posterService.fetchPosterList()
.recover(emptyList())
源码很简单:
kotlin
public fun <T> ApiResponse<T>.recover(fallback: T): ApiResponse<T> {
if (this is ApiResponse.Failure) {
return ApiResponse.Success(data = fallback)
}
return this
}
可以把 recover 和其他扩展函数串起来用:
kotlin
val response = posterService.fetchPosterList()
.onError { logger.error("API error: ${message()}") }
.onException { crashReporter.record(throwable) }
.recover(emptyList())
想要惰性求值,可以用 Lambda 版本的写法:
kotlin
val response = posterService.fetchPosterList()
.recover { posterDao.getCachedPosterList() }
recoverWith
这个操作符用于通过执行另一个本身也返回 ApiResponse 的操作来进行恢复。这在你的兜底逻辑不仅仅是一个静态值,而是另一个 API 调用或者数据库查询(且它们自己也可能失败)时非常有用:
kotlin
val response = primaryService.fetchPosterList()
// 或 .suspendRecoverWith { failure ->
.recoverWith { failure ->
// 尝试调用备份服务;注意这个备份服务返回的也是 ApiResponse
backupService.fetchPosterList()
}
逻辑很顺:主服务挂了,就调备份服务。如果连备份服务也挂了,那这个失败就是最终结果。
如果要调用挂起函数,记得用 suspendRecoverWith。
数据校验
有时候,一个 HTTP 200 OK 的响应并不代表一切都好。服务器虽然返回了成功状态码,但数据本身可能是不合法的、空的,或者是缺胳膊少腿的。Sandwich 提供了一套校验扩展(Validation Extensions),能帮你把这些不符合业务要求的"伪成功"响应,强制转换成失败(Failure)。
validate
你可以通过它定义一个判断条件(Predicate)。如果数据没通过这个校验(即返回 false),原本成功的响应就会立马"变身"成一个 ApiResponse.Failure.Error,并带上你指定的错误信息:
kotlin
val response = posterService.fetchPosterList()
.validate(
predicate = { it.isNotEmpty() }, // 列表必须不为空
errorMessage = { "Poster list cannot be empty" }, // 否则报错
)
这样一来,如果列表是空的,下游的处理逻辑就会收到一个 Failure.Error,尽管从 HTTP 协议层面上看请求是成功的。对于那些服务端逻辑出错却只给你扔个空数组、而不返回标准错误码的 API 来说,这简直是救星。
requireNotNull
这个函数用于强制要求成功数据里的某个字段必须非空(Non-null) 。如果你选中的那个字段是 null,整个响应就会被转换成失败:
kotlin
val response = userService.fetchUser()
.requireNotNull(
selector = { it.profileImage }, // 选择要校验的字段
errorMessage = { "Profile image is required" },
)
这里有个巧妙的设计:这个操作不仅做了校验,还顺手把数据给提取 出来了。它直接将 ApiResponse<User> 转换成了 ApiResponse<String>(也就是 profileImage 的类型),一步完成了"提取数据"和"非空校验"这两个动作。
当然,如果你的校验逻辑比较复杂,比如需要查数据库或者调别的服务验证,那就用挂起版本的 suspendValidate:
kotlin
val response = userService.fetchUser()
.suspendValidate { user ->
userValidator.isValid(user) // 这里可以放心地调用挂起函数
}
过滤列表响应
针对那些返回列表数据的 API,Sandwich 提供了非常顺手的过滤扩展(Filtering Extensions)。你可以像操作普通集合一样,通过一个判断条件(Predicate)直接把不符合要求的数据从成功响应里剔除掉:
kotlin
val response = posterService.fetchPosterList()
.filter { poster -> poster.isActive } // 只保留活跃的海报
这样返回的 ApiResponse<List<Poster>> 里就干干净净,只剩下"活跃"的海报了,而且不需要我们手动拆箱修改再装箱。如果原本的请求就是失败的,过滤器完全不会生效,错误信息会原样透传下去,不会被误伤。
反向操作当然也是支持的,用 filterNot 即可:
kotlin
val response = posterService.fetchPosterList()
.filterNot { poster -> poster.isDeprecated } // 剔除已废弃的海报
当后端接口很"高冷",不支持通过查询参数来筛选数据,逼着你只能在客户端侧(Client Side)自己动手时,这招特别好使。
可以把它和其他扩展函数串起来,打造一条无懈可击的数据处理流水线(Pipeline):
kotlin
val response = posterService.fetchPosterList()
.validate(predicate = { it.isNotEmpty() }) { "No posters found" } // 1. 先校验列表非空
.filter { it.isActive } // 2. 再过滤出活跃项
.mapSuccess { sortedByDescending { it.createdAt } } // 3. 接着按时间倒序排列
.recover(emptyList()) // 4. 万一哪里崩了,兜底返回空列表
这段代码逻辑极其顺畅:校验 -> 过滤 -> 排序(映射) -> 兜底。每一步操作的都是 ApiResponse<List<Poster>>,组合干净,读起来舒服,维护起来也轻松。
合并多个响应
在实际开发中,我们经常遇到一个屏幕需要同时加载多个接口数据的情况。比如一个 App 的首页,可能既要拉取用户的个人资料,又要加载推荐的海报列表;或者一个仪表盘页面,需要同时显示设置、通知和活动数据。Sandwich 提供了非常实用的 zip 扩展,能帮你把多个独立的 ApiResponse 实例,像拉链一样合并成一个单一的响应对象。
zip
它的作用是合并两个 ApiResponse 实例。逻辑非常清晰:只有当两个请求都成功时,转换函数才会执行;只要其中任何一个失败了,就会直接返回那个失败的结果:
kotlin
val usersResponse = userService.fetchUsers()
val postersResponse = posterService.fetchPosters()
// 将两个响应合并成一个 HomeScreenData
val combined = usersResponse.zip(postersResponse) { users, posters ->
HomeScreenData(users = users, posters = posters)
}
combined.onSuccess {
// 只有两个都成功,才会走到这里
_homeData.value = data
}.onFailure {
// 只要有一个挂了,错误就会透传到这里
_error.value = message()
}
这比你写两层嵌套的 onSuccess 回调,或者用 async/await 后再手动去检查每一个 Deferred 的异常要优雅太多了。
当然,如果你不需要转换成特定的数据对象(比如 HomeScreenData),只是想简单地把它们凑成一对,也可以直接用无参数的 zip:
kotlin
val paired = usersResponse.zip(postersResponse)
// 返回类型是:ApiResponse<Pair<List<User>, List<Poster>>>
合并分页响应
当你需要一次性拉取同一个接口的多页数据,并把结果拼成一个完整的列表时,可以使用 merge 扩展。
下面这个例子同时拉取了第 0、1、2 页的数据:
kotlin
val response = posterService.fetchPosterList(page = 0).merge(
posterService.fetchPosterList(page = 1),
posterService.fetchPosterList(page = 2),
mergePolicy = ApiResponseMergePolicy.PREFERRED_FAILURE,
)
response.onSuccess {
// `data` 包含了三页数据的总和
_posters.value = data
}.onError {
// 只要有一页出错了,就会走到这里
_error.value = message()
}
这里有个关键参数 mergePolicy,它决定了失败时的处理策略。
PREFERRED_FAILURE(默认推荐):严谨模式。只要这几页请求里有一个失败了,整体结果就是失败。IGNORE_FAILURE:宽容模式。它会收集那些成功的页面数据,自动忽略失败的请求。
使用 Peek "窥视"响应
Peek 系列扩展函数允许你在不修改响应内容的前提下,对响应进行观测。这是处理副作用(Side Effects)的最佳场所------比如打日志、埋点统计、写入缓存或者上报 Crash。这些操作应该伴随着响应发生,但绝不应该改变响应本身:
kotlin
val response = posterService.fetchPosterList()
.peekSuccess { posters ->
analytics.trackPostersLoaded(posters.size) // 埋点
logger.info("Loaded ${posters.size} posters")
}
.peekError { error ->
errorTracker.trackApiError(error.statusCode) // 错误追踪
logger.warn("API error: ${error.message()}")
}
.peekException { exception ->
crashReporter.recordException(exception.throwable) // 异常上报
logger.error("Network exception: ${exception.message}")
}
每一个 peek 函数都会原封不动地返回原始的 ApiResponse。你可以把它们随意穿插在其他扩展函数的前后:
kotlin
val response = posterService.fetchPosterList()
.peekSuccess { analytics.trackSuccess() }
.peekFailure { analytics.trackFailure() }
.recover(emptyList()) // 注意:peek 在 recover 之前运行,所以它能捕获到原始的失败信息
如果用
peekFailure/peekError/peekException,要注意一下在.recover()前面调用,因为经过recover()响应已经变成了ApiResponse.Success。
还有一个通用的 peek,不管成功还是失败,它都会执行:
kotlin
val response = posterService.fetchPosterList()
.peek { logger.info("Response received: $it") }
如果你的副作用操作包含挂起函数(比如写入数据库),记得使用带 suspend 前缀的版本:suspendPeekSuccess、suspendPeekError、suspendPeekException、suspendPeekFailure 和 suspendPeek。
其实
peekError和onError没有本质区别,只是语义上把它们区分成两个函数:
kotlinpublic inline fun <T> ApiResponse<T>.peekError( crossinline action: (ApiResponse.Failure.Error) -> Unit, ): ApiResponse<T> { if (this is ApiResponse.Failure.Error) { action(this) } return this } public inline fun <T> ApiResponse<T>.onError( crossinline onResult: ApiResponse.Failure.Error.() -> Unit, ): ApiResponse<T> { if (this is ApiResponse.Failure.Error) { onResult(this) } return this }
自定义错误类型
现实世界里的 API,通常都会返回一套结构化的错误信息,里面包含了错误码和具体的提示消息。作为开发者肯定希望在 Kotlin 代码里操作的是强类型的对象,而不是去生啃那些原始的字符串或者 JSON 数据。
Sandwich 允许你通过继承 ApiResponse.Failure.Error 或 ApiResponse.Failure.Exception,来定义属于你自己的错误和异常类型:
kotlin
data object LimitedRequest : ApiResponse.Failure.Error(
payload = "your request is limited",
)
data object WrongArgument : ApiResponse.Failure.Error(
payload = "wrong argument",
)
data object UnKnownError : ApiResponse.Failure.Exception(
throwable = RuntimeException("unknown error"),
)
data object HttpException : ApiResponse.Failure.Exception(
throwable = RuntimeException("http exception"),
)
这些自定义类型可以完美兼容任何 ApiResponse<T>(不管 T 是什么)。原理在于:Failure.Error 和 Failure.Exception 实际上继承自 Failure<Nothing>。得益于 Kotlin 的协变(Covariance),Nothing 是所有类型的子类型,所以它能自动适配任何泛型参数。
到了下游层级(比如 ViewModel),就可以直接用 when 表达式对这些自定义类型进行模式匹配,优雅 🍷!
kotlin
response.onError {
when (this) {
LimitedRequest -> showRateLimitDialog() // 是限流错误,弹窗提示
WrongArgument -> showValidationError() // 是参数错误,提示校验失败
else -> showGenericError() // 其他错误,兜底处理
}
}.onException {
when (this) {
HttpException -> showHttpErrorUI() // HTTP 协议层面的异常
UnKnownError -> showGenericErrorUI() // 未知异常
else -> showNetworkError() // 网络异常
}
}
全局失败映射器 (Global Failure Mappers)
定义自定义错误类型固然好用,但问题是:你还是得去解析错误响应体(Body),然后手动创建对应的自定义类型对象。如果每一个 API 调用都要用 flatMap 手写一遍这个逻辑,那我们之前费劲想干掉的样板代码(Boilerplate)岂不是又死灰复燃了?
Sandwich 给出的解法是:全局失败映射器。你只需要在应用初始化的时候注册一次,剩下的事它全包了:
kotlin
// 在你的 Application.onCreate() 或者 DI 模块里配置
SandwichInitializer.sandwichFailureMappers += ApiResponseFailureMapper { failure ->
// 检查是不是 HTTP 错误
if (failure is ApiResponse.Failure.Error) {
// 尝试读取错误体字符串
val errorBody = (failure.payload as? Response)?.body?.string()
if (errorBody != null) {
// 解析 JSON
val error: ErrorMessage = Json.decodeFromString(errorBody)
// 根据错误码返回自定义的错误对象
return@ApiResponseFailureMapper when (error.code) {
10000 -> LimitedRequest
10001 -> WrongArgument
10002 -> HttpException
else -> UnKnownError
}
}
}
// 如果没匹配上,就原样返回
failure
}
一旦注册成功,这个映射器就会自动拦截并转换你整个应用里所有 的 ApiResponse.Failure。不管你调的是 fetchPosterList()、fetchUser() 还是 login(),所有的失败都会经过这个映射器。
这样一来,ViewModel 和 Repository 代码就变得无比干净,只需要处理那些具体的自定义类型:
kotlin
val response = service.fetchMovieList()
response.onSuccess {
_movies.value = data
}.onException {
// 直接匹配自定义对象,无需关心 JSON 解析
when (this) {
LimitedRequest -> showRateLimitUI()
WrongArgument -> showValidationUI()
HttpException -> showHttpErrorUI()
UnKnownError -> showGenericErrorUI()
else -> showDefaultErrorUI()
}
}
对于 Ktor 或 Ktorfit 用户,读取错误体通常是一个挂起操作(比如 HttpResponse.bodyAsText()),这时候你需要使用 ApiResponseFailureSuspendMapper:
kotlin
SandwichInitializer.sandwichFailureMappers += object : ApiResponseFailureSuspendMapper {
override suspend fun map(apiResponse: ApiResponse.Failure<*>): ApiResponse.Failure<*> {
if (apiResponse is ApiResponse.Failure.Error) {
// 这里的 bodyAsText() 是个挂起函数
val errorBody = (apiResponse.payload as? HttpResponse)?.bodyAsText()
if (errorBody != null) {
val error: ErrorMessage = Json.decodeFromString(errorBody)
return when (error.code) {
10000 -> LimitedRequest
10001 -> WrongArgument
else -> UnKnownError
}
}
}
return apiResponse
}
}
这个挂起映射器会在挂起上下文中被正确地等待(Await),确保映射后的响应能正确返回给调用者。甚至可以在同一个列表中同时注册同步和挂起的映射器,它们会按顺序依次执行。
全局操作符 (Global Operators)
如果说失败映射器(Failure Mappers)的职责是转换失败响应,那么全局操作符(Global Operators)的职责就是监听所有的响应(连成功的也不放过)以处理那些"横切关注点"。
这是做集中式日志记录 、数据埋点分析 、Token 自动刷新 或者全局错误提示的最佳场所:
kotlin
SandwichInitializer.sandwichOperators += object : ApiResponseOperator<Any>() {
override fun onSuccess(apiResponse: ApiResponse.Success<Any>) {
logger.info("API success: ${apiResponse.data}")
}
override fun onError(apiResponse: ApiResponse.Failure.Error) {
logger.error("API error: ${apiResponse.message()}")
// 检查是不是 401 未授权
if (apiResponse.statusCode == StatusCode.Unauthorized) {
// 触发全局登出或者 Token 刷新逻辑
authManager.onUnauthorized()
}
}
override fun onException(apiResponse: ApiResponse.Failure.Exception) {
logger.error("API exception: ${apiResponse.message}")
crashReporter.recordException(apiResponse.throwable)
}
}
一旦注册,这个 Operator 就会作用于每一个通过 Sandwich 创建的 ApiResponse 对象(无论是 Retrofit、Ktor 还是 Ktorfit 产生的)。
上面代码里对 401 Unauthorized 的处理是全局生效的:这意味着你项目里任何一个 API 调用,只要报了 401,都会自动触发 authManager 的逻辑。再也不需要在每个调用处去重复写检查 401 的代码了,真正的"一处编写,到处运行"。
如果你的全局操作涉及挂起函数(比如耗时的埋点上报),请使用 ApiResponseSuspendOperator,它能保证在挂起上下文中正确执行:
kotlin
SandwichInitializer.sandwichOperators += object : ApiResponseSuspendOperator<Any>() {
override suspend fun onSuccess(apiResponse: ApiResponse.Success<Any>) {
analyticsService.trackSuccess() // 这里可以安全地调用挂起函数
}
override suspend fun onError(apiResponse: ApiResponse.Failure.Error) {
analyticsService.trackError(apiResponse.message()) // 挂起函数
}
override suspend fun onException(apiResponse: ApiResponse.Failure.Exception) {
analyticsService.trackException(apiResponse.throwable) // 挂起函数
}
}
At the End
作为译者,翻译完后,想额外聊几句。
坦白讲,Sandwich 并不是什么高深莫测的"黑科技",它的地位也不像 Retrofit 那样是 Android 开发的"基础设施"。甚至可以说,它的核心原理并不复杂,只要你愿意,完全可以参考它的思路手写一套类似的 Result 封装。
但是在实际开发中,它确实提供了一种足够优雅的解法,把网络请求中那些恼人的异常处理、状态判断,从散落在各处的 try-catch 补丁,变成了一种标准化的、类型安全的数据流,让数据从 Data Source 到 UI 层的流转变得清晰可见。
当然,架构从来没有标准答案,Sandwich 是解决问题的一种方式,但绝不是唯一方式。如果你对这种处理模式有不同的见解,或者在项目中有更好的实践经验,欢迎在评论区理性交流探讨。
