異步編程的兩種場景
在異步編程中,回調函數通常服務于兩種不同場景:
- 一次性資源獲取:等待異步操作完成并返回結果。
- 持續事件通知。監聽并響應多個狀態變更。
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)