为什么我们需要学习Kotlin协程?在如何实现异步操作这个问题上,JVM中早就有像RxJava或者Reactor这样成熟的库。此外,Java本身也有多线程支持。也有许多人选择普通又古老的回调(callback)方式。毫无疑问,我们已经有了很多选择。
答案是Kotlin协程的功能远不止如此。协程的概念起源于1963年,经过很多年之后才真正有了标准行业实现。协程用一种为了在现实生活中的用例中提供完美的帮助而设计的库,将半个世纪前的论文展示的强大能力连接了起来。另外,Kotlin协程是支持多平台的,这意味着它可以在所有Kotlin平台使用(例如:JVM, JS, IOS等等)。最后,它不需要你大改代码结构,我们可以很轻松的使用Kotlin协程提供的各种功能(这对于RxJava或者回调来说是很难的),这使得Kotlin协程对新人非常友好。
让我们在实战中看看吧。我们将探索如何通过协程和其他通用方法去解决不同的常见用例。我分了2个典型的用例:Android和后台业务逻辑实现。
当你作为前端实现应用逻辑,你经常要做的是:
- 从一个或多个数据源(API,数据库,首选项,另一个应用程序等等)获取一些数据
- 处理这些数据
- 对数据做一些事情(显示在视图中,存储在数据库中,发送给API)
为了使我们的讨论更实际,让我们首先假设我们正在开发一个Android应用程序。我们将从一个问题开始,在这个问题中,我们需要从API获取新闻,对它们进行排序,并在屏幕上显示它们。我们要编写的函数代码可能会这样表示:
fun onCreate() {
val news = getNewsFromApi()
val sortedNews = news.sortedByDesending { it.publishedAt }
view.showNews(sortedNews)
}
遗憾的是,仅仅这样这还不够。如果我们在主线程上运行这个函数(主线程是唯一能够更新应用程序视图的线程),我们会阻塞整个应用程序。这就是为什么在Android上在主线程上做网络操作是非法的,上面的代码会抛出异常。如果我们运行在另一个线程上,我们将不能在视图上显示新闻,因为这只能在主线程上完成。
我们可以通过两次切换线程来解决这些问题,如下面的代码所示:
fun onCreate() {
thread{ //第一次切换
val news = getNewsFromApi()
val sortedNews = news.sortedByDesending { it.publishedAt }
runOnUiThread { //第二次切换
view.showNews(sortedNews)
}
}
}
这种线程切换的方式在一些应用程序中仍在使用,然而它是有问题的,原因如下:
- 这里没有取消这些线程的机制,因此我们经常面临着内存泄漏
- 制造这么多线程的代价是很昂贵的
- 频繁地切换线程是令人困惑和难以管理的
- 代码将不必要地变得更大、更复杂
考虑到所有这些问题,我们需要找到一个更好的机制。
这里有一个方式可以提供帮助:回调。当我们使用该方式时,我们需要将一个函数(一旦数据准备好时立即调用)传递给另一个函数。回调函数(这里不是指回调!)开始执行数据获取,其余的事情发生在另一个线程中,一旦获得数据,我们的回调就会被调用。这种方式表示如下:
fun onCreate() {
getNewsFromApi { news ->
val sortedNews = news.sortedByDesending { it.publishedAt }
view.showNews(sortedNews)
}
}
我们仍然可能面临内存泄漏,因为我们不会取消不需要的线程。但至少回调函数承担了切换线程的责任。回调架构解决了这个简单的问题,但它也有很多缺点。为了探索它们,让我们讨论一个更复杂的情况,我们需要从三个不同的地方获取数据:
fun onCreate() {
getNewsFromApi { config ->
getNewsFromApi(config) { news ->
getUserFromApi { user ->
view.showNews(user, news)
}
}
}
}
这段代码说完美还非常遥远,原因如下:
- 获取新闻和用户数据的操作可能是并行的,但我们当前的回调架构不支持这一点(用回调很难实现)
- 回调不支持取消,会导致内存泄漏
- 越来越多的缩进使得代码难以阅读,带有多个回调函数的代码通常被认为是高度不可读的,这种情况被称为“回调地狱”,特别是在一些老的Node.JS项目中
- 当我们使用回调时,很难控制之后的流程
以下显示进度条的方式将不起作用:
fun onCreate() {
showProgressBar()
showNes()
hideProgressBar() // 错误
}
进度条本应该立即显示,然后在新闻加载之后被隐藏,但实际上在它显示之后立即被隐藏(异步操作)。为了让这段代码正常工作,我们需要将隐藏进度条作为回调:
fun onCreate() {
showProgressBar()
showNes{
hideProgressBar()
}
}
这就是为什么回调对于重要的项目来说并不完美的原因。让我们看看另一种方法:RxJava和响应流。
在Java (Android和后端)中流行的另一种方法是使用响应流(或响应式扩展):RxJava或其后继者Reactor。使用这种方法,所有操作都发生在一个可以启动、被处理和观察的数据流中。这种流支持线程切换和并发处理,因此它们经常用于应用程序的并行处理。下面是我们使用RxJava解决问题的方法:
fun onCreate() {
// disposables在当用户退出界面时需要被调用cancel()取消流
disposables += getNewsFromApi()
.subscribeOn(Schedulers.io())
.observerOn(AndroidSchedulers.mainThread())
.map { news ->
news.sortedByDescending { it.publishedAt }
}
.subscribe { sortedNews ->
view.showNews(sortedNews)
}
}
这绝对是一个比回调更好的解决方案:没有数据泄漏,支持取消,正确使用线程。唯一的问题是它很复杂,如果你从一开始就将其与“理想的”代码进行比较(如下所示),你会发现它们完全不一样:
fun onCreate() {
val news = getNewsFromApi()
val sortedNews = news.sortedByDescending { it.publishedAt }
view.showNews(sortedNews)
}
在RxJava中,所有例如subscribeOn, observeOn, map或subscribe的函数都需要去学习、取消流的方法需要明确被调用。函数返回的对象必须被包装成Observable或者Single:
fun getNewsFromApi(): Single<List<News>>
现在考虑第二个问题,展示数据之前,我们需要调用三个接口,RxJava可以正确地解决这个问题,但它更加复杂。
fun showNews() {
disposables += Observable.zip(
getConfigFromApi().flatMap { getNewsFromApi(it) },
getUserFromApi(),
Function2 { news: List<News>, config: Config -> Pair(news, config) }
)
.subscribeOn(Schdulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe { (news, config) -> view.showNews(news, config) }
}
这段代码确实是并发的,也没有内存泄漏,但我们需要引入RxJava函数,如zip、flatMap、将值包装成Pair,并对其进行解构。这是一个很好的实现,只是它相当复杂。最后,让我们看看协程提供了什么。
kotlin协程引入的核心功能是在某个点挂起一个协程,并在以后的某个点恢复它的能力。由于这一点,我们可以在主线程上运行代码,并在从API请求数据时将其挂起。当协程被挂起时,线程不会被阻塞,可以自由运行,它可以更改视图或处理其他协程。一旦数据准备好了,协程正在等待主线程(这是一种罕见的情况,但可能有一个协程队列在等待它),一旦它等到主线程,它可以从它停止的地方继续执行。 这张图片显示了函数updateNews和updateProfile运行在主线程中不同的协程上。这个2个函数可以上下互换,因为它们挂起了协程,而不是阻塞线程。当函数updateNews正在等待网络响应时,updateProfile将使用主线程。这里假设getUserData没有挂起,因为用户数据已经被缓存了,所以它可以运行直到完成。这点时间还不够等到网络响应,所以那时主线程没有被使用(它可以被其他函数使用)。一旦得到网络响应,我们获取主线程并在函数updateNews上使用它,从getNewsFromApi()之后开始继续执行。
根据定义,协程是可以挂起和恢复的组件。在JavaScript、Rust或者Python等语言中可以找到类似async/await/generators的概念,同时也使用着协程。尽管它们的能力非常有限。
所以我们的第一个问题可以通过使用Kotlin协程来解决:
fun showNews() {
scope.launch {
val news = getNewsFromApi()
val sortedNews = news.sortedByDescending { it.publishedAt }
view.showNews(sortedNews)
}
}
这段代码与我们从一开始就想要的几乎相同!在这个解决方案中,代码运行在主线程上,但从来没有阻塞它。由于挂起机制,当我们需要等待数据时,我们挂起(而不是阻塞)了协程。当协程被挂起时,主线程可以去做其他事情,比如绘制一个漂亮的进度条动画。一旦数据准备好,我们的协程将再次接管主线程,并从它之前停止的地方开始执行。
还有一个问题,如果有三个接口呢?可以这样解决:
fun showNews() {
scope.launch {
val config = getConfigFromApi() // 花费1秒
val news = getNewsFromApi(config) // 花费1秒
val user = getUserFromApi() // 花费1秒
view.showNews(user, news)
}
}
这是一个看起来不错的解决方案,但它的工作起来却不是最佳的。这些调用将顺序地(一个接一个地)发生,所以如果每个调用花费1秒,整个函数将花费3秒,如果API调用并行执行,我们可以实现只花费2秒。这就是Kotlin协程库可以帮助我们的地方,使用async函数立即新起一个带有某些请求的协程,并在稍后等待其结果(使用await函数):
fun showNews() {
scope.launch {
val config = async { getConfigFromApi() } // 花费1秒
val news = async { getNewsFromApi(config.await()) } // 花费1秒
val user = async { getUserFromApi() } // 花费1秒,但由于是并行,在上面的api执行的时候这个api也在同时执行,所以时间已经被包含进去了
view.showNews(user.await(), news.await())
}
}
这段代码仍然很简单,易读。它使用了async/await模式,这种模式在其他语言(包括JavaScript或c#)中很流行,它也是高效的,并且没有内存泄漏(假设我们调用了取消方法,这毫不费力,我们将在后面解释),现在我们所实现的代码既简单又良好。
有了Kotlin协程,我们可以轻松地实现不同的用例并使用Kotlin的其他特性。例如,它们不会阻塞for循环或集合处理函数。接下来您可以看到如何并行或顺序的加载下一个页面。
// 所有页面都会被同时加载
fun showNews() {
scope.launch {
val allNews = (0 until getNumberOfPages())
.map { page -> async { getNewsFromApi(page) } }
.flatMap { it.await() }
view.showAllNews(allNews)
}
}
// 页面一个接一个的加载
fun showPagesFromFirst() {
scope.launch {
for (page in 0 until getNumberOfPages()) {
val news = getNewsFromApi(page)
view.showNextPage(news)
}
}
}
后端开发者不用担心主线程的问题,但是阻塞主线程仍然是不好的,因为线程开销很大,它们需要被创建、维护、还需要分配内存空间给它们。如果你的应用被数百万用户使用,并且在等待数据库或者其他服务响应的时候阻塞了线程,这将给内存和处理器增加巨大的成本(用于创建、维护和同步这些线程)。
通过下面的代码片段可以将上述问题可视化,代码片段模拟了一个的后台服务,并且有10万用户正在请求数据。第一段代码开启了10万个线程,然后让它们分别休眠1秒(模拟等待数据库或其他服务的响应)。如果你在电脑上运行这段代码,你会看到过了一会儿才会把所有点打印出来,又或者直接抛出OutOfMemoryError异常然后程序中断,这就是运行如此多的线程所需要花费的成本。第二个代码片段使用了协程而不是线程,挂起了协程而不是休眠线程,如果你运行它,这个程序过1秒就会打印所有点,启动这些协程的成本非常低,几乎不会被注意到。
fun main() {
repeat(100_00) {
thread {
Thread.sleep(1000L)
print(".")
}
}
}
fun main() = runBlocking {
repeat(100_00) {
launch {
delay(1000)
print(".")
}
}
}
协程在后端中为我们提供了简单性,大多数情况下,我们只需在一个挂起函数中调用另一个挂起函数,如此,在协程帮助我们提高编码效率的同时,我们也可以忘掉我们正在使用协程。当我们需要引入一些并发特性时,我们可以使用例如async、Channel或者flow来轻松实现。下面的例子展示了一个挂起函数调用另外一个挂起函数,当我们使用协程时,挂起函数和普通函数的唯一区别就是大多数函数被标记为suspend修饰符。第二个代码片段展示了如何在挂起函数上轻松使用并发——我们使用coroutineScope包装一个函数,并且在函数内部可以自由使用协程建造工具例如async。
suspend fun getArticle(articleKey: String, lang: Language): ArticleJson? {
return articleRepository.getArticle(articleKey, lang)
?.let { toArticleJson(it) }
}
suspend fun getAllArticles(userUuid: String?, lang: Language): List<ArticleJson> = coroutineScope {
val user = async { userRepo.findUserByUUID(userUuid) }
val articles = articleRepo.getArticles(lnag)
articles
.filter { hasAccess(user.await(), it) }
.map { toArticleJson(it) }
}
我希望你现在有信心去学习更多关于Kotlin协程的知识。它不仅仅是一个库,它还让并发编程变得更加简单。如果你有信心,让我们继续学习。在本章的剩余部分,我们将探索挂起是如何工作的——先从使用的角度,然后深入到底层。在第二章中,我们将介绍Kotlin协程库(名为kotlinx.coroutines)提供的基本概念和工具。第三章是关于Channel和Flow,它们在某种程度上可以替代RxJava或Reactor。准备好了吗??我们开始冒险吧。