阅读 2100

史上最详Android版kotlin协程入门进阶实战(一)

banners_twitter.png

前言

笔者在写这篇文章的时候纠结了很久,不知道该以怎样的形式去讲解kotlin协程知识。笔者以前在学习的时候,也白嫖过各种各样的知识,看过很多文章,大概能够总结为三种::

  • 讲的太浅,三两句话就带过去了,看完以后就只剩下索然无味image.png
  • 讲的太深,从头到尾都是晕乎乎的,最后总结就是三句话:我在哪,我在干嘛,手机真好玩image.png
  • 内容适中,但是用到实际开发中时开始各种突然的翻车,然后挠头:怎么结果跟我想象的不一样啊image.png

知识的学习过程就像谈恋爱一样,讲究循序渐进。上来就想深入了解的话,那大概率是很容易翻车的。但聊得太浅吧,感情又不到位,后续想深入后很难。没有系统的学习是很难讲学到的知识点融会贯通的,因为笔者的想法是:"让读者在更容易吸收kotlin协程知识的同时,能够无缝衔接到实际应用开发中去"。所以接下来对于每一个知识点讲解,笔者将会根据不同的阶段,讲解到不同的深度,至于这个实际的深度是否能够满足读者们的需求,那就只能各位自己去体验了。

文章概览

本文章面向的对象是具有一定的kotlin基础和Android开发的基础。笔者会以第一视角出发,从零开始创建项目进行讲解。文章主要在于讲解Kotlin协程的基本使用、项目应用以及部分协程原理知识。附带会将讲解一些kotlin知识、Android Jetpack组件、常用第三方框架的基本使用等(不会深究原理,只是基础使用)。文章主要分为5个层次:

  1. kotlin协程的基础用法。
  2. kotlin协程的关键知识点初步讲解。
  3. 使用kotlin协程开发Android的应用。
  4. kotlin协程结合Android Jetpack组件应用。
  5. kotlin协程结合第三方框架的使用,如:RetrofitOkHttpcoil等。
  6. 深入kotlin协程原理(如:CoroutineContextDispatcherCoroutineExceptionHandlerContinuationScheduler等等)

由于文章涉及到的只是点比较多、内容可能过长,可以根据自己的能力水平和熟悉程度分阶段跳着看。如有讲述的不正确的地方劳烦各位动动小手私信给笔者,万分感谢image.png

由于时间原因,笔者白天工作只有晚上空闲时间才能写作,所以更新频率应该在2-3天一篇,当然我也会尽量的利用时间,争取能够提前发布。为了方便阅读将本文章拆分个多个章节,根据自己需要选择对应的章节,现在也只是目前笔者心里的一个大概目录,最终以更新为准:

扩展系列

文章中使用的主要版本号

  • Android studio 4.1.3
  • kotlin: 1.4.32
  • kotlinx-coroutines-android:1.4.3
  • Retrofit: 2.9.0
  • okhttp: 4.9.0
  • coil: 1.2.0
  • room: 2.2.5

项目创建以及配置

下面我们就开始进入正题,首先我们使用Android studio(后面简写为AS),创建一个以kotlin为开发语言的工程项目KotlinCoroutineDemo,然后我们先在projectbuild.gradle中引用下面配置

  classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.4.32"
复制代码

然后在app的build.gradle中引用相关配置

    // Kotlin
    implementation "org.jetbrains.kotlin:kotlin-stdlib:1.4.32"
    // 协程核心库
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.3"
    // 协程Android支持库
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.3"
}
复制代码

现在我们就可以愉快的开始我们下一步演示开发了。记得搬好小板凳带上瓜子花生用心看

Kotlin协程的基础介绍

为了方便我们在此文章把kotlin协程简称为协程(Coroutine)。

协程是什么

很多人听到协程的时候第一想法就是协程是什么,笔者在这里也不会去给它下定义,推荐您移步带kotlin官方网站去找相关解释。但是我们这里引用官方的一段原话:

  • 协程通过将复杂性放入库来简化异步编程。程序的逻辑可以在协程中顺序地表达,而底层库会为我们解决其异步性。该库可以将用户代码的相关部分包装为回调、订阅相关事件、在不同线程(甚至不同机器!)上调度执行,而代码则保持如同顺序执行一样简单。

  • 协程是一种并发设计模式,您可以在Android平台上使用它来简化异步执行的代码

