Kotlin入门系列:Coroutine协程

1 协程的概念和基本使用

1.1 什么是协程

关于协程是什么这个问题,需要区分两个角度,一个是广义的协程,一个是kotlin的协程。

什么是协程?

  • 协程是一种在程序中处理并发任务的方案,也是这种方案的一个组件

  • 它和线程属于一个层级的概念,是一种和线程不同的并发任务解决方案;一套系统(可以是操作系统,也可以是一种编程语言)可以选择不同的方案处理并发任务,你可以使用线程,也可以使用协程

    • 协程中不存在线程,也不存在并行(并行与并发不是一个概念)

并行(Concurrency)表示当前进程既有前台又有后台,这就叫并行;并发(Parallelism)表示同一时间将时间暂停了,暂停的这一时刻可能同时有多个线程一起在进行的,这就叫并发。

kotlin的协程是什么?

  • kotlin的协程和广义的协程不是一种东西,kotlin的协程(确切说是kotlin for java的协程,它是基于JVM的,所以kotlin的协程底层还是线程)是一个线程框架

  • kotlin的协程不需要关心并行并发这些,因为kotlin的协程是基于线程的,线程本来就有并发并行,那kotlin的协程也会有

kotlin的协程 Coroutine 其实就是在kotlin提供的一套线程API,让我们不用过多关心线程也可以方便的写出并发操作,能够很方便的在同一个代码块里进行多次的线程切换

1.2 协程的基本使用

使用协程需要在项目中添加依赖:

implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.9")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9")

在我们使用协程时,一般都会使用一个函数 launch() 创建一个协程,然后在 launch() 函数指定要切换的线程,比较常用的有 Dispatchers.IODispatchers.Main 表示后台线程和主线程。在 launch() 函数块中包裹的代码,就是协程。

// 切换到后台执行耗时操作
GlobalScope.launch(Dispatchers.IO) {
	saveToDatabase(data)
}
// 切换到前台更新界面操作
GlobalScope.launch(Dispatchers.Main) {
	updateViews(data)
}
// Thread
thread {
	...
}

而我们在实际开发中,更多的是网络请求执行耗时操作,然后获取到结果后再更新UI,线程切换上还可以使用 withContext() 进行线程的切换,执行完后会自动的把线程切换回来,这就是协程。

// 不要使用这种方式写协程,典型的回调地狱!!!
GlobalScope.launch(Dispatchers.IO) {
	val user = api.getUser()
	GlobalScope.launch(Dispatchers.Main) {
		nameTv.text = user.name
	}
}

// 正确的写法
// Coroutine协程强大的地方在于可以直接进行线程切换
GlobalScope.launch(Dispatchers.Main) {
	// 使用withContext()切换线程处理后会自动将线程切换回来
	val user = withContext(Dispatchers.IO) {
		api.getUser() // 网络请求:后台线程
	}

	withContext(Dispatchers.IO) { ... }
	withContext(Dispathers.IO) { ... }	
	...
	
	nameTv.text = user.name // 更新UI:主线程
}

// 也可以将withContext()写成一个挂起函数抽离出来让代码更加具有可读性
GlobalScope.launch(Dispatchers.Main) {
	val user = suspendingGetUser()
	nameTv.text = user.name
}

suspend fun suspendingGetUser() {
	withContext(Dispatchers.IO) {
		api.getUser()
	}
}

GlobalSope.launch(Dispatchers.Main) {
	val avatar = async { api.getAvatar(user) } // 异步后台获取用户头像
	val logo = async { api.getCompanyLogo(user) } // 异步获取公司logo
	val merged = suspendingMerge(avatar, logo) // 合并两行结果
	show(merged) // 显示
}

2 suspend挂起

2.1 什么是协程的挂起

挂起的是什么?挂起的是协程。上面讲到协程就是那些被 launch() 函数包裹的代码,在代码执行到挂起函数的时候,协程就会被从当前线程挂起;当协程执行完操作后会自动的把线程切回到当前线程(简单理解挂起其实就是切了一个线程,具体说就是一个稍后会被自动切换回来的线程切换)。

