Kotlin协程实践 - HTTP页面内容异步下载示例

3,134 阅读8分钟

协程

相信大家都对协程这个词很有兴趣,网上也有大量介绍协程的文章,但是大多数都是介绍概念及理论的,很少看到有使用实际案例的文章,笔者刚看到协程的概念时也是欣喜不已,觉得非常有用,很强大,能解决很多实际问题,但是总觉得不知道该如何下手去运用到实际项目中去,因此打算专门撰写这篇协程实战的文章,通过一个HTTP网页URL页面内容的下载实践来讲述解释协程的原理与使用,希望能对大家学习使用并理解协程有所帮助。

概念

协程 - 轻量级线程
虽然Kotlin中使用线程已经很方便了,但还是推荐使用协程代替线程。
协程主要是让原来要使用“异步+回调方式”写出来的复杂代码, 简化成可以用看似同步的方式写出来(对线程的操作进一步抽象)。 这样我们就可以按串行的思维模型去组织原本分散在不同上下文中的代码逻辑,而不需要去处理复杂的状态同步问题,基本上也不再需要接口处理代码了。

先来看看如下代码:

    fun startCoroutine(name: String) {
        println("  ### 1. Coroutine start in ${Thread.currentThread()}")
        val c1 = GlobalScope.launch(Dispatchers.Default) {
            println("    *** 2. ${name} launch start in ${Thread.currentThread()}")
            delay(1000)
            println("    *** 3. ${name} End of launch in ${Thread.currentThread()}")
        }

        println("  ### 4. Coroutine End. in ${Thread.currentThread()}")
    }


startCoroutine("CO1")

输出结果:

  ### 1. Coroutine start in Thread[main,5,main]
  ### 4. Coroutine End. in Thread[main,5,main]
    *** 2. CO1 launch start in Thread[DefaultDispatcher-worker-1,5,main]
    *** 3. CO1 End of launch in Thread[DefaultDispatcher-worker-3,5,main]

GlobalScope.launch(Dispatchers.Default) 用于启动协程。 从输出结果可以看出,启动协程之前,是在主线程中,但是协程启动后,协程的代码Block是在子线程中执行的。这不是重点,重点在于delay过后,协程的代码一定是在子线程执行的,哪怕launch指定了Unconfined参数,协程一开始将在主线程中执行,但是delay依然不会阻塞主线程,但它的确可以在指定的时间过后返回代码块继续执行后面的代码。这就是delay的强大之处,这个delay是不可以在协程外部的代码中调用的。

协程调度器 功能描述
Dispatchers.Default 运行在 Dispatchers.Default 的线程池中
Dispatchers.Main 运行在主线程中
Dispatchers.IO 运行在 IO 线程中
Dispatchers.Unconfined 运行在当前线程中

PS:之前低版本的那套launch/await 全局函数已经废弃,新版本必须使用GlobalScope.xxx。

协程的作用,就是让开发者感觉是在多线程中工作一样,可以异步处理耗时操作,但实际上可能并没有真正使用线程,而就在同一线程中切换。协程的切换是由编译器来完成的,因而开销很小,并不依赖系统资源,你可以开100000个协程,而无法启动100000个线程。

delay跟线程的sleep很相似,都是延时一段时间,但是不同点在于,delay不会阻塞当前线程,而是挂起协程本身,从而将线程资源释放出来,供其它协程使用。

我们所必须要了解的是,在协程中,当你的耗时任务做完之后,你的代码很可能不在刚才的线程当中,此时必须要注意代码的线程安全问题,例如访问UI,你可以使用runOnUiThread { }。

在startCoroutine的结尾处,可以使用c1.join()来等待协程结束,一旦使用join,编译器便提醒必须添加suspend关键字,该函数也必须在协程中调用。

再来看看修改后的代码:

suspend fun startCoroutine(name: String) {
    println("  ### 1. Coroutine start in ${Thread.currentThread()}")
    val c1 = GlobalScope.launch(Dispatchers.Default) {
        println("    *** 2. ${name} launch start in ${Thread.currentThread()}")
        delay(3000)
        println("    *** 3. ${name} End of launch in ${Thread.currentThread()}")
    }
    c1.join()
    println("  ### 4. Coroutine End. in ${Thread.currentThread()}")
}

该方法因为添加了suspend关键字,因此只能在协程中调用:

    GlobalScope.launch(Dispatchers.Main) {
        startCoroutine("CO1")
    }

输出结果如下:

   ### 1. Coroutine start in Thread[main,5,main]
     *** 2. CO1 launch start in Thread[DefaultDispatcher-worker-2,5,main]
     *** 3. CO1 End of launch in Thread[DefaultDispatcher-worker-3,5,main]
   ### 4. Coroutine End. in Thread[main,5,main]

可以看到,代码中的日志顺序,是按1、2、3、4的顺序输出的了,join函数会等待协程结束。由于我指定了startCoroutine在Dispatchers.Main父协程中运行,因此当join等待子协程完成之后,又回到了主线程执行,这种方式来更新UI的话,都不再需要使用runOnUiThread了,很适合用于做动画。

协程实战

我们通过一个网络URL加载Web数据的实例,来展示协程对于异步处理的强大之处。
首先,需要在build.gradle中添加:

    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.1.1"

在AndroidManifest.xml中添加:

<uses-permission android:name="android.permission.INTERNET" />

新建一个UrlDownload类:

