Kotlin
Kotlin已经被谷歌指定为Android的第一开发语言,现在大多数团队都在改用kotlin进行开发。而kotlin的版本发布也挺快,目前出了一些新的东西可以进行尝试。
Coroutine
2018年10月的样子,Kotlin1.3正式发布,其中有一项特性是Android开发中以前从未有过的,那就是Coroutine,而且是正式版。
其实Coroutine的概念在1963年就由梅尔文*康威(一个牛逼的计算机科学家)提出,但是直到近代才逐渐走进大多数开发者的视野。比如:Python、Lua、C#、Go等等语言已经支持Coroutine了。其中Go更是凭借着Coroutine,成为了目前比较火热的服务端开发语言,在处理高并发上有着天然的优势。
下面就进入正题,Coroutine实现原理及给我们带来了什么。
同步编程
先看看同步编程的代码,其中setToken与setUseInfo为主线程设置TextView:
/**
* @Author : xialonghua
* @Date : Create in 2019/1/28
* @Description : 阻塞式编程
*/
class Demo1(button: Button, infoView: TextView) : Demo(button, infoView) {
override fun onCreate() {
//模拟获取token
val token = URL("https://www.baidu.com/getToken").readText().md5()
setToken(token)
val userInfo = URL("https://www.baidu.com/userInfo?$token").readText().md5()
setUserInfo(userInfo)
}
}
异步编程
异步编程的目的是解决:如何防止应用因为执行代码而陷入阻塞?
Coroutine就是众多解决方案中的一种。那么除了Coroutine有哪些常用的方法呢?
- Thread
- Callback
- Future/Promise/Rx
下面我们就先来介绍一下这几种方法。
Thread
这是最原始的解决方案,Thread就是为此而生,他是操作系统级别的解决方案。如果不想陷入阻塞,直接起一个Thread即可。
/**
* @Author : xialonghua
* @Date : Create in 2019/1/28
* @Description : thread
*/
class Demo2(button: Button, infoView: TextView) : Demo(button, infoView) {
override fun onCreate(){
//模拟获取token
thread {
val token = URL("https://www.baidu.com/getToken").readText().md5()
uiHandler.post {
setToken(token)
}
val userInfo = URL("https://www.baidu.com/userInfo?$token").readText().md5()
uiHandler.post {
setUserInfo(userInfo)
}
}
}
}
因为现代应用程序都是有一个主线程,而UI操作必须在主线程中去做。那么使用thread方式时,需要显示的将结果抛到主线程中去处理,这其实是增加了线程切换的复杂度。
Callback
再后来,发现基于thread其实写的代码还是比较多的,挺复杂的。所以有人又想出了另外一种方法将结果通过Callback的方式返回给UI线程。
/**
* @Author : xialonghua
* @Date : Create in 2019/1/28
* @Description : callback
*/
class Demo3(button: Button, infoView: TextView) : Demo(button, infoView) {
override fun onCreate() {
getToken { token ->
setToken(token)
getUserInfo(token){ userInfo ->
setUserInfo(userInfo)
}
}
}
private fun getToken(callback: (token: String) -> Unit){
thread {
val token = URL("https://www.baidu.com/getToken").readText().md5()
uiHandler.post {
callback(token)
}
}
}
private fun getUserInfo(token: String, callback: (userinfo: String) -> Unit){
thread {
val userInfo = URL("https://www.baidu.com/userInfo?$token").readText().md5()
uiHandler.post {
callback(userInfo)
}
}
}
}
我们可以看到这种方式将线程切换、计算封装到了一个方法内部,对外通过一个Callback接口给出计算结果。那么在使用的过程中简化了操作,无需关系具体细节。一个Callback嵌套另一个Callback,只有1~2层嵌套看上去还挺美好。但是,我们实际使用中发现当逻辑比较复杂时,会出现N层嵌套的情况,可想而知会有多少层缩进。可读性与复杂程度成指数级下降。这就是可怕的:回调地狱
Future/Promise/Rx
好了,好在时代在进步。出现了Future/Promise/Rx这几种方法。之所以把这几种放在一起,是因为他们有相似之处。再一个Promise/Rx我不太熟就不多讲了,参照Future即可(如有不对欢迎指正)。Future在java 1.8中提供了一个基于数据流向的封装,把所有计算都看做数据从第一步处理到下一步处理再到下一步。这样就把Callback嵌套给拍平了。只有一层逻辑。
/**
* @Author : xialonghua
* @Date : Create in 2019/1/28
* @Description : future
*/
class Demo4(button: Button, infoView: TextView) : Demo(button, infoView) {
override fun onCreate() {
CompletableFuture.supplyAsync {
URL("https://www.baidu.com/getToken").readText().md5()
}.thenApply {
setToken(it)
it
}.thenApplyAsync {
URL("https://www.baidu.com/userInfo?$it").readText().md5()
}.thenAccept {
setUserInfo(it)
}
}
}
我们可以看到,就一层,数据一路向下传递直到最后。具体的异步还是同步细节,都被封装到了Futrue内部。让开发者更加关注业务逻辑。其实要说也有缺点,我感觉API太复杂了,上手比较难,容易被不会用的人玩出翔来。。
Coroutine
前面说了那么多方法,对比下前面说的同步编程方法,我认为把异步化为同步最简单。异步编程除了异步不阻塞UI这个天大的好处,比起同步编程的顺着写逻辑更复杂,从Future/Promise/Rx来看,他们也是通过封装尽量将异步复杂的切换扁平化,来达到简化的目的。但是从写代码来看,还是太复杂了。没有纯粹的同步代码好写。
这时候Coroutine出现了,他以语言\编译器级别的支持将异步编程变成了同步编程。
/**
* @Author : xialonghua
* @Date : Create in 2019/1/28
* @Description : coroutines suspend
*/
class Demo6(button: Button, infoView: TextView) : Demo(button, infoView) {
override fun onCreate() {
GlobalScope.launch(Dispatchers.Main) {
val a = async { computeA() }
val b = async { computeB() }
delay(2000)
setUserInfo("sum : ${a.await() + b.await()}")
}
}
private suspend fun computeA() : Int{
repeat(3){
delay(1000)
}
return 125
}
private suspend fun computeB() : Int{
repeat(3){
delay(1000)
}
return 100
}
}
稍微改变下需求更加直观,同时求computeA/computeB的值并显示到UI。我们可以看到代码很简单,如果不要同时计算AB,可以去掉async。这不就是同步的写法么?运行起来会发现并没有阻塞UI。对,就是这么神奇。
基础概念
那么接下来我们就简单介绍下如何使用协程。还是回到getToken的吧。。虽然写的不太合适但是能够直观的了解协程的相关概念。
/**
* @Author : xialonghua
* @Date : Create in 2019/1/28
* @Description : coroutines
*/
class Demo5(button: Button, infoView: TextView) : Demo(button, infoView) {
lateinit var job : Job //1. 每个协程都是一个job,可以取消
override fun onCreate() {
job = GlobalScope /*2. 所有的协程都在一个作用域下执行*/.launch {//3. launch 表示启动一个协程
val token = getToken() //挂起函数执行完后协程会被挂起,等待被恢复的时机
launch(
this.coroutineContext + Dispatchers.Main //4. 每个协程都有个一个context
){
setToken(token)
}
val userInfo = async {
getUserInfo(token)
}
launch(Dispatchers.Main){
delay(3000)
setUserInfo(userInfo.await() /*可以等待数据返回,与launch的区别*/)
}
}
// job.cancel()
// setText("job is canceled")
}
private suspend fun getToken() : String{
delay(100)
return URL("https://www.baidu.com/getToken").readText().md5()
}
private suspend fun getUserInfo(token: String): String{
delay(100)
return URL("https://www.baidu.com/userInfo?$token").readText().md5()
}
}
从上面代码可以看到几个关键的点。
- GlobalScope
- CoroutineContext
- launch/async
- Job
- cancel
- Dispatchers
- suspend
这里不具体的去说如何使用,而是把几个关键的概念拎出来描述清楚,那么以后就能很好理解了如何使用了。
GlobalScope
如其名Scope、Global。两层含义。Global表示是一个全局的作用域。还有其他的Scope,也可以自己实现接口CoroutineScope定义作用域。所有的协程都是在作用域下运行。
CoroutineContext
看到Context,我们很容易想到Android里的Context。对,每个协程都对应有一个context,Context的作用就是用来保存协程相关的一些信息。比如Dispatchers、Job、名字、等等。他的数据结构其实挺妖,我看了半天才看懂。
最终的实现是一个叫CombinedContext的类,其实就是一个链表,每个节点保存了一个Key。
launch/async
scope和context都具备了,那么如何启动Coroutine呢?也很简单launch或者async就可以了,像启动一个线程一样简单。我们把这种叫做Builder。可以启动各式各样的协程。
其中launch和async的区别只有一个async返回的对象可以调用await方法挂起Coroutine直到async执行完毕。
Job
job也好理解,每次启动一个Coroutine会返回一个job对象。job对象可以对Coroutine进行取消操作,async返回的job还能挂起当前Coroutine直到Coroutine的job执行完毕。
cancel
前面说到Coroutine是可以取消的。直接使用Job的cancel方法即可。
取消需要其他配合
但是需要注意的是,如果Coroutine中执行的代码是无法退出的,比如while(true)。那么调用了cancel是不起作用的。只有在suspend方法结束的时候才会去生效。但是我们可以做一点改进:while(isActive)。isActive是Coroutine的状态,如果调用了cancel,isActive会变成false。
父子Coroutine
我们很容易想到,Coroutine中启动Coroutine的情况。在Kotlin中Coroutine是有父子关系的,那么父子关系默认遵守以下几条规律:
- Coroutine之间是父子关系,默认继承父Coroutine的context
- 父Coroutine会等待所有子Coroutine完成或取消才会结束
- 父Coroutine如果取消或者异常退出则会取消所有子Coroutine
- 子Coroutine异常退出则会取消父Coroutine
- 取消可以被try…finally捕获,如果已经取消会抛出异常
Dispatchers
这个也比较好理解,我们知道Coroutine本质上还是得依附于thread去执行。因此我们需要一个调度器来指定Coroutine具体执行在哪一个thread。
suspend
suspend关键字可以说是实现Coroutine的关键。它表示这个函数是可以被挂起的,只能在suspend修饰的方法中调用suspend方法。
也就是说代码执行到suspend方法或者suspend方法结束,会切换到其他Coroutine的其他suspend方法执行。这也很好的解释了前面的demo中,computeA和computeB是如何并行执行的。launch启动的Coroutine里的代码为什么没有阻塞UI。因为suspend方法遇到delay或者其他suspend方法,会被挂起而不是像Thread.sleep那样阻塞住线程,等到合适的时机suspend方法会被恢复执行。
至于中间是如何挂起并且如何恢复,后续会讲解。
原理解析
下面从源码的方面来简述,Coroutine到底是如何实现函数挂起的。我们分几部来讲。这里炒一个代码。。自己弄实在是麻烦。
suspend fun postItem(item: Item): PostResult {
val token = requestToken()
val post = createPost(token, item)
val postResult = processPost(post)
return postResult
}
编译期处理
suspend方法用起来挺简单,但实际上背后Kotlin做了很多不为人知的事情。
首先,被suspend关键字修饰后,在编译期间,我们看看编译器做了哪些事情。
CPS(Continuation Passing Style)
编译器做的第一件事就是CPS转换。
- 将函数返回值去掉
- 添加cont: Continuation参数,将结果放入resumeWith回调中。
//转换后的伪代码
fun postItem(item: Item, cont: Continuation): Any?{
}
我们再看看Continuation里有什么。
@SinceKotlin("1.3")
public interface Continuation<in T> {
public val context: CoroutineContext
public fun resumeWith(result: Result<T>)
}
看到resumeWith没有,想到能做什么了吗?
对,异步与流程控制。做了CPS变换后,中间就有了无限的可能,比如可以不直接执行postItem里的代码,而是通过Continuation决定何时再去执行具体的代码。这不就可以实现了函数的挂起与恢复吗?
说白了其实kotlin中的Coroutine本质上还是基于回掉去实现,只是它帮我们将细节封装在了编译期间。在外在看来,与阻塞编程没有区别。
那么具体的函数实现放哪去了呢?
题外话:尾递归与CPS
说到CPS大家都不清楚。说到递归,应该再熟悉不过。可是这几个词摆在一起是为什么呢?
递归
自己调用自己。。但是有个问题。递归的性能众所周知,而且如果太多会出现栈溢出。有什么优化方案呢?答案:循环。问题又来了,有的语言压根就没有循环这一说!那么有什么优化方案呢?答案:尾递归。
尾递归
为什么叫尾递归。因为递归的函数调用被放到了最后,所以叫尾递归。没那么简单。。还必须他的执行并不依赖上一次的执行结果,这样编译器会将代码优化成类似循环的结构。这样每次调用不用保存上次的栈,每次执行都是重新开始。因此尾递归在效率上比递归高出不少,而且保留了可读性。
还是上个代码对比下把,经典例子斐波拉切数列,可以执行下对比下耗时:
/**
* @Author : xialonghua
* @Date : Create in 2019/1/28
* @Description : a new file
*/
//编译器会将尾递归优化成循环
fun fibonacci_tail(n: Int, acc1: Int, acc2: Int): Int {
return if (n < 2) {
acc1
} else {
fibonacci_tail(n - 1, acc2, acc1 + acc2)
}
}
// 递归
fun fibonacci(n: Int): Int {
return if (n <= 2) {
1
} else {
fibonacci(n - 1) + fibonacci(n - 2)
}
}
那么这和CPS什么关系呢?
有没发现尾递归在递归函数参数上多了2个,将计算结果给直接给到下次递归。再看CPS转换,是不是在函数后面加了个Continuation,然后把执行结果放入resumeWith回调中,然后继续执行。简直一毛一样阿,将结果直接给到下次计算,而不是自上而下又自下而上的调用栈关系。
状态机
@SinceKotlin("1.3")
public abstract class BaseContinuationImpl:Continuation<in T> {
protected abstract fun invokeSuspend(result: Result<Any?>): Any?
}
知道了是如何挂起和恢复,那么suspend方法里还有别的suspend方法呢?编译器还做了点别的事情。。那就是状态机。首先将具体实现放到了invokeSuspend中。它把每一步suspend方法都用一个状态表示。当一个suspend方法执行完后,将状态改变,然后交由Continuation的resumeWith来继续执行下一个步骤。说白了就像递归一样不停的调用resumeWith来向前推进,至于是直接返回还是继续挂起,取决于resumeWith的返回值。值得注意的是子suspend也会持有父suspend的Continuation实例,形成一个链表,这样就能在子suspend执行完后回到父suspend继续执行。
非编译期处理
异步
前面讲的这些其实本质上还是在同一个线程不停的回调执行,并没有实现异步,并没有将Coroutine分布到其他线程。那么是如果做到异步的呢?
Interceptor and Dispatcher
通过前面可以知道Continuation是持有父Continuation引用的,是一个链表。那么引入Interceptor的概念,在原本的调用链里加入一个InterceptorContinuation,里面包含Dispatcher的引用。它的resumeWith里不干别的,就把下一个Continuation的resumeWith通过Dispatcher丢到其他线程里执行。
至于线程的调度就交给Dispatcher去完成,Dispatcher可以有多种实现,比如使用线程池、使用Handler等等。
是不是很巧妙?
到这里Coroutine的实现原理就说的差不多了。后面再讲讲其他的。
与Thread的对比
这个其实没什么好比了。创建10000个线程的话内存肯定是吃不消,但是创建10000个Coroutine肯定是没问题的。通过前面的讲解,很容易知道Coroutine只是一个Continuation链表,它只会占用链表的内存空间,比一个thread消耗不是一个量级
用同步的方法编写异步代码
这一块还是值得说道的。怎么写代码最简单,无疑是同步代码最简单。那么Coroutine带来的好处显而易见,写代码只管按照同步去写,至于何时挂起何时恢复全由Coroutine内部处理。至少外在看起来就是同步的代码。感觉说还是说不清,还是给一个例子来说明吧。
看demo思考一个问题,满足如下需求,用其他异步编程方法需要多少代码:
- 点击按钮开始计数,并把按钮disable
- 使用retrofit请求百度并将返回结果转成MD5(算法UUID代替),每隔2秒toast输出一次
- 取消计时,并设置textview文本为”Hello World Finish”
- enable按钮
- activity destroy的时候停止计时、请求网络、定时弹toast
我再贴出使用了Coroutine的核心代码来对比一下,有没感觉简单清晰很多:
class MainActivity2 : AppCompatActivity(), CoroutineScope, AnkoLogger {
private val job = SupervisorJob()
override val coroutineContext: CoroutineContext = Dispatchers.Main + job
@SuppressLint("SetTextI18n")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main2)
helloClick.onClick {
val tickJob = launch {
repeat(100000){
delay(10)
helloClick.text = "Hello World $it"
info("====")
}
}
helloClick.isEnabled = false
try {
val result = api.getBaidu().await()
repeat(3){
toast(result.md5())
delay(2000)
}
}catch (e: Exception){
e.printStackTrace()
//请求异常处理
toast("网络错误")
}
tickJob.cancel()
helloClick.isEnabled = true
helloClick.text = "Hello World Finish"
}
}
override fun onDestroy() {
super.onDestroy()
job.cancel()
}
private fun View.onClick(handler: (suspend CoroutineScope.(v: android.view.View?)->Unit)){
setOnClickListener { v ->
launch {
handler(v)
}
}
}
}
源码阅读关键类帮助
我列了一些关于Coroutine关键的类,看源码可以从这些地方入手:
- CoroutineContext、CombinedContext context的具体实现
- Continuation、BaseContinuationImpl、ContinuationImpl、SuspendLambda 编译处理以及流程控制(挂起恢复)
- ContinuationInterceptor、CoroutineDispatcher 拦截器与dispatcher,实现了异步
- AbstractCoroutine builder的实现抽象父类
- CoroutineScope Coroutine的scope
总结
前面写了这么多,其实大多都是描述Coroutine的本质。文字比较多有可能没有描述清楚,欢迎拍砖。下面我自己总结两点:
- Coroutine的性能消耗对比Thread微乎其微
- 更重要的是它带来了一种新的编程方式,让异步编程不再复杂
demo源码:https://github.com/xialonghua/AndroidCoroutineRetrofit