GlobalScope.launch(Dispatchers.Main) {
	// 被launch()包裹的代码就是协程,也就是下面的两句代码
	// 在执行到这个挂起函数suspendingGetImage()的时候,协程就会被从当前线程挂起
	// 说白了就是下面的两句代码从正在执行它的线程上脱离了,从suspendingGetImage()这句代码开始线程不再运行协程了
	// 线程不理下面的两句协程,让协程自己去干自己的事情
	// 那协程干嘛去了?suspendingGetImage()它切换到一个后台线程Dispatchers.IO去调接口了
	// 那执行完之后呢?执行完之后它会自动的帮我们把线程自动切换回当前线程,从Dispatchers.IO切换到Dispatchers.Main
	val image = suspendingGetImage(imageId)
	avatarIv.setImageBitmap(image)
}

// 一个用关键字suspend声明的挂起函数
suspend fun suspendingGetImage(imageId: String) {
	witchContext(Dispatchers.IO) {
		api.getImage(imageId)
	}
}

2.2 不用协程,用线程能不能自动切回

classicIoCode(true, ::uiCode)

private fun classicIoCode(toUiThread: Boolean = true, block(): () -> Unit) {
	val executor = ThredPoolExecutor(5, 20, 1, TimeUnit.MINUTES, LinkedBlockingQueue(1000))
	executor.execute {
		Thread.sleep(1000)
		println("Coroutines classic function ${Thread.currentThread().name}")
		// 默认执行完耗时操作就切换到主线程,否则就在子线程
		if (toUiThread) {
			runOnUiThread {
				block()
			}
		} else {
			block()
		}
	}
}

private fun ioCode() {
	println("another io task")
}

上面的代码看着好像是实现了执行完耗时任务后将线程切换回来主线程:如果我是 classicIoCode() 的调用者,我要切到主线程就传 true 执行完切到主线程,不切换就不传或传 false

但这和协程的自动切换回来有本质上的不同:

  • 协程是自动切换回来,重点在于回来协程切换回来是会回到协程挂起前的线程。比如 GlobalScope.launch(Dispatchers.Main) 指定了上下文是主线程,那么在里面的挂起函数就会在执行完耗时任务后自动切换回这个上下文

  • 例子中的线程切换是做不到的,它不知道怎么回去,这都需要调用者手动回去。例子中是 classicIoCode()true 或默认就切换到主线程,否则还是在执行耗时任务的线程运行。那现在改下上面的代码:

    thread {
    	aaa()
    	bbb()
    	classicIoCode(block = ::uiCode)
    	ccc()
    }
    

    现在需要 classicIoCode() 回到 thread { } 指定的这个线程中,无论传 truefalse 都是做不到的。

2.3 suspend的作用

2.3.1 为什么suspend挂起函数要在协程或在另一个挂起函数中调用

在上面的代码中,我们在 launch() 中使用 suspendingGetImage() 的时候如果没有在函数中声明 suspend 关键字会在IDE报错,提示挂起函数要在协程中调用或者在另一个挂起函数中调用。

在这里插入图片描述
从上面的解释我们知道,挂起它是需要恢复 resume 的,是要在挂起函数切换线程后将线程切换回来,而恢复resume这个功能它是协程的,所以说如果一个挂起函数不在协程中被调用,那么它就没办法让我们在挂起函数切换线程后再将线程重新切换回来。

2.3.2 suspend关键字的作用是什么

GlobalScope.launch(Dispatchers.Main) {
	suspendingPrint() 
	suspendingGetImage()
}

suspend fun suspendingPrint() {
	println("suspendingPrint Thread:${Thread.currentThread().name}")
}

suspend fun suspendingGetImage() {
	withContext(Dispatchers.IO) {
		println("suspendingGetImage Thread:${Thread.currentThread().name}")
	}
}

输出:
suspendingPrint Thread: main
suspendingGetImage Thread: DefaultDispatcher-worker-1

打印结果是 suspendingPrint() 即使加上了 suspend 关键字,它还是运行在主线程;而 suspendingGetImage() 是线程切换成功的,它指定了上下文 withContext(Dispatchers.IO)

根据上面的分析,suspend 关键字并不起到挂起协程的功能或切换线程的作用,那么它到底是用来干嘛的?

suspend 关键字是用来提醒的,函数的创建者对函数的调用者的提醒,并不会起到切换线程的作用。

suspend 关键字声明的挂起函数是一个耗时操作,实际将函数切换到子线程执行的是 withContext(Dispatchers.IO),函数的创建者用挂起的方式将操作放在了后台运行,所以是提醒调用这个函数API的调用者,要将这个函数放在协程并且提醒调用者它是一个耗时任务。

