Kotlin协程(入门向)

最近在学习kotlin的协程,分享一下学习经验!

〇、什么是协程?

官方解释:

协程是轻量级的线程。

个人理解:
协程相当于Kotlin中的“线程池”。

一、如何使用

1. 添加依赖

build.gradle中加入

    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.2.1"
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.2.1"

2. 使用

本章只介绍协程的基本用法,并和传统的回调方式、线程池、Rxjava等进行简单的对比。

2.1 情景1:基本用法

当前线程是主线程。我们需要从url中获取到数据,并加载到textView中。

使用回调

    HttpUtil.get(url, object : HttpUtil.Callback {
        override fun onResponse(s: String) {
            runOnUiThread {
                textView.text = s
            }
        }
    })

使用协程

    GlobalScope.launch { // 开启协程
        val response = HttpUtil.get(url)  // 同步请求
        withContext(Dispatchers.Main) { // 切换到主线程,效果等同于runOnUiThread
            textView.text = response
        }
    }

在这里,使用GlobalScope.launch函数开启协程。在其中,使用withContext(Dispatchers.Main)将线程切换到主线程,进行UI操作。这样就完成了一个最简单的协程实例。
这个例子中,协程并没有比回调方式简洁很多。但是接下来的例子中,呈现了一种被称为“回调地狱”的场景。

2.2 情景2:回调地狱

简单列举一个情形:

我们需要调用url1以获取到url2所需要的参数,再调用url2以获取到url3所需要的参数,最后调用url3获取到所需要的数据。
也就是说,必须要等到url1的请求结果出来再请求url2,再等到url2的请求结果出来再请求url3,最后请求url3的返回结果才是真实所需要的数据。

同时,网络请求函数定义如下:
fun HttpUtil.get(url: String, callback: HttpUtil.Callback)
fun HttpUtil.get(url: String, callback: (String)->Unit)

2.2.1 传统方式(使用回调)

    HttpUtil.get(url1, object : HttpUtil.Callback {
        override fun onResponse(response: String) {
            HttpUtil.get(url2, mapOf(Pair("param", response)), object : HttpUtil.Callback {
                override fun onResponse(response: String) {
                    HttpUtil.get(url3, mapOf(Pair("param", response)), object : HttpUtil.Callback {
                        override fun onResponse(response: String) {
                            runOnUiThread {
                                textView.text = response
                            }
                        }
                    })
                }
            })
        }
    })

太可怕了!当然,我们可以使用高阶函数来代替匿名类来优化一下这段代码:

    HttpUtil.get(url1) {
        HttpUtil.get(url2, makeParam(it)) {
            HttpUtil.get(url3, makeParam(it)) { response ->
                runOnUiThread() {
                    textView.text = response
                }
            }
        }
    }

虽然简洁了很多,但是这么多花括号,看着还是挺不爽的!

2.2.2 使用协程

    GlobalScope.launch {
        val response1 = HttpUtil.get(url1)
        val response2 = HttpUtil.get(url2, mapOf(Pair("param", response1)))
        val response3 = HttpUtil.get(url3, mapOf(Pair("param", response2)))
        withContext(Dispatchers.Main) {
            textView.text = response3
        }
    }

可以看到,在这种情形下使用协程,可以使代码变得整洁许多,并且逻辑变得非常清晰。

既然回调方式可以通过高阶函数优化,协程同样有优化的方式。我们可以改造一下HttpUtil.get方法,其在IO线程中执行:

    // HttpUtil.get
    suspend fun get(url: String): String {
        return withContext(Dispatchers.IO) {
            ...
        }
    }

这里使用了suspend关键字,表示这个函数会将协程挂起;换句话说,这个函数是耗时函数。
这样一来,情景2使用协程的代码就可以这么写了:

    GlobalScope.launch(Dispatchers.Main) {
        val response1 = HttpUtil.get(url1)
        val response2 = HttpUtil.get(url2, makeParam(response1))
        val response3 = HttpUtil.get(url3, makeParam(response2))
        textView.text = response3
    }

省去了切换线程的代码后,是不是更简洁了?对比一下回调方式,不得不说协程真香!

2.3 情景3:合并请求结果

我们需要分别请求url1url2获取到需要的数据,并以此二者返回值为参数调用url3获得最终的数据。

2.3.1 使用回调 + CountDonwLatch

