本文将用三个例子展示在 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() PhotoUploadScreen( isUploading = viewModel.isLoading, onUploadPhotoClick = { viewModel.uploadPhoto() } ) } } } } } }
界面没啥东西,总共有两个屏幕,首页会跳转到PhotoUploadScreen,PhotoUploadScreen上面有一个按钮,点击会调用PhotoUploadViewModel的uploadPhoto()方法模拟图片上传。
主要关注PhotoUploadViewModel的uploadPhoto()方法代码:
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 启动的协程作用域没有被真正取消,这与我们的预期不符,当用户按下返回键关闭页面时,我们期望的是取消协程中正在后台执行的上传任务,否则这容易导致内存泄漏。
这里的问题所在,是因为我们在 ProfileRepository 的 uploadPhoto() 方法中 try-catch 了所有的异常,这其中就包括协程取消异常:CancellationException 。
协程的取消其实非常依赖于 CancellationException ,该异常不会取消它的父协程,这个异常通常会被忽略静默处理,但是假如你捕获了该异常,那么父协程就不会感知到任何取消通知,也就不会取消协程。
所以,请不要捕获 CancellationException 类型的异常,要么在try-catch时总是指定具体的异常类型(如IOException、HttpException), 要么捕获到 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...
说明 ProfileRepository 的 uploadPhoto() 方法中逻辑没有被执行了。
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() } } }
这个示例中,主要关注 OfflineFirstRepository 的saveNote方法代码,这里的意图是先向本地数据库插入一个标志位 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的线程取消有点类似。
解决方法可以使用 isActive 或 ensureActive() 来检查 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}") } } } }
这样,当通过 isActive 为 false 时就会退出 while 循环,或 ensureActive() 检测到协程已经取消,就会抛出 CancellationException 同样会终止 while 循环,后续的读取文件内容就不会继续执行。
注意,检测协程是否被已被取消,最好是在循环中,或者如果没有循环逻辑则每隔一段代码调用一次检测,所以上面读取文件的代码最好不要使用it.readBytes()这样一行代码去读,因为这样就没有办法可以设置检查点。
如果你想更加深入和全面的了解有关 Kotlin 协程的异常和取消相关知识,可以参考我们的另外两篇博文:
- 【深入理解Kotlin协程】协程作用域、启动模式、调度器、异常和取消【使用篇】
- 【深入理解Kotlin协程】使用Job控制协程的生命周期