## 参考文章
[Kotlin 的协程用力瞥一眼 - 学不会协程?很可能因为你看过的教程都是错的](https://kaixue.io/kotlin-coroutines-1/)
## 协程是什么
协程并不是 Kotlin 提出来的新概念,其他的一些编程语言,例如:Go、Python 等都可以在语言层面上实现协程,甚至是 Java,也可以通过使用扩展库来间接地支持协程。
当在网上搜索协程时,我们会看到:
* Kotlin 官方文档说「本质上,协程是轻量级的线程」。
* 很多博客提到「不需要从用户态切换到内核态」、「是协作式的」等等。
作为 Kotlin 协程的初学者,这些概念并不是那么容易让人理解。这些往往是作者根据自己的经验总结出来的,只看结果,而**不管过程就不容易理解协程**。
「协程 Coroutines」源自 Simula 和 Modula-2 语言,这个术语早在 1958 年就被[Melvin Edward Conway](https://kaixue.io/kotlin-coroutines-1/%5Bhttps://zh.wikipedia.org/wiki/%E9%A9%AC%E5%B0%94%E6%96%87%C2%B7%E5%BA%B7%E5%A8%81%5D(https://zh.wikipedia.org/wiki/%E9%A9%AC%E5%B0%94%E6%96%87%C2%B7%E5%BA%B7%E5%A8%81))发明并用于构建汇编程序,说明**协程是一种编程思想**,并不局限于特定的语言。
Go 语言也有协程,叫 Goroutines,从英文拼写就知道它和 Coroutines 还是有些差别的(设计思想上是有关系的),否则 Kotlin 的协程完全可以叫 Koroutines 了。
因此,对一个新术语,我们需要知道什么是「标准」术语,什么是变种。
当我们讨论协程和线程的关系时,很容易**陷入中文的误区**,两者都有一个「程」字,就觉得有关系,其实就英文而言,Coroutines 和 Threads 就是两个概念。
从 Android 开发者的角度去理解它们的关系:
* 我们所有的代码都是跑在线程中的,而线程是跑在进程中的。
* 协程没有直接和操作系统关联,但它不是空中楼阁,它也是跑在线程中的,可以是单线程,也可以是多线程。
* 单线程中的协程总的执行时间并不会比不用协程少。
* Android 系统上,如果在主线程进行网络请求,会抛出`NetworkOnMainThreadException`,**对于在主线程上的协程也不例外,这种场景使用协程还是要切线程的**。
协程**设计的初衷是为了解决并发问题,让 「协作式多任务」 实现起来更加方便**。
视频里讲到,**协程就是 Kotlin 提供的一套线程封装的 API(前提是对JVM而言),但并不是说协程就是为线程而生的**。
不过,我们学习 Kotlin 中的协程,一开始确实可以从线程控制的角度来切入。因为在 Kotlin 中,协程的一个典型的使用场景就是线程控制。就像 Java 中的`Executor`和 Android 中的`AsyncTask`,Kotlin 中的协程也有对 Thread API 的封装,让我们可以在写代码时,不用关注多线程就能够很方便地写出并发操作。
在 Java 中要实现并发操作通常需要开启一个`Thread`:
~~~java
new Thread(new Runnable() {
@Override
public void run() {
...
}
}).start();
~~~
这里仅仅只是开启了一个新线程,至于它何时结束、执行结果怎么样,我们在主线程中是无法直接知道的。
Kotlin 中同样可以通过线程的方式去写:
~~~kotlin
Thread({
...
}).start()
~~~
可以看到,和 Java 一样也摆脱不了直接使用`Thead`的那些困难和不方便:
* 线程什么时候执行结束
* 线程间的相互通信
* 多个线程的管理
我们可以用 Java 的`Executor`线程池来进行线程管理:
~~~kotlin
val executor = Executors.newCachedThreadPool()
executor.execute({
...
})
~~~
用 Android 的`AsyncTask`来解决线程间通信:
~~~kotlin
object : AsyncTask<T0, T1, T2> {
override fun doInBackground(vararg args: T0): String { ... }
override fun onProgressUpdate(vararg args: T1) { ... }
override fun onPostExecute(t3: T3) { ... }
}
~~~
`AsyncTask`是 Android 对线程池`Executor`的封装,但它的缺点也很明显:
* 需要处理很多回调,如果业务多则容易陷入「回调地狱」。
* 硬是把业务拆分成了前台、中间更新、后台三个函数。
看到这里你很自然想到使用 RxJava 解决回调地狱,它确实可以很方便地解决上面的问题。
RxJava,准确来讲是 ReactiveX 在 Java 上的实现,是一种响应式程序框架,我们通过它提供的「Observable」的编程范式进行链式调用,可以很好地消除回调。
使用协程,同样可以像 Rx 那样有效地消除回调地狱,不过无论是设计理念,还是代码风格,两者是有很大区别的,协程在写法上和普通的顺序代码类似。
>[success] 这里并不会比较 RxJava 和协程哪个好,或者讨论谁取代谁的问题,我这里只给出一个建议,你最好都去了解下,因为协程和 Rx 的设计思想本来就不同。
下面的例子是使用协程进行网络请求获取用户信息并显示到 UI 控件上:
~~~kotlin
launch({
val user = api.getUser() // 👈 网络请求(IO 线程)
nameTv.text = user.name // 👈 更新 UI(主线程)
})
~~~
这里只是展示了一个代码片段,**`launch`并不是一个顶层函数,它必须在一个对象中使用**,我们之后再讲,这里只关心它内部业务逻辑的写法。
**`launch`函数加上实现在`{}`中具体的逻辑,就构成了一个协程**。
通常我们做网络请求,要不就传一个 callback,要不就是在 IO 线程里进行阻塞式的同步调用,而在这段代码中,上下两个语句分别工作在两个线程里,但写法上看起来和普通的单线程代码一样。
这里的`api.getUser`是一个**挂起函数**,所以能够保证`nameTv.text`的正确赋值,这就涉及到了协程中最著名的「非阻塞式挂起」。这个名词看起来不是那么容易理解,我们后续的文章会专门对这个概念进行讲解。现在先把这个概念放下,只需要记住协程就是这样写的就行了。
这种「**用同步的方式写异步的代码**」看起来很方便吧,那么我们来看看协程具体好在哪。
## 协程好在哪
### 开始之前
在讲之前,我们需要先了解一下「闭包」这个概念,调用 Kotlin 协程中的 API,经常会用到闭包写法。
其实闭包并不是 Kotlin 中的新概念,在 Java 8 中就已经支持。
我们先以`Thread`为例,来看看什么是闭包:
~~~kotlin
// 创建一个 Thread 的完整写法
Thread(object : Runnable {
override fun run() {
...
}
})
// 满足 SAM,先简化为
Thread({
...
})
// 使用闭包,再简化为
Thread {
...
}
~~~
**形如`Thread {...}`这样的结构中`{}`就是一个闭包**。
**在 Kotlin 中有这样一个语法糖:当函数的最后一个参数是 lambda 表达式时,可以将 lambda 写在括号外。这就是它的闭包原则**。
在这里需要一个类型为`Runnable`的参数,而`Runnable`是一个接口,且只定义了一个函数`run`,这种情况满足了 Kotlin 的[SAM](https://medium.com/tompee/idiomatic-kotlin-lambdas-and-sam-constructors-fe2075965bfb),可以转换成传递一个 lambda 表达式(第二段),因为是最后一个参数,根据闭包原则我们就可以直接写成`Thread {...}`(第三段) 的形式。
对于上文所使用的`launch`函数,可以通过闭包来进行简化 :
~~~kotlin
launch {
...
}
~~~
### 基本使用
前面提到,**`launch`函数不是顶层函数,是不能直接用的,可以使用下面三种方法来创建协程**
~~~kotlin
// 方法一,使用 runBlocking 顶层函数
runBlocking {
getImage(imageId)
}
// 方法二,使用 GlobalScope 单例对象
//👇 可以直接调用 launch 开启协程
GlobalScope.launch {
getImage(imageId)
}
// 方法三,自行通过 CoroutineContext 创建一个 CoroutineScope 对象
// 👇 需要一个类型为 CoroutineContext 的参数
val coroutineScope = CoroutineScope(context)
coroutineScope.launch {
getImage(imageId)
}
~~~
* 方法一通常适用于单元测试的场景,而业务开发中不会用到这种方法,因为**它是线程阻塞的**。
* 方法二和使用`runBlocking`的区别在于不会阻塞线程。但在 Android 开发中同样不推荐这种用法,因为**它的生命周期会和 app 一致,且不能取消**(什么是协程的取消后面的文章会讲)。
* 方法三是比较推荐的使用方法,我们可以通过`context`参数去管理和控制协程的生命周期(这里的`context`和 Android 里的不是一个东西,是一个更通用的概念,会有一个 Android 平台的封装来配合使用)。
关于`CoroutineScope`和`CoroutineContext`的更多内容后面的文章再讲。
**协程最常用的功能是并发,而并发的典型场景就是多线程**。可以使用`Dispatchers.IO`参数把任务切到 IO 线程执行:
~~~kotlin
coroutineScope.launch(Dispatchers.IO) {
...
}
~~~
也可以使用`Dispatchers.Main`参数切换到主线程:
~~~kotlin
coroutineScope.launch(Dispatchers.Main) {
...
}
~~~
所以在「协程是什么」一节中讲到的异步请求的例子完整写出来是这样的:
~~~kotlin
coroutineScope.launch(Dispatchers.Main) { // 在主线程开启协程
val user = api.getUser() // IO 线程执行网络请求
nameTv.text = user.name // 主线程更新 UI
}
~~~
而通过 Java 实现以上逻辑,我们通常需要这样写:
~~~java
api.getUser(new Callback<User>() {
@Override
public void success(User user) {
runOnUiThread(new Runnable() {
@Override
public void run() {
nameTv.setText(user.name);
}
})
}
@Override
public void failure(Exception e) {
...
}
});
~~~
这种回调式的写法,打破了代码的顺序结构和完整性,读起来相当难受。
### 协程的「1 到 0」
对于回调式的写法,如果并发场景再复杂一些,代码的嵌套可能会更多,这样的话维护起来就非常麻烦。但如果你使用了 Kotlin 协程,多层网络请求只需要这么写:
~~~kotlin
coroutineScope.launch(Dispatchers.Main) { // 开始协程:主线程
val token = api.getToken() // 网络请求:IO 线程
val user = api.getUser(token) // 网络请求:IO 线程
nameTv.text = user.name // 更新 UI:主线程
}
~~~
如果遇到的场景是多个网络请求需要等待所有请求结束之后再对 UI 进行更新。比如以下两个请求
~~~kotlin
api.getAvatar(user, callback)
api.getCompanyLogo(user, callback)
~~~
如果使用回调式的写法,那么代码可能写起来既困难又别扭。于是我们可能会选择妥协,通过先后请求代替同时请求:
~~~kotlin
api.getAvatar(user) { avatar ->
api.getCompanyLogo(user) { logo ->
show(merge(avatar, logo))
}
}
~~~
在实际开发中如果这样写,本来能够并行处理的请求被强制通过串行的方式去实现,可能会导致等待时间长了一倍,也就是性能差了一倍。
而如果使用协程,可以直接把两个并行请求写成上下两行,最后再把结果进行合并即可:
~~~kotlin
coroutineScope.launch(Dispatchers.Main) {
// 👇 async 函数之后再讲
val avatar = async { api.getAvatar(user) } // 获取用户头像
val logo = async { api.getCompanyLogo(user) } // 获取用户所在公司的 logo
val merged = suspendingMerge(avatar, logo) // 合并结果
/👆
show(merged) // 更新 UI
}
~~~
可以看到,即便是比较复杂的并行网络请求,也能够通过协程写出结构清晰的代码。需要注意的是`suspendingMerge`并不是协程 API 中提供的方法,而是我们自定义的一个可「挂起」的结果合并方法。至于挂起具体是什么,可以看下一篇文章。
**让复杂的并发代码,写起来变得简单且清晰,是协程的优势**。
这里,两个没有相关性的后台任务,因为用了协程,被安排得明明白白,互相之间配合得很好,也就是我们之前说的「协作式任务」。
本来需要回调,现在直接没有回调了,这种从 1 到 0 的设计思想真的妙哉。
在了解了协程的作用和优势之后,我们再来看看协程是怎么使用的。
## 协程怎么用
### 在项目中配置对 Kotlin 协程的支持
在使用协程之前,我们需要在`build.gradle`文件中增加对 Kotlin 协程的依赖:
* 项目根目录下的`build.gradle`:
~~~groovy
buildscript {
...
// 👇
ext.kotlin_coroutines = '1.3.1'
...
}
~~~
* Module 下的`build.gradle`:
~~~groovy
dependencies {
...
/👇 依赖协程核心库
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlin_coroutines"
//👇 依赖当前平台所对应的平台库
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines"
...
}
~~~
Kotlin 协程是以官方扩展库的形式进行支持的。而且,我们所使用的「核心库」和 「平台库」的版本应该保持一致。
* 核心库中包含的代码主要是协程的公共 API 部分。有了这一层公共代码,才使得协程在各个平台上的接口得到统一。
* 平台库中包含的代码主要是协程框架在具体平台的具体实现方式。因为多线程在各个平台的实现方式是有所差异的。
完成了以上的准备工作就可以开始使用协程了。
### 开始使用协程
协程最简单的使用方法,其实在前面章节就已经看到了。我们可以通过一个`launch`函数实现线程切换的功能:
~~~kotlin
coroutineScope.launch(Dispatchers.IO) {
...
}
~~~
**这个`launch`函数,它具体的含义是:我要创建一个新的协程,并在指定的线程上运行它。这个被创建、被运行的所谓「协程」是谁?就是你传给`launch`的那些代码,这一段连续代码叫做一个「协程」**。
所以,**什么时候用协程?当你需要切线程或者指定线程的时候。你要在后台执行任务?切!**
~~~kotlin
launch(Dispatchers.IO) {
val image = getImage(imageId)
}
~~~
然后需要在前台更新界面?再切!
~~~kotlin
coroutineScope.launch(Dispatchers.IO) {
val image = getImage(imageId)
launch(Dispatchers.Main) {
avatarIv.setImageBitmap(image)
}
}
~~~
好像有点不对劲?这不还是有嵌套嘛。
**如果只是使用`launch`函数,协程并不能比线程做更多的事**。不过协程中却有一个很实用的函数:**`withContext`。这个函数可以切换到指定的线程,并在闭包内的逻辑执行结束之后,自动把线程切回去继续执行**。那么可以将上面的代码写成这样:
~~~kotlin
coroutineScope.launch(Dispatchers.Main) { // 👈 在 UI 线程开始
val image = withContext(Dispatchers.IO) { // 👈 切换到 IO 线程,并在执行完成后切回 UI 线程
getImage(imageId) // 👈 将会运行在 IO 线程
}
avatarIv.setImageBitmap(image) // 👈 回到 UI 线程更新 UI
}
~~~
这种写法看上去好像和刚才那种区别不大,但如果你需要频繁地进行线程切换,这种写法的优势就会体现出来。可以参考下面的对比:
~~~kotlin
// 第一种写法
coroutineScope.launch(Dispatchers.IO) {
...
launch(Dispatchers.Main){
...
launch(Dispatchers.IO) {
...
launch(Dispatchers.Main) {
...
}
}
}
}
// 通过第二种写法来实现相同的逻辑
coroutineScope.launch(Dispatchers.Main) {
...
withContext(Dispatchers.IO) {
...
}
...
withContext(Dispatchers.IO) {
...
}
...
}
~~~
**由于可以"自动切回来",消除了并发代码在协作时的嵌套**。由于消除了嵌套关系,我们甚至可以把`withContext`放进一个单独的函数里面:
~~~kotlin
launch(Dispatchers.Main) { // 👈 在 UI 线程开始
val image = getImage(imageId)
avatarIv.setImageBitmap(image) // 👈 执行结束后,自动切换回 UI 线程
}
// 👇
fun getImage(imageId: Int) = withContext(Dispatchers.IO) {
...
}
~~~
这就是之前说的「**用同步的方式写异步的代码**」了。
不过如果只是这样写,编译器是会报错的:
~~~kotlin
fun getImage(imageId: Int) = withContext(Dispatchers.IO) {
// IDE 报错 Suspend function'withContext' should be called only from a coroutine or another suspend funcion
}
~~~
意思是说,**`withContext`是一个`suspend`函数,它需要在协程或者是另一个`suspend`函数中调用**。
## suspend
`suspend`是 Kotlin 协程最核心的关键字,几乎所有介绍 Kotlin 协程的文章和演讲都会提到它。它的中文意思是「暂停」或者「可挂起」。如果你去看一些技术博客或官方文档的时候,大概可以了解到:**「代码执行到`suspend`函数的时候会『挂起』,并且这个『挂起』是非阻塞式的,它不会阻塞你当前的线程。」**
上面报错的代码,其实只需要在前面加一个`suspend`就能够编译通过:
~~~kotlin
//👇
suspend fun getImage(imageId: Int) = withContext(Dispatchers.IO) {
...
}
~~~
本篇文章到此结束,而`suspend`具体是什么,「非阻塞式」又是怎么回事,函数怎么被挂起,这些疑问的答案,将在下一篇文章全部揭晓。
- 前言
- Kotlin简介
- IntelliJ IDEA技巧总结
- idea设置类注释和方法注释模板
- 像Android Studion一样创建工程
- Gradle
- Gradle入门
- Gradle进阶
- 使用Gradle创建一个Kotlin工程
- 环境搭建
- Androidstudio平台搭建
- Eclipse的Kotlin环境配置
- 使用IntelliJ IDEA
- Kotlin学习路线
- Kotlin官方中文版文档教程
- 概述
- kotlin用于服务器端开发
- kotlin用于Android开发
- kotlin用于JavaScript开发
- kotlin用于原生开发
- Kotlin 用于数据科学
- 协程
- 多平台
- 新特性
- 1.1的新特性
- 1.2的新特性
- 1.3的新特性
- 开始
- 基本语法
- 习惯用法
- 编码规范
- 基础
- 基本类型
- 包与导入
- 控制流
- 返回与跳转
- 类与对象
- 类与继承
- 属性与字段
- 接口
- 可见性修饰符
- 扩展
- 数据类
- 密封类
- 泛型
- 嵌套类
- 枚举类
- 对象
- 类型别名
- 内嵌类
- 委托
- 委托属性
- 函数与Lambda表达式
- 函数
- Lambda表达式
- 内联函数
- 集合
- 集合概述
- 构造集合
- 迭代器
- 区间与数列
- 序列
- 操作概述
- 转换
- 过滤
- 加减操作符
- 分组
- 取集合的一部分
- 取单个元素
- 排序
- 聚合操作
- 集合写操作
- List相关操作
- Set相关操作
- Map相关操作
- 多平台程序设计
- 平台相关声明
- 以Gradle创建
- 更多语言结构
- 解构声明
- 类型检测与转换
- This表达式
- 相等性
- 操作符重载
- 空安全
- 异常
- 注解
- 反射
- 作用域函数
- 类型安全的构造器
- Opt-in Requirements
- 核心库
- 标准库
- kotlin.test
- 参考
- 关键字与操作符
- 语法
- 编码风格约定
- Java互操作
- Kotlin中调用Java
- Java中调用Kotlin
- JavaScript
- 动态类型
- kotlin中调用JavaScript
- JavaScript中调用kotlin
- JavaScript模块
- JavaScript反射
- JavaScript DCE
- 原生
- 并发
- 不可变性
- kotlin库
- 平台库
- 与C语言互操作
- 与Object-C及Swift互操作
- CocoaPods集成
- Gradle插件
- 调试
- FAQ
- 协程
- 协程指南
- 基础
- 取消与超时
- 组合挂起函数
- 协程上下文与调度器
- 异步流
- 通道
- 异常处理与监督
- 共享的可变状态与并发
- Select表达式(实验性)
- 工具
- 编写kotlin代码文档
- 使用Kapt
- 使用Gradle
- 使用Maven
- 使用Ant
- Kotlin与OSGI
- 编译器插件
- 编码规范
- 演进
- kotlin语言演进
- 不同组件的稳定性
- kotlin1.3的兼容性指南
- 常见问题
- FAQ
- 与Java比较
- 与Scala比较(官方已删除)
- Google开发者官网简介
- Kotlin and Android
- Get Started with Kotlin on Android
- Kotlin on Android FAQ
- Android KTX
- Resources to Learn Kotlin
- Kotlin样品
- Kotlin零基础到进阶
- 第一阶段兴趣入门
- kotlin简介和学习方法
- 数据类型和类型系统
- 入门
- 分类
- val和var
- 二进制基础
- 基础
- 基本语法
- 包
- 示例
- 编码规范
- 代码注释
- 异常
- 根类型“Any”
- Any? 可空类型
- 可空性的实现原理
- kotlin.Unit类型
- kotlin.Nothing类型
- 基本数据类型
- 数值类型
- 布尔类型
- 字符型
- 位运算符
- 变量和常量
- 语法和运算符
- 关键字
- 硬关键字
- 软关键字
- 修饰符关键字
- 特殊标识符
- 操作符和特殊符号
- 算术运算符
- 赋值运算符
- 比较运算符
- 逻辑运算符
- this关键字
- super关键字
- 操作符重载
- 一元操作符
- 二元操作符
- 字符串
- 字符串介绍和属性
- 字符串常见方法操作
- 字符串模板
- 数组
- 数组介绍创建及遍历
- 数组常见方法和属性
- 数组变化以及下标越界问题
- 原生数组类型
- 区间
- 正向区间
- 逆向区间
- 步长
- 类型检测与类型转换
- is、!is、as、as-运算符
- 空安全
- 可空类型变量
- 安全调用符
- 非空断言
- Elvis操作符
- 可空性深入
- 可空性和Java
- 函数
- 函数式编程概述
- OOP和FOP
- 函数式编程基本特性
- 组合与范畴
- 在Kotlin中使用函数式编程
- 函数入门
- 函数作用域
- 函数加强
- 命名参数
- 默认参数
- 可变参数
- 表达式函数体
- 顶层、嵌套、中缀函数
- 尾递归函数优化
- 函数重载
- 控制流
- if表达式
- when表达式
- for循环
- while循环
- 循环中的 Break 与 continue
- return返回
- 标签处返回
- 集合
- list集合
- list集合介绍和操作
- list常见方法和属性
- list集合变化和下标越界
- set集合
- set集合介绍和常见操作
- set集合常见方法和属性
- set集合变换和下标越界
- map集合
- map集合介绍和常见操作
- map集合常见方法和属性
- map集合变换
- 集合的函数式API
- map函数
- filter函数
- “ all ”“ any ”“ count ”和“ find ”:对集合应用判断式
- 别样的求和方式:sumBy、sum、fold、reduce
- 根据人的性别进行分组:groupBy
- 扁平化——处理嵌套集合:flatMap、flatten
- 惰性集合操作:序列
- 区间、数组、集合之间转换
- 面向对象
- 面向对象-封装
- 类的创建及属性方法访问
- 类属性和字段
- 构造器
- 嵌套类(内部类)
- 枚举类
- 枚举类遍历&枚举常量常用属性
- 数据类
- 密封类
- 印章类(密封类)
- 面向对象-继承
- 类的继承
- 面向对象-多态
- 抽象类
- 接口
- 接口和抽象类的区别
- 面向对象-深入
- 扩展
- 扩展:为别的类添加方法、属性
- Android中的扩展应用
- 优化Snackbar
- 用扩展函数封装Utils
- 解决烦人的findViewById
- 扩展不是万能的
- 调度方式对扩展函数的影响
- 被滥用的扩展函数
- 委托
- 委托类
- 委托属性
- Kotlin5大内置委托
- Kotlin-Object关键字
- 单例模式
- 匿名类对象
- 伴生对象
- 作用域函数
- let函数
- run函数
- with函数
- apply函数
- also函数
- 标准库函数
- takeIf 与 takeUnless
- 第二阶段重点深入
- Lambda编程
- Lambda成员引用高阶函数
- 高阶函数
- 内联函数
- 泛型
- 泛型的分类
- 泛型约束
- 子类和子类型
- 协变与逆变
- 泛型擦除与实化类型
- 泛型类型参数
- 泛型的背后:类型擦除
- Java为什么无法声明一个泛型数组
- 向后兼容的罪
- 类型擦除的矛盾
- 使用内联函数获取泛型
- 打破泛型不变
- 一个支持协变的List
- 一个支持逆变的Comparator
- 协变和逆变
- 第三阶段难点突破
- 注解和反射
- 声明并应用注解
- DSL
- 协程
- 协程简介
- 协程的基本操作
- 协程取消
- 管道
- 慕课霍丙乾协程笔记
- Kotlin与Java互操作
- 在Kotlin中调用Java
- 在Java中调用Kotlin
- Kotlin与Java中的操作对比
- 第四阶段专题练习
- 朱凯Kotlin知识点总结
- Kotlin 基础
- Kotlin 的变量、函数和类型
- Kotlin 里那些「不是那么写的」
- Kotlin 里那些「更方便的」
- Kotlin 进阶
- Kotlin 的泛型
- Kotlin 的高阶函数、匿名函数和 Lambda 表达式
- Kotlin协程
- 初识
- 进阶
- 深入
- Kotlin 扩展
- 会写「18.dp」只是个入门——Kotlin 的扩展函数和扩展属性(Extension Functions / Properties)
- Kotlin实战-开发Android