这种情景下,使用回调方式,需要用到CountDonwLatch,它几乎是为了这种情况量身定制的:

    val countDownLatch = CountDownLatch(2)
    var param1 = ""
    var param2 = ""
    HttpUtil.get(url1) {
        param1 = it
        countDownLatch.countDown()
    }
    HttpUtil.get(url2) {
        param2 = it
        countDownLatch.countDown()
    }
    thread {
        countDownLatch.await()
        HttpUtil.get(url3, makeParam(param1, param2)) { response ->
            runOnUiThread() {
                textView.text = response
            }
        }
    }

同时,我们可以通过一些外力来实现这个功能,比如RxJava或者线程池。

2.3.2 使用RxJava

RxJava中的zip函数可以合并两个Observable:

    Observable.zip<String, String, String>(
        Observable.create<String> { it.onNext(HttpUtil.get(url1)) }
            .subscribeOn(Schedulers.io()),
        Observable.create<String> { it.onNext(HttpUtil.get(url2)) }
            .subscribeOn(Schedulers.io()),
        BiFunction { param1, param2 ->
            HttpUtil.get(url3, makeParam(param1, param2))
        })
        .subscribeOn(Schedulers.io())
        .observeOn(AndroidSchedulers.mainThread())
        .subscribe { response ->
            textView.text = response
        }

2.3.3 使用线程池

使用线程池的submit函数与Future对象可以很好的处理这种情形:

    val executor = Executors.newCachedThreadPool()
    // 因为future.get()会阻塞线程,所以不能在主线程中执行
    executor.execute {
        val param1Future = executor.submit(Callable { HttpUtil.get(url1) })
        val param2Future = executor.submit(Callable { HttpUtil.get(url2) })
        val params = makeParam(param1Future.get(), param2Future.get())
        val response = executor.submit(Callable { HttpUtil.get(url3, params) }).get()
        runOnUiThread {
            textView.text = response
        }
    }

2.3.4 使用协程的async函数

协程的处理方式与线程池类似,不过更加简洁清晰:

    GlobalScope.launch(Dispatchers.IO) {
        val param1 = async { HttpUtil.get(url1) }
        val param2 = async { HttpUtil.get(url2) }
        val response3 = HttpUtil.get(url3, makeParam(param1.await(), param2.await()))
        withContext(Dispatchers.Main) {
            textView.text = response3
        }
    }

这里使用了async函数,其中的代码块会异步执行,不阻塞当前线程,并返回一个Deferred对象,相当于线程池的Future;而之后再调用await函数,获取其执行结果,这一步是阻塞的,相当于线程池中的Future.get()
如果不使用async的话,代码会顺序阻塞执行,而不是并发执行了。

3. 协程与线程池

情景2.3中,可以看到,协程与Java线程池的使用方式非常的相似。
其实,协程的底层就是使用线程池实现的,不过并不是Java中的线程池,而是Kotlin自己实现的线程池。

性能对比

先说结论:协程的多线程执行速度并不会比线程池更快。

协程与线程池

如图所示,在任务数量为10万时,使用Executors.newCachedThreadPool()只用了20秒就执行完了所有任务,当然代价则是CPU稳稳的100%,电脑几近卡死。而使用协程,以Dispatcher.IO作为调度器,执行任务的总时间达到了惊人的3万秒;不过虽然慢是慢了点,线程数最高只有104,占用资源少。
也就是说,比起线程池来讲,协程更轻量级一点,占用更少的资源,而代价是更低的效率。对于Android开发来说,其实很少遇到超高并发的场景。
虽然我们可以使用线程池作为自定义调度器,不过这样做不是画蛇添足么?

不过按照源码注释中的说法,Dispatchers.IO默认最大线程数量为64或者cpu核心数。至于为什么到了104,我也不知道,或许是。
我们可以通过以下代码修改这个最大线程数,比如修改成1000:

    System.setProperty(IO_PARALLELISM_PROPERTY_NAME, "1000")

将最大线程数修改为1000之后,又执行了一下10万任务挑战:


协程与线程池

可以看到,执行时间的确缩短了很多。

二、一些详细说明

1. 一些概念

CoroutineContext
CoroutineContext直译过来是协程上下文,表示一个协程的上下文环境,和Android中的Context类似。它包含了一系列的元素集合,其中最主要的是Job

