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

相关推荐
雨白1 小时前
Android 快捷方式实战指南:静态、动态与固定快捷方式详解
android
hqk1 小时前
鸿蒙项目实战:手把手带你实现 WanAndroid 布局与交互
android·前端·harmonyos
LING2 小时前
RN容器启动优化实践
android·react native
恋猫de小郭4 小时前
Flutter 发布官方 Skills ,Flutter 在 AI 领域再添一助力
android·前端·flutter
Kapaseker9 小时前
一杯美式搞懂 Any、Unit、Nothing
android·kotlin
黄林晴9 小时前
你的 Android App 还没接 AI?Gemini API 接入全攻略
android
恋猫de小郭19 小时前
2026 Flutter VS React Native ,同时在 AI 时代 VS Native 开发,你没见过的版本
android·前端·flutter
冬奇Lab20 小时前
PowerManagerService(上):电源状态与WakeLock管理
android·源码阅读
BoomHe1 天前
Now in Android 架构模式全面分析
android·android jetpack
二流小码农1 天前
鸿蒙开发:上传一张参考图片便可实现页面功能
android·ios·harmonyos