Kotlin 协程中容易让人忽视的 Cancellation 陷阱

本文将用三个例子展示在 Kotlin 协程中比较容易忽视的 Cancellation 问题。

Cancellation 陷阱示例一

class MyActivity: ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MyComposeApplicationTheme() {
                val navController = rememberNavController()
                NavHost(navController = navController, startDestination = "home") {
                    composable("home") {
                        HomeScreen(
                            onNextClick = { navController.navigate("photo_upload") }
                        )
                    }
                    composable("photo_upload") {
                        val viewModel = viewModel<PhotoUploadViewModel>()
                        PhotoUploadScreen(
                            isUploading = viewModel.isLoading,
                            onUploadPhotoClick = { viewModel.uploadPhoto() }
                        )
                    }
                }
            }
        }
    }
}

界面没啥东西,总共有两个屏幕,首页会跳转到PhotoUploadScreenPhotoUploadScreen上面有一个按钮,点击会调用PhotoUploadViewModeluploadPhoto()方法模拟图片上传。

主要关注PhotoUploadViewModeluploadPhoto()方法代码:

class PhotoUploadViewModel: ViewModel() {

    var isLoading: Boolean by mutableStateOf(false)
        private set

    private val repository = ProfileRepository()
    
    fun uploadPhoto() {
        viewModelScope.launch {
            isLoading = true
            Log.d("PhotoUploadViewModel", "Uploading photo...")
            repository.uploadPhoto()
            isLoading = false
            Log.d("PhotoUploadViewModel", "Photo update finished")
        }
    }
}

class ProfileRepository {
    suspend fun uploadPhoto() {
        try {
            delay(5000L) // 延时 5s 模拟上传图片耗时
        } catch (e: Exception) {
            e.printStackTrace() 
        }
    }
}

这个例子运行之后,点击上传按钮后,马上按back键返回,会发现log输出:

Uploading photo...
Photo update finished

可见 repository.uploadPhoto() 方法中的上传代码在返回键之后还是会被执行,这意味着 viewModelScope.launch 启动的协程作用域没有被真正取消,这与我们的预期不符,当用户按下返回键关闭页面时,我们期望的是取消协程中正在后台执行的上传任务,否则这容易导致内存泄漏。

这里的问题所在,是因为我们在 ProfileRepositoryuploadPhoto() 方法中 try-catch 了所有的异常,这其中就包括协程取消异常:CancellationException

协程的取消其实非常依赖于 CancellationException ,该异常不会取消它的父协程,这个异常通常会被忽略静默处理,但是假如你捕获了该异常,那么父协程就不会感知到任何取消通知,也就不会取消协程。

所以,请不要捕获 CancellationException 类型的异常要么在try-catch时总是指定具体的异常类型(如IOExceptionHttpException), 要么捕获到 CancellationException 异常时总是将其抛出,如下:

class ProfileRepository {
    suspend fun uploadPhoto() {
        try {
            delay(5000L)
        } catch (e: Exception) {
            if(e is CancellationException) throw e // 捕获到 CancellationException 时重新抛出
            e.printStackTrace()
        }
    }
}

或者:

class ProfileRepository {
    suspend fun uploadPhoto() {
        try {
            delay(5000L)
        } catch (e: IOException) {
            e.printStackTrace()
        } catch (e: HttpException) {
            e.printStackTrace()
        }
    }
}

现在这个示例运行后,点击按钮上传,立马按返回键,协程会被真正的取消掉,log输出:

Uploading photo... 

说明 ProfileRepositoryuploadPhoto() 方法中逻辑没有被执行了。

Cancellation 陷阱示例二

@Entity
data class Note(
    val title: String,
    val description: String,
    val isSynced: Boolean,
    @PrimaryKey
    val id: Int = 0
)


interface NoteApi {
    abstract fun saveNote(note: Note)
}

interface NoteDao {
    @Upsert
    suspend fun upsertNote(note: Note) 
}
    
class OfflineFirstRepository(private val dao: NoteDao, private val api: NoteApi) {

    suspend fun saveNote(note: Note) {
        try {
            dao.upsertNote(note.copy(isSynced = false))

            api.saveNote(note)

            dao.upsertNote(note.copy(isSynced = true))
        } catch (e: Exception) {
            if (e is CancellationException) throw e 
            e.printStackTrace()
        }
    }
}

这个示例中,主要关注 OfflineFirstRepositorysaveNote方法代码,这里的意图是先向本地数据库插入一个标志位 isSynced = false 表示同步尚未完成,随后调用 NoteApi 向后端服务器提交 note 数据,当提交完毕之后,再更新本地数据库中的标志位 isSynced = true 表示同步完成。

这里捕获到 CancellationException 会将其重新抛出,所以协程取消不会有问题。