简单的概括就是我们可以,以同步的方式去编写异步执行的代码。协程是依赖于线程,但是协程挂起时不需要阻塞线程,几乎是无代价的。所以协程像是一种用户态的线程,非常轻量级,一个线程中可以创建N个协程。协程的创建是过CoroutineScope创建,协程的启动方式有三种:

  1. runBlocking:T 启动一个新的协程并阻塞调用它的线程,直到里面的代码执行完毕,返回值是泛型T,就是你协程体中最后一行是什么类型,最终返回的是什么类型T就是什么类型。
  2. launch:Job 启动一个协程但不会阻塞调用线程,必须要在协程作用域(CoroutineScope)中才能调用,返回值是一个Job。
  3. async:Deferred<T> 启动一个协程但不会阻塞调用线程,必须要在协程作用域(CoroutineScope)中才能调用。以Deferred对象的形式返回协程任务。返回值泛型TrunBlocking类似都是协程体最后一行的类型。

等等,好像哪里不对,奇怪的知识点突然有点增多啊image.png。上面提到协程体中最后一行是什么类型,最终返回的是什么类型T就是什么类型,好像跟我们想的不一样,返回值不应该是用return吗,学过kotlin的会知道,在的kotlin高阶函数中,lambda表达式如果你没有显式返回一个值,那它将隐式返回最后一个表达式的值。

JobDeferred协程作用域又是些啥玩意image.png 。不急,慢慢来,我们一个一个的来解释清楚。

什么是Job 、Deferred 、协程作用域

Job我们可以认为他就是一个协程作业是通过CoroutineScope.launch生成的,同时它运行一个指定的代码块,并在该代码块完成时完成。我们可以通过isActiveisCompletedisCancelled来获取到Job的当前状态。Job的状态如下图所示,摘自官方文档:

协程的生命周期

State[isActive][isCompleted][isCancelled]
New (optional initial state)falsefalsefalse
Active (default initial state)truefalsefalse
Completing (transient state)truefalsefalse
Cancelling (transient state)falsefalsetrue
Cancelled (final state)falsetruetrue
Completed (final state)falsetruefalse

我们可以通过下图可以大概了解下一个协程作业从创建到完成或者取消,Job在这里不扩展了,后面我们会在实际使用过程中去讲解。

                                      wait children
+-----+ start  +--------+ complete   +-------------+  finish  +-----------+
| New | -----> | Active | ---------> | Completing  | -------> | Completed |
+-----+        +--------+            +-------------+          +-----------+
                 |  cancel / fail       |
                 |     +----------------+
                 |     |
                 V     V
             +------------+                           finish  +-----------+
             | Cancelling | --------------------------------> | Cancelled |
             +------------+                                   +-----------+
复制代码

Deferred

Deferred继承自Job,我们可以把它看做一个带有返回值的Job

public interface Deferred<out T> : Job {
    //返回结果值,或者如果延迟被取消,则抛出相应的异常
    public suspend fun await(): T  
    public val onAwait: SelectClause1<T>
    public fun getCompleted(): T
    public fun getCompletionExceptionOrNull(): Throwable?
}
复制代码

我们需要重点关注await()方法,可以看到await()方法返回结果是T,说明我们可以通过await()方法获取执行流的返回值,当然如果出现异常或者被取消执行,则会抛出相对应的异常。

什么是作用域

协程作用域(Coroutine Scope)是协程运行的作用范围。launchasync都是CoroutineScope扩展函数CoroutineScope定义了新启动的协程作用范围,同时会继承了他的coroutineContext自动传播其所有的 elements和取消操作。换句话说,如果这个作用域销毁了,那么里面的协程也随之失效。就好比变量的作用域,如下面test方法中的money变量

private fun test(){ // scope start
    int money = 100;
    println(money)
} // scope end
// println(money)  
复制代码

此时money是不能被调用,因为AS会提示 Unresolved reference: money。协程作用域也是这样一个作用,可以用来确保里面的协程都有一个作用域的限制。我们开发过程中最常见的场景就内存泄露,协程同样存在这样的问题,后面我们再细细讲解协程作用域CoroutineScope的相关知识,这里只是作为基础点讲解,不继续往下延伸。

Kotlin协程的基础用法