我们在写Java的时候如果不留意里面的代码在主线程调用了一个函数操作,就会导致线程卡顿甚至ANR;如果我们在Kotlin中用 suspend 关键字声明了挂起函数提醒调用者这个函数是一个耗时后台执行的任务,就能避免这种在主线程调用耗时操作的问题。

3 非阻塞式挂起

3.1 什么是非阻塞式挂起

非阻塞式,其实就是不卡线程。无论是使用Kotlin的挂起函数切线程了,还是用Java的Thread切线程了,其实都是非阻塞式的。

所以协程的非阻塞式挂起,其实就是用阻塞的方式写出了非阻塞的代码而已,本质上耗时操作还是得切线程,更新界面还是得主线程(任何代码都是阻塞的,只是耗时操作的代码在人类感知中比较明显而已)。

3.2 协程和线程的关系

在Kotlin里,协程就是基于线程而实现的一套更上层的工具API框架,类似于Java的 Executors 和 Android的 Handler API。协程的本质还是线程。

4 kotlin协程总结

kotlin的协程是什么?

  • kotlin的协程就是一个线程框架

  • 挂起就是可以自动切回来的切线程,这是线程无法做到的

  • 非阻塞式就是协程可以用看起来阻塞式的代码写出非阻塞的操作(简单理解就是用看起来同步的代码写出实质上异步的操作)

5 Coroutine实战

5.1 Retrofit对协程的支持

关于 Retrofit 怎么使用,可以查看 RetrofitRetrofit 相关注解说明 Retrofit的基本使用

implementation 'com.squareup.retrofit2:retrofit:2.6.1'
implementation 'com.squareup.retrofit2:converter-gson:2.4.0'

Repo.Kt

data class Repo(val name: String)

Api.Kt

interface Api {
    @GET("users/{user}/repos")
    fun listRepos(@Path("user") user: String): Call<List<Repo>>
}

val retrofit = Retrofit.Builder()
    .baseUrl("https://api.github.com/")
    .addConverterFactory(GsonConverterFactory.create())
    .build()
val api = retrofit.create(Api::class.java)
api.listRepos("vincent").enqueue(object: Callback<List<Repo>?> {
    override fun onResponse(call: Call<List<Repo>?>, response: Response<List<Repo>?>) {
        val name = response.body()?.get(0)?.name
        textView.text = name
    }

    override fun onFailure(call: Call<List<Repo>?>, t: Throwable) {

    }
})

使用 Retrofit 做网络请求非常简单,就是创建 Retrofit 实例,Retrofit 经过动态代理获取到我们的 Api 接口管理类,最后调用接口。

Retrofit 从2.6开始支持协程,Retrofit 的协程使用如下:

interface Api {

	// 将请求接口声明为suspend挂起函数
    @GET("users/{user}/repos")
    suspend fun listReposKt(@Path("user") user: String): List<Repo>
}

GlobalScope.launch(Dispatchers.Main) {
    val repos = api.listReposKt("vincent")
    val name = repos[0].name
    textView.text = name
}

可以发现使用协程的 Retrofit 请求网络变得更加简单了,消除了callback,接口在调用时已经切换到工作线程处理。

5.2 kotlin的协程和RxJava

RxJava 在协程没有出现之前,可以说它极大的方便了java和android开发,响应式编程的链式结构让我们无论是处理网络请求还是复杂的数据操作都会变得简单且易读。关于 RxJava 这里也不会过多提及,网上有很多其他文章已经介绍了RxJava的使用,或者到github查看 RxJava。RxJava最新是RxJava3,相比RxJava2改动不是很大,演示还是会使用RxJava2。

implementation 'com.squareup.retrofit2:adapter-rxjava2:2.4.0'
implementation 'io.reactivex.rxjava2:rxjava:2.2.3'
implementation 'io.reactivex.rxjava2:rxandroid:2.1.1'

Api.Kt

interface Api {

	// 返回值修改为RxJava的Single
    @GET("users/{user}/repos")
    fun listReposRx(@Path("user") user: String): Single<List<Repo>>
}

val retrofit = Retrofit.Builder()
    .baseUrl("https://api.github.com/")
    .addConverterFactory(GsonConverterFactory.create())
    .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) // 添加RxJava转换的支持
    .build()
val api = retrofit.create(Api::class.java)