假设代码在 dao.upsertNote(note.copy(isSynced = false))api.saveNote(note) 中时协程被取消,那么本地记录的同步标志为 false 在下次应用启动时会进行根据保存的标志位进行请求重试。

这个示例的问题在于,协程的取消可以是在任何时机,不一定在某个方法当中,例如在 api.saveNote(note)执行完毕,但是还没有开始执行 dao.upsertNote(note.copy(isSynced = true)) 这句时,用户按下了返回键,此时协程被取消,那么同步标识就不会被更新为 true。在这个示例中,可能不会有太大问题,因为顶多在下次重启时,本地读取判断同步标识为 false 会再次向服务器提交一遍,但这需要根据你的业务场景来,假如不允许重复提交,那么就有可能产生bug。

解决的方法之一是使用 Room 中的 @Transaction 事务处理,将多个数据库操作封装到一个原子事务当中。另一个解决方法是使用 withContext(NonCancellable)

class OfflineFirstRepository(private val dao: NoteDao, private val api: NoteApi) {

    suspend fun saveNote(note: Note) {
        try {
            dao.upsertNote(note.copy(isSynced = false))

            api.saveNote(note)

            withContext(NonCancellable) {
                dao.upsertNote(note.copy(isSynced = true))
            }
        } catch (e: Exception) {
            if (e is CancellationException) throw e
            e.printStackTrace()
        }
    }
}

withContext(NonCancellable) { } 包裹的代码不能被取消,一定会执行,当 api.saveNote(note) 执行完毕后,假设用户按下返回键,那么 withContext(NonCancellable) 中的代码仍然会执行。

Cancellation 陷阱示例三

class PhotoUploadViewModel: ViewModel() {
	...
    fun readFile(context: Context) {
        viewModelScope.launch {
            val job = launch {
                FileReader(context).readFileFromAssets("60MB.bin")
            }
            delay(3L) 
            job.cancel()
            println("Read file cancelled")
        }
    }
}

class FileReader(private val context: Context) {
    suspend fun readFileFromAssets(name: String): ByteArray {
        return withContext(Dispatchers.IO) {
            context.assets.open(name).use {
                it.readBytes()
            }
        }.also { println("Read file with size ${it.size}") }
    }
}

上面代码readFile方法中会启动一个子协程去读取文件内容,这里在启动读取文件的子协程3毫秒后,马上调用 job.cancel() 取消了子协程,但是发现log输出:

Read file cancelled
Read file with size  62914560

这说明读取文件的子协程还是执行完了,读取文件的任务没有真正被取消掉。

这个问题的所在,是因为协程的取消是协作的,也就是说协程并不是一定能被取消的如果协程正在执行不可中断的计算任务,并且没有检查取消的话,那么它是不能被取消的。这一点上跟Java的线程取消有点类似。

解决方法可以使用 isActiveensureActive() 来检查 Job 状态,例如:

class FileReader(private val context:Context) {

    suspend fun readFileFromAssets(name: String): ByteArray {
        return withContext(Dispatchers.IO) {
            context.assets.open(name).use {
                val byteArrayOutputStream = ByteArrayOutputStream()
                val buffer = ByteArray(4096)
                var bytesRead: Int
                while (it.read(buffer).also { bytesRead = it } != -1) {
                    ensureActive()
                    byteArrayOutputStream.write(buffer, 0, bytesRead)
                }
                byteArrayOutputStream.toByteArray()
            }.also { println("Read file with size ${it.size}") }
        }
    }
}

或者:

class FileReader(private val context:Context) {

    suspend fun readFileFromAssets(name: String): ByteArray {
        return withContext(Dispatchers.IO) {
            context.assets.open(name).use {
                val byteArrayOutputStream = ByteArrayOutputStream()
                val buffer = ByteArray(4096)
                var bytesRead: Int
                while (isActive && it.read(buffer).also { bytesRead = it } != -1) {
                    byteArrayOutputStream.write(buffer, 0, bytesRead)
                }
                byteArrayOutputStream.toByteArray()
            }.also { println("Read file with size ${it.size}") }
        }
    }
}

这样,当通过 isActivefalse 时就会退出 while 循环,或 ensureActive() 检测到协程已经取消,就会抛出 CancellationException 同样会终止 while 循环,后续的读取文件内容就不会继续执行。

注意,检测协程是否被已被取消,最好是在循环中,或者如果没有循环逻辑则每隔一段代码调用一次检测,所以上面读取文件的代码最好不要使用it.readBytes()这样一行代码去读,因为这样就没有办法可以设置检查点。

如果你想更加深入和全面的了解有关 Kotlin 协程的异常和取消相关知识,可以参考我们的另外两篇博文:

  • 14
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

川峰

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值