现在我们开始使用协程,首先我们在MainActivity的xml布局中新建一个Button按钮然后设置好点击事件,然后创建一个start()方法,通过Button的点击事件执行。现在我们开始在start方法中使用协程。

刚才我们上面提到启动协程有三种方式,接下来我们先看看如何通过runBlockinglaunchasync启动协程,我们直接在start方法中使用,但是由于我们的launchasync启动,只能在协程的作用域下启动,那我们又该怎么办呢?

运行第一个协程

在Android中有一个名为GlobalScope全局顶级协程,这个协程是在整个应用程序生命周期内运行的。我们就以此协程来使用launchasync启动,代码如下:

import android.os.Bundle
import android.util.Log
import android.view.View
import android.widget.Button
import android.widget.LinearLayout
import androidx.appcompat.app.AppCompatActivity
import androidx.constraintlayout.widget.Group
import androidx.viewpager.widget.ViewPager
import kotlinx.coroutines.*
import java.lang.NullPointerException

class MainActivity : AppCompatActivity() {
    private lateinit var btn:Button
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        btn = findViewById(R.id.btn)
        btn.setOnClickListener {
            start()
        }
    }

    private fun start(){
        runBlocking {
            Log.d("runBlocking", "启动一个协程")
        }
        GlobalScope.launch{
            Log.d("launch", "启动一个协程")
        }
        GlobalScope.async{
            Log.d("async", "启动一个协程")
        }
    }
}
复制代码

然后运行app,点击按钮执行start()方法。我们就可以在控制台上看到如下输出:

D/runBlocking: 启动一个协程
D/launch: 启动一个协程
D/async: 启动一个协程
复制代码

image.png,so easy。协程原来这么简单,那我们接着继续往下走。上面提到过三种启动方式分别会的得到各自的返回信息。我们现在增加三个变量然后分别用协程进行赋值,同时进行输出:

private fun start(){
    val runBlockingJob = runBlocking {
        Log.d("runBlocking", "启动一个协程")
    }
    Log.d("runBlockingJob", "$runBlockingJob")
    val launchJob = GlobalScope.launch{
        Log.d("launch", "启动一个协程")
    }
    Log.d("launchJob", "$launchJob")
    val asyncJob = GlobalScope.async{
        Log.d("async", "启动一个协程")
        "我是返回值"
    }
    Log.d("asyncJob", "$asyncJob")
}
复制代码

然后运行,我们可以在控制台上看到如下输出:

D/runBlocking: 启动一个协程
D/runBlockingJob: 41
D/launchJob: StandaloneCoroutine{Active}@3b8b871
D/launch: 启动一个协程
D/async: 启动一个协程
D/asyncJob: DeferredCoroutine{Active}@63f2656
复制代码

也有可能是

D/runBlocking: 启动一个协程
D/runBlockingJob: 41
D/launchJob: StandaloneCoroutine{Active}@1344515
D/asyncJob: DeferredCoroutine{Active}@38c002a
D/async: 启动一个协程
D/launch: 启动一个协程
复制代码

还有可能是

D/runBlocking: 启动一个协程
D/runBlockingJob: 41
D/launch: 启动一个协程
D/launchJob: StandaloneCoroutine{Active}@b94e973
D/async: 启动一个协程
D/ asyncJob: DeferredCoroutine{Active}@f7aa030
复制代码

嗯哼,什么情况image.png怎么后面4条日志顺序还是随机的。没有看懂的童鞋,说明你没有仔细看上面的文字image.png。知识点来了,赶紧拿出你的小本本记下来,我们一个一个的来分析。

我们在上面提到过runBlocking启动的是一个新的协程并阻塞调用它的线程,我们对比输出日志可以看到前两条runBlocking的相关输出日志的位置顺序是不会变化的,这就证明我们之前所说的runBlocking会阻塞调用它的线程,直到runBlocking运行结束才继续往下执行。

接下来我们再继续往下看,我们看到后面四条日志是无序的,但是launchJob始终在asyncJob前面。而launchasync协程体内的日志输出是无序的。每执行一次看到的顺序都有可能跟之前的不一样。我们前面提到过launchasync都是启动一个协程但不会阻塞调用线程,所以launchJob始终在asyncJob前面(2个协程之间不是很明显,你们自己在尝试的时候,可以同时启动5个甚至更多协程去看日志输出)

