Kotlin 协程(Coroutines)是 Kotlin 语言中的一种轻量级并发编程工具,旨在简化异步编程和并发任务的处理。通常,我们在项目中的以下操作用会使用到协程:
- 网络请求:通过协程处理网络请求,避免阻塞主线程,提高应用响应速度。
- 数据库操作:使用协程执行数据库操作,确保操作在后台线程中进行,避免阻塞 UI。
- 并发任务:通过协程并发执行多个任务,提高程序的并发性能。
- 定时任务:使用协程处理定时任务和延迟操作。
正确使用协程能够帮助我们更优雅和快速的实现线程切换和处理并发问题。然后,如果使用不恰当,就会降低任务处理效率,获取引发非预期的问题。
本篇文章将会通过一些隐藏在代码里的危险案例,找出这些隐蔽的错误。在直接给出结论之前,希望读者先试着先思考一下问题所在,并尝试解决。
案例一
suspend fun getAllAddress(userIds: List<Long>): List<String> {
val addresses = mutableListOf<String>()
for(id in userIds) {
addresses.add(getAddress(id))
}
return addresses
}
suspend fun getAddress(userId: Long): String {
//来源于数据库、文件、或者网络
delay(1000L)
return return "TCL world E city D${Random.nextInt(4)}"
}
功能解读
本案例实现了一个根据userid信息获取所有地址的功能。getAddress方法是耗时方法,从数据库、io文件或者网络获取。这里用delay模拟耗时。
看下文之前,先思考一下问题所在。
ok,公布答案。这里的问题就在getAllAdress方法的for循环中。由于函数默认调用方式会按照顺序的执行方式(串行),陷入for循环后,每一次的调用会等待上一次执行完毕。也就是说,这个函数会根据用户的数量增加,等待时间成倍的增加。
for(id in userIds) {
addresses.add(getAddress(id)) //每一次调用getAddress都会等待上一次执行完毕
}
解决方法:
串行任务改为并行,使用Deffered+await方式,同步发起耗时请求,等待所有的请求结束后返回结果。
简单介绍一下deffered和await的用作
-
Deferred
:Deferred
是一个接口,表示一个可以延迟计算的值或任务。它类似于 Java 中的Future
或 JavaScript 中的Promise
。Deferred
对象通常由async
协程构建器创建,并表示一个异步计算的结果。Deferred
可以通过await
方法来获取计算结果,await
会挂起协程直到结果可用。
-
await
:await
是一个挂起函数,用于等待Deferred
对象的结果。调用await
会挂起当前协程,直到Deferred
完成并返回结果。- 如果
Deferred
任务失败,await
会抛出相应的异常。
有了这个思路后,我们再来改进这个方法。改进后如下:
1 suspend fun getAllAddress(userIds: List<Long>): List<String> {
2 val addresses = mutableListOf<Deferred<String>>()
3 coroutineScope {
4 for (id in userIds) {
5 val res = async{
6 getAddress(id)
7 }
8 addresses.add(res)
9 }
10 }
11 return addresses.awaitAll()
}
方法说明:
- 将address集合改为Deffered<String>类型,目的是为了等待所有的任务执行完成,最终调用其awitAll方法返回结果,如行11所示。
- 行5利用async方法,同步执行getAddress方法调用,不用等待结果,需要注意的是,这个方法是需要再coroutine生命周期内执行。若是在viewModel中,行5可以直接使用viewModel.async方法,同时删掉行3,简化代码。
总结:当同时有多个耗时操作发起时,考虑并行方式,等待最后一个任务完成,节约等待时间。
案例二
suspend fun doWithTimeOut() {
val job = CoroutineScope(Dispatchers.Default).launch {
var random = Random.nextInt(1000)
while (random != 500) {
random = Random.nextInt(1000)
}
}
delay(500L)
job.cancel()
}
功能解读
这段代码在协程作用域启动了一个异步操作,在1000个数里生成随机数。退出条件有2个,1是随机数等于500,2是等待500ms后,取消协程。
这段代码可以模拟协程执行耗时任务,以及超时退出。
那么这段代码有什么问题呢?
在公布答案之前,先说个题外话。相信用过java中Thread类的同学都知道,要取消一个线程的执行,可以有以下几个方法:
调用Thread.stop方法。该方法已废弃,因为Thread.stop
方法会强制终止线程的执行,而不考虑线程当前的状态和所持有的资源,这可能导致资源泄漏、不一致状态和其他难以调试的问题。- 通过设置标志位,为保证标志位的可见性,应当将其申明为volatile。
- 通过Thread.interrupt和isInterrupted方法,检查线程是否已经被中断。
其实本质上,方法2和3都是一样的。都是在执行线程过程中,检查是否已经被中断,区别只是在由系统帮助还是我们自己维护这个变量。也就是说,被请求的线程需要安全的停止,依靠的是定期检查其中断状态,并在适当的时候响应中断请求。
协程停止的原理其实也一样。回看案例二的代码,我们期望job.cancel调用后,它能够停止协程,实际上执行后并不会停止,因为协程取消是协作性的,协程需要定期检查其取消状态并响应取消请求。
因此这段代码可以这么优化:
suspend fun doWithTimeOut() {
val job = CoroutineScope(Dispatchers.Default).launch {
var random = Random.nextInt(1000)
/**
* 方法一:isActive变量,
* 检查该线程是否是活跃状态
*/
while (random != 500&& isActive) {
random = Random.nextInt(1000)
/**
* 方法二:ensureActive方法
* 用于确保协程处于活动状态。如果协程被取消,ensureActive 会抛出 CancellationException,并终止循环。
*/
ensureActive()
}
}
delay(500L)
job.cancel()
}
isActive
和 ensureActive
都是用于检查协程活动状态的重要工具,但它们的作用和使用方式有所不同。isActive
返回一个布尔值,适用于手动检查和处理取消逻辑,而 ensureActive
通过抛出异常来强制终止协程,适用于需要快速响应取消请求的场景。
总结:在执行可取消的线程/协程前,注意对内部的中断状态进行判断
案例三
suspend fun doSomething(): Result<String> {
val result = readFromFile()
return if (result == "Success") {
Result.success(result)
} else Result.failure(Exception())
}
suspend fun readFromFile(): String {
//模拟耗时操作
delay(3000L)
return if (isSuccess()) "Success" else "Error!
}
功能解读
模拟网络请求,根据网络请求结果返回成功与否。
老规矩,先自行分析一下这个代码的缺陷。
咋看之下,这个代码没有问题。事实上,依赖外部发起的指定的协程上下文,如IO或Default,确实没有任何问题。那如果发起的协程指定为Main呢,岂不是相当于在主线程执行耗时操作?当然,这个问题很容易被识别以及修改,但是这个问题我们不能忽视,也就是我们的readFromFile方法并不是Main-Safety的。所谓的Main-Safety,指的是在主线程(UI 线程)上执行代码时,确保不会阻塞主线程,从而保持应用的响应性和流畅性。
用本案例来说,我不应该关注外部是哪个线程调用我的readFromFile函数,我都要保证在子线程中执行。ok,我们修改一下这里的代码,利用切换协程的调度器withConext函数,指定其运行的线程。代码如下:
suspend fun readFromFile(): String {
return withContext(Dispatchers.IO){
delay(3000L)
if (isSuccess()) "Success" else "Error!"
}
}
总结:任何耗时操作函数,都应该保证Main-Safety特性
案例四
//Activity
class MyActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
...
findViewById<Button>(R.id.test).setOnClickListener{
lifecycleScope.launch{
viewModel.doSth()
}
}
}
}
//ViewModel
class MyViewModel : ViewModel() {
suspend fun doSth(){
//模拟耗时操作
delay(1000)
}
}
功能解读
当用户点击按钮时,触发
setOnClickListener
中的代码,启动一个新的协程,调用viewModel中的suspend方法处理耗时操作。
这段代码问题在于,ViewModel层不应该暴露suspend函数给UI(Activity、fragment。。)
如何理解呢?举个例子,如果我们在点击button的时候,刚好发生了屏幕旋转,默认情况下Activity会走销毁后重建,由于绑定了Activity生命周期,其lifecycleScrope内的调用会被cancel。没有地方发生泄漏,一切ok,但这可能不符合预期。如果用户点击发起的事件是网络请求,数据库的读写等等,因为一次屏幕旋转就停止,显然是不对的。
解决这个问题的方法就是在viewModel中使用协程。代码修改如下:
//Activity
class MyActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
...
findViewById<Button>(R.id.test).setOnClickListener{
viewModel.doSth()
}
}
}
//ViewModel
class MyViewModel : ViewModel() {
fun doSth(){
viewModelScope.launch {
//模拟耗时操作
delay(1000)
}
}
}
ViewModel能更好地管理和存储与 UI 相关的数据。它的主要作用包括以下几个:
-
数据持久化:
ViewModel
的生命周期比Activity
或Fragment
更长,能够在配置变化(如屏幕旋转)时保留数据,避免数据丢失。 -
与 UI 控件的生命周期解耦:
ViewModel
与Activity
或Fragment
的生命周期解耦,避免了内存泄漏和不必要的资源消耗。 -
简化数据管理:
ViewModel
提供了一种集中管理 UI 数据的方式,使得代码更加清晰和可维护。 -
支持异步操作:
ViewModel
通常与LiveData
或Flow
配合使用,支持异步数据加载和观察,简化了异步操作的处理。
依赖于数据持久化的功能,使用ViewModel类中发起协程操作,就不会容易中断。
总结:避免在ViewModel层暴露suspend函数给UI,而应该使用ViewModel中的协程发起异步请求。