本文将用三个例子展示在 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() }
)
}
}
}
}
}
}
界面没啥东西,总共有两个屏幕,首页会跳转到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 协程的异常和取消相关知识,可以参考我们的另外两篇博文: