i@yujinyan.me

Blog

Kotlin 协程与 Retrofit

Retrofit 2.6.0 支持用 Kotlin suspend 函数定义接口。 本文介绍如何通过自定义 Retrofit Call Adapter 和 Converter 打造最舒适的协程使用体验。

  • Call Adapter: 自定义请求执行的逻辑,包括线程切换等;
  • Converter: 自定义反序列化逻辑,如何将请求获得的 bytes 转成对象。

剧透最终效果:

// Retrofit 接口定义
// 简洁起见,后文省略外面这个 UserApi
interface UserApi {
  suspend fun getUser(id: Int):
    ApiResponse<User>
}

data class User(val name: String)

// 调用示例 1:
lifecycleScope.launch {
  retrofit.create<UserApi>()
    .getUser(1)
    .getOrNull()
    // 主线程更新 UI
    ?.let { binding.nameLabel.text = it.name }
}

// 调用示例 2:
lifecycleScope.launch {
  val user: User = retrofit.create<UserApi>()
    .getUser(1)
    .guardOk { return@launch }
  // 拿到非 null 的 User 继续后面的业务逻辑
}

// 还没有结束,文章最后会介绍一个进一步简化的方案 ;-)

这个方案受到了 Jake Wharton Making Retrofit Work for You 演讲的启发。 Jake 也是 Retrofit 的维护者。在演讲中,他推荐利用好 Retrofit 提供的自定义反序列化以及请求执行的 API,适应 adapt 自己的业务逻辑和接口。

背景

假设我们的接口返回这样的 JSON 数据,请求成功时 errcode 字段返回值为 0,同时有一个 data 字段存放数据:

{
  "errcode": 0,
  "msg": "",
  "data": {
    "id": 1,
    "name": "Peter Parker"
  }
}

异常情况下 errcode 不为 0 ,同时会有 msg 字段返回展示给用户的错误信息:

{
  "errcode": 401,
  "msg": "无权访问"
}

Retrofit interface 设计

我们先抛开实现,探讨一下怎么设计 Retrofit 的 interface 才能让调用方更舒适地使用协程。

去掉「信封」

舒适的封装应该让调用方尽可能爽,越简单越好。 可以看到对业务真正有用的数据在 data 里面,外面套了一个“信封”。绝大部分情况下我们只需要拿正常情况下的数据,然后继续执行后续的业务逻辑。 如果每次调用都要手动去检查一遍 errcode == 0 会非常冗余。一种最简单的设计是直接返回去掉信封后的数据类型:

suspend fun getUser(
  @Query("id") id: Int
): User 

data class User(val id: Int, val name: String)

// 在主线程开启协程并更新 UI
// 🚨 危险:请求异常会让应用崩溃
lifecycleScope.launch {
  val user = retrofit.create<UserApi>().getUser(1)
  binding.userNameLabel.text = user.name
}

异常处理

直接返回信封内数据类型的设计理论上可行:正常情况下调用很爽,如果出现异常可以借助 try catch 获得具体的异常信息。 但是,按照 Kotlin 协程的设计,我们应该直接在主线程调用封装的 suspend 函数。 如果函数抛出异常会抛在主线程,导致应用崩溃。 从函数签名上也能看出来:一旦不能正常返回 User 数据类型,运行时只能抛出异常。 这样调用方必须进行 try catch,写起来非常麻烦。更加糟糕的是大家完全可以忘记 try catch,还很有可能写错:

// - Kotlin 标准库的 runCatching,比 try catch 写起来舒服一点点
// - 🚨 错误的 try catch,无法捕获 launch 协程块的异常
runCatching { 
  lifecycleScope.launch {
    val user = retrofit
      .create<UserService>()
      .getUser(1)
    binding.userNameLabel.text = user.name
  }
} 

小心!上面这个例子的 try catch 写错了。如果协程块内抛出异常还是会 crash。原因是错误地 try catch 了 Coroutine builder launch。 Coroutine builder 在 CoroutineScope 中开启协程块以后会立即返回,builder 内的协程与 launch 周围的代码并发执行。 协程块内的异常逻辑无法被 launch 外的 try catch 捕获。 正确的写法是在协程块内部 try catch:

lifecycleScope.launch {
  val user = runCatching { retrofit.create<UserService>().getUser(1) } 
    .onFailure { if (it is CancellationException) throw it }
    .getOrNull() ?: return@launch
  binding.userNameLabel.text = user.name
}

另外,try catch suspend 函数的时候需要注意重新抛出 CancellationException,否则可能导致协程块无法及时取消。 详细可以查看 JCIP Notes - Interruption and Cancellation。

💡

好的封装设计应该让正确的写法最简单,默认最简单的写法是正确的写法。

为了避免 try catch 协程异常的麻烦和潜在的失误,笔者建议在 suspend 函数内部封装中 catch 所有异常,并将异常在函数签名上体现。

一种方案是返回 nullable 的类型。这样可以利用 Kotlin 的空安全操作符 ?.,?: 和 !!:

suspend fun getUser(
  @Query("id") id: Int
): User? 

lifecycleScope.launch {
  retrofit.create<UserApi>()
    .getUser(1)
    ?.let { binding.nameLabel.text = it.name }
}

这似乎是相当地道的优雅设计,值得推荐。但使用 nullable 我们无法告诉调用方发生了什么类型的异常。 对调用方来说只有成功 != null 或者失败 == null 两种可能。不过很多情况下这样的区分已经足够了。

另外对于异常情况,应该在项目中有一个统一的位置进行处理,比如在 errcode != 0 时给用户展示提示、网络请求异常时上报等。 在业务调用接口的位置到处 try catch 做临时(ad hoc)的异常处理不够健壮: 大家完全可以忘记做异常处理,或者处理得非常粗糙。同时,异常处理代码可能会造成大量冗余,看不清正常代码逻辑。

Retrofit 的 Call Adapter 可以帮助我们在 Retrofit 的执行逻辑中嵌入自定义的逻辑,实现统一捕获处理所有异常的目标。 后文将给出一个参考实现。

As a rule of thumb, you should not be catching exceptions in general Kotlin code. That’s a code smell. Exceptions should be handled by some top-level framework code of your application to alert developers of the bugs in the code and to restart your application or its affected operation. Roman Elizarov, Project Lead for Kotlin

“

原则上,**不要在 Kotlin 业务逻辑代码中 catch 异常。**这是一种 Code Smell。异常应该在应用顶层的基础设施代码中进行统一处理:比如进行上报或者重试出错的步骤。

设计 ApiResponse 类型

为了让调用方能够获取到异常信息,不可避免要将返回值塞在一个能够体现成功/失败结果的壳里面。但我们不原样照着返回的格式进行反序列化,而是进行一定的封装。 比如请求正常情况下,msg 字段没有任何用处,可以省略。 请求结果大概可以分成三种情况:

  • 正常响应:我们可以从 data 字段获取后续业务逻辑需要的数据;
  • 业务逻辑异常:接口请求成功,但是后端返回数据告诉我们业务逻辑异常,我们需要在 UI 展示异常信息;
  • 其他技术异常:网络请求错误、反序列化错误等,我们可能需要根据情况进行上报。

落实到代码里,可以用 Kotlin Sealed Class 进行表示:

sealed class ApiResponse {
  // 正常响应情况调用方不需要 errcode, msg
  data class Ok<T>(
    val data: T
  )

  data class BizError<T>(
    val errcode: Int,
    val msg: String
  ): ApiResponse<T>

  data class OtherError<T>(
    val throwable: Throwable
  ): ApiResponse<T>
}

suspend fun getUser(@Query("id") id: Int)
  : ApiResponse<User>

lifecycleScope.launch {
  val response = retrofit.create<UserApi>().getUser(1)

  // 可以使用 when 对 ApiResponse 的类型进行区分
  // 作为表达式使用的时候可以利用 when
  // 穷尽枚举的特性
  when (response) {
    is ApiResponse.Ok -> {/**/}
    is ApiResponse.BizError -> {/**/}
    is ApiResponse.OtherError -> {/**/}
  }
}