launchasync协程体内的日志是无序的,这是因为协程采用的是并发设计模式,所以launchasync的协程体内的log日志输出是无序方式,这就解释了launchasync都是启动一个协程但不会阻塞调用线程,同时也解释了log日志之间输出顺序之间的关系(这里描述是不严谨的,后面会补充)。

难道就这样结束了吗?那你想的可就太简单了。刚才我们提到协程采用的是并发设计模式,多个协程并发执行的。那如果这个时候,我们把启动协程放在同一协程作用域下启动的是顺序又该是怎么样的呢? 大家可以先思考一下这个问题,回头我们再来看这个问题。

runBlocking的返回值

现在我们回到之前的话题,我们看到输出的日志信息中runBlockingJob的输出结果是41,为什么是这么一个数值,其实他默认返回是一个该协程作业的当前状态

image.png 我们通过runBlocking方法可以看到,其返回值是调用了joinBlocking方法,而在joinBlocking方法中

image.png

我们看到joinBlocking方法返回了一个state强转成泛型T类型。我们现在大概知道runBlocking返回的是个什么东西了。如果在runBlocking协程最后一行增加一个返回值:

 val runBlockingJob = runBlocking {
        Log.d("Coroutine", "runBlocking启动一个协程")
        "我是runBlockingJob协程的返回值"
 }
复制代码

我们将会看到如下输出:

D/Coroutine: runBlocking启动一个协程
D/runBlockingJob: 我是runBlockingJob协程的返回值
复制代码

runBlocking它的设计目的是将常规的阻塞代码连接到一起,主要用于main函数和测试中。根据本文章的目标我们后续将不再往下扩展。

继续往下走,我们看到launchJob输出的是一个StandaloneCoroutine对象,为什么会是一个StandaloneCoroutine对象呢,不是说好的返回一个Job吗?

别慌,稳住!继续往下看

launch函数

image.png

我们看到launch函数中有3个参数contextstartblock,同时都带有默认值,虽然我们不知道这三个参数是干什么用的,但是我们可以看名知其意,不妨先大胆的猜测一下,我们这里先跳过,后面再针对这三个参数做一些基本讲解。我们看到launch方法最终返回的是一个coroutine对象,由于我们没有传入值其最后返回的是一个StandaloneCoroutine对象,跟我们输出的日志结果一致。那为什么笔者会说launch返回的是一个Job呢。我们再继续看看StandaloneCoroutine又是一个什么鬼,通过查找继承关系我们可以看到,StandaloneCoroutine就是一个Job,现在就一目了然了。

private open class StandaloneCoroutine(...) : AbstractCoroutine<Unit>(parentContext, active){
    //此处省略.....
}
复制代码
public abstract class AbstractCoroutine<in T>(...) : JobSupport(active), Job, Continuation<T>, CoroutineScope {
//此处省略.....
}
复制代码

async函数

同理我们也看看async函数,和launch拥有同样的3个参数contextstartblock,默认值都是一样的,最终返回的是也是一个coroutine对象。只是async返回的DeferredCoroutine对象。 image.png

private open class DeferredCoroutine<T>(...) : AbstractCoroutine<T>(parentContext, active), Deferred<T>, SelectClause1<T> {
 //此处省略.....
}
复制代码

同样的都是继承 AbstractCoroutine<Unit>类,但是DeferredCoroutine同时也继承Deferred<T>接口。这么看来DeferredCoroutine就是一个Deferred<T>,一个携带有返回值Job。那么问题来了,我们要怎么获取到这个Deferred<T>携带的返回值T呢。

我们在一开始的时候提到需要重点关注Deferredawait()方法,我们可以通过返回Deferred对象,调用await()方法来获取返回值,我们看到await()前面有个suspend关键字,这又是个额啥玩意。

  public suspend fun await(): T
复制代码

挂起函数

suspend是协程的关键字,表示这个一个挂起函数,每一个被suspend饰的方法只能在suspend方法或者在协程中调用。现在我们修改之前的代码,同时多增加几条输出日志:

private fun start(){
    GlobalScope.launch{
        val launchJob = launch{
            Log.d("launch", "启动一个协程")
        }
        Log.d("launchJob", "$launchJob")
        val asyncJob = async{
            Log.d("async", "启动一个协程")
            "我是async返回值"
        }
        Log.d("asyncJob.await", ":${asyncJob.await()}")
        Log.d("asyncJob", "$asyncJob")
    }
}
复制代码