Job
Job是一个接口,继承自CoroutineContext.Element。一个Job代表了一项后台任务,每一个协程对应了一个Job。通过launch函数与async函数创建协程,都会返回一个Job对象,通过这个Job对象,可以管理这个协程。Job接口定义了包括但不限于start(启动相关联的协程)、cancel(取消任务)、join(挂起所在的协程直到当前任务完成)等函数。
简单的来说,我们创建协程就是为了完成某项任务,而Job就对应了这项任务。

示例:

fun main() = runBlocking {
    val job1 = launch {
        delay(1000)
        println("job1")
    }
    val job2 = launch {
        delay(500)
        println("job2")
    }
    println("flag 1")
    job2.cancel()
    job1.join()
    println("flag 2")
}

输出:

flag 1
job1
flag 2

因为job2.cancel()取消了job2,所以没有输出job2;而job1.join()挂起了当前协程,所以直到job1输出之后,才输出flag2

和Java中的线程池作类比的话,这里的Job类似于Java线程池中的Futurelaunch函数类似于Java线程池中的submit(runnable),而async则类似于submit(callable)

CoroutineScope
CoroutineScope直译为协程作用域。所有的协程创建函数,比如我们平时使用的launchasync,都是CoroutineScope的扩展函数。
这么说还是让人很困惑,所以这玩意儿到底有啥用?我查看了许多人的博客,没有一个人说清楚这点的。

最后,还是仔细阅读了官方的文档才理解。
每个协程都对应了一个CoroutineScope(作用域)。CoroutineScope包含了协程的上下文、Job、子协程等。通过扩展函数launchasynccancel等,实现了开启子协程、取消所有子任务等功能。在这个作用域下新开启的协程,则是当前协程的子协程CoroutineScope可以管理协程的生命周期不如把CoroutineScope译作“协程管家”好了。
对于Android开发来说,在Activity中使用协程,会遇到这种情况:当Activity需要销毁的时候,如果协程继续执行,那么就会造成内存泄漏。
有了CoroutineScope,这个问题就很好解决了。首先在Activity中创建一个最高级的CoroutineScope,当需要使用协程的时候,都通过这个作用域来创建协程。这样,所有协程都是这个作用域下的子协程。当销毁Activity时,只需要调用其cancel()函数,就可以取消所有正在执行的任务了。

    // inside an Activity
    val mainScope = MainScope()

    fun someNetwork() {
        mainScope.launch {
            //...
        }
    }

    override fun onDestroy() {
        super.onDestroy()
        mainScope.cancel()
    }

调度器

协程上下文中包含了一个调度器,它限制了协程在哪些线程中执行。
在第一章的例子中,有使用到withContext(Dispatchers.IO),这其中的Dispatchers.IO就是调度器。

Kotlin内置了四种调度器:

  • Dispatchers.Default
    默认的调度器,基于JVM上的共享线程池,最大线程数为CPU核心数。
  • Dispatchers.IO
    专为IO操作设计的调度器,默认最大线程数为64与CPU核心数的较大值。
  • Dispatchers.Main
    UI主线程调度器。
  • Dispatchers.Unconfined
    无限制调度器。在第一个挂起点之前,在调用它的线程中执行;之后由该挂起函数决定。

2. 不建议使用GlobalScope

GlobalScope是一个特殊的全局CoroutineScope,它不与任何Job绑定。GlobalScope只应该使用在生命周期与整个应用程序相同、且不被取消的协程中。

在第一章中的例子中,我使用了GlobalScope.launch来启动一个协程,这是不被建议的。由于GlobalScope不与任何Job绑定,所以通过它创建的协程无法取消;当在Activity中使用,这很可能会导致内存泄漏。
取而代之的,应该使用非全局的CoroutineScope,如MainScope

class CoroutineScopeActivity : AppCompatActivity() {
    val mainScope = MainScope() // 非全局的CoroutineScope

    fun someNetwork() {
        mainScope.launch {
            //...
        }
    }

    override fun onDestroy() {
        super.onDestroy()
        mainScope.cancel() // 当Activity销毁时,取消所有任务
    }
}
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,377评论 6 496
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,390评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 159,967评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,344评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,441评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,492评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,497评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,274评论 0 269
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,732评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,008评论 2 328
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,184评论 1 342
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,837评论 4 337
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,520评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,156评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,407评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,056评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,074评论 2 352