加点空安全的语法糖

我们将异常体现在类型系统,而不是抛出来,这样安全得多。但是绝大部分场景调用方不需要,也不应该做这样详细的异常处理。所以我们加上一对扩展函数,让调用方能够使用 Kotlin nullable 的语法糖:

fun <T> ApiResponse<T>.getOrNull(): T? = when(this) { 
  is Ok -> data
  is BizError, is OtherError -> null
}
fun <T> ApiResponse<T>.getOrThrow(): T = when(this) { 
  is Ok -> data
  is BizError -> throw BizException(errcode, msg)
  is OtherError -> throw throwable
}

class BizException(
  val errcode: Int
  override val msg: String
): RuntimeException()

// 调用方
lifecycleScope.launch {
  retrofit.create<UserApi>()
    .getUser(1)
    .getOrNull() 
    ?.let { binding.nameLabel.text = it.name }
}

函数的命名参考了 Kotlin 标准库类似 get getOrNull , first firstOrNull 这样的约定:第一类抛出异常,第二类返回 nullable 类型。考虑到客户端抛异常非常危险,我们将 get 命名为 getOrThrow,在方法名上进行强调。(实际上也可以考虑不加抛异常的版本,项目里估计没人用。)

借鉴 Swift 的 guard 关键字

getOrNull 常用于后接一个 ?.let 只处理成功情况:如果请求成功,用这个数据 it 更新 UI,否则什么也不发生。 如果失败的情况需要做些动作,可以用 if / else 或者 when 判断类型:

val response = retrofit.create<UserApi>().getUser(1)

if (response is ApiResponse.Ok) {
  val user: User = response.data
  // ...
} else {
  // 更新 UI 展示异常状态
  pageState.value = PageState.Error
}

if … else 如果嵌套过多会让代码可读性变差,使用提前退出(early exit)的风格,我们先处理失败的情况并退出当前块, 这样成功的 case 一路向下,更加简单清晰:

val response = retrofit.create<UserApi>.getUser(1)

if (response !is ApiResponse.Ok) {
  pageState.value = PageState.Error
  return 
}

val user: User = response.data
// ...
// 拿到非 null 的 User 继续后面的业务逻辑

但是有人认为 early exit 的风格不够健壮,因为有可能会忘记写提前退出的 return,造成逻辑错误。

Swift 是如此喜爱 early exit,专门为此加了个关键字 guard。guard 类似 if, 但是多了一层保证:编译器会确保 else 块里面 return 或者 throw,退出当前块,使得 early exit 的风格和 if … else 一样健壮。

guard let user = getUser(1) else {
  pageState.value = PageState.Error
  return
}

// ...
// 拿到非 null 的 User 继续后面的业务逻辑

在 Kotlin 中我们可以借助 inline 的扩展函数实现类似效果。其中关键是 block 返回值是 Nothing:

inline fun <T> ApiResponse<T>.guardOk(
  block: () -> Nothing 
): T {
    if (this !is ApiResponse.Ok<T>) {
        block()
    }
    return this.data
}

val user: User = retrofit.create<UserApi>
  .getUser(1)
  .guardOk {
    pageState.value = PageState.Error
    return@launch 
  }

// ...
// 拿到非 null 的 User 继续后面的业务逻辑

实现:Retrofit Call Adapter

为了让 Retrofit 捕获所有异常,我们写一个 CatchingCallAdapterFactory, 继承 Retrofit 的 CallAdapter.Factory。 这个 CatchingCallAdapterFactory 暴露一个 ErrorHandler 用于配置全局的异常处理逻辑。

val retrofit = Retrofit.Builder()
  .baseUrl(/**/)
  .addCallAdapterFactory(CatchingCallAdapterFactory( // highlight-line
    object: CatchingCallAdapterFactory.ErrorHandler {
      // 如果是业务逻辑异常给用户展示错误信息
      override fun onBizError(errcode: Int, msg: String) {
        toast("$errcode - $msg")
      }
      // 如果是其他异常进行上报
      override fun onOtherError(throwable: Throwable) {
        report(throwable)
      }
    }
  ))
  //...