api.listReposRx("vincent")
    .subscribeOn(Schedulers.io())
    .observeOn(AndroidSchedulers.mainThread())
    .subscribe(object: SingleObserver<List<Repo>?> {
        override fun onSubscribe(d: Disposable) {

        }

        override fun onSuccess(t: List<Repo>) {
            textView.text = t[0].name
        }

        override fun onError(e: Throwable) {
        }

    })

RxJava除了丰富的操作符外,线程切换也是非常简单,通过一句代码 subscribeOn() 指定工作线程,observeOn() 指定切换回UI线程或其他线程。

RxJava也支持更复杂的处理,比如将两个网络请求合并后返回:

Single.zip<List<Repo>, List<Repo>, Boolean>(
    api.listReposRx("vincent"),
    api.listReposRx("vincent"),
    BiFunction { t1, t2 -> t1[0].name == t2[0].name })
    .subscribeOn(Schedulers.io())
    .observeOn(AndroidSchedulers.mainThread())
    .subscribe(object: SingleObserver<Boolean> {
        override fun onSubscribe(d: Disposable) {

        }

        override fun onSuccess(t: Boolean) {
            textView.text = t.toString()
        }

        override fun onError(e: Throwable) {
        }

    })

用协程处理上面的需求是怎样的呢?

GlobalScope.launch(Dispatchers.Main) {
    // 使用async可以开启一个新的协程,并且它不会马上返回结果
    // 两个async是并行的,通过await()获取结果,await()是一个挂起函数耗时
    val one = async { api.listReposKt("vincent") }
    val two = async { api.listReposKt("vincent") }
    val isSame = one.await()[0].name == two.await()[0].name
    textView.text = isSame.toString()
}

或许有人会问:RxJava相比起协程,哪个简单?

无论是RxJava还是协程,它们都有一定的学习成本,但在两种都掌握的前提下,总体来看其实还是协程比较简单好用一些。

5.3 CoroutineScope

实际开发场景经常会遇到一种常见的情况:界面销毁时线程还在后台运行,可能在线程持有着外部Context等引用,最终导致内存泄露。

在上面讲到的RxJava也存在这个问题,它使用的是 DisposableCompositeDisposable 管理:

class MainActivity : AppCompatActivity() {
	private var disposable: Disposable? = null

	override fun onCreate(savedInstanceState: Bundle?) {
		super.onCreate(savedInstanceState)
		setContentView(R.layout.activity_main)

		disposable = api.listReposRx("vincent")
			.subscribeOn(Schedulers.io())
			.observeOn(AndroidSchedulers.mainThread())
			.subscribe(Consumer {})
	}

	override fun onDestroy() {
		disposable?.dispose()
		super.onDestroy()
	}
}class MainActivity : AppCompatActivity() {
	private val compositeDisposable = CompositeDisposable()

	override fun onCreate(savedInstanceState: Bundle?) {
		super.onCreate(savedInstanceState)
		setContentView(R.layout.activity_main)

		val disposable = api.listReposRx("vincent")
			.subscribeOn(Schedulers.io())
			.observeOn(AndroidSchedulers.mainThread())
			.subscribe(Consumer {})
		compositeDisposable.add(disposable)
	}

	override fun onDestroy() {
		compositeDisposable.dispose()
		super.onDestroy()
	}
}

在协程中这种问题被称为协程泄露(Coroutine Leak)。在协程中解决这类问题,可以使用 CoroutineScope,和RxJava处理的方式差不多:

class MainActivity: AppCompatActivity() {
	private val scope = MainScope()

	override fun onCreate(savedInstanceState: Bundle?) {
		super.onCreate(savedInstanceState)
		setContentView(R.layout.activity_main)

		scope.launch {
			// do something
		}
	}

	override fun onDestroy() {
		scope.cancel() // 界面退出时取消
		super.onDestroy()
	}
}

说到 CoroutineScope,在协程中也有一个 coroutineScope 需要做下区分:

private val scope = MainScope()

scope.launch {
    coroutineScope {
        launch {
            delay(1000)
            println("coroutineScope execute1")
        }

        launch {
            delay(1000)
            println("coroutineScope execute2")
        }
    }

    println("mainScope execute")
}

输出:
coroutineScope execute1
coroutineScope execute2
mainScope execute

这里的 coroutineScope 它是一个函数,它会先把外面的协程挂起,让 coroutineScope 里面的协程先执行完之后,再去执行 scope.launch { } 协程。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值