Kotlin协程-协程的常见进阶使用
Kotlin协程系列:
通过之前的文章,我们理解协程的启动方式,切换线程的方式,挂起函数,阻塞与非阻塞的区别。
理解了协程的上下文,调度线程,管理协程的Job,异常管理等。
理解了协程的作用域,父子协程的概念,GlobalScop、MainScope 和 Android 特有的 lifecycleScope viewModelScope 等。了解他们的异同与使用方法。
那么在实际开发中,我们如何使用协程呢?又有哪些注意点呢?比如网络请求如何使用与封装,自定义协程还有哪些用法,协程如何并发与加锁等等。
我们一起往下看。
一、协程中使用网络请求与封装
一般来说我们的网络请求应该是这样的
private fun doNetWork() {
val api = DemoRetrofit.apiService.getNews("1", "2")
val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->
YYLogUtils.e(throwable.message ?: "网络错误")
}
MainScope().launch(exceptionHandler) {
val work = async(Dispatchers.IO) {
api.execute() //同步请求
}
//获取到异步的网络请求结果
val response = work.await()
response?.let {
if (response.isSuccessful) {
val bean = response.body()
YYLogUtils.w(bean.toString())
} else {
when (response.code()) {
402 -> {
toast("token过期了")
}
500 -> {
toast("服务器错误")
}
}
}
}
work.invokeOnCompletion {
// 协程关闭时,取消任务
if (work.isCancelled) {
api.cancel()
}
}
}
}
结合了我们之前讲到的,我们这样请求一个网络,可以处理Http的错误和自定义的Api错误。
这样写,难道每一个网络请求我都得写一遍,能不能封装一下,当然可以了,我们封装一个扩展方法当工具类,然后使用DSL的回调方式来回调结果(DSL不理解可以看看我之前的文章Kotlin的高阶函数进阶)。
我们先定义一个DSL的实现
class RetrofitResultCallbackDsl<ResultType> {
var api: (Call<ResultType>)? = null
internal var onSuccess: ((ResultType?) -> Unit)? = null
internal var onComplete: (() -> Unit)? = null
internal var onFailed: ((error: String?, code: Int) -> Unit)? = null
fun onSuccess(block: (ResultType?) -> Unit) {
this.onSuccess = block
}
fun onComplete(block: () -> Unit) {
this.onComplete = block
}
fun onFailed(block: (error: String?, code: Int) -> Unit) {
this.onFailed = block
}
fun clean() {
onSuccess = null
onComplete = null
onFailed = null
}
}
使用DSL和Retrofit同步请求的封装
fun <ResultType> CoroutineScope.retrofit(
init: RetrofitResultCallbackDsl<ResultType>.() -> Unit
) {
//先初始化DSL类
val retrofitCoroutine = RetrofitResultCallbackDsl<ResultType>()
init(retrofitCoroutine)
//如果DSL是接口实现类,这里需要绑定接口,我们这里没有实现接口,就无需绑定了
//DSL初始化完成之后启动协程
launch(Dispatchers.Main) {
retrofitCoroutine.api?.let { it ->
val work = async(Dispatchers.IO) {
try {
it.execute()
} catch (e: Exception) {
//这里DSL如果是绑定接口的无需我们自己手动调用,一般都是接口调用,我们这里没有绑定接口就自己手动调用了,
retrofitCoroutine.onFailed?.invoke("网络错误", -1)
null
}
}
work.invokeOnCompletion {
if (work.isCancelled) {
it.cancel()
retrofitCoroutine.clean()
}
}
val response = work.await()
retrofitCoroutine.onComplete?.invoke()
response?.let {
if (response.isSuccessful) {
retrofitCoroutine.onSuccess?.invoke(response.body())
} else {
when (response.code()) {
402 -> {
toast("token过期了")
}
500 -> {
toast("服务器错误")
}
}
retrofitCoroutine.onFailed?.invoke(response.errorBody()?.toString(), response.code())
}
}
}
}
}
注意这里的DSL的实现回调的时候和一般的DSL有区别,一般大家都是教学DSL实现接口,这里我没有实现接口,那么在回调的时候就需要自己手动回调了。
那么使用的时候:
MainScope().retrofit<String> {
api = DemoRetrofit.apiService.getNews("1", "2")
onComplete {
YYLogUtils.w("网络请求执行完毕")
}
onSuccess { result ->
YYLogUtils.w("成功的结果:" + result)
}
onFailed { error, code ->
YYLogUtils.e("网络请求出错了")
}
}
是不是很有OkHttp回调的风格了,不过这样确实是回调,只是把之前用接口的回调改为了DSL回调,本质它还是回调。
有没有更优雅一点的方式啊,不是说协程就是消除回调吗?这样使用协程与不用协程有什么区别,这。。。
二、协程与Retrofit的挂起方式的封装
确实,上面都是使用协程+同步的方式,其实协程在2.6之后就支持了挂起 suspend 的方式。那么我们使用 协程 + 挂起函数岂不是绝配。
例如我们定义Api的时候,我们就能直接标记方法为 suspend
@GET("/index.php/api/employee/industry")
suspend fun getIndustry(
@Header("Content-Type") contentType: String,
@Header("Accept") accept: String
): BaseBean<List<Industry>>
那么我们封装这样的网络请求带错误处理的时候就要这么来
suspend fun <T : Any> Any.extRequestHttp(call: suspend () -> BaseBean<T>): OkResult<T> {
return try {
val response = call()
if (response.code == 200) {
OkResult.Success(response.data)
} else {
OkResult.Error(ApiException(response.code, response.message))
}
} catch (e: Exception) {
e.printStackTrace()
OkResult.Error(handleExceptionMessage(e))
}
}
fun handleExceptionMessage(e: Exception): IOException {
return when (e) {
is UnknownHostException -> IOException("Unable to access domain name, unknown domain name.")
is JsonParseException -> IOException("Data parsing exception.")
is HttpException -> IOException("The server is on business. Please try again later.")
is ConnectException -> IOException("Network connection exception, please check the network.")
is SocketException -> IOException("Network connection exception, please check the network.")
is SocketTimeoutException -> IOException("Network connection timeout.")
is RuntimeException -> IOException("Error running, please try again.")
else -> IOException("unknown error.")
}
}
直接定义一个扩展方法,请求网络,需要注意的是我们的参数是 suspend 我们内部的实现并没有开启协程,所以我们自己的方法必须也要加上 suspend 前缀,确保它最后实在协程内执行的。
当然了,如果不想用try catch的话,使用 runCatching 也是一样的。
runCatching {
call.invoke()
}.onSuccess { response: BaseBean<T> ->
if (response.code == 200) {
OkResult.Success(response.data)
} else {
OkResult.Error(ApiException(response.code, response.message))
}
}.onFailure { e ->
e.printStackTrace()
OkResult.Error(handleExceptionMessage(Exception(e.message, e)))
}
其实 runCatching 的实现和try-catch是一样的,语法糖而已,内部还是try-catch的实现。
使用的时候,一般都是现在Repository中定义
suspend fun getIndustry(): OkResult<List<Industry>> {
return extRequestHttp {
DemoRetrofit.apiService.getIndustry(
Constants.NETWORK_CONTENT_TYPE,
Constants.NETWORK_ACCEPT_V1
)
}
}
这里一样的内部的实现并没有开启协程,所以我们自己的方法必须也要加上 suspend 前缀,确保它最后实在协程内执行。
真正的调用在ViewModel中
fun requestIndustry() {
viewModelScope.launch {
//开始Loading
loadStartLoading()
val result = mRepository.getIndustry()
result.checkResult({
//处理成功的信息
toast("list:$it")
liveData.value = it
}, {
//失败
liveData.value = null
})
loadHideProgress()
}
}
这样就完成一次封装到使用的全部过程,可以看到确实是比协程+同步的要简单一些了。如果想看更完整的协程+挂起可以看看我之前的文章协程的使用与封装。
三、自定义协程的几种方法
之前讲到协程作用域的时候,我们有些特殊情况需要自定义协程作用域。一起看看自定义协程有哪几种方式
3.1 自己管理Job
其实最简单也是最直接的做法是像RxJava那样,自己管理Dispose对象
open class BaseVM : ViewModel(){
val jobs = mutableListOf<Job>()
override fun onCleared() {
super.onCleared()
jobs.forEach { it.cancel() }
}
}
class UserVM : BaseVM() {
val userData = StateLiveData<UserBean>()
fun login() {
jobs.add(GlobalScope.launch {
YYLogUtils.w("切换到一个协程3")
delay(3000)
YYLogUtils.w("协程3执行完毕")
})
}
自己关联和管理job,destroy的时候关闭job
3.2 委托 MainScope 管理
我们可以使用 MainScope 管理当前类的协程作用域,这里不止使用MainScope,但是用它最方便。
比如我们可以在一个自定义View中都可以使用协程了:
class AsyncView @JvmOverloads constructor(context: Context?, attrs: AttributeSet? = null, defStyleAttr: Int = 0) :
View(context, attrs, defStyleAttr), CoroutineScope by MainScope() {
fun doSth(block: (View) -> Unit) {
launch {
YYLogUtils.w("切换到一个协程")
delay(3000)
YYLogUtils.w("协程执行完毕")
block(view)
}
}
override fun onDetachedFromWindow() {
cancel()
super.onDetachedFromWindow()
}
}
3.3 原始的 CoroutineScope 实现
我们可以直接让我们的类实现 CoroutineScope 接口,但是我们需要指定协程的上下文,我们可以这样。
abstract class CoroutineDialog : Dialog, CoroutineScope {
// 默认上下文使用context.dispatcher()
override val coroutineContext: CoroutineContext by lazy { context.dispatcher() }
...
}
比如我想封装一个带协程的PopupWindow,我这样封装一个基类
/**
* 自定义带协程作用域的弹窗
*/
abstract class CoroutineScopeCenterPopup(activity: FragmentActivity) : CenterPopupView(activity), CoroutineScope {
private lateinit var job: Job
private val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->
YYLogUtils.e(throwable.message ?: "Unkown Error")
}
//此协程作用域的自定义 CoroutineContext
override val coroutineContext: CoroutineContext
get() = Dispatchers.Main + job + CoroutineName("CenterPopupScope") + exceptionHandler
override fun onCreate() {
job = Job()
super.onCreate()
}
override fun onDismiss() {
job.cancel() // 关闭弹窗后,结束所有协程任务
YYLogUtils.w("关闭弹窗后,结束所有协程任务")
super.onDismiss()
}
}
那么我就可以直接使用这个基类的实现了:
class InterviewAcceptPopup(private val mActivity: FragmentActivity) : CoroutineScopeCenterPopup(mActivity) {
override fun getImplLayoutId(): Int {
return R.layout.dialog_interview_accept
}
override fun onCreate() {
super.onCreate()
val btnYes = findViewById<TextView>(R.id.btn_y)
btnYes.click {
doSth()
}
}
private fun doSth() {
launch {
YYLogUtils.w("执行在协程中...")
delay(1000L)
YYLogUtils.w("执行完毕...")
dismiss()
}
}
}
实际开发中如果是涉及到 Android 页面的一些生命周期的,我们可以使用viewModelScope、lifecycleScope 。如果是其他的页面比如 View 或者 Dialog 或者干脆不涉及到页面的一些地方,我们就可以使用以上的几种方法来实现自定义的协程作用域。
当然还有更多的自定义协程作用域方法,我没有穷举出来,如果大家有更多更好的方法也可以评论区讨论一下。
四、协程的异常封装
网络请求我们可以使用统一的封装和异常处理,那么其他的一些任务,一些耗时的操作也想使用封装之后的异常处理怎么办?
其实大致的逻辑上面的网络请求封装中有讲到一点,但是他们封装的比较完善,是网络请求专用的,并不适用于其他的协程任务,这里对上面的封装做一些拆解,做个青春版的异常封装封装。
在之前的协程上下文的文章中,我们简单的封装了一下异常启动与回调处理。
fun CoroutineScope.safeLaunch(
onError: ((Throwable) -> Unit)? = null,
onLaunchBlock: () -> Unit
) {
val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
onError?.invoke(throwable)
}
this.launch(exceptionHandler) {
onLaunchBlock.invoke()
}
}
这种方法以及能用了,也是最简单的异常封装,那么如果想对这个方案进行更细致的封装,我们可以使用一个简单的DSL语法扩展,对成功和异常进行分别的进行回调,实现过程如下:
定义回调的对象
class CoroutineBuilder<T> {
var onRequest: (suspend () -> T)? = null
var onSuccess: ((T) -> Unit)? = null
var onError: ((Throwable) -> Unit)? = null
}
使用高阶扩展函数回调
fun <T> CoroutineScope.safeLaunch(init: CoroutineBuilder<T>.() -> Unit) {
val result = CoroutineBuilder<T>().apply(init())
val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
result.onError?.invoke(throwable)
}
this.launch(exceptionHandler) {
val res: T? = result.onRequest?.invoke()
res?.let {
result.onSuccess?.invoke(it)
}
}
}
使用的时候
lifecycleScope.safeLaunch<String> {
onRequest = {
//这里请求网络或者其他耗时的操作
"1234" //这里一定要return指定的数据,放最后一行即可
}
onSuccess = {
//成功的回调
}
onError = {
//异常的回调
}
}
那么不管是一些简单的网络请求还是一些其他的协程任务都可以使用这样的封装去使用和处理异常了。
五、协程的并发与锁
之前我们就讲到过 并发使用 async ,切换线程使用 withContext 。本质原因是 async 函数是创建一个协程,而 withContext 函数只是一个挂起函数。
launch {
YYLogUtils.w("查看运行的线程1:" + Thread.currentThread().name)
val start = System.currentTimeMillis()
val res1 = async {
YYLogUtils.w("查看运行的线程2:" + Thread.currentThread().name)
delay(1000)
"123"
}
val res2 = async(Dispatchers.IO) {
YYLogUtils.w("查看运行的线程3:" + Thread.currentThread().name)
delay(2000)
"456"
}
val res3 = res1.await() + res2.await()
YYLogUtils.w("并发耗时:" + (System.currentTimeMillis() - start))
withContext(Dispatchers.IO) {
YYLogUtils.w("查看运行的线程4:" + Thread.currentThread().name)
delay(1000)
YYLogUtils.w("res3:$res3")
}
YYLogUtils.w("查看运行的线程4:" + Thread.currentThread().name)
}
这样的代码大家就能看懂了,打印结果如下:
为什么要这么写,是因为有人认为 async 就是异步的,异步的才是并发的。No No No ,async 翻译过来是异步的意思,但是这里它并不是异步的,async 只是创建一个新的协程而已,并发是协程的并发,而不是异步而并发,可以看到我们创建一个 async 函数,我们打印它的线程默认是主线程的,除非你指定线程运行,如第二个 async 函数,我们指定了线程才是异步的。
这么写就是为了不要混淆协程的并发与线程的异步并发的区别,并发协程与并发异步线程不同,再次强调,因为它是创建了协程所以并发,而不是异步才并发的。
再举例,创建协程的又不止 async 函数一家,我们的 launch 一样的能创建协程,我们试试看能不能并发:
launch {
YYLogUtils.w("查看运行的线程1:" + Thread.currentThread().name)
val start = System.currentTimeMillis()
val res1 = async {
YYLogUtils.w("查看运行的线程2:" + Thread.currentThread().name)
delay(1000)
"123"
}
val res2 = async(Dispatchers.IO) {
YYLogUtils.w("查看运行的线程3:" + Thread.currentThread().name)
delay(2000)
"456"
}
launch {
YYLogUtils.w("查看运行的线程4:" + Thread.currentThread().name)
delay(1000)
}
val res3 = res1.await() + res2.await()
YYLogUtils.w("并发耗时:" + (System.currentTimeMillis() - start))
withContext(Dispatchers.IO) {
YYLogUtils.w("查看运行的线程5:" + Thread.currentThread().name)
delay(1000)
YYLogUtils.w("res3:$res3")
}
YYLogUtils.w("查看运行的线程6:" + Thread.currentThread().name)
}
打印结果证明是并发的:
那为什么我们一般并发都使用 async 而不用 launch 。那是因为他们虽然都是创建了协程,但是 launch 返回的是 Job 对象 ,而 async 返回的是一个 Deferred 可以通过它拿到处理之后的值。
比如这样:
launch {
YYLogUtils.w("查看运行的线程1:" + Thread.currentThread().name)
val start = System.currentTimeMillis()
val res1 = async {
YYLogUtils.w("查看运行的线程2:" + Thread.currentThread().name)
delay(1000)
"123"
}
val res2 = async(Dispatchers.IO) {
YYLogUtils.w("查看运行的线程3:" + Thread.currentThread().name)
delay(2000)
"456"
}
val res4 = launch {
YYLogUtils.w("查看运行的线程4:" + Thread.currentThread().name)
delay(1000)
"789"
}
val res3 = res1.await() + res2.await()
YYLogUtils.w("并发耗时:" + (System.currentTimeMillis() - start))
}
我们能拿到 res4 的值吗? 它返回的是 789 吗?不是它返回的是Job对象 。所以我们才在需要接收返回值的并发中使用 async ,而如果不需要返回值 那么我们使用 launch 也是可以并发的。
协程的并发跟线程是否在主线程和子线程没关系,我们再举例:
launch {
YYLogUtils.w("查看运行的线程1:" + Thread.currentThread().name)
val start = System.currentTimeMillis()
val res1 = async {
YYLogUtils.w("查看运行的线程2:" + Thread.currentThread().name)
delay(1000)
"123"
}
val res2 = async {
YYLogUtils.w("查看运行的线程3:" + Thread.currentThread().name)
delay(2000)
"456"
}
val res3 = res1.await() + res2.await()
YYLogUtils.w("并发耗时:" + (System.currentTimeMillis() - start))
withContext(Dispatchers.IO) {
YYLogUtils.w("查看运行的线程5:" + Thread.currentThread().name)
delay(1000)
YYLogUtils.w("res3:$res3")
}
YYLogUtils.w("查看运行的线程6:" + Thread.currentThread().name)
}
打印结果:
并发的线程如果都在主线程,一样的并发的。
那有同学要问了,newki newki,你这个不对啊,我们使用 Retrofit 请求网络,我们标记 suspend 之后再协程里面直接使用,它就是异步的 , 你看我这样,这样,再这样。并发 + 异步 so easy , async 我不也没有指定异步吗,它可不就是异步的吗!
viewModelScope.launch {
val industryResult = async {
mRepository.getIndustry()
}
val schoolResult = async {
mRepository.getSchool()
}
val industry = industryResult.await()
val school = schoolResult.await()
}
好吧,其实 Retrofit 是比较特殊的情况,他的 ApiService 虽然标记了 suspend ,看起来我们是直接使用了,但是其实内部 Retrofit 的动态代理的时候会找到你是否标记了 suspend ,然后它会对 suspend 做单独的处理 。我不太会讲源码,大家如果有兴趣可以全局搜索一下 SuspendForResponse
类 和 awaitResponse
类,看看 Retrofit 怎么把 suspend 转换为协程处理的,这里我就不贴 Retrofit 的源码了。
并发是没有问题了,协程与线程一样,对于数据的操作无法保持原子性,所以在协程中,需要使用原子性的数据结构,例如使用 synchronized
来处理
线程中锁都是阻塞式,在没有获取锁时无法执行其他逻辑,而协程可以通过挂起函数解决这个,没有获取锁就挂起协程,获取后再恢复协程,协程挂起时线程并没有阻塞可以执行其他逻辑。这种互斥锁就是 Mutex
。关于同步锁的问题,我打算后面再专门出一期 Kotlin协程-并发安全的几种解决方案与性能对比,说一下并发安全的问题方案有哪些,并且分析他们的性能问题,这里我就简单的以 Mutex
为例子简单的讲解,
使用Mutex的方式
suspend fun CoroutineScope.massiveRun(action: suspend () -> Unit) {
val n = 100 // 启动的协程数量
val k = 1000 // 每个协程重复执行同一动作的次数
val time = measureTimeMillis {
List(n) {
launch {
repeat(k) { action() }
}
}
}
YYLogUtils.w("Completed ${n * k} actions in $time ms")
}
//使用
var counter = 0
val mutex = Mutex()
runBlocking {
massiveRun {
mutex.withLock {
counter++
}
}
}
YYLogUtils.w("Counter = $counter")
打印Log:
总结
匆匆忙忙,本系列到处就结束了,到这里协程比较系统的基本知识学的差不多了,基本使用是没什么问题了。另外一些比较没那么常用的知识点,后面我打算单独出一期当做协程基础知识的拓展。
做这个系列,其实我本人也是系统的整理了一次协程相关的知识点,虽然我过不到一个月还是会忘,但是整理出这个体系可以让大家更方便的复习。
再说回协程,其实不管是协程的 挂起
还是协程的 并发
,还是协程的 阻塞
,我们可以看到协程和线程还是有区别的。虽然大家都说协程是线程的封装,但是我还是想让大家把协程和线程区分开来学习,这样更方便理解。个人的一点愚见吧。
如果大家有不明白的我更推荐你从系列的第一篇开始看,内部的实现是一步一步层层递进的。
协程的概念与框架比较大,我本人如有讲解不到位或错漏的地方,希望同学们可以指出交流。
如果感觉本文对你有一点点点的启发,还望你能点赞
支持一下,你的支持是我最大的动力。
Ok,本篇就此完结了。
我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿。