这是《Kotlin 协程基础课》的第1篇文章。
正因为她觉得一切都无所谓,所以生活给她什么,她便接受什么。少年时代,她觉得选择为时过早,而现在已是青年,她又觉得改变为时过晚。
系列基础课前言
对于Kotlin学习而言,要想从“入门”走到“精通”,协程(Coroutines)是必须迈过去的一道坎。接下来一周时间,我会在之前零散学习的基础上,总结成一系列基础课文章,作为对协程的阶段性学习小结。文章目录如下:
- 01.协程的基本概念与用法
- 02.非阻塞式挂起(suspend)函数
- 03.理解协程的域(Scope)和调度器(Dispatcher)
- 04.用AAC&协程优化Android架构设计
什么是协程
但凡学一门新知识,总是离不开5w1h。说起“协程”,很多人第一反应是2号线北新泾那家做旅游的互联网公司,不过,此“协程”非彼“携程”,协程(Coroutines)并不是一个新的概念,它的年纪要比Kotlin语言大得多。“协程 Coroutines”源自 Simula 和 Modula-2 语言,这个术语早在 1958 年就被 Melvin Edward Conway 发明并用于构建汇编程序,说明协程是一种编程思想,并不局限于特定的语言。目前很多现代语言都有协程的实现,比如Go、JavaScript、C#等。
协程作为一种编程思想,目的是简化并行代码编写,可以让我们以同步的方式写异步逻辑。对于Kotlin而言,“协程”一词是指实现了协程思想的一系列API的总称。Kotlin协程的底层实现是线程。
没有协程的日子里
由于Android是单一UI线程的框架,势必要进行很多UI线程以外的耗时操作,在协程之前,我们通常用这些技术来实现诸如网络请求、数据库读写等功能:
- AsyncTask:是Android原生的异步任务写法,写过的人就知道它有多难用,业务逻辑被分散在前中后三个方法里,冗长的boilerplate代码。更有甚者,一旦发生嵌套,光是一层层回调就能把人搞疯掉。
- Thread:直接开线程并不是一种好的设计,只有新手才这么干。
- ThreadPool or ExecutorService:这比
new Thread
好一些,但同样要处理UI、工作线程切换的问题,以及无法避免的回调。 - Handler:一个工作线程的Handler用来处理耗时任务,处理完成后丢给主线程Handler,简单、直接、朴实无华。
这时协程(Coroutines)来了,为我们推翻回调地狱、样板代码、内存泄漏、线程切换几座大山,Android开发者终于翻身农奴把歌唱,敢叫日月换新天。
第一个协程Demo
用Thread.sleep模拟耗时操作
在本文我们暂且不谈Android环境,在更通用的环境下展现协程的用法。首先我们模拟一个耗时操作与非耗时操作混合的场景,看下面一段代码,它在main
函数里依次打印one two three
。
1 | fun main(args: Array<String>) { |
同时有另一个函数,它会耗时3s后,打印出参数在控制台。我们用直白的Thread.sleep
来进行延时模拟。
1 | fun printDelayed(msg: String) { |
然后将主程序的print(two)
改为耗时任务。
1 | fun main(args: Array<String>) { |
运行后,输出如下,非常符合预期。
1 | one |
用delay模拟耗时操作
Kotlin的协程库提供了另一种延时的API,delay
,我们把原来的Thread.sleep
替换为delay
:
1 | fun printDelayed(msg: String) { |
此时Android Studio会在delay
处提示错误:suspend function delay should be called only from a coroutine or another suspend function。翻译过来就是“delay是一个挂起函数,它只能在协程中、或者另一个挂起函数里面被调用”。我们把程序整体改写一下。
1 | suspend fun printDelayed(msg: String) { |
运行后发现程序的输出为:
1 | one |
为什么不是132而是123呢,要从runBlocking
的定义说起。它会“新起一个协程运行后续代码,并且在该协程的运行过程中阻塞原线程(在demo中是主线程),直至协程运行结束”。而在协程运行时是按照代码顺序逐行运行的。所以打印出来的是123而非132。
为runBlocking指定运行线程
我们在print123blocking
方法里打印出当前线程名。
1 | fun print123Blocking() = runBlocking { |
控制台输出如下:
1 | one - in thread: main @coroutine#1 |
可见这个方法是在当前(main)线程里运行的,并且同属于@coroutine#1
。
我们可以为runBlocking
指定运行的线程,在协程的语言里,使用Dispatcher
来表明这一概念。我们改写一下print123Blocking
方法,使用Dispatcher.Default
打印1和2。样例代码与系统输出如下,可见我们为其指定了Dispatcher的代码段运行在另一个线程里,且打印顺序仍然为123,这就是Blocking的厉害之处。
1 | fun print123Blocking() { |
控制台输出:
1 | one - in thread: DefaultDispatcher-worker-1 @coroutine#1 |
如何利用协程打印出132
全局后台线程:GlobalScope.launch
可以在blocking域中使用GlobalScope.launch{ ... }
来指定后台线程运行任务,我们基于此将原有代码改造一下。
1 | fun print123Blocking() = runBlocking { |
这段代码的输出是不可知的,有可能是以下两种情况,只打印3或者打印31,这是什么原因呢?因为我们是在后台全局线程中启动的“打印12任务”,后台线程是不会阻止主线程运行结束的,所以2是肯定打不出来,而1能否打印出来就看运行时线程调度情况了。
1 | // case 1 |
等待任务完成
我们可以简单粗暴地使用delay
来等待后台任务完成。
1 | fun print123Blocking() = runBlocking { |
控制台输出为:
1 | three - in thread: main @coroutine#1 |
但是这种处理方法非常丑陋,而且这个delay
的时长很难设置,设置长了吧,会导致无用的等待浪费时间;设置短了吧,有可能在后台线程输出之前就结束任务,有没有更优雅的写法呢?答案是有的,job.join()
为我们提供了等待任务完成的功能,代码如下所示。
1 | fun print123Blocking() = runBlocking { |
自定义Dispatcher
Dispatcher为协程的运行指定了线程,常见Dispatchers如下:
- Dispatcher.IO:进行IO密集型操作,如数据库读写、文件读写、网络交互
- Dispatcher.Default:进行CPU密集型操作,如列表排序、JSON解析、DiffUtils
- Dispatcher.Main:仅存在于Android框架,调用
suspend
方法、进行UI操作、更新LiveData
看到这里你可能已经理解了,Dispatcher其实就是线程的另一种表现形式,我们甚至可以自定义一个Dispatcher:
1 | fun print123Blocking() = runBlocking { |
运行结果如下(在play.kotlin上面总是超时不知道为啥),意味着我们完全可以高度定制Dispatcher的实现,虽然大部分时间使用默认的Dispatcher就已足够。
1 | one - from thread main |
有返回值的suspend函数
最后一部分内容是跟Android开发密切相关的,大部分时间我们需要通过网络、数据库进行一些读取数据耗时操作,函数会有返回值,我们模拟一个网络操作,它读取一个startNum
参数,等待1s延时后,返回startNum * 10
。通过 async { ... }.await()
可以获取耗时函数的返回值,代码如下:
1 | // 模拟1s延时网络操作 |
并发进行耗时操作
因为是3个耗时1s操作并发,我们自然而然希望它们同时运行,总耗时1s而不是3s,要如何实现呢?把所有的await()
调用写入同一个语句,编译器会优化它们,使其同时运行。
1 | // 调用延时操作,同时await,耗时共1s |
使用withContext的简化写法
async/await 会运行在当前线程中,对于网络操作一般的做饭是让其在IO线程运行,对于计算密集型操作则是在CPU(Default)线程运行,使用withContext
可以同时完成async/await
的操作,但缺点是这三个操作只能相继运行,无法同时运行。
1 | // 调用延时操作 |
小结
本文介绍了协程的基本用法,在阅读完本文后,你应当掌握以下知识点:
- 声明耗时函数,以及在协程代码块里调用耗时函数
- 切换运行环境
- 等待任务完成
- 并行进行耗时操作,获取操作结果