CatchingCallAdapterFactory 参考实现:

class CatchingCallAdapterFactory(
  val defaultErrorHandler: ErrorHandler? = null
) : CallAdapter.Factory() {

  // 用于配置全局的异常处理逻辑
  interface ErrorHandler {
    fun onBizError(errcode: Int, msg: String)
    fun onOtherError(throwable: Throwable)
  }

  override fun get(
    returnType: Type,
    annotations: Array<out Annotation>,
    retrofit: Retrofit
  ): CallAdapter<*, *>? {
    // suspend 函数在 Retrofit 中的返回值其实是 `Call`
    // 例如:Call<ApiResponse<User>>
    if (getRawType(returnType) != Call::class.java) return null
    check(returnType is ParameterizedType)

    // 取 Call 里边一层泛型参数
    val innerType: Type = getParameterUpperBound(0, returnType)
    // 如果不是 ApiResponse 则不由本 CallAdapter.Factory 处理
    if (getRawType(innerType) != ApiResponse::class.javava) return null

    // 获取后续代理
    val delegate: CallAdapter<*, *> = retrofit
      .nextCallAdapter(this, returnType, annotations)

    return CatchingCallAdapter(
      innerType,
      retrofit,
      delegate,
      defaultErrorHandler
    )
  }

  class CatchingCallAdapter(
    val dataType: Type,
    val retrofit: Retrofit,
    val delegate: CallAdapter<*, *>,
    val errorHandler: ErrorHandler?
  ) : CallAdapter<Any, Call<Any>> {
    override fun responseType(): Type
        = delegate.responseType()
    override fun adapt(call: Call<Any>): Call<Any>
        = CatchingCall(call, dataType as ParameterizedType, errorHandler)
  }

  class CatchingCall(
    private val delegate: Call<Any>,
    private val wrapperType: ParameterizedType,
    private val errorHandler: ErrorHandler?
  ) : Call<Any> {

    override fun enqueue(
      // suspend 其实是 callback
      // suspend 的返回值通过这个 callback 传递
      callback: Callback<Any>
    ): Unit = delegate.enqueue(object : Callback<Any> {
      override fun onResponse(call: Call<Any>, response: Response<Any>) {
        // 无论请求响应成功还是失败都回调 Response.success
        if (response.isSuccessful) {
          val body = response.body()
          if (body is ApiResponse.BizError<*>) {
            errorHandler?.onBizError(body.errcode, body.msg)
          }
          callback.onResponse(this@CatchingCall, Response.success(body))
        } else {
          val throwable = HttpException(response.code(), response)
          errorHandler?.onOtherError(throwable)
          callback.onResponse(
            this@CatchingCall,
            Response.success(ApiResponse.OtherError(throwable))
          )
        }
      }

      override fun onFailure(call: Call<Any>, t: Throwable) {
        errorHandler?.onOtherError(t)
        callback.onResponse(
          this@CatchingCall,
          Response.success(ApiResponse.OtherError<Any>(t))
        )
      }
    })

    override fun clone(): Call<Any> =
      CatchingCall(delegate, wrapperType, errorHandler)
    override fun execute(): Response<Any> =
      throw UnsupportedOperationException()
    override fun isExecuted(): Boolean = delegate.isExecuted
    override fun cancel(): Unit = delegate.cancel()
    override fun isCanceled(): Boolean = delegate.isCanceled
    override fun request(): Request = delegate.request()
    override fun timeout(): Timeout = delegate.timeout()
  }
}

实现:Retrofit Converter

针对 ApiResponse 的不同 case,我们需要配置自定义 JSON 反序列化解析的逻辑。 Retrofit 可以通过 addConverterFactory 注入自定义的类型转换器(不一定仅仅是 JSON 数据格式,也可以是 XML,Protocol Buffers 等), 适配不同的反序列化库。

JSON 反序列化库的选择

