Android 多层架构下如何优雅地处理 API 响应与异常

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)拿到一个空列表时,完全一脸懵逼,根本分不清三种截然不同的情况:

  1. 请求成功了,服务器确实返回了一个空列表。
  2. 请求失败了,比如报了个 401 未授权错误。
  3. 压根没网,连请求都没发出去。

这三种情况最终都是拿到一个空列表,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.Success
  • ApiResponse.Failure.Error
  • ApiResponse.Failure.Exception
1. ApiResponse.Success

代表请求成功。 意味着服务器返回了 2xx 范围的状态码,并且响应体(Body)也成功反序列化了。此时可以通过 data 属性直接获取反序列化后的数据对象:

kotlin 复制代码
val apiResponse = ApiResponse.Success(data = posterList)
val data: List<Poster> = apiResponse.data

如果用的是 Retrofit,ApiResponse.Success 还携带了原始的响应元数据比如 statusCodeheaders

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 Unauthorized403 Forbidden404 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
}

当然,如果你的业务逻辑不需要分得那么细,不在乎到底是服务器报错还是断网了,可以用 onFailureErrorException 这两种失败情况"一把抓":

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)版本的扩展函数。

调用一个挂起函数发起网络请求,成功了就再调用另一个挂起函数保存到数据库,这种场景太常见了,但标准的 onSuccessonErroronException 接收的都是普通 Lambda,不能在这些 Lambda 内直接调用挂起函数,我们可以使用 suspendOnSuccesssuspendOnErrorsuspendOnException

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 等任何耗时操作,不需要再手动嵌套一层 launchwithContext

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(保持不变),要么就是你自定义的错误类型(比如 LimitedRequestWrongArgument)。

这样一来,下游层级(比如 ViewModel)就可以直接对这些自定义类型进行模式匹配(Pattern Match),不用重复去解析 JSON 错误体了:

kotlin 复制代码
response.onError {
  when (this) {
    LimitedRequest -> showRateLimitDialog() // 限流错误,弹窗提示
    WrongArgument -> showValidationError()  // 参数错误,提示校验失败
    else -> showGenericError()              // 其他错误,兜底处理
  }
}

串行依赖请求的处理

现实世界的开发里,经常会遇到这种"串行"的工作流:后一个 API 调用必须依赖前一个调用的结果。

例如:你需要先去获取一个认证 Token,拿到 Token 后再去拉取用户详情,最后根据用户的名字去查询海报列表。在这个链条中,只要有任何一环掉链子,整个流程就应该立即停止,并抛出那个步骤的错误。

Sandwich 专门为此提供了 thensuspendThen 这两个中缀函数(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 失败了,后面的 getUserDetailsqueryPosters 压根就不会执行。

任意一步的失败都会直接"穿透"到最后的 onFailure 处理函数。

你甚至可以把 suspendThenmapSuccess 结合起来,打一套"组合拳",直接把最终结果转换成 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 前缀的版本:suspendPeekSuccesssuspendPeekErrorsuspendPeekExceptionsuspendPeekFailuresuspendPeek

其实 peekErroronError 没有本质区别,只是语义上把它们区分成两个函数:

kotlin 复制代码
public 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.ErrorApiResponse.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.ErrorFailure.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 是解决问题的一种方式,但绝不是唯一方式。如果你对这种处理模式有不同的见解,或者在项目中有更好的实践经验,欢迎在评论区理性交流探讨。

相关推荐
冬奇Lab9 小时前
Android系统启动流程深度解析:从Bootloader到Zygote的完整旅程
android·源码阅读
泓博11 小时前
Android中仿照View selector自定义Compose Button
android·vue.js·elementui
zhangphil12 小时前
Android性能分析中trace上到的postAndWait
android
十里-13 小时前
vue2的web项目打包成安卓apk包
android·前端
p***199413 小时前
MySQL——内置函数
android·数据库·mysql
兆子龙14 小时前
我成了🤡, 因为不想看广告,花了40美元自己写了个鸡肋挂机脚本
android·javascript
儿歌八万首15 小时前
Android 全局监听神器:registerActivityLifecycleCallbacks 解析
android·kotlin·activity
弹幕教练宇宙起源16 小时前
cmake文件介绍及用法
android·linux·c++
&岁月不待人&16 小时前
一个Android高级开发的2025总结 【个人总结无大话】
android
吴声子夜歌16 小时前
RxJava——FlowableProcessor详解
android·echarts·rxjava