使用协程简化异步资源获取操作
异步编程的两种场景
在异步编程中,回调函数通常服务于两种不同场景:
- 一次性资源获取:等待异步操作完成并返回结果。
- 持续事件通知。监听并响应多个状态变更。
Kotlin为这两种场景提供了解决方案:使用挂起函数简化一次性资源获取,使用流处理持续事件通知。关于事件流处理方案详见《将listener转换为事件流》一文。本文聚焦第一种场景:如何简化异步资源获取操作。
异步编程的挑战
异步获取资源接口一般会提供两个状态回调函数:
interface ResourceStateListener {fun onReady(resource: Resource)fun onGone(error: Throwable)
}
有些复杂接口可能提供更多回调函数:
interface ComplexResourceStateListener {fun onOpen(resource: Resource)fun onReady(resource: Resource)fun onError(error: Throwable)fun onConfigureFailure(errorCode: Int)fun onClose()
}
客户代码的核心需求是获取可用资源。根据奥卡姆剃刀“如无必要,勿增实体”的原则,可以将所有资源不可用状态onError/onConfigureFailure/onClose合并成一个:onGone。考虑到onOpen事件只和资源清理有关,不执行业务操作。因此我们真正要关心的只有onReady和onGone。
传统回调的困境
根据上面的例子,我们可以得到代码:
fun openResource(resId: String, listener: ResourceStateListener) { ... }val resId = "1"
openResource(resId, object: ResourceStateListener {override fun onReady(resource: Resource) { ... }override fun onGone(error: Throwable) { ... }
})
传统回调模式存在以下问题:
- 代码逻辑分散。资源申请逻辑、使用资源逻辑、错误处理逻辑、资源清理逻辑分割在不同上下文中,代码难以追踪资源状态变化的完整路径。
- 状态管理困难。客户代码需要引入额外的变量来在回调函数之间传递资源对象或状态信息,代码复杂度,容易出错。
- 容易泄露资源。由于代码分散,难以追踪状态变化的完整路径,很难正确释放资源。容易存在资源泄露或重复释放。
- 可读性和可维护性差。回调模式无法满足结构化编程的“单一入口,单一出口”要求,代码难以阅读、理解和修改。
从结构化编程的角度来看,传统回调模式将申请资源代码、使用资源代码和异常处理代码分散在不同的上下文中,无法形成单一入口单一出口的逻辑结构,几乎是现代版的goto变种。
以同步形式编写异步代码
重新观察获取资源的过程:
- 程序向系统提交资源申请。系统以异步方式处理申请。
- 程序等待系统通知申请结果。
- 程序根据结果执行操作。
考虑到Kotlin挂起函数和协程非常适合等待场景,我们可以构造一个挂起函数,发起异步请求后函数挂起,等待回调函数唤醒。对于onReady事件,通过resume唤醒,进入使用资源逻辑。对于onGone事件,通过resumeWithException唤醒,进入错误处理逻辑。同时利用结构化并发特性,在协程退出时清理资源。
suspend fun openResource(resId: String): Resource = suspendCancellableCoroutine { cont ->var internalRes: Resource? = nullval listener = object : ComplexResourceStateListener {override fun onOpen(resource: Resource) {internalRes = resource // 保存底层资源对象引用,必要时手动释放。}override fun onReady(resource: Resource) {if (cont.isActive) {cont.resume(resource) // 资源就绪,唤醒协程。}}override fun onError(error: Throwable) {if (cont.isActive) {cont.resumeWithException(ResourceUnavailableException("Resource error", error))}}override fun onConfigureFailure(errorCode: Int) {if (cont.isActive) {cont.resumeWithException(ResourceUnavailableException("Configuration failed: $errorCode"))}}override fun onClose() {if (cont.isActive) {cont.resumeWithException(ResourceUnavailableException("Resource closed prematurely"))}}}// 发起异步请求。resource.openResource(resId, listener)// 设置资源清理操作。cont.invokeOnCancellation {// 移除监听器,避免后续无效回调干扰和内存泄漏。removeStateListener(listener)// 释放底层资源对象。internalRes?.release()internalRes = null}// 协程在此挂起,等待唤醒。
}// 资源不可用异常
class ResourceUnavailableException(message: String, cause: Throwable? = null) : Exception(message, cause)
封装成挂起函数之后,就可以在协程中以同步的形式编码。
try {val res = openResource(resId)useResource(res)
} catch (e: Exception) {handleError(e)
}
可以看到,使用协程封装回调函数拥有以下优势:
- 同步风格代码。使用线性代码结构来表达业务逻辑,代码简洁、意图清晰、可读性良好。
- 逻辑完整。资源申请、使用、错误处理逻辑集中在同一个上下文中,理解和维护成本低。
- 资源安全。利用协程的invokeOnCancellation和结构化并发特性,保证资源安全释放,减少资源泄漏风险。
- 错误处理简单。所有导致资源不可用的底层错误统一转换成语义清晰的单一异常,简化客户代码错误处理逻辑。
- 支持取消。利用协程的取消机制可以取消操作,释放资源。
- 接口简单。对客户代码屏蔽了复杂的底层细节,只发布一个简单的挂起函数,提高接口的易用性和语义清晰度。
通过使用协程进行封装,我们将原本支离破碎、难以管理的异步代码转变成结构清晰、资源安全、易于编写和维护的“同步”代码。即提升了开发效率,也大幅增强了程序健壮性。
参考资料
- 协程指南
- 将listener转换为事件流
- 只崩溃软件
- 我对续体传递风格CPS的理解
- 结构化并发
- 结构化并发(2)