现在我们通过GlobalScope.launch启动里一个协程,同时在协程体里面通过launch直接又启动了2个协程。为什么我们没有在协程体使用GlobalScope.launch启动,而是使用launch直接启动。前面我们提到过调用launch必须要在协程作用域(Coroutine Scope)中才能调用,因为通过runBlockinglaunchasync启动的协程体等同于协程作用域,所以这里我们就可以直接使用launch启动一个协程。我们运行一下,接着看看日志输出:

D/launchJob: StandaloneCoroutine{Active}@f3d8da3
D/launch: 启动一个协程
D/async: 启动一个协程
D/await: :我是async返回值
D/asyncJob: DeferredCoroutine{Completed}@d6f28a0
复制代码

也有可能是这样的

D/launchJob: StandaloneCoroutine{Active}@f3d8da3
D/async: 启动一个协程
D/launch: 启动一个协程
D/asyncJob.await: :我是async返回值
D/asyncJob: DeferredCoroutine{Completed}@d6f28a0
复制代码

现在我们看到asyncJob.await也是输出我们之前定义好的返回值,同时DeferredCoroutine的状态变成了{Completed},这是因为await()是在不阻塞线程的情况下等待该值的完成并继续执行,当deferred计算完成后返回结果值,或者如果deferred被取消,则抛出相应的异常CancellationException。但是又因为await()是挂起函数,他会挂起调用他的协程。所以我们看到的DeferredCoroutine的状态是{Completed},同时输出的await日志也是在最后面。

好了,至此。我们对runBlockinglaunchasync的相关介绍就到这里了。

Android中的协程并发与同步

现在我们回过头来看,我们在上面提到过:"因为协程采用的是并发设计模式,所以导致launchasync的协程体内的log日志输出是无序方式(这样说是不严谨)"。

因为协程是采用就是并发的设计模式,这句话的大多数环境下是没有问题。但是,但是,但是,这里需要注意的小细节来了。如果某个协程满足以下几点,那它里面的子协程将会是同步执行的:

  • 父协程的协程调度器是处于Dispatchers.Main情况下启动。
  • 同时子协程在不修改协程调度器下的情况下启动。
private fun start() {
    GlobalScope.launch(Dispatchers.Main) {
        for (index in 1 until  10) {
            //同步执行
            launch {
                Log.d("launch$index", "启动一个协程")
            }
        }
    }
}
复制代码
D/launch1: 启动一个协程
D/launch2: 启动一个协程
D/launch3: 启动一个协程
D/launch4: 启动一个协程
D/launch5: 启动一个协程
D/launch6: 启动一个协程
D/launch7: 启动一个协程
D/launch8: 启动一个协程
D/launch9: 启动一个协程
复制代码
private fun start() {
    GlobalScope.launch {
        for (index in 1 until  10) {
            //并发执行
            launch {
                Log.d("launch$index", "启动一个协程")
            }
        }
    }
}
复制代码
D/launch1: 启动一个协程
D/launch2: 启动一个协程
D/launch3: 启动一个协程
D/launch4: 启动一个协程
D/launch5: 启动一个协程
D/launch6: 启动一个协程
D/launch9: 启动一个协程
D/launch7: 启动一个协程
D/launch8: 启动一个协程
复制代码

那么子协程将是同步执行的,这是在Android平台上如果协程处于Dispatchers.Main调度器,它会将协程调度到UI事件循环中执行,即通常在主线程上执行,这样就能理解为什么是同步执行了吧。如果是不同步的话,那我在操作UI刷新的时候,就会出现各种问题啦image.png

如果其中的某一个子协程将他的协程调度器修改为非Dispatchers.Main,那么这个子协程将会与其他子协程并发执行,这里我们就不在演示,各位看官可以自己动手试验一下。毕竟知识光看不动手,是很难将知识吸收到位的image.png

下一本章节我们将在会对以下知识点做初步讲解,包含上文提到的launchasync函数中的3个参数作用。清单如下:

  1. 协程调度器CoroutineDispatcher
  2. 协程下上文CoroutineContext作用
  3. 协程启动模式CoroutineStart
  4. 协程作用域CoroutineScope
  5. 挂起函数以及suspend关键字的作用

原创不易。如果您喜欢这篇文章,您可以动动小手点赞收藏image.png

文章分类
Android
文章标签