目前,Kotlin 项目推荐使用 Moshi。Moshi 相比 Gson 对 Kotlin 的支持更加完善。比如下面这个例子:

data class User(
  val name: String
)

val user = gson.fromJson("{}", User::class.java)

println(user) // User(name=null)
user.name.length 💣// NullPointerException!

Gson 通过反射创建出一个 User 类型的对象,但是 Gson 并不区分 Kotlin 的可空/非空类型,直接返回了属性都是 null 的对象,导致我们后续使用这个“残缺”对象的时候抛出空指针异常。 我们的 CatchingCallAdapter 理应捕获包括反序列化在内的所有异常,但是 Gson 这样的行为逃过了我们的异常捕获逻辑,隐患侵入了业务逻辑代码。

Moshi 没有这样的问题,拿到无法解析的数据会统一抛出 JsonDataException。CatchingCallAdapter 捕获后会处理成 ApiResponse.OtherError。

Moshi 对比 Gson 的优势可以参考下面的链接:

Please don’t use Gson. 2 out of 3 maintainers agree: it’s deprecated. Use Moshi, Jackson, or kotlinx.serialization which all understand Kotlin’s nullability. Gson does not and will do dumb things, and it won’t be fixed. Please abandon it. Signed, a Gson maintainer.

“

请不要再使用 Gson 了。Gson 三位维护者中有两位认为 Gson 实际上已经废弃了,请考虑使用 Moshi、Jackson 或者 kotlinx.serialization。 这些库都支持 Kotlin 的可空类型,而 Gson 不支持,同时还有其他愚蠢的问题,不会被修复。请抛弃它。 落款:一位 Gson 维护者。

上面引用的是 Jake Wharton 的观点。新项目建议优先考虑 Moshi,已经用了 Gson 的项目迁移有一定风险,建议慎重。

使用 Moshi ,目前有下面几种选项:

  1. 和 Gson 一样使用反射,但是需要间接依赖 2.5 MiB 大小的 kotlin-reflect;
  2. 使用注解处理器为所有标记 @JsonClass(generateAdapter = true) 的类生成 JsonAdapter;
  3. 同 2 代码生成,但是不用注解处理器,而是使用 Kotlin Symbol Processing;
  4. 类似 1,但是使用 kotlinx-metadata,比 kotlin-reflect 更加轻量级。

其中 3 和 4 在 MoshiX 项目中,似乎略带有试验性质; 另外需要注意代码生成的好处是性能更高,但是生成的代码占用体积也不小,并且需要显式地为所有需要反序列化的类配置相应的 JsonAdapter,对已有的项目有一些侵入性。

kotlinx.serialization 是 Kotlin 官方出品的序列化/反序列化方案,也是注解标记,代码生成的方案。 但是代码生成集成在编译器中(类似 @Parcelize 和 KSP),开发体验可能更好,Kotlin 特性支持更加丰富,应该是 Kotlin 上的首选方案。但暂不支持流式解析,见此 issue。

综合来看,目前似乎可以先使用 Moshi,等 kotlinx.serialization 成熟后搜索替换注解进行迁移。

Moshi 实现

下面是 Moshi 自定义解析 ApiResponse 的参考实现,Gson 大同小异:

class MoshiApiResponseTypeAdapterFactory : JsonAdapter.Factory {

  override fun create(
    type: Type,
    annotations: MutableSet<out Annotation>,
    moshi: Moshi
  ): JsonAdapter<*>? {
    val rawType = type.rawType
    if (rawType != ApiResponse::class.java) return null

    // 获取 ApiResponse 的泛型参数,比如 User
    val dataType: Type = (type as? ParameterizedType)
      ?.actualTypeArguments?.firstOrNull()
      ?: return null

    // 获取 User 的 JsonAdapter
    val dataTypeAdapter = moshi.nextAdapter<Any>(
      this, dataType, annotations
    )

    return ApiResponseTypeAdapter(rawType, dataTypeAdapter)
  }

  class ApiResponseTypeAdapter<T>(
    private val outerType: Type,
    private val dataTypeAdapter: JsonAdapter<T>
  ) : JsonAdapter<T>() {
    override fun fromJson(reader: JsonReader): T? {
      reader.beginObject()

      var errcode: Int? = null
      var msg: String? = null
      var data: Any? = null

      while (reader.hasNext()) {
        when (reader.nextName()) {
          "errcode" -> errcode = reader.nextString().toIntOrNull()
          "msg" -> msg = reader.nextString()
          "data" -> data = dataTypeAdapter.fromJson(reader)
          else -> reader.skipValue()
        }
      }

      reader.endObject()

      return if (errcode != 0)
        ApiResponse.BizError(
          errcode ?: -1,
          msg ?: "N/A"
        ) as T
      else ApiResponse.Ok(
        errcode = errcode,
        data = data
      ) as T?
    }

    // 不需要序列化的逻辑
    override fun toJson(writer: JsonWriter, value: T?): Unit
      = TODO("Not yet implemented")
  }
}

使用:

private val moshi = Moshi.Builder()
  .add(MoshiApiResponseTypeAdapterFactory()) 
  .build()

val retrofit = Retrofit.Builder()
  .baseUrl(/**/)
  .addCallAdapterFactory(CatchingCallAdapterFactory(
    object: CatchingCallAdapterFactory.ErrorHandler {
      // 如果是业务逻辑异常给用户展示错误信息
      override fun onBizError(errcode: Int, msg: String) {
        toast("$errcode - $msg")
      }
      // 如果是其他异常进行上报
      override fun onOtherError(throwable: Throwable) {
        report(throwable)
      }
    }
  .addConverterFactory( // highlight-line
    MoshiConverterFactory.create(moshi) // highlight-line
  )
  // 配置 OkHttp,API 鉴权等逻辑在这里配置
  .client(/**/)
  .build()

One More Thing:使用 Result 作为返回值

文章开头的例子使用 Kotlin 标准库提供的 runCatching 方法进行 try catch。runCatching 方法的返回值是 Result,上面提供了很多有用的方法:

suspend fun getUser(id: Int): Result<User>

lifecycleScope.launch {
  val result = getUser(1)
    .onFailure {/**/}
    .onSuccess {/**/}

  result.isSuccess
  result.isFailure

  val exception: Throwable? = result.exceptionOrNull()
  val user1: User? = result.getOrNull()
  val user2: User = result.getOrThrow()
}

之前 Kotlin 不允许将 Result 作为函数的返回值。 这个限制在 Kotlin 1.5 中被去除。 这样我们可以考虑用 Result 作为 Retrofit interface 方法的返回类型:

// 需要 Kotlin 1.5
suspend fun getUser(id: Int): Result<User>

使用 Result 的话调用方可以拿到异常信息,但是无法在最外层区分 BizError 和 OtherError。 不过实际看下来几乎没有调用方需要做这样的区分,让这种极少用到的 case 变得麻烦一些似乎是好的权衡。

更加令人期待的是 Kotlin 计划让空安全操作符同样适用于 Result,于是我们可以这样写:

// 需要 Kotlin 1.5,以及尚未发布的特性

// 调用示例 1:
lifecycleScope.launch {
  retrofit.create<UserApi>()
    .getUser(1) 
    ?.let { binding.nameLabel.text = it.name } 
}

// 调用示例 2:
lifecycleScope.launch {
  val user: User = retrofit.create<UserApi>()
    .getUser(1) 
    ?.run { return@launch } 
  // 拿到非 null 的 User 继续后面的业务逻辑
}

得益于直接作用于 Result 类型的空安全操作符,我们不需要专门定义扩展函数转换成可空类型,调用起来也更加精简。

适配 suspend 函数和 Result 返回值的 Call Adapter 可以参考或者直接使用这个库: yujinyan/retrofit-suspend-result-adapter。

如果项目中的接口像本文的例子一样外面包了一层“信封”,可以用自己的反序列化库写一个 Converter。 这个测试用例提供了一个 Moshi 的参考实现。

参考资料

评论区 Discussions · 也可前往 GitHub 评论区 互动