class UrlDownload {
    // kotlin没有static方法,而是要使用伴生对象来替代
    companion object {
        suspend fun asyncDownload(url: String): String? {
            return GlobalScope.async(Dispatchers.Default) {
                download(url)
            }.await()
        }

        fun download(url: String): String {
            var urlConn : HttpURLConnection? = null
            var strBuffer = StringBuffer()
            var inputStream: InputStream? = null
            var buffer: BufferedReader? = null
            var inputReader: InputStreamReader? = null

            try {
                urlConn = URL(url).openConnection() as HttpURLConnection
                inputStream = urlConn.getInputStream()
                inputReader = InputStreamReader(inputStream)
                buffer = BufferedReader(inputReader)
                do {
                    var line = buffer.readLine()
                    strBuffer.append(line)
                } while (line != null)

            } catch (e: Exception){
                e.printStackTrace()
            } finally {
                inputReader?.close()
                buffer?.close()
                inputStream?.close()
                urlConn?.disconnect()
            }

            return strBuffer.toString()
        }
    }
}

fun startDownload() {
    var url = "https://m.weibo.cn/"
    GlobalScope.launch(Dispatchers.Default) {
        var content = UrlDownload.asyncDownload(url) // 这是一个异步执行的耗时的操作
        println(content)
    }
}

执行以上程序,在主线程调用startDownload()函数,可以看到控制台打印出了网页内容。请注意整个程序没有定义任何回调接口,但结果的确是在业务层打印出来的,阅读代码就好像是同步执行的一样,你也可以看的出,以上代码并不会阻塞主线程。

  • download(url: String)是一个同步方法,实现联网返回网页数据的功能,该方法会阻塞当前线程,不能在主线程调用。
  • asyncDownload方法添加了suspend关键字,说明该函数将被挂起并异步执行,等到异步执行完毕才会返回结果。
  • suspend关键字声明的函数,是一个挂起函数,只能在协程里面调用。
  • 编译器将每一个挂起点的前后作为独立的代码片段,这些代码片段在需要的时候才会执行,不会阻塞当前线程,内部使用状态机来保证协程状态的恢复以及代码片段的顺序执行。
  • 执行了挂起方法之后,无法确定是在哪个线程恢复执行,除非指定了Dispatchers.Main调度器。

如果需要一层一层的往上传递,那么将startDownload做个简单改造即可:

suspend fun startDownload(url: String): String? {
    return GlobalScope.async(Dispatchers.Default) {
        UrlDownload.asyncDownload(url)
    }.await()
}

fun appStartDownload() {
    var url = "https://m.weibo.cn/"
    GlobalScope.launch(Dispatchers.Default) {
        var content = startDownload(url)
        println(content)
    }
}

GlobalScope.launch 启动一个协程,并返回这个协程对象,我们可以调用 join()来等待协程结束,join没有返回值。
await() 则有返回值,可以返回数据,要使用await(),必须使用GlobalScope.async来启动协程。再来看看上述启动代码的学习修改版本:

    suspend fun startDownload(url: String): String? {
        println("### 1. startDownload start in ${Thread.currentThread()}")
        var r =  GlobalScope.async(Dispatchers.Default) {
            println("  ### 2. startDownload in ${Thread.currentThread()}")
            UrlDownload.asyncDownload(url)
            println("  ### 3. startDownload in ${Thread.currentThread()}")
        }.await()

        println("### 4. startDownload End. in ${Thread.currentThread()}")
        return "### startDownload TEST ###"
    }

输出结果如下:

   ### 1. startDownload start in Thread[DefaultDispatcher-worker-1,5,main]
     ### 2. startDownload in Thread[DefaultDispatcher-worker-2,5,main]
     ### 3. startDownload in Thread[DefaultDispatcher-worker-3,5,main]
   ### 4. startDownload End. in Thread[DefaultDispatcher-worker-3,5,main]

从日志可以看出,虽然日志顺序也是严格按照代码中1、2、3、4的顺序执行的,但是4号日志跟1号日志已经不在同一个线程,而是跟3号日志在同一个线程。这就是异步等待await的结果,所以该方法必须使用suspend关键字,告诉编译器这个是协程函数,必须在协程中调用。不然随意切换客户代码的线程,肯定要出乱子的。这就是协程的关键,也是协程的强大之处,但是越是强大的东西,使用时一定要知道它的特点,虽然使用起来很简单。

刚才的代码,有一个费解的地方:

suspend fun startDownload(url: String): String? {
    return GlobalScope.async(Dispatchers.Default) {
        UrlDownload.asyncDownload(url) // 其实await是将这行代码的返回结果作为返回值了
    }.await()
}

那么细心的同学可能会问,如果我在这里写了两行代码呢?既然是实战学习,当然不能放过这个问题,继续编写学习测试代码:

suspend fun startDownload(url: String): String? {
    return GlobalScope.async(Dispatchers.Default) {
        UrlDownload.asyncDownload(url) 
        UrlDownload.asyncDownload("https://www.xxx.com/")
        "### 返回值 ###"
    }.await()
}

测试发现,await会将最后一个表达式的值作为返回值,而前面的多个asyncDownload都会执行,而且是顺序执行,原因是asyncDownload内部本身也使用了协程await()来等待,我们把那个协程叫子协程,启动子协程的协程叫父协程。那么如果我们希望两个下载任务能够同时并行进行呢,当然有办法,那就是再启动一个新的父协程去执行UrlDownload.asyncDownload即可,要知道kotlin中是可以启动100000个协程的,上线只受内存限制。

至此,相信读者对于协程的概念、使用都能很好的理解了,测试代码就不再贴出来了,有兴趣的同学可以自行编写代码来验证,以加深理解。

Kotlin快速入门 - 安卓开发新趋势,Java转Kotlin开发,花一天时间就够了 之前写的一篇文章,但是不知怎么在掘金发布不了,于是附上一个简书链接。