kotlin从Java到入门
kotlin从Java到入门
主要是基于大二学生的java水平,一些java中的(或者很相似的)基本都省略了,一些冷门的也是。
现在这里仅仅是我对官网教程的粗摘以及很少的笔记,后续如果继续学习会进一步增补。
谁再说kt像java我一定给他一jio
不过该有的都有
可以在 Kotlin 中调用 Android 或其他 Java 语言库的 API 。Kotlin 与 Java 语言具有互操作性。此设计让 Kotlin 代码可以透明地调用 Java 语言方法;对于 Kotlin 独有的功能,可采用注释轻松向 Java 代码公开。 对于未使用任何 Kotlin 特有语义的 Kotlin 文件,Java 代码可以直接引用,无需添加任何注释。两相结合,就可以同时使用 Java 代码和 Kotlin 代码。
可以在同一个项目中同时使用 Java 文件和 Kotlin 文件。可以根据自己的喜好或多或少采用 Kotlin 语言进行开发。java和kotlin可以同时存在,可以混编开发。
Tips:
我的IDEA有点小问题,我不确定是否只有我会出现这种情况。
我的kt代码的main()
都无法直接运行(新建项目时语言选的是kt),但是可以通过java的main()
去调用kt类中的main()
,于是我就使用这种方法运行kt代码。
后来找到了原因,kt不要把main()
放在某个类里,这种风格更偏向py而非java。
数据类型
一些数据类型
整数类型
类型 | 位宽 | 最小值 | 最大值 |
---|---|---|---|
Byte | 8 | -128 | 127 |
Short | 16 | -32768 | 32767 |
Int | 32 | -2,147,483,648 (-2^31) | 2,147,483,647 (2^31 - 1) |
Long | 64 | -9,223,372,036,854,775,808 (-2^63) | 9,223,372,036,854,775,807 (2^63 - 1) |
1 | val number = 100 //默认是 Int 类型 |
Tips
所有未超出
Int
最大值的整型值初始化的变量都默认为Int
类型,如果初始值超过了其最大值,那么推断为Long
类型。在数字值后面显式添加L
表示一个Long
类型
Float、Double浮点类型
跟java一样,可略过
Kotlin 中提供了 Float
和 Double
两种类型来分别表示单精度和双精度的浮点数类型。
类型 | 位宽 |
---|---|
Float | 32 |
Double | 64 |
1 | val doubleNumber = 3.1415928888 //默认是Double类型 |
Tips
Kotlin 对于小数的默认推断是
Double
类型。如果需要显式将一个小数指定为Float
类型需要在数值尾部加入f
或F
。由于Float
类型十进制位数是6位
,所以上述例子中floatNumber
实际值大小为3.1415926
,后面就会出现进度丢失舍弃。在 Kotlin 中还有一点与 Java 不同的是,Kotlin 中数字不存在隐式的拓宽转换。比如一个函数参数为
Double
的函数只能接收Double
类型,不能接收Float
、Int
或者其他数字类型
布尔类型
跟java基本一样,可略过
在 Kotlin 使用Boolean
表示布尔类型,它只有两个值 true
和 false
。注意可空类型Boolean?
类型会存在装箱操作。
1 | val isVisible: Boolean = false |
字符类型
跟java一样,可略过
在 Kotlin 中字符用 Char
类型表示
1 | fun testChar(char: Char) { |
字符的值需要用单引号括起来: '0'
、'9'
。
1 | fun decimalDigitValue(c: Char): Int { |
字符串类型
这个。。。有点博采众长的感觉,建议看一下
在 Kotlin 中字符串用 String
类型表示。字符串是不可变的。 字符串的元素——字符可以使用索引运算符访问: s[i]
。 可以用 for 循环迭代字符串(这与cpp类似):
1 | val str="1234567890" |
字符串模板
字符串字面值可以包含模板表达式 ,即一些小段代码,会求值并把结果合并到字符串中。 模板表达式以美元符($
)开头,由一个简单的名字构成:
1 | val number = 100 |
或者用花括号${}
括起来的任意表达式:
1 | val text = "This is Text" |
字符串与转义字符串内部都支持模板。 如果你需要在原始字符串中表示字面值 $
字符(它不支持反斜杠转义),你可以用下列语法:
1 | val price = "${'$'}9.99" |
和 Java 一样,Kotlin 可以用 +
操作符连接字符串。这也适用于连接字符串与其他类型的值。
1 | val age = 28 |
字符串的值
Kotlin 有两种类型的字符串字面值:转义字符串可以有转义字符, 以及原始字符串可以包含换行以及任意文本。以下是转义字符串的一个示例:
1 | val s = "Hello, world!\n" // \n换行 |
字符串使用三个引号("""
)分界符括起来,内部没有转义并且可以包含换行以及任何其他字符:
1 | val text = """ |
还可以通过 trimMargin()
函数去除前导空格:
1 | val text = """ |
类型强制转换
在 Kotlin 中与 Java 不同是通过调用 toInt、toDouble、toFloat
之类函数来实现数字类型的强制转换的。
相当于java数据类型的
parse()
类型 | 强转函数 |
---|---|
Byte | toByte() |
Short | toShort() |
Int | toInt() |
Long | toLong() |
Float | toFloat() |
Double | toDouble() |
Char | toChar() |
1 | val number =100 //声明一个整形 number对象 |
数字运算
四则运算
与Java同
位运算
Kotlin 中的位运算和 Java 不同的是没有用特殊符号来表示,可以采用了中缀函数方式调用具名函数。
shl(bits)
– 有符号左移【shl是Shift Logical Left的缩写】shr(bits)
– 有符号右移ushr(bits)
– 无符号右移and(bits)
– 位与or(bits)
– 位或inv()
– 位非xor(bits)
– 位异或
后面这四个可能更常用,以及这里与java还是有区别
1 | val vip = true |
数据容器
数组
初始化时必须指定大小,不能动态调整大小,元素可重复。
创建
创建方式与java相比差别还是比较大
1 | val array : Array<Int> = arrayOf(1,2,3) // 若显示指明为Array则必须加泛型 |
原生类型数组
在Kotlin中也有无装箱开销的专门的类来表示原生类型数组:
毕竟java里的List泛型也必须装箱嘛对吧,这里就是避免装箱
原生类型数组 | 解释 |
---|---|
ByteArray | 字节型数组 |
ShortArray | 短整型数组 |
IntArray | 整型数组 |
LongArray | 长整型数组 |
BooleanArray | 布尔型数组 |
CharArray | 字符型数组 |
FloatArray | 浮点型数组 |
DoubleArray | 双精度浮点型数组 |
1 | // 1.创建并初始化一个IntArray [1, 2, 3, 4, 5] |
Tips
在
Kotlin
数组类型不是集合中的一种,但是它又和集合有着太多相似的地方。数组和集合可以互换
初始化集合的时候可以传入一个数组
基本操作
for循环——元素遍历
1 | for (item in array) { // 元素遍历 |
for循环——下标遍历
1 | for (i in array.indices) { // 根据下标再取出对应位置的元素 |
for循环——遍历元素(带索引)
1 | for ((index, item) in array.withIndex()) { // 同时遍历下标 和 元素 |
forEach遍历数组
1 | array.forEach { |
forEach增强版
1 | array.forEachIndexed { index, item -> |
集合
集合元素数量可变,元素可能可以重复。
- List: 是一个有序集合,可通过索引(反映元素位置的整数)访问元素。元素可以重复。
- 与数组的区别是长度可变
- Set: 是唯一元素的集合。它反映了集合(set)的数学抽象:一组无重复的对象。一般来说 set 中元素的顺序并不重要。例如,字母表是字母的集合(set)。
- Map: (或者字典)是一组键值对。键是唯一的,每个键都刚好映射到一个值,值可以重复(同Java)。
创建方式 | 示例 | 说明 | 是否可变 |
---|---|---|---|
arrayListOf | val array = arrayListOf | 必须指定元素类型 | 可变 |
listOf | val array = listOf | 必须指定元素类型,必须指定初始化数据元素 | 不可变 |
arrayMapOf<K,V>() mutableMapOf<K,V> 相同元素类型的字典 | val array= arrayMapOf(Pair(“key”,“value”)) val array= mutableMapOf() | 初始元素使用Pair包装 | 可变 |
mapOf | val array= mapOf(Pair(“key”,“value”)) | 元素使用Pair包装,必须指定初始元素 | 不可变 |
arraySetOf | val array= arraySetOf | 会对元素自动去重 | 可变 |
setOf | val array= arraySetOf | 对元素自动去重,必须指定元素类型。 | 不可变 |
不难发现,每个不可变集合都有对应的可变集合,也就是以mutable
或者array
为前缀的集合。
增删改查
1 | val stringList = listOf("one", "two", "one") 以list集合为例,set,map同样具备以下能力 |
变换操作
在Kotlin中提供了强大对的集合排序的API,让我们一起来学习一下:
1 | val numbers = mutableListOf(1, 2, 3, 4) |
流程控制
此处差别较大,建议细看
Break 与 Continue 标签
在 Kotlin 中任何表达式都可以用标签来标记。 标签的格式为标识符后跟 @
符号,例如:abc@
、fooBar@
。 要为一个表达式加标签,我们只要在其前加标签即可。
1 | loop@ for (i in 1..100) { |
现在,我们可以用标签限定 break
或者 continue
:
1 | loop@ for (i in 1..100) { |
标签限定的 break
跳转到刚好位于该标签指定的循环后面的执行点。 continue
继续标签指定的循环的下一次迭代。
for-in
遍历
1 | for(i in 1 until 10 step 2){ |
1 | for(i in 10 downTo 1){ |
返回到标签
Kotlin 中函数可以使用函数字面量、局部函数与对象表达式实现嵌套。 标签限定的 return
允许我们从外层函数返回。 最重要的一个用途就是从 lambda 表达式中返回。回想一下我们这么写的时候, 这个 return
表达式从最直接包围它的函数——foo
中返回:
1 | //sampleStart |
注意,这种非局部的返回只支持传给内联函数的 lambda 表达式。 如需从 lambda 表达式中返回,可给它加标签并用以限定 return
。
1 | //sampleStart |
现在,它只会从 lambda 表达式中返回。通常情况下使用隐式标签更方便,因为该标签与接受该 lambda 的函数同名。
1 | //sampleStart |
或者,我们用一个匿名函数替代 lambda 表达式。 匿名函数内部的 return
语句将从该匿名函数自身返回
1 | //sampleStart |
请注意,前文三个示例中使用的局部返回类似于在常规循环中使用 continue
。
并没有 break
的直接等价形式,不过可以通过增加另一层嵌套 lambda 表达式并从其中非局部返回来模拟:
1 | //sampleStart |
当要返一个回值的时候,解析器优先选用标签限定的返回:
1 | return@a 1 |
这意味着“返回 1
到 @a
”,而不是“返回一个标签标注的表达式 (@a 1)
”。
基本语法
1 | fun main(){ |
同时kt代码结尾无须分号。
变量关键字
val
:相当于final
,var
则是可以重复赋值。
如果变量赋了初始化的值,则其变量类型可以省略,没有赋初值则必须标明数据类型。
1 | val a: Int = 1 |
函数
跟java写法风格还是有区别,但是该有的都有
函数体可以是表达式。其返回类型可以推断出来。
1 | fun sum(a: Int, b: Int) = a + b |
无返回的值,相当于java的void:
1 | fun printSum(a: Int, b: Int): Unit { |
Unit
返回类型可以省略。
1 | //sampleStart |
函数参数可以有默认值,当省略相应的参数时使用默认值。这可以减少重载数量:
1 | fun read( |
覆盖方法总是使用与基类型方法相同的默认参数值。 当覆盖一个有默认参数值的方法时,必须从签名中省略默认参数值:
1 | open class A { |
如果一个默认参数在一个无默认值的参数之前,那么该默认值只能通过使用具名参数调用该函数来使用:
1 | fun foo( |
如果在默认参数之后的最后一个参数是 lambda 表达式,那么它既可以作为具名参数在括号内传入,也可以在括号外传入(当然,在括号外传入在风格上感觉怪怪的):
1 | fun foo( |
可变数量的参数(varargs)
也不怎么常用,感觉
函数的参数(通常是最后一个)可以用 vararg
修饰符标记:
1 | fun <T> asList(vararg ts: T): List<T> { |
在本例中,可以将可变数量的参数传递给函数:
1 | val list = asList(1, 2, 3) |
在函数内部,类型 T
的 vararg
参数的可见方式是作为 T
数组,如上例中的 ts
变量具有类型 Array <out T>
。
只有一个参数可以标注为 vararg
。如果 vararg
参数不是列表中的最后一个参数, 可以使用具名参数语法传递其后的参数的值,或者,如果参数具有函数类型,则通过在括号外部传一个 lambda。
当调用 vararg
-函数时,可以逐个传参,例如 asList(1, 2, 3)
。如果已经有一个数组并希望将其内容传给该函数,那么使用伸展(spread)操作符(在数组前面加 *
):
1 | val a = arrayOf(1, 2, 3) |
If you want to pass a primitive type array into vararg
, you need to convert it to a regular (typed) array using the toTypedArray()
function:
如果要将基元类型数组传递给 vararg
,则需要使用 toTypedArray ()
函数将其转换为常规(类型化)数组:
1 | val a = intArrayOf(1, 2, 3) // IntArray is a primitive type array |
中缀表示法
java中没有,而且感觉会破坏代码风格
标有 infix
关键字的函数也可以使用中缀表示法(忽略该调用的点与圆括号)调用。 中缀函数必须满足以下要求:
1 | infix fun Int.shl(x: Int): Int { …… } |
中缀函数调用的优先级低于算术操作符、类型转换以及
rangeTo
操作符。 以下表达式是等价的:
1 shl 2 + 3
等价于1 shl (2 + 3)
0 until n * 2
等价于0 until (n * 2)
xs union ys as Set<*>
等价于xs union (ys as Set<*>)
另一方面,中缀函数调用的优先级高于布尔操作符
&&
与||
、is-
与in-
检测以及其他一些操作符。这些表达式也是等价的:
a && b xor c
等价于a && (b xor c)
a xor b in c
等价于(a xor b) in c
请注意,中缀函数总是要求指定接收者与参数。当使用中缀表示法在当前接收者上调用方法时,需要显式使用 this
。这是确保非模糊解析所必需的。
1 | class MyStringCollection { |
局部函数
Kotlin 支持局部函数,即一个函数在另一个函数内部:
1 | fun dfs(graph: Graph) { |
局部函数可以访问外部函数(闭包)的局部变量。在上例中,visited
可以是局部变量:
1 | fun dfs(graph: Graph) { |
尾递归函数
只是一种简写方式,条件较多,并非不可替代。
高阶函数与 lambda 表达式
Kotlin 函数都是头等的,这意味着它们可以存储在变量与数据结构中,并可以作为参数传给其他高阶函数(高阶函数是将函数用作参数或返回值的函数)以及从其他高阶函数返回。可以像操作任何其他非函数值一样对函数进行操作。
高阶函数样例:
此函数接受一个初始累积值与一个接合函数,并通过将当前累积值与每个集合元素连续接合起来代入累积值来构建返回值:
1 | fun <T, R> Collection<T>.fold( |
在上述代码中,参数 combine
具有函数类型 (R, T) -> R
,因此 fold
接受一个函数作为参数, 该函数接受类型分别为 R
与 T
的两个参数并返回一个 R
类型的值。 在 for
循环内部调用该函数,然后将其返回值赋值给 accumulator
。
为了调用 fold
,需要传给它一个函数类型的实例作为参数, 而在高阶函数调用处,lambda 表达式广泛用于此。
1 | fun main() { |
lambda表达式
Lambda 表达式语法
Lambda 表达式的完整语法形式如下:
1 | val sum: (Int, Int) -> Int = { x: Int, y: Int -> x + y } |
- lambda 表达式总是括在花括号中。
- 完整语法形式的参数声明放在花括号内,并有可选的类型标注。
- 函数体跟在一个
->
之后。 - 如果推断出的该 lambda 的返回类型不是
Unit
,那么该 lambda 主体中的最后一个(或可能是单个)表达式会视为返回值。
如果将所有可选标注都去除,看起来如下:
1 | val sum = { x: Int, y: Int -> x + y } |
传递末尾的 lambda 表达式
按照 Kotlin 惯例,如果函数的最后一个参数是函数,那么作为相应参数传入的 lambda 表达式可以放在圆括号之外(IDEA系列的IDE也都是这么推荐的):
1 | val product = items.fold(1) { acc, e -> acc * e } |
这种语法也称为拖尾 lambda 表达式。
如果该 lambda 表达式是调用时唯一的参数,那么圆括号可以完全省略:
1 | run { println("...") } |
it
:单个参数的隐式名称
一个 lambda 表达式只有一个参数很常见。
If the compiler can parse the signature without any parameters, the parameter does not need to be declared and ->
can be omitted. 该参数会隐式声明为 it
:
1 | ints.filter { it > 0 } // 这个字面值是“(it: Int) -> Boolean”类型的 |
从 lambda 表达式中返回一个值
可以使用限定的返回语法从 lambda 显式返回一个值。 否则,将隐式返回最后一个表达式的值。
因此,以下两个片段是等价的:
1 | ints.filter { |
这一约定连同在圆括号外传递 lambda 表达式一起支持 LINQ-风格 的代码:
1 | strings.filter { it.length == 5 }.sortedBy { it }.map { it.uppercase() } |
下划线用于未使用的变量
如果 lambda 表达式的参数未使用,那么可以用下划线取代其名称:
1 | map.forEach { _, value -> println("$value!") } |
内联函数
java不支持直接声明为内联函数,cpp可能会见得多一些,如不熟可跳过
使用高阶函数会带来一些运行时的效率损失:每一个函数都是一个对象,并且会捕获一个闭包。 闭包那些在函数体内会访问到的变量的作用域。 内存分配(对于函数对象和类)和虚拟调用会引入运行时间开销。
但是在许多情况下通过内联化 lambda 表达式可以消除这类的开销。 下述函数是这种情况的很好的例子。lock()
函数可以很容易地在调用处内联。 考虑下面的情况:
1 | lock(l) { foo() } |
编译器没有为参数创建一个函数对象并生成一个调用。取而代之,编译器可以生成以下代码:
1 | l.lock() |
为了让编译器这么做,需要使用 inline
修饰符标记 lock()
函数:
1 | inline fun <T> lock(lock: Lock, body: () -> T): T { …… } |
inline
修饰符影响函数本身和传给它的 lambda 表达式:所有这些都将内联到调用处。
内联可能导致生成的代码增加。不过如果使用得当(避免内联过大函数), 性能上会有所提升,尤其是在循环中的“超多态(megamorphic)”调用处。
noinline
如果不希望内联所有传给内联函数的 lambda 表达式参数都内联,那么可以用 noinline
修饰符标记不希望内联的函数参数:
1 | inline fun foo(inlined: () -> Unit, noinline notInlined: () -> Unit) { …… } |
可以内联的 lambda 表达式只能在内联函数内部调用或者作为可内联的参数传递。 但是 noinline
的 lambda 表达式可以以任何喜欢的方式操作,包括存储在字段中、或者进行传递。
如果一个内联函数没有可内联的函数参数并且没有具体化的类型参数,编译器会产生一个警告,因为内联这样的函数很可能并无益处(如果你确认需要内联, 那么可以用
@Suppress("NOTHING_TO_INLINE")
注解关掉该警告)。
非局部返回
在 Kotlin 中,只能对具名或匿名函数使用正常的、非限定的 return 来退出。 要退出一个 lambda 表达式,需要使用一个标签。 在 lambda 表达式内部禁止使用裸 return
,因为 lambda 表达式不能使包含它的函数返回:
1 | fun ordinaryFunction(block: () -> Unit) { |
但是如果 lambda 表达式传给的函数是内联的,该 return 也可以内联。因此可以这样:
1 | inline fun inlined(block: () -> Unit) { |
这种返回(位于 lambda 表达式中,但退出包含它的函数)称为非局部返回。 通常会在循环中用到这种结构,其内联函数通常包含:
1 | fun hasZeros(ints: List<Int>): Boolean { |
请注意,一些内联函数可能调用传给它们的不是直接来自函数体、而是来自另一个执行上下文的 lambda 表达式参数,例如来自局部对象或嵌套函数。在这种情况下,该 lambda 表达式中也不允许非局部控制流。若要指示内联函数的 lambda 参数不能使用非本地返回值,请用修饰符 crossinline
标记 lambda 参数:
1 | inline fun f(crossinline body: () -> Unit) { |
break
和continue
在内联的 lambda 表达式中还不可用,但我们也计划支持它们。比如这样是非法的:
1
2
3
4
5
6
7 fun foo() {
listOf(1, 2, 3, 4, 5).forEach {
if (it == 3) continue
print(it)
}
print(" done with explicit label")
}这看起来确实让人不那么舒服,原因和解决方案我们在流程控制中讲过,比如可以这样
1
2
3
4
5
6
7 fun foo() {
listOf(1, 2, 3, 4, 5).forEach lit@{
if (it == 3) return@lit // 局部返回到该 lambda 表达式的调用者——forEach 循环
print(it)
}
print(" done with explicit label")
}
具体化的类型参数
有时候需要访问一个作为参数传递的一个类型:
1 | fun <T> TreeNode.findParentOfType(clazz: Class<T>): T? { |
在这里向上遍历一棵树并且检测每个节点是不是特定的类型。 这都没有问题,但是调用处不是很优雅:
1 | treeNode.findParentOfType(MyTreeNode::class.java) |
更好的解决方案是只要传一个类型给该函数,可以按以下方式调用它:
1 | treeNode.findParentOfType<MyTreeNode>() |
为能够这么做,内联函数支持具体化的类型参数,于是可以这样写:
1 | inline fun <reified T> TreeNode.findParentOfType(): T? { |
上述代码使用 reified
修饰符来限定类型参数使其可以在函数内部访问它, 几乎就像是一个普通的类一样。由于函数是内联的,不需要反射,正常的操作符如 !is
和 as
现在均可用。此外,还可以按照如上所示的方式调用该函数:myTree.findParentOfType<MyTreeNodeType>()
。
虽然在许多情况下可能不需要反射,但仍然可以对一个具体化的类型参数使用它:
1 | inline fun <reified T> membersOf() = T::class.members |
普通的函数(未标记为内联函数的)不能有具体化参数。 不具有运行时表示的类型(例如非具体化的类型参数或者类似于 Nothing
的虚构类型)不能用作具体化的类型参数的实参。
内联属性
inline
修饰符可用于没有幕后字段的属性的访问器。 你可以标注独立的属性访问器:
1 | val foo: Foo |
你也可以标注整个属性,将它的两个访问器都标记为内联(inline
):
1 | inline var bar: Bar |
在调用处,内联访问器如同内联函数一样内联。
公有 API 内联函数的限制
当一个内联函数是 public
或 protected
而不是 private
或 internal
声明的一部分时, 就会认为它是一个模块级的公有 API。可以在其他模块中调用它,并且也可以在调用处内联这样的调用。
这带来了一些由模块做这样变更时导致的二进制兼容的风险—— 声明一个内联函数但调用它的模块在它修改后并没有重新编译。
为了消除这种由非公有 API 变更引入的不兼容的风险,公有 API 内联函数体内不允许使用非公有声明,即,不允许使用 private
与 internal
声明以及其部件。
一个 internal
声明可以由 @PublishedApi
标注,这会允许它在公有 API 内联函数中使用。 当一个 internal
内联函数标记有 @PublishedApi
时,也会像公有函数一样检测其函数体。
操作符重载
https://book.kotlincn.net/text/operator-overloading.html
类与对象
可见性修饰符
类、对象、接口、构造函数、方法与属性及其 setter 都可以有可见性修饰符。 getter 总是与属性有着相同的可见性。
在 Kotlin 中有这四个可见性修饰符:private
、 protected
、 internal
和 public
。 默认可见性是 public
。
private
意味着只该成员在这个类内部(包含其所有成员)可见;protected
意味着该成员具有与private
一样的可见性,但也在子类中可见。internal
意味着能见到类声明的本模块内的任何客户端都可见其internal
成员。- 一个模块是编译在一起的一套 Kotlin 文件
- 个人感觉在小项目内跟public没什么区别
public
意味着能见到类声明的任何客户端都可见其public
成员。
在 Kotlin 中,外部类不能访问内部类的 private 成员。
如果你覆盖一个 protected
或 internal
成员并且没有显式指定其可见性,该成员还会具有与原始成员相同的可见性。
类
构造函数
跟Java区别还是不小
而且kt进行类的实例化不需要new
在 Kotlin 中的一个类可以有一个主构造函数以及一个或多个次构造函数。主构造函数是类头的一部分:它跟在类名与可选的类型参数后。
1 | class Person constructor(firstName: String) { /*……*/ } |
如果主构造函数没有任何注解或者可见性修饰符,可以省略这个 constructor
关键字。
1 | class Person(firstName: String) { /*……*/ } |
主构造函数不能包含任何的代码。初始化的代码可以放到以 init
关键字作为前缀的初始化块(initializer blocks)中。
在实例初始化期间,初始化块按照它们出现在类体中的顺序执行,与属性初始化器交织在一起:
1 | //sampleStart |
输出:
1 | First property: hello |
次构造函数
类也可以声明前缀有 constructor
的次构造函数:
1 | class Person(val pets: MutableList<Pet> = mutableListOf()) |
如果类有一个主构造函数,每个次构造函数需要委托给主构造函数, 可以直接委托或者通过别的次构造函数间接委托。委托到同一个类的另一个构造函数用 this
关键字即可:
1 | class Person(val name: String) { |
请注意,初始化块中的代码实际上会成为主构造函数的一部分。委托给主构造函数会作为次构造函数的第一条语句,因此所有初始化块与属性初始化器中的代码都会在次构造函数体之前执行。
即使该类没有主构造函数,这种委托仍会隐式发生,并且仍会执行初始化块:
1 | //sampleStart |
如果一个非抽象类没有声明任何(主或次)构造函数,它会有一个生成的不带参数的主构造函数。构造函数的可见性是 public。
如果你不希望你的类有一个公有构造函数,那么声明一个带有非默认可见性的空的主构造函数:
1 | class DontCreateMe private constructor () { /*……*/ } |
在 JVM 上,如果主构造函数的所有的参数都有默认值,编译器会生成一个额外的无参构造函数,它将使用默认值。这使得 Kotlin 更易于使用像 Jackson 或者 JPA 这样的通过无参构造函数创建类的实例的库。
1 class Customer(val customerName: String = "")
继承
在 Kotlin 中所有类都有一个共同的超类 Any
,对于没有超类型声明的类它是默认超类:
1 | class Example // 从 Any 隐式继承 |
Any
有三个方法:equals()
、 hashCode()
与 toString()
。因此,为所有 Kotlin 类都定义了这些方法。
默认情况下,Kotlin 类是最终(final)的——它们不能被继承。 要使一个类可继承,请用 open
关键字标记它:
1 | open class Base // 该类开放继承 |
如需声明一个显式的超类型,请在类头中把超类型放到冒号之后:
1 | open class Base(p: Int) |
如果派生类有一个主构造函数,其基类可以(并且必须)将其参数在该主构造函数中初始化。
如果派生类没有主构造函数,那么每个次构造函数必须使用super
关键字初始化其基类型,或委托给另一个做到这点的构造函数。 请注意,在这种情况下,不同的次构造函数可以调用基类型的不同的构造函数:
1 | class MyView : View { |
覆盖方法
Kotlin 对于可覆盖的成员以及覆盖后的成员需要显式修饰符(关键字):
1 | open class Shape { |
Circle.draw()
函数上必须加上 override
修饰符。如果没写,编译器会报错。 如果函数没有标注 open
如 Shape.fill()
,那么子类中不允许定义相同签名的函数, 无论加不加 override
。将 open
修饰符添加到 final 类(即没有 open
的类) 的成员上不起作用。
标记为 override
的成员本身是开放的,因此可以在子类中覆盖。如果你想禁止再次覆盖, 使用 final
关键字:
1 | open class Rectangle() : Shape() { |
覆盖属性
属性与方法的覆盖机制相同。在超类中声明然后在派生类中重新声明的属性必须以 override
开头,并且它们必须具有兼容的类型。 每个声明的属性可以由具有初始化器的属性或者具有 get
方法的属性覆盖:
1 | open class Shape { |
你也可以用一个 var
属性覆盖一个 val
属性,但反之则不行。 这是允许的,因为一个 val
属性本质上声明了一个 get
方法, 而将其覆盖为 var
只是在子类中额外声明一个 set
方法。
请注意,你可以在主构造函数中使用 override
关键字作为属性声明的一部分:
1 | interface Shape { |
派生类初始化顺序
在构造派生类的新实例的过程中,第一步完成其基类的初始化 (在之前只有对基类构造函数参数的求值),这意味着它发生在派生类的初始化逻辑运行之前。
1 | //sampleStart |
运行结果:
1 | Constructing the derived class("hello", "world") |
这意味着,基类构造函数执行时,派生类中声明或覆盖的属性都还没有初始化。在基类初始化逻辑中(直接或者通过另一个覆盖的 open
成员的实现间接)使用任何一个这种派生类属性,都可能导致不正确的行为或运行时故障。 设计一个基类时,应该避免在构造函数、属性初始化器或者 init
块中使用 open
成员。
调用超类实现
派生类中的代码可以使用 super
关键字调用其超类的函数与属性访问器的实现,这与java一样:
1 | open class Rectangle { |
在一个内部类中访问外部类的超类,可以使用由外部类名限定的 super
关键字来实现:super@Outer
:
1 | open class Rectangle { |
覆盖规则
在 Kotlin 中,实现继承由下述规则规定:如果一个类从它的直接超类继承相同成员的多个实现, 它必须覆盖这个成员并提供其自己的实现(也许用继承来的其中之一)。
如需表示采用从哪个超类型继承的实现,请使用由尖括号中超类型名限定的 super
,如 super<Base>
:
1 | open class Rectangle { |
可以同时继承 Rectangle
与 Polygon
, 但是二者都有各自的 draw()
实现,所以必须在 Square
中覆盖 draw()
, 并为其提供一个单独的实现以消除歧义。
属性
属性的简单声明跟声明一个变量并没有太大区别。
Getter 与 Setter
声明一个属性的完整语法如下:
1 | var <propertyName>[: <PropertyType>] [= <property_initializer>] |
其初始器(initializer)、getter 和 setter 都是可选的。属性类型如果可以从初始器, 或其 getter 的返回值(如下文所示)中推断出来,也可以省略:
1 | var initialized = 1 // 类型 Int、默认 getter 和 setter |
一个只读属性的语法和一个可变的属性的语法有两方面的不同: 1、只读属性的用 val
而不是 var
声明 2、只读属性不允许 setter。
可以为属性定义自定义的访问器。如果定义了一个自定义的 getter,那么每次访问该属性时都会调用它 (这让可以实现计算出的属性)。以下是一个自定义 getter 的示例:
1 | //sampleStart |
如果可以从 getter 推断出属性类型,则可以省略它:
1 | val area get() = this.width * this.height |
如果定义了一个自定义的 setter,那么每次给属性赋值时都会调用它, except its initialization. 一个自定义的 setter 如下所示:
1 | var stringRepresentation: String |
按照惯例,setter 参数的名称是 value
,但是如果你喜欢你可以选择一个不同的名称。
下面这个我刚开始也有点懵,确实在语法上与java区别比较大
如果你需要改变对一个访问器进行注解或者改变其可见性,但是不需要改变默认的实现, 你可以定义访问器而不定义其实现:
1 | var setterVisibility: String = "abc" |
幕后字段
这个很有意思
在 Kotlin 中,字段仅作为属性的一部分在内存中保存其值时使用。字段不能直接声明。 然而,当一个属性需要一个幕后字段时,Kotlin 会自动提供。这个幕后字段可以使用 field
标识符在访问器中引用:
1 | var counter = 0 // 这个初始器直接为幕后字段赋值 |
field
标识符只能用在属性的访问器内。
如果属性至少一个访问器使用默认实现, 或者自定义访问器通过 field
引用幕后字段,将会为该属性生成一个幕后字段。
例如,定义如下类:
1 | class Person { |
反编译看下 Java 代码:
1 | public final class Person { |
生成的 getName 和 setName 中的内容与之前定义的一致,但好像哪里不太对。。属性 name 去哪了?难道是因为声明的时候自定义了 getter 和 setter,但没有加初始值,所以给省略了么?话不多说,加上试试:
1 | class Person { |
很不幸,编译器提示有错误,看看报错信息:
1 | Initializer is not allowed here because this property has no backing field |
也就是说,没有幕后字段时不允许进行初始化
这个时候再反编译一下:
1 | public final class Person { |
诶。。。看到属性 name 了,但还缺少声明它的地方,结合之前的报错信息,那应该就是和这个 backing field
有点关系了。
backing field
即幕后字段,结合 Kotlin 文档来看,当 getter 和 setter 有一个为默认实现,或者在 getter 和 setter 中通过 filed
标识符引用幕后字段时,才会自动生成幕后字段,怎么理解呢?再看下上边的例子,如果我们改为:
1 | class Person { |
或是
1 | class Person { |
可以发现都不会再报错,反编译后,可以看到也有变量 name 生成:
1 | public final class Person { |
看到这里其实就很清楚了,幕后字段在有默认访问器的情况下,需要生成访问器,访问器里必定需要使用字段,而当自定义访问器里需要使用字段值时,也必须有该字段,否则就会存在在 getter 里调用 getter 这种递归调用的情况了。
参考https://wavever.github.io/2020/03/25/%E7%90%86%E8%A7%A3-Koltin-%E4%B8%AD%E7%9A%84%E5%B9%95%E5%90%8E%E5%AD%97%E6%AE%B5%E5%92%8C%E5%B9%95%E5%90%8E%E5%B1%9E%E6%80%A7/
延迟初始化属性与变量
一般地,属性声明为非空类型必须在构造函数中初始化。 然而,这经常不方便。例如:属性可以通过依赖注入来初始化, 或者在单元测试的 setup 方法中初始化。 这种情况下,你不能在构造函数内提供一个非空初始器。 但你仍然想在类体中引用该属性时避免空检测。
为处理这种情况,你可以用 lateinit
修饰符标记该属性:
1 | public class MyTest { |
该修饰符只能用于在类体中的属性(不是在主构造函数中声明的 var
属性, 并且仅当该属性没有自定义 getter 或 setter 时),也用于顶层属性与局部变量。 该属性或变量必须为非空类型,并且不能是原生类型。
在初始化前访问一个 lateinit
属性会抛出一个特定异常,该异常明确标识该属性被访问及它没有初始化的事实。
检测一个 lateinit var
是否已初始化
要检测一个 lateinit var
是否已经初始化过,请在该属性的引用上使用 .isInitialized
:
1 | if (foo::bar.isInitialized) { |
此检测仅对可词法级访问的属性可用,当声明位于同一个类型内、位于其中一个外围类型中或者位于相同文件的顶层的属性时。
接口
接口的逻辑与java基本相同。可以既包含抽象方法的声明也包含实现。与抽象类不同的是,接口无法保存状态。它可以有属性但必须声明为抽象或提供访问器实现,在接口中声明的属性不能有幕后字段(backing field),因此接口中声明的访问器不能引用它们。同样使用关键字 interface
来定义接口
1 | interface MyInterface { |
接口继承
类似java,一个接口可以从其他接口派生,意味着既能提供基类型成员的实现也能声明新的函数与属性。很自然地,实现这样接口的类只需定义所缺少的实现。
1 | interface Named { |
覆盖冲突
实现多个接口时,可能会遇到实现同名方法的问题(跟继承那一部分讲的覆盖规则是一样的):
1 | interface A { |
上例中,接口 A 和 B 都定义了方法 foo()
和 bar()
。 两者都实现了 foo()
, 但是只有 B 实现了 bar()
(bar()
在 A 中标记为抽象, 因为在接口中没有方法体时默认为抽象)。 现在,如果实现 A 的一个具体类 C,那么必须要重写 bar()
并实现这个抽象方法。
然而,如果从 A 和 B 派生 D,需要实现从多个接口继承的所有方法,并指明 D 应该如何实现它们。这一规则既适用于继承单个实现(bar()
)的方法也适用于继承多个实现(foo()
)的方法。
函数式(SAM)接口与转换
只有一个抽象方法的接口称为函数式接口或 单一抽象方法(SAM)接口。函数式接口可以有多个非抽象成员,但只能有一个抽象成员。
可以用 fun
修饰符在 Kotlin 中声明一个函数式接口。
1 | fun interface KRunnable { |
SAM 转换
对于函数式接口,可以通过 lambda 表达式实现 SAM 转换,从而使代码更简洁、更有可读性。
使用 lambda 表达式可以替代手动创建实现函数式接口的类。 通过 SAM 转换, Kotlin can convert any lambda expression whose signature matches the signature of the interface’s single method into the code, which dynamically instantiates the interface implementation.
例如,有这样一个 Kotlin 函数式接口:
1 | fun interface IntPredicate { |
如果不使用 SAM 转换,那么你需要像这样编写代码:
1 | // 创建一个类的实例 |
通过利用 Kotlin 的 SAM 转换,可以改为以下等效代码:
1 | // 通过 lambda 表达式创建一个实例 |
扩展
Kotlin 能够扩展一个类的新功能而无需继承该类。 例如,你可以为一个你不能修改的来自第三方库中的类编写一个新的函数。 这个新增的函数就像那个原始类本来就有的函数一样,可以用普通的方法调用。 这种机制称为 扩展函数 。此外,也有 扩展属性 , 允许你为一个已经存在的类添加新的属性。
扩展方法
Kotlin的扩展函数允许存在类成员进行调用的函数定义在这个类的外部。这样可以很方便的扩展一个已经存在的类,为它添加额外的方法。在Kotlin源码中,有大量的扩展函数来扩展Java,这样使得Kotlin比Java更方便使用,效率更高。
1 | class Jump { |
详细可以参阅https://doc.devio.org/as/book/docs/Part1/Android%E5%BC%80%E5%8F%91%E5%BF%85%E5%A4%87Kotlin%E6%A0%B8%E5%BF%83%E6%8A%80%E6%9C%AF/KotlinExtensions.html
数据类
数据类就是一些只保存数据的类,类似于DTO,但是是用来保存数据的。用关键字data
在类前标记。
1 | data class User(val name: String, val age: Int) |
编译器自动从主构造函数中声明的所有属性导出以下成员:
equals()
/hashCode()
toString()
格式是"User(name=John, age=42)"
componentN()
函数 按声明顺序对应于所有属性。copy()
函数
为了确保生成的代码的一致性以及有意义的行为,数据类必须满足以下要求:
- 主构造函数需要至少有一个参数。
- 主构造函数的所有参数需要标记为
val
或var
。 - 数据类不能是抽象、开放(open)、密封(sealed,后面讲)或者内部(指不能是内部类,但可以是internal )的。
此外,数据类成员的生成遵循关于成员继承的这些规则:
- 如果在数据类体中有显式实现
equals()
、hashCode()
或者toString()
,或者这些函数在父类中有final
实现,那么不会生成这些函数,而会使用现有函数。 - 如果超类型具有
open
的componentN()
函数并且返回兼容的类型, 那么会为数据类生成相应的函数,并覆盖超类的实现。如果超类型的这些函数由于签名不兼容或者是 final 而导致无法覆盖,那么会报错。 - 不允许为
componentN()
以及copy()
函数提供显式实现。
数据类可以扩展其他类(示例请参见密封类)。
在 JVM 中,如果生成的类需要含有一个无参的构造函数,那么属性必须指定默认值。
在类体中声明的属性
对于那些自动生成的函数,编译器只使用在主构造函数内部定义的属性。 如需在生成的实现中排除一个属性,请将其声明在类体中:
1 | data class Person(val name: String) { |
在 toString()
、 equals()
、 hashCode()
以及 copy()
的实现中只会用到 name
属性, 并且只有一个 component 函数 component1()
。虽然两个 Person
对象可以有不同的年龄, 但它们会视为相等。
1 | data class Person(val name: String) { |
密封类
原文英文,我自己翻译了一下
密封类和接口表示受限制的类层次结构,它们提供对继承的更多控制。密封类的所有直接子类在编译时都是已知的。在编译具有密封类的模块之后,不能出现其他子类。例如,第三方客户端不能在其代码中扩展您的密封类。因此,密封类的每个实例都有一个来自有限集合的类型,类型在这个类在编译时是已知的(Thus, each instance of a sealed class has a type from a limited set that is known when this class is compiled)。
密封接口及其实现也是如此,一旦编译了实现了密封接口的模块,就不会出现新的实现(The same works for sealed interfaces and their implementations: once a module with a sealed interface is compiled, no new implementations can appear)。
在某种意义上,密封类类似于枚举类: 枚举类型的值集也受到限制,但是每个枚举常量只作为一个实例存在,而一个密封类的子类可以有多个实例,每个实例都有自己的状态。
例如,考虑一个库的 API。它可能包含错误类,让库用户处理它可能抛出的错误。如果此类错误类的层次结构包括在公共 API 中可见的接口或抽象类,那么没有什么可以阻止在客户端代码中实现或扩展它们。然而,库不知道在它之外声明的错误,所以它不能用自己的类一致地处理这些错误。使用密封的错误类层次结构,库作者可以确保他们知道所有可能的错误类型,以后不会出现其他错误类型。
若要声明一个密封的类或接口,请将修饰符sealed
放在其名称之前:
1 | sealed interface Error |
一个密封类是自身抽象的,它不能直接实例化并可以有抽象(abstract
)成员。
Constructors of sealed classes can have one of two visibilities: protected
(by default) or private
:
1 | sealed class IOError { |
Sealed classes and when expression
使用密封类的关键好处在于使用 when
表达式的时候。 如果能够验证语句覆盖了所有情况,就不需要为该语句再添加一个 else
子句了。 当然,这只有当你用 when
作为表达式(使用结果)而不是作为语句时才有用。
1 | fun log(e: Error) = when(e) { |
型变
阅读这一部分前建议先深入了解了解java的泛型机制,虽然直接去学kt也并没有太大难度但是如果你想进行两者的对比从而从中收获些什么的话,还是建议多少学一下。
所以java的泛型问题在哪呢?类型擦除
Java 类型系统中最棘手的部分之一是通配符类型, 而 Kotlin 中没有。 相反,Kotlin 有声明处型变(declaration-site variance)与类型投影(type projections)。
我们来思考下为什么 Java 需要这些神秘的通配符。在 《Effective Java》第三版 很好地解释了该问题—— 第 31 条:利用有限制通配符来提升 API 的灵活性。 首先,Java 中的泛型是不型变的,这意味着 List<String>
并不是 List<Object>
的子类型。 如果 List
不是不型变的,它就没比 Java 的数组好到哪去,因为如下代码会通过编译但是导致运行时异常:
1 | // Java |
Java 禁止这样的事情以保证运行时的安全。但这样会有一些影响。例如, 考虑 Collection
接口中的 addAll()
方法。该方法的签名应该是什么?直觉上, 需要这样写:
1 | // Java |
但随后,就无法做到以下这样(完全安全的)的事:
1 | // Java |
这就是为什么 addAll()
的实际签名是以下这样:
1 | // Java |
通配符类型参数 ? extends E
表示此方法接受 E
或者 E
的一个子类型对象的Collection
集合,而不只是 E
自身。 这意味着我们可以安全地从其中 (该集合中的元素是 E 的子类的实例)读取 E
,但不能写入, 因为我们不知道什么对象符合那个未知的 E
的子类型。 反过来,该限制可以得到想要的行为:Collection<String>
表示为 Collection<? extends Object>
的子类型。 简而言之,带 extends
限定的通配符类型使得类型是协变的(covariant
)。
理解为什么这能够工作的关键相当简单:如果只能从集合中获取元素, 那么使用 String
的集合, 并且从其中读取 Object
也没问题 。反过来,如果只能向集合中 放入 元素 , 就可以用 Object
集合并向其中放入 String
:在 Java 中有 List<? super String>
是 List<Object>
的一个超类。
后者称为逆变性(contravariance
),并且对于 List <? super String>
你只能调用接受 String
作为参数的方法 (例如,你可以调用 add(String)
或者 set(int, String)
),如果调用函数返回 List<T>
中的 T
, 你得到的并非一个 String
而是一个 Object
。
Joshua Bloch 称那些你只能从中读取的对象为生产者,并称那些只能向其写入的对象为消费者。他建议:
“为了灵活性最大化,在表示生产者或消费者的输入参数上使用通配符类型”, 并提出了以下助记符:
PECS 代表生产者-Extends、消费者-Super(Producer-Extends, Consumer-Super)。
如果你使用一个生产者对象,如
List<? extends Foo>
,在该对象上不允许调用add()
或set()
, 但这并不意味着它是不可变的:例如,没有什么阻止你调用clear()
从列表中删除所有元素,因为clear()
根本无需任何参数。通配符(或其他类型的型变)保证的唯一的事情是类型安全。不可变性完全是另一回事。
声明处型变
假设有一个泛型接口 Source<T>
,该接口中不存在任何以 T
作为参数的方法,只是方法返回 T
类型值:
1 | // Java |
那么,在 Source <Object>
类型的变量中存储 Source <String>
实例的引用是极为安全的—— 没有消费者-方法可以调用。但是 Java 并不知道这一点,并且仍然禁止这样操作:
1 | // Java |
为了修正这一点,我们必须声明对象的类型为 Source<? extends Object>
。这么做毫无意义, 因为我们可以像以前一样在该对象上调用所有相同的方法,所以更复杂的类型并没有带来价值。 但编译器并不知道。
在 Kotlin 中,有一种方法向编译器解释这种情况。这称为声明处型变: 可以标注 Source
的类型参数T
来确保它仅从 Source<T>
成员中返回(生产),并从不被消费。 为此请使用 out
修饰符:
1 | interface Source<out T> { |
一般原则是:当一个类 C
的类型参数 T
被声明为 out
时,它就只能出现在 C
的成员的输出位置, 但回报是 C<Base>
可以安全地作为 C<Derived>
的超类。
简而言之,可以说类 C
是在参数 T
上是协变的,或者说 T
是一个协变的类型参数。 可以认为 C
是 T
的生产者,而不是 T
的消费者。
out
修饰符称为型变注解,并且由于它在类型参数声明处提供, 所以它提供了声明处型变。 这与 Java 的使用处型变相反,其类型用途通配符使得类型协变。
另外除了 out
,Kotlin 又补充了一个型变注解:in
。它使得一个类型参数逆变,即只可以消费而不可以生产。逆变类型的一个很好的例子是 Comparable
:
1 | interface Comparable<in T> { |
类型投影
使用处型变:类型投影
将类型参数 T
声明为 out
非常简单,并且能避免使用处子类型化的麻烦, 但是有些类实际上不能限制为只返回 T
! 一个很好的例子是 Array
:
1 | class Array<T>(val size: Int) { |
该类在 T
上既不能是协变的也不能是逆变的。这造成了一些不灵活性。考虑下述函数:
1 | fun copy(from: Array<Any>, to: Array<Any>) { |
这个函数应该将项目从一个数组复制到另一个数组。让我们尝试在实践中应用它:
1 | val ints: Array<Int> = arrayOf(1, 2, 3) |
这里我们遇到同样熟悉的问题:Array <T>
在 T
上是不型变的,因此 Array <Int>
与 Array <Any>
都不是另一个的子类型。为什么? 再次重复,因为 copy
可能有非预期行为,例如它可能尝试写一个 String
到 from
, 并且如果我们实际上传递一个 Int
的数组,以后会抛 ClassCastException
异常。
1 | To prohibit the `copy` function from writing to `from`, you can do the following: |
这就是类型投影:意味着 from
不仅仅是一个数组,而是一个受限制的(投影的)数组。 只可以调用返回类型为类型参数 T
的方法,如上,这意味着只能调用 get()
。 这就是使用处型变的用法,并且是对应于 Java 的 Array<? extends Object>
、 但更简单。
你也可以使用 in
投影一个类型:
1 | fun fill(dest: Array<in String>, value: String) { …… } |
Array<in String>
对应于 Java 的 Array<? super String>
,也就是说,你可以传递一个 CharSequence
数组或一个 Object
数组给 fill()
函数。
星投影
这个确实没怎么看懂
有时你想说,你对类型参数一无所知,但仍然希望以安全的方式使用它。 这里的安全方式是定义泛型类型的这种投影,该泛型类型的每个具体实例化都会是该投影的子类型。
Kotlin 为此提供了所谓的星投影语法:
- 对于
Foo <out T : TUpper>
,其中T
是一个具有上界TUpper
的协变类型参数,Foo <*>
等价于Foo <out TUpper>
。 意味着当T
未知时,你可以安全地从Foo <*>
读取TUpper
的值。 - 对于
Foo <in T>
,其中T
是一个逆变类型参数,Foo <*>
等价于Foo <in Nothing>
。 意味着当T
未知时, 没有什么可以以安全的方式写入Foo <*>
。 - 对于
Foo <T : TUpper>
,其中T
是一个具有上界TUpper
的不型变类型参数,Foo<*>
对于读取值时等价于Foo<out TUpper>
而对于写值时等价于Foo<in Nothing>
。
如果泛型类型具有多个类型参数,则每个类型参数都可以单独投影。 例如,如果类型被声明为 interface Function <in T, out U>
,可以使用以下星投影:
Function<*, String>
表示Function<in Nothing, String>
。Function<Int, *>
表示Function<Int, out Any?>
。Function<*, *>
表示Function<in Nothing, out Any?>
。
星投影非常像 Java 的原始类型,但是安全。
泛型函数
基本与java一样,可略过
不仅类可以有类型参数。函数也可以有。类型参数要放在函数名称之前:
1 | fun <T> singletonList(item: T): List<T> { |
要调用泛型函数,在调用处函数名之后指定类型参数即可:
1 | val l = singletonList<Int>(1) |
可以省略能够从上下文中推断出来的类型参数,所以以下示例同样适用:
1 | val l = singletonList(1) |
泛型约束
基本与java逻辑相同
嵌套类与内部类
用到的不多而且逻辑跟java很像,很容易理解,也可以略过
类可以嵌套在其他类中:
1 | class Outer { |
还可以使用带有嵌套的接口。所有类与接口的组合都是可能的:可以将接口嵌套在类中、将类嵌套在接口中、将接口嵌套在接口中。
1 | interface OuterInterface { |
内部类
标记为 inner
的嵌套类能够访问其外部类的成员。内部类会带有一个对外部类的对象的引用:
1 | class Outer { |
枚举类
枚举类的最基本的应用场景是实现类型安全的枚举:
1 | enum class Direction { |
每个枚举常量都是一个对象。枚举常量以逗号分隔。
java中枚举类就是一组常量
每一个枚举都是枚举类的实例,可以这样初始化:
1 | enum class Color(val rgb: Int) { |
匿名类
枚举常量可以声明其带有相应方法以及覆盖了基类方法的自身匿名类 。
1 | enum class ProtocolState { |
如果枚举类定义任何成员,那么使用分号将成员定义与常量定义分隔开。
在枚举类中实现接口
一个枚举类可以实现接口(但不能从类继承),可以为所有条目提供统一的接口成员实现,也可以在相应匿名类中为每个条目提供各自的实现。 只需将想要实现的接口添加到枚举类声明中即可,如下所示:
1 | import java.util.function.BinaryOperator |
使用枚举常量
Kotlin 中的枚举类也有合成方法用于列出定义的枚举常量以及通过名称获取枚举常量。这些方法的签名如下(假设枚举类的名称是 EnumClass
):
1 | EnumClass.valueOf(value: String): EnumClass |
如果指定的名称与类中定义的任何枚举常量均不匹配, valueOf()
方法会抛出 IllegalArgumentException
异常。
可以使用 enumValues<T>()
与 enumValueOf<T>()
函数以泛型的方式访问枚举类中的常量:
1 | enum class RGB { RED, GREEN, BLUE } |
每个枚举常量都具有在枚举类声明中获取其名称与位置的属性:
1 | val name: String |
内联类
比如我有这样的类或者数据类:
1 | class User1 { |
这种数据包装类效率很低,而且占内存。因为这个类实际上只包装了一个String
的数据,但是因为他是一个单独声明的类,所以如果实例化的话还需要单独给这个类创建一个实例,放在jvm
的heap
内存里。
如果有一种办法,既可以让这个数据类保持它单独的类型,又不那么占空间,那么可以考虑内联类。
1 | value class User(private val username: String) |
一些使用规定:
如果编译目标包括
JVM
,还需要添加@JvmInline
注解,否则无法通过编译只能提供主构造器。
和 data class 类似,需要定义构造器参数
可以在主构造器中定义有且仅有一个只读属性 (
var
不行,只能val
)内联类不能继承类或被继承,但是可以继承接口
内联类可以实现接口
内联类支持普通类中的一些功能。特别是,内联类可以声明属性与函数以及init
块
1 |
|
内联类属性不能有幕后字段。它们只能有简单的可计算属性(没有 lateinit
/委托属性)。
继承
内联类允许去继承接口
1 | interface Printable { |
内联类不能继承其他的类而且必须是 final
(也不能被继承)。
表示方式
在生成的代码中,Kotlin 编译器为每个内联类保留一个包装器。内联类的实例可以在运行时表示为包装器或者基础类型。这就类似于 Int
可以表示为原生类型 int
或者包装器 Integer
。
为了生成性能最优的代码,Kotlin 编译更倾向于使用基础类型而不是包装器。 然而,有时候使用包装器是必要的。一般来说,只要将内联类用作另一种类型, 它们就会被装箱。
1 | interface I |
因为内联类既可以表示为基础类型有可以表示为包装器,引用相等对于内联类而言毫无意义,因此这也是被禁止的。
别名
类型别名为现有类型提供替代名称。 如果类型名称太长,你可以另外引入较短的名称,并使用新的名称替代原类型名。
它有助于缩短较长的泛型类型。 例如,通常缩减集合类型是很有吸引力的:
1 | typealias NodeSet = Set<Network.Node> |
你可以为函数类型提供另外的别名:
1 | typealias MyHandler = (Int, String, Any) -> Unit |
你可以为内部类和嵌套类创建新名称:
1 | class A { |
类型别名不会引入新类型。 它们等效于相应的底层类型。
内联类与类型别名
初看起来,内联类似乎与类型别名非常相似。然而,关键的区别在于类型别名与其基础类型(以及具有相同基础类型的其他类型别名)是赋值兼容的,而内联类却不是这样。
换句话说,内联类引入了一个真实的新类型,与类型别名正好相反,类型别名仅仅是为现有的类型取了个新的替代名称 (别名):
1 | typealias NameTypeAlias = String |
对象表达式与对象声明
Java中,不管是为了实现接口,或者是抽象类,我们总是习惯使用匿名内部类。最熟悉的例子,莫过于对单击事件的监听.
1 | btn.setOnClickListener(new OnClickListener{ |
Kotlin没有匿名内部类,对应的,考虑对象表达式。
对象表达式
对象表达式以object
关键字开头,最简单的,比如:
1 | btn.setOnClickListener(object : OnClickListener{...});//这其实是一个继承 |
如需创建一个继承自某个(或某些)类型的匿名类的对象,在object
和冒号(:)之后指定此类型。然后实现或重写这个类的成员,就像从它继承一样:
1 | window.addMouseListener(object : MouseAdapter() { |
如果超类型有一个构造函数,那么传递适当的构造函数参数给它。 多个超类型可以由跟在冒号后面的逗号分隔的列表指定:
1 | open class A(x: Int) { |
当匿名对象被用作局部或私有的类型,但不是内联声明(函数或属性)时,其所有成员都可以通过此函数或属性访问:
1 | class C { |
如果此函数或属性为公共或私有内联,则其实际类型为:
如果匿名对象没有声明超类,则为
Any
匿名对象的声明的超类型(如果确实存在这样的类型)
如果有多个声明的超类型,则为显式声明的类型(The explicitly declared type if there is more than one declared supertype)
在所有这些情况下,无法访问添加到匿名对象中的成员。如果重写成员是在函数或属性的实际类型中声明的,则可以访问它们。
1 | interface A { |
匿名对象的变量访问
对象表达式中的代码可以访问来自包含它的作用域的变量:
1 | fun countClicks(window: JComponent) { |
对象声明
对象声明,我们可以理解为 java 中的单例模式:
1 | object DataProviderManager { |
这称为对象声明。并且它总是在 object
关键字后跟一个名称。 就像变量声明一样,对象声明不是一个表达式,不能用在赋值语句的右边。
对象声明的初始化过程是线程安全的并且在首次访问时进行。
如需引用该对象,直接使用其名称即可:
1 | DataProviderManager.registerDataProvider(……) |
这些对象可以有超类型:
1 | object DefaultListener : MouseAdapter() { |
对象声明不能在局部作用域(即不能直接嵌套在函数内部),但是它们可以嵌套到其他对象声明或非内部类中。对象声明在另一个类的内部时,这个对象并不能通过外部类的实例访问到该对象,而只能通过类名来访问 同样该对象也不能直接访问到外部类的方法和变量
使用时完全符合单例,比如:
1 | object Site { |
伴生对象
Kotlin中没有static
的概念,之所以能抛弃静态成员,主要原因在于它允许包级属性和函数的存在,而且 Kotlin 为了维持与 Java 完全的兼容性,为静态成员提供了多种替代方案:
- 使用 包级属性和包级函数:主要用于 全局常量 和 工具函数 ;
- 使用 伴生对象:主要用于与类有紧密联系的变量和函数;
- 使用 @JvmStatic 注解:与伴生对象搭配使用,将变量和函数声明为真正的 JVM 静态成员。
类内部的对象声明可以用 companion
关键字标记,这样它就与外部类关联在一起,我们就可以直接通过外部类访问到对象的内部元素。当然我们也可以省略对象的对象名,使用 Companion
来代替。看起来很像java中的静态方法调用
1 | class MyClass { |
**注意:**一个类里面只能声明一个内部关联对象,即关键字 companion
只能使用一次。
其自身所用的类的名称可用作该类的伴生对象的引用:
1 | class MyClass1{ |
请伴生对象的成员看起来像java的静态成员,但在运行时他们仍然是真实对象的实例成员。例如还可以实现接口:
1 | interface Factory<T>{ |
我的反编译代码:
1 | import kotlin.jvm.internal.DefaultConstructorMarker; |
使用伴生对象实际上是在这个类内部创建了一个名为 Companion
的静态单例内部类
伴生对象中定义的属性会直接编译为外部类的私有静态字段,var和val的区别就是无有final
函数会被编译为伴生对象的方法
伴生对象的扩展
如果一个类定义有一个伴生对象,也可以为伴生对象定义扩展函数与属性,就像伴生对象的常规成员一样,可以只使用类名作为限定符来调用伴生对象的扩展成员:
1 | class MyClass{ |
委托
https://juejin.cn/post/6958346113552220173
- 类委托: 一个类的方法不在该类中定义,而是直接委托给另一个对象来处理。
- 属性委托: 一个类的属性不在该类中定义,而是直接委托给另一个对象来处理。
- 局部变量委托: 一个局部变量不在该方法中定义,而是直接委托给另一个对象来处理。
类委托
Kotlin 类委托的语法格式如下:
1 | class <类名>(b : <基础接口>) : <基础接口> by <基础对象> |
举例:
1 | // 基础接口 |
基础类和被委托类都实现同一个接口,编译时生成的字节码中,继承自 Base 接口的方法都会委托给基础对象处理。
属性委托
Kotlin 属性委托的语法格式如下:
1 | val/var <属性名> : <类型> by <基础对象> |
举例:
1 | class Example { |
基础类不需要实现任何接口,但必须提供 getValue() 方法,如果是委托可变属性,还需要提供 setValue()。在每个属性委托的实现的背后,Kotlin 编译器都会生成辅助属性并委托给它。 例如,对于属性 prop,会生成「辅助属性」 prop$delegate。 而 prop 的 getter() 和 setter() 方法只是简单地委托给辅助属性的 getValue() 和 setValue() 处理。
1 | //源码: |
注意事项:
- thisRef —— 必须与属性所有者类型相同或者是它的超类型。
- property —— 必须是类型 KProperty<*> 或其超类型。
- value —— 必须和属性同类型或者是它的超类型。
局部变量委托
局部变量也可以声明委托,例如:
1 | fun main(args: Array<String>) { |
后面的以后再看吧
空安全
跟dart
逻辑基本一样,用?
标注可为空的变量如var a : String? = "123"
,调用时使用?.
进行调用如var b : a?.length
,明确断言不为空则使用!!
如var c = a!!.length
,若为空则使用某个非空值则用?:
比如var d = a.length ?: 0
。
如果对象不是目标类型,那么常规类型转换可能会导致 ClassCastException
。 另一个选择是使用安全的类型转换,如果尝试转换不成功则返回 null
:
1 | val aInt: Int? = a as? Int |
相等性
Kotlin 中有两类相等性:
- 结构相等(
==
——用equals()
检测); - 引用相等(
===
——两个引用指向同一对象)。
结构相等
结构相等由 ==
以及其否定形式 !=
操作判断。 按照约定,像 a == b
这样的表达式会翻译成:
1 | a?.equals(b) ?: (b === null) |
如果 a
不是 null
则调用 equals(Any?)
函数,否则(a
是 null
)检测 b
是否与null
引用相等。
请注意,当与 null
显式比较时完全没必要优化你的代码: a == null
会被自动转换为 a === null
。
如需提供自定义的相等检测实现,请覆盖 equals(other: Any?): Boolean
函数。 名称相同但签名不同的函数,如 equals(other: Foo)
并不会影响操作符 ==
与 !=
的相等性检测。
结构相等与 Comparable<……>
接口定义的比较无关,因此只有自定义的 equals(Any?)
实现可能会影响该操作符的行为。
协程
提醒:android studio请确保导入了依赖如implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4'
如果了解过异步(比如
flutter async
,或者python的异步与协程)的话,这部分应该会比较好理解吧参考https://www.cnblogs.com/joy99/p/15805916.html,保姆级教程
协程是可挂起计算的实例。它在概念上类似于一个线程,在这个意义上,它需要一个代码块来运行,这个代码块与其余代码并发工作。但是,协程不绑定到任何特定的线程。它可以在一个线程中暂停执行,在另一个线程中恢复执行。
Go、Python 等很多编程语言在语言层面上都实现协程,java 也有三方库实现协程,只是不常用, Kotlin 在语言层面上实现协程,对比 java, 主要还是用来解决异步任务线程切换的痛点。
线程的上下文切换都需要内核参与,而协程的上下文切换,完全由用户去控制,避免了大量的中断参与,减少了线程上下文切换与调度消耗的资源。协程可以被认为是轻量级的线程,但是使用上还是有较大差别。
线程是操作系统层面的概念,协程是语言层面的概念。Kotlin在语言级别并没有实现一种同步机制(锁),还是依靠Kotlin-JVM的提供的Java关键字(如synchronized
),即锁的实现还是交给线程处理。因而Kotlin协程本质上只是一套基于原生Java线程池的封装。
Kotlin 协程的核心竞争力在于:它能简化异步并发任务,以同步方式写异步代码。
基本使用
基本使用
场景: 开启工作线程执行一段耗时任务,然后在主线程对结果进行处理。
常见的处理方式:
- 自己定义回调,进行处理
- 使用 线程/线程池,
Callable
- 线程
Thread(FeatureTask(Callable)).start
- 线程池
submit(Callable)
Android
:Handler
、AsyncTask
、Rxjava
使用协程:
1 | val coroutineScope = CoroutineScope(Dispatchers.Main) |
这里需要注意的是: Dispatchers.Main
是 Android 里面特有的,如果是java程序里面是用则会抛出异常。
源码:
1 | public fun CoroutineScope.launch( |
而且如果你留意,会发现Dispatchers.Main
其实是CoroutineContext
类型的参数,CoroutineContext
与CoroutineScope
后文都会讲
创建协程的三种方式
使用 runBlocking
顶层函数创建:
1 | runBlocking { |
使用 GlobalScope
单例对象创建
1 | GlobalScope.launch { |
自行通过 CoroutineContext
创建一个 CoroutineScope
对象
1 | val coroutineScope = CoroutineScope(context) |
- 方法一通常适用于单元测试的场景,而业务开发中不会用到这种方法,因为它是线程阻塞的。
- 方法二和使用
runBlocking
的区别在于不会阻塞线程。但在 Android 开发中同样不推荐这种用法,因为它的生命周期会只受整个应用程序的生命周期限制,且不能取消。 - 方法三是比较推荐的使用方法,我们可以通过
context
参数去管理和控制协程的生命周期(这里的context
和 Android 里的不是一个东西,是一个更通用的概念,会有一个 Android 平台的封装来配合使用)。
举例
下面这段代码可能需要使用AS运行而非IDEA
1 | import kotlinx.coroutines.* |
在很多情况下,主线程创建并启动子线程,如果子线程中要进行大量的耗时运算,主线程将可能早于子线程结束。如果主线程需要知道子线程的执行结果时,就需要等待子线程执行结束了。主线程可以sleep(xx),但这样的xx时间不好确定,因为子线程的执行时间不确定,join()方法比较合适这个场景。
java主线程的代码块中,如果碰到了t.join()
方法,此时主线程需要等待(阻塞),等待子线程结束了(Waits for this thread to die.
),才能继续执行t.join()
之后的代码块。kt也是类似的逻辑
结构性并发
协程遵循结构化并发的原则,这意味着新的协程只能在一个特定的 CoroutineScope
中启动,这个 CoroutineScope
限定了协程的生命周期。所有协程完成后,代码块才真正结束。
在实际的应用程序中,您将启动许多协程。结构化并发确保它们不会丢失,也不会泄漏。外部作用域在其所有子协同程序完成之前不能完成。结构化并发还确保正确报告代码中的任何错误,并且永远不会丢失。
代码块抽离
让我们将 launch { ... }
中的代码块提取到一个单独的函数中。当对这段代码执行“提取函数”重构时,我们将获得一个带有suspend
修饰符的新函数。挂起函数可以像常规函数一样在协程中使用,但是它们的附加特性是它们可以依次使用其他挂起函数(比如本例中的delay
)来挂起协程的执行。
1 | import kotlinx.coroutines.* |
取消协程
与线程类比,java 线程其实没有提供任何机制来安全地终止线程。
Thread
类提供了一个方法 interrupt()
方法,用于中断线程的执行。调用interrupt()
方法并不意味着立即停止目标线程正在进行的工作,而只是传递了请求中断的消息。然后由线程在下一个合适的时机中断自己。
但是协程提供了一个 cancel()
方法来取消作业。
1 | fun main() = runBlocking { |
也可以使用函数 cancelAndJoin
, 它合并了对 cancel 以及 join 的调用。
问题:
如果先调用 job.join()
后调用 job.cancel()
是是什么情况?
取消是协作的
协程并不是一定能取消,协程的取消是协作的。一段协程代码必须协作才能被取消。所有 kotlinx.coroutines
中的挂起函数都是可被取消的 。它们检查协程的取消, 并在取消时抛出 CancellationException
。
如果协程正在执行计算任务,并且没有检查取消的话,那么它是不能被取消的。例如
1 | fun main() = runBlocking { |
可见协程并没有被取消。为了能真正停止协程工作,我们需要定期检查协程是否处于 active 状态。
检查 job 状态
一种方法是在 while(i<5)
中添加检查协程状态的代码,代码如下:
1 | while (i < 5 && isActive) |
这样意味着只有当协程处于 active 状态时,我们工作的才会执行。
另一种方法使用协程标准库中的函数 ensureActive()
, 它的实现是这样的:
1 | public fun Job.ensureActive(): Unit { |
代码如下:
1 | while (i < 5) { // 一个执行计算的循环,只是为了占用 CPU |
ensureActive()
在协程不在 active 状态时会立即抛出异常。
使用 yield()
yield()
和 ensureActive
使用方式一样。yield
会进行的第一个工作就是检查任务是否完成,如果 Job 已经完成的话,就会抛出 CancellationException
来结束协程。yield
应该在定时检查中最先被调用。
1 | while (i < 5) { // 一个执行计算的循环,只是为了占用 CPU |
等待协程的执行的结果
对于无返回值的的协程使用 launch()
函数创建,如果需要返回值,则通过 async
函数创建。
使用 async
方法启动 Deferred
(也是一种 job), 可以调用它的 await()
方法获取执行的结果。
形如下面代码:
1 | val asyncDeferred = async { |
deferred
也是可以取消的,对于已经取消的 deferred
调用 await()
方法,会抛出JobCancellationException
异常。
同理,在 deferred.await
之后调用 deferred.cancel()
, 那么什么都不会发生,因为任务已经结束了。
关于 async
的具体用法后面异步任务再讲。
协程的异常处理
由于协程被取消时会抛出 CancellationException
,所以我们可以把挂起函数包裹在 try/catch
代码块中,这样就可以在 finally
代码块中进行资源清理操作了。
协程的超时
在实践中绝大多数取消一个协程的理由是它有可能超时。 当你手动追踪一个相关 Job 的引用并启动,使用 withTimeout
函数。
1 | fun main() = runBlocking { |
结果:
1 | start... |
withTimeout()
抛出了 TimeoutCancellationException
,它是 CancellationException
的子类。 我们之前没有在控制台上看到堆栈跟踪信息的打印。这是因为在被取消的协程中 CancellationException
被认为是协程执行结束的正常原因。 然而,在这个示例中我们在 main 函数中正确地使用了 withTimeout()
。如果有必要,我们需要主动 catch
异常进行处理。
当然,还有另一种方式: 使用 withTimeoutOrNull
。
withTimeout
是可以有返回值的,执行 withTimeout
函数,会阻塞并等待执行完返回结果或者超时抛出异常。
withTimeoutOrNull
用法与 withTimeout
一样,只是在超时后返回 null 。
并发与挂起
使用 async 并发
打印当前线程信息的函数,原文没写,我按照原文输出格式自己写的:
1
2
3 fun printWithThreadInfo( s :String = "" ){
println("thread id: ${Thread.currentThread().id}, thread name: ${Thread.currentThread().name} -->$s")
}
考虑一个场景: 开启多个任务,并发执行,所有任务执行完之后,返回结果,再汇总结果继续往下执行。
针对这种场景,解决方案有很多,比如 java 的 FeatureTask
。
前面提到有返回值的协程,我们通常使用 async
函数来启动。
这里看一段代码:
1 | fun main() = runBlocking { |
async
启动一个协程后,调用 await
方法后,会阻塞,等待结果的返回,同样能达到效果。
源码:
与CoroutineScope.launch()
方法结构逻辑极其相似
1 | public fun <T> CoroutineScope.async( |
懒启动/lazy start
async
可以通过将 start
参数设置为 CoroutineStart.LAZY
变成惰性的。在这个模式下,调用 await
获取协程执行结果的时候,或者调用 Job 的 start
方法时,协程才会启动。
1 | fun main() = runBlocking { |
但是如果去掉了两个start()
,结果变为:
1 | thread id: 14, thread name: DefaultDispatcher-worker-1 --> |
也就是说,会先启动第一个协程,执行完毕,再启动第二个协程(在同一线程),执行完。相当于顺序执行而非并发。
挂起函数
这一部分在上面的代码块抽离中其实有过预告,关于
suspend
关键字
还是上面的例子,加入我们把任务 a 的计算过程提取成一个函数。如下:
1 | fun main() = runBlocking { |
此时会发现,编译器报错了。
1 | delay(1000) // 模拟耗时操作 |
该行报错为:Suspend function 'delay' should be called only from a coroutine or another suspend function
挂起函数 delay
应该在另一个挂起函数调用。
查看 delay
函数源码:
1 | public suspend fun delay(timeMillis: Long) { |
可以看到,方法签名用 suspend
修饰,表示该函数是一个挂起函数。解决这个异常,只需要将我们定义的 calA()
方法也用 suspend
修饰,使其变成一个挂起函数。
使用
suspend
关键字修饰的函数成为挂起函数,挂起函数只能在另一个挂起函数,或者协程中被调用。在挂起函数中可以调用普通函数(非挂起函数)。
协程上下文和作用域
协程上下文 CoroutineContext
协程总是运行在一些以 CoroutineContext
类型为代表的上下文中。协程上下文是各种不同元素的集合。其中主元素是协程中的 Job
以及它的调度器。
协程上下文包含当前协程scope
的信息, 比如 Job
,ContinuationInterceptor
,CoroutineName
和CoroutineId
。在CoroutineContext
中,是用map
来存这些信息的,如
1 | val job = context[Job] |
前文基本使用模块我们提到了协程上下文,下面代码中使用的是协程调度器dispatcher
,使用该构造器作为了一个上下文(按理来说好像是不能这么用的,毕竟变量类型都不一样),但如果打印context
与dispatcher
会发现都是java.util.concurrent.ThreadPoolExecutor
。
我们用这个上下文建立了一个作用域myScope
,在这个作用域内执行launch
。
为啥不是
Dispatchers.Main
:这是安卓独有的,如果是java程序里面是用则会抛出异常
1 | fun main() { |
协程上下文包含一个 协程调度器 (CoroutineDispatcher
),它确定了相关的协程在哪个线程或哪些线程上执行。协程调度器可以将协程限制在一个特定的线程执行,或将它分派到一个线程池,亦或是让它不受限地运行。
其实所有的协程构建器诸如 launch
和 async
接收一个可选的 CoroutineContext
参数,它可以被用来显式的为一个新协程或其它上下文元素指定一个调度器。(或许这就是上面代码使用dispatcher
的原因)
比如:
1 | import kotlinx.coroutines.* |
当调用 launch { …… } 时不传参数,它从启动了它的 CoroutineScope
中承袭了上下文(以及调度器)。
CoroutineContext
最重要的两个信息是 Dispatcher 和 Job, 而 Dispatcher 和 Job 本身又实现了CoroutineContext
的接口。是其子类。这个设计就很有意思了。
有时我们需要在协程上下文中定义多个元素。我们可以使用 + 操作符来实现。 比如说,我们可以显式指定一个调度器来启动协程并且同时显式指定一个命名:
1 | launch(Dispatchers.Default + CoroutineName("test")) { |
这得益于 CoroutineContext
重载了操作符 +
。
协程作用域 CoroutineScope
CoroutineScope
即协程运行的作用域,它的源码如下:
1 | public interface CoroutineScope { |
可以看出CoroutineScope
的代码很简单,主要作用是提供 CoroutineContext
。
作用域可以管理其域内的所有协程。一个CoroutineScope
可以有许多的子scope
。协程内部是通过 CoroutineScope.coroutineContext
自动继承自父协程的上下文。而 CoroutineContext
就是在作用域内为协程进行线程切换的快捷方式。
注意:当使用 GlobalScope
来启动一个协程时,则新协程的作业没有父作业。 因此它与这个启动的作用域无关且独立运作。GlobalScope
包含的是 EmptyCoroutineContext
。
EmptyCoroutineContext
这个上下文相信你在前文的源码中已经见过
- 一个父协程总是等待所有的子协程执行结束。父协程并不显式的跟踪所有子协程的启动,并且不必使用 Job.join 在最后的时候等待它们。
- 取消父协程会取消所有的子协程。所以使用 Scope 来管理协程的生命周期。
- 默认情况下,协程内,某个子协程抛出一个非
CancellationException
异常,未被捕获,会传递到父协程,任何一个子协程异常退出,那么整体都将退出。
创建 CoroutineScope
创建一个 CoroutineScope
, 只需调用 public fun CoroutineScope(context: CoroutineContext)
方法,传入一个 CoroutineContext
对象。
在协程作用域内,启动一个子协程,默认自动继承父协程的上下文,但在启动时,我们可以指定传入上下文。
1 | val dispatcher = Executors.newFixedThreadPool(1).asCoroutineDispatcher() |
SupervisorJob
启动一个协程,默认是实例化的是 Job 类型。该类型下,协程内,某个子协程抛出一个非 CancellationException
异常,未被捕获,会传递到父协程,任何一个子协程异常退出,那么整体都将退出。
为了解决上述问题,可以使用SupervisorJob
替代Job
,SupervisorJob
与Job
基本类似,区别在于不会被子协程的异常所影响。
1 | private val svJob = SupervisorJob() |
协程并发中的数据同步问题
线程中的数据同步问题
经典例子:
1 | var flag = true |
程序并没有像我们所期待的那样,在一秒之后,退出,而是一直处于循环中。
给 flag
加上 volatile
关键修饰:
1 |
|
没有用 volatile
修饰 flag
之前,其改变不具有可见性,一个线程将它的值改变后,另一个线程却 “不知道”,所以程序没有退出。当把变量声明为 volatile
类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序。volatile
变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取volatile
类型的变量时总会返回最新写入的值。
在访问volatile
变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此volatile
变量是一种比sychronized
关键字更轻量级的同步机制。
参见https://www.zwn-blog.xyz/2022/04/21/java-volatile/
当对非volatile
变量进行读写的时候,每个线程先从内存拷贝变量到CPU缓存中。如果计算机有多个CPU,每个线程可能在不同的CPU上被处理,这意味着每个线程可以拷贝到不同的 CPU cache 中。
而声明变量是 volatile
的,JVM 保证了每次读变量都从内存中读,跳过 CPU cache 这一步。
volatile
修饰的遍历具有如下特性:
- 保证此变量对所有的线程的可见性,当一个线程修改了这个变量的值,
volatile
保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。但普通变量做不到这点,普通变量的值在线程间传递均需要通过主内存来完成。 - 禁止指令重排序优化。
- 不会阻塞线程。
如果在 while 循环里加一行打印,即使去掉 volatile
修饰,也可以退出程序,查看 println()
源码,最终发现,里面有synchronized
修饰的同步代码块,
那么问题来了,synchronized
到底干了什么。
按理说,synchronized
只会保证该同步块中的变量的可见性,发生变化后立即同步到主存,但是,flag 变量并不在同步块中,实际上,JVM对于现代的机器做了最大程度的优化,也就是说,最大程度的保障了线程和主存之间的及时的同步,也就是相当于虚拟机尽可能的帮我们加了个volatile
,但是,当CPU被一直占用的时候,同步就会出现不及时,也就出现了后台线程一直不结束的情况。
协程中的数据同步问题
看如下例子:
1 | class Test { |
执行输出结果:
1 | thread id: 15, thread name: DefaultDispatcher-worker-4 ---> end count: 58059 |
并不是我们期待的 100000。很明显,协程并发过程中数据不同步造成的。
我们也会尝试使用 volatile
修饰变量,但并不能解决问题。volatile
在并发中保证可见性,但是不保证原子性。 count++
该运算,包含读、写操作,并非一次原子操作。这样并发情况下,自然得不到期望的结果。
协程数据同步的解决方案
方案一
使用具有 incrementAndGet
原子操作的 AtomicInteger
类,private var count = AtomicInteger()
,并使用count.incrementAndGet()
代替count++
。
温馨提示,这个类是java类。
原理是CAS指令,参见https://www.zwn-blog.xyz/2022/09/13/CAS%E6%8C%87%E4%BB%A4%E4%B8%8EMESI%E7%BC%93%E5%AD%98%E4%B8%80%E8%87%B4%E6%80%A7%E5%8D%8F%E8%AE%AE/
方案二:同步操作
对数据的增加进行同步操作。可以同步计数自增的代码块:
1 | class Test { |
或者使用 ReentrantLock 操作。
1 | class Test { |
在协程中的替代品叫做 Mutex
, 它具有 lock 和 unlock 方法,关键的区别在于, Mutex.lock() 是一个挂起函数,它不会阻塞当前线程。还有 withLock
扩展函数,可以方便的替代常用的 mutex.lock();
、try { …… } finally { mutex.unlock() }
模式:
1 | class Test { |
方案三:Actors
一个 actor 是由协程、 被限制并封装到该协程中的状态以及一个与其它协程通信的 通道 组合而成的一个实体。一个简单的 actor 可以简单的写成一个函数, 但是一个拥有复杂状态的 actor 更适合由类来表示。
有一个 actor 协程构建器,它可以方便地将 actor 的邮箱通道组合到其作用域中(用来接收消息)、组合发送 channel 与结果集对象,这样对 actor 的单个引用就可以作为其句柄持有。
使用 actor 的第一步是定义一个 actor 要处理的消息类。 Kotlin 的密封类很适合这种场景。 我们使用 IncCounter
消息(用来递增计数器)和 GetCounter
消息(用来获取值)来定义 CounterMsg
密封类。 后者需要发送回复。CompletableDeferred
通信原语表示未来可知(可传达)的单个值, 这里被用于此目的。
1 | // 计数器 Actor 的各种类型 |
接下来定义一个函数,使用 actor 协程构建器来启动一个 actor:
1 | // 这个函数启动一个新的计数器 actor |
主要代码:
1 | class Test { |
actor 本身执行时所处上下文(就正确性而言)无关紧要。一个 actor 是一个协程,而一个协程是按顺序执行的,因此将状态限制到特定协程可以解决共享可变状态的问题。实际上,actor 可以修改自己的私有状态, 但只能通过消息互相影响(避免任何锁定)。
actor 在高负载下比锁更有效,因为在这种情况下它总是有工作要做,而且根本不需要切换到不同的上下文。
实际上, CoroutineScope.actor()
方法返回的是一个 SendChannel
对象。Channel
也是 Kotlin 协程中的一部分。
通道Channel
概念
Channel 翻译过来为通道或者管道,实际上就是个队列, 是一个面向多协程之间数据传输的 BlockQueue
,用于协程间通信。Channel 允许我们在不同的协程间传递数据。形象点说就是不同的协程可以往同一个管道里面写入数据或者读取数据。它是一个和 BlockingQueue
非常相似的概念。区别在于:BlockingQueue
使用 put
和 take
往队列里面写入和读取数据,这两个方法是阻塞的。而 Channel 使用 send
和 receive
两个方法往管道里面写入和读取数据。这两个方法是非阻塞的挂起函数,鉴于此,Channel 的 send
和 receive
方法也只能在协程中使用。
简单使用
1 | fun main() = runBlocking { |
关闭 Channel
我们可以使用 close()
方法关闭 Channel,来表明没有更多的元素将会进入通道。
1 | val channel = Channel<Int>() |
从概念上来讲,调用 close
方法就像向通道发送了一个特殊的关闭指令,这个迭代停止,说明关闭指令已经被接收了。所以这里能够保证所有先前发送出去的原色都能在通道关闭前被接收到。
对于一个 Channel,如果我们调用了它的 close()
,它会立即停止接受新元素,也就是说这时候它的 isClosedForSend
会立即返回 true,而由于 Channel 缓冲区的存在,这时候可能还有一些元素没有被处理完,所以要等所有的元素都被读取之后 isClosedForReceive
才会返回 true。
Channel 的类型
Channel 是一个接口,它继承了 SendChannel
和 ReceiveChannel
两个接口
1 | public interface Channel<E> : SendChannel<E>, ReceiveChannel<E> |
SendChannel
SendChannel
提供了发射数据的功能,有如下重点接口:
send()
是一个挂起函数,将指定的元素发送到此通道,在该通道的缓冲区已满或不存在时挂起调用者。如果通道已经关闭,调用发送时会抛出异常。trySend()
如果不违反其容量限制,则立即将指定元素添加到此通道,并返回成功结果。否则,返回失败或关闭的结果。close()
关闭通道。isClosedForSend()
判断通道是否已经关闭,如果关闭,调用 send 会引发异常。
ReceiveChannel
ReceiveChannel
提供了接收数据的功能,有如下重点接口:
receive()
如果此通道不为空,则从中检索并删除元素;如果通道为空,则挂起调用者;如果通道为接收而关闭,则引发ClosedReceiveChannel()
异常。tryReceive()
如果此通道不为空,则从中检索并删除元素,返回成功结果;如果通道为空,则返回失败结果;如果通道关闭,则返回关闭结果。receiveCatching()
如果此通道不为空,则从中检索并删除元素,返回成功结果;如果通道为空,则返回失败结果;如果通道关闭,则返回关闭的原因。isEmpty()
判断通道是否为空isClosedForReceive()
判断通道是否已经关闭,如果关闭,调用receive()
会引发异常。cancel(cause: CancellationException? = null)
以可选原因取消接收此频道的剩余元素。此函数用于关闭通道并从中删除所有缓冲发送的元素。iterator()
返回通道的迭代器
BroadcastChannel
这个通道和一般的通道区别在于他的每个数据可以被每个作用域全部接收到; 默认的通道一个数据被接收后其他的协程是无法再接收到数据的
广播通道通过全局函数创建对象
1 | public fun <E> BroadcastChannel(capacity: Int): BroadcastChannel<E> |
本身广播通道继承自SendChannel
,只能发送数据,通过函数可以拿到接收通道
1 | public fun openSubscription(): ReceiveChannel<E> |
取消通道
1 | public fun cancel(cause: CancellationException? = null) |
将Channel转成BroadcastChannel
1 | fun <E> ReceiveChannel<E>.broadcast( |
通过扩展函数在协程作用域中快速创建一个广播发送通道
1 | public fun <E> CoroutineScope.broadcast( |
过时提醒
BroadcastChannel
源码中的说明:
1 | Note: This API is obsolete since 1.5.0. It will be deprecated with warning in 1.6.0 and with error in 1.7.0. It is replaced with `SharedFlow`. |
BroadcastChannel
对于广播式的任务来说有点太复杂了。使用通道进行状态管理时会出现一些逻辑上的不一致。例如,可以关闭或取消通道。但由于无法取消状态,因此在状态管理中无法正常使用!
BroadcastChannel
被标记为过时了,在 Kotlin 1.6.0 版本中使用将显示警告,在 1.7.0 版本中将显示错误。请使用 SharedFlow
和 StateFlow
替代它。关于 SharedFlow
和 StateFlow
将在下文中讲到。
不同类型的 Channel
Kotlin 协程库中定义了多个 Channel 类型,所有channel类型的receive
方法都是同样的行为: 如果channel不为空, 接收一个元素, 否则挂起。
它们的主要区别在于:
- 内部可以存储元素的数量
send()
是否可以被挂起
Channel 的不同类型:
Rendezvous channel
: 与建立大小为零的缓冲通道(Buffered channel)相同。 其中一个功能(send或receive)始终被挂起,直到调用另外一个功能为止。 若是调用了send函数,但消费者没有准备好处理该元素则receive会挂起,而且send也会被挂起。 一样,若是调用了receive函数且通道为空,换句话说,没有准备好发送该元素的的send被挂起-receive也会被挂起。(默认是这个)Unlimited channel
: 无限元素,send不被挂起,最接近队列的模拟,若是没有更多的内存,则会抛出OutOfMemoryException
。Buffered channel
: 指定大小,生产者能够将元素发送到此通道,直到达到最大限制。 全部元素都在内部存储。 通道已满时,下一个send呼叫将被挂起,直到出现更多可用空间。Conflated channel
: 新元素会覆盖旧元素, receiver只会得到最新元素, send永不挂起。
创建 Channel:
1 | val rendezvousChannel = Channel<String>() |
多协程样例
1 | fun main() = runBlocking<Unit> { |
Produce
相当于生产者
上面介绍的属于创建Channel对象来发送和接收数据,但是还可以通过扩展函数快速创建并返回一个具备发送数据的ReceiveChannel
对象
1 | public fun <E> CoroutineScope.produce( |
context
: 可以通过协程上下文决定调度器等信息capacity
: 初始化通道空间
ProducerScope
该接口继承自SendChannel
以及CoroutineScope
, 具备发送通道数据以及协程作用域作用
当produce
作用域执行完成会关闭通道, 前面已经提及关闭通道无法继续接收数据
等待取消
该函数会在通道被取消时回调其函数参数, 前面提及协程取消时可以通过finally
来释放内存等操作, 但是通道取消无法使用finally只能使用该函数
1 | public suspend fun ProducerScope<*>.awaitClose(block: () -> Unit = {}) |
Actor
相当于消费者
可以通过actor
函数创建一个具备通道作用的协程作用域
1 | public fun <E> CoroutineScope.actor( |
context
: 协程上下文capacity
: 通道缓存空间start
: 协程启动模式onCompletion
: 完成回调block
: 回调函数中可以进行发送数据
该函数和produce
函数相似,
produce
返回ReceiveChannel
, 外部进行数据接收;actor
返回的SendChannel
, 外部进行数据发送- actor的回调函数拥有属性
channel:Channel
, 既可以发送数据又可以接收数据, produce的属性channel属于SendChannel
- 无论是
produce
或者actor
他们的通道都属于Channel, 既可以发送又可以接收数据, 只需要类型强转即可 - 本身Channel可以进行双向数据通信, 但是设计produce和actor属于设计思想中的生产者和消费者模式
- 他们都属于协程作用域和数据通道的结合
Flow
Kotlin 协程中使用挂起函数可以实现非阻塞地执行任务并将结果返回回来,但是只能返回单个计算结果。但是如果希望有多个计算结果返回回来,则可以使用 Flow。
Flow 的简单使用
1 | private fun createFlow(): Flow<Int> = flow { |
上述代码使用 flow{ ... }
来构建一个 Flow 类型,具有如下特点:
flow{ ... }
内部可以调用suspend
函数;createFlow
不需要suspend
来标记;(为什么没有标记为挂起函数,去可以调用挂起函数?)- 使用
emit()
方法来发送数据; - 使用
collect()
方法来收集结果。
flow的发送函数
emit
不是线程安全的不允许其他线程调用, 如果需要线程安全请使用channelFlow
而不是flow
channelFlow
使用send
函数发送数据
创建常规 Flow 的常用方式:
flow{…}
1 | flow { |
flowOf()
1 | flowOf(1,2,3).onEach { |
flowOf()
构建器定义了一个发射固定值集的流, 使用 flowOf
构建 Flow 不需要显示调用 emit()
发射数据
asFlow()
1 | listOf(1, 2, 3).asFlow().onEach { |
使用 asFlow()
扩展函数,可以将各种集合与序列转换为流,也不需要显示调用 emit()
发射数据.
集合或者Sequence都可以通过asFlow
函数转成Flow对象
Channel通道转成Flow
1 | public fun <T> ReceiveChannel<T>.consumeAsFlow(): Flow<T> |
甚至挂起函数也可以转成Flow
1 | public fun <T> (suspend () -> T).asFlow(): Flow<T> |
Flow 是冷流(惰性的)
在调用末端流操作符(collect 是其中之一)之前, flow{ … } 中的代码不会执行。我们称之为冷流。
1 | private fun createFlow(): Flow<Int> = flow { |
结果如下:
1 | calling collect... |
这是一个返回一个 Flow 的函数 createFlow
没有标记 suspend
的原因,即便它内部调用了 suspend 函数,但是调用 createFlow
会立即返回,且不会进行任何等待。而再每次收集结果的时候,才会启动流。
那么有没有热流呢? 后面讲的 ChannelFlow
就是热流。只有上游产生了数据,就会立即发射给下游的收集者。
Flow 的取消
流采用了与协程同样的协助取消。流的收集可以在当流在一个可取消的挂起函数(例如 delay)中挂起的时候取消。取消Flow 只需要取消它所在的协程即可。
以下示例展示了当 withTimeoutOrNull
块中代码在运行的时候流是如何在超时的情况下取消并停止执行其代码的:
1 | fun simple(): Flow<Int> = flow { |
注意,在 simple 函数中流仅发射两个数字,产生以下输出:
1 | Emitting 1 |
Terminal flow operators 末端流操作符
末端操作符是在流上用于启动流收集的挂起函数。 collect 是最基础的末端操作符,但是还有另外一些更方便使用的末端操作符:
- 转化为各种集合,toList/toSet/toCollection
- 获取第一个(first)值,最后一个(last)值与确保流发射单个(single)值的操作符
- 使用 reduce 与 fold 将流规约到单个值
- count
launchIn
/produceIn
/broadcastIn
下面看几个常用的末端流操作符
转化为集合
1 | public suspend fun <T> Flow<T>.toList(destination: MutableList<T> = ArrayList()): List<T> |
reduce
reduce 类似于 Kotlin 集合中的 reduce 函数,能够对集合进行计算操作。前面提到,reduce 是一个末端流操作符。
1 | fun main() = runBlocking { |
输出结果:
1 | 15 |
fold
fold 也类似于 Kotlin 集合中的 fold,需要设置一个初始值,fold 也是一个末端流操作符。
1 | fun main() = runBlocking { |
输出结果:
1 | 115 |
launchIn
launchIn
用来在指定的 CoroutineScope
内启动 flow, 需要传入一个参数: CoroutineScope
源码:
1 | public fun <T> Flow<T>.launchIn(scope: CoroutineScope): Job = scope.launch { |
示例:
1 | private val mDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher() |
输出结果:
1 | 1 |
再看一个例子:
1 | fun main() = runBlocking{ |
我们希望并行执行两个 Flow ,看下输出结果:
1 | 1 |
结果并不是并行执行的,这个很好理解,因为第一个 collect 不执行完,不会走到第二个。
正确地写法应该是,为每个 Flow 单独起一个协程:
1 | fun main() = runBlocking<Unit>{ |
或者使用 launchIn, 写法更优雅:
1 | fun main() = runBlocking<Unit>{ |
输出结果:
1 | 1 |
中间转换操作符
map
map 操作符用于 List 表示将 List 中的每个元素转换成新的元素,并添加到一个新的 List 中,最后再讲新的 List 返回,map 操作符用于 Flow 表示将流中的每个元素进行转换后再发射出来。
1 | fun main() = runBlocking { |
输出:
1 | string: 1 |
transform
在使用 transform 操作符时,可以任意多次调用 emit ,这是 transform 跟 map 最大的区别(map不允许)
1 | fun main() = runBlocking { |
输出结果:
1 | 2 |
onEach
遍历
1 | fun main() = runBlocking { |
输出:
1 | onEach: 1 |
filter
按条件过滤
1 | fun main() = runBlocking { |
输出结果:
1 | 2 |
drop
/ dropWhile
drop 过滤掉前几个元素
1 | fun main() = runBlocking { |
dropWhile 过滤满足条件的元素
1 | /** |
take
take 操作符只取前几个 emit 发射的值
1 | fun main() = runBlocking { |
输出:
1 | 1 |
zip
zip 是可以将2个 flow 进行合并的操作符
1 | fun main() = runBlocking { |
输出结果:
1 | 1 and one |
zip 操作符会把 flowA 中的一个 item 和 flowB 中对应的一个 item 进行合并。即使 flowB 中的每一个 item 都使用了 delay() 函数,在合并过程中也会等待 delay() 执行完后再进行合并。
如果 flowA 和 flowB 中 item 个数不一致,则合并后新的 flow item 个数,等于较小的 item 个数
1 | fun main() = runBlocking { |
输出结果:
1 | 1 and one |
combine
combine 也是合并,但是跟 zip 不太一样。
使用 combine 合并时,每次从 flowA 发出新的 item ,会将其与 flowB 的最新的 item 合并。
1 | fun main() = runBlocking { |
输出结果:
1 | 1 and one |
flattenContact
和 flattenMerge
扁平化处理
flattenContact
flattenConcat 将给定流按顺序展平为单个流,而不交错嵌套流。
源码:
1 |
|
例子:
1 | fun main() = runBlocking { |
输出:
1 | 1 |
flattenMerge
fattenMerge 有一个参数,并发限制,默认位 16。
源码:
1 |
|
可见,参数必须大于0, 并且参数为 1 时,与 flattenConcat 一致。
1 | fun main() = runBlocking { |
输出结果:
1 | 1 |
flatMapMerge
和 flatMapContact
flatMapMerge 由 map、flattenMerge 操作符实现
1 |
|
例子:
1 | fun main() = runBlocking { |
输出结果:
1 | 1 |
flatMapContact 由 map、flattenConcat 操作符实现
1 |
|
例子:
1 | fun main() = runBlocking { |
输出结果:
1 | 1 |
flatMapMerge 和 flatMapContact 都是将一个 flow 转换成另一个流。
区别在于: flatMapMerge 不会等待内部的 flow 完成 , 而调用 flatMapConcat 后,collect 函数在收集新值之前会等待 flatMapConcat 内部的 flow 完成。
flatMapLatest
当发射了新值之后,上个 flow 就会被取消。
1 | fun main() = runBlocking { |
输出结果:
1 | begin flatMapLatest 1 |
生命周期
onStart 流启动时
Flow 启动开始执行时的回调,在耗时操作时可以用来做 loading。
1 | fun main() = runBlocking { |
输出结果:
1 | onStart |
onCompletion 流完成时
Flow 完成时(正常或出现异常时),如果需要执行一个操作,它可以通过两种方式完成:
使用 try … finally 实现
1 | fun main() = runBlocking { |
通过 onCompletion 函数实现
1 | fun main() = runBlocking { |
输出:
1 | 1 |
Flow 异常处理
catch 操作符捕获上游异常
前面提到的 onCompletion
用来Flow是否收集完成,即使是遇到异常也会执行。
1 | fun main() = runBlocking { |
输出:
1 | produce data: 1 |
其实在 onCompletion
中是可以判断是否有异常的, onCompletion(action: suspend FlowCollector<T>.(cause: Throwable?) -> Unit)
是有一个参数的,如果flow 的上游出现异常,这个参数不为 null,如果上游未出现异常,则为 null, 据此,我们可以在 onCompletion
中判断异常。但是, onCompletion
只能判断是否出现了异常,并不能捕获异常。捕获异常可以使用 catch
操作符。
1 | fun main() = runBlocking { |
输出结果:
1 | produce data: 1 |
- catch 操作符用于实现异常透明化处理, catch 只是中间操作符不能捕获下游的异常,。
- catch 操作符内,可以使用 throw 再次抛出异常、可以使用 emit() 转换为发射值、可以用于打印或者其他业务逻辑的处理等等
retry/retryWhen
1 | public fun <T> Flow<T>.retry( |
Backpressure
背压
Backpressure
是响应式编程的功能之一, Rxjava 中的 Flowable 支持的 Backpressure
策略有:
- MISSING:创建的 Flowable 没有指定背压策略,不会对通过 OnNext 发射的数据做缓存或丢弃处理。
- ERROR:如果放入 Flowable 的异步缓存池中的数据超限了,则会抛出
MissingBackpressureException
异常。 - BUFFER:Flowable 的异步缓存池同 Observable 的一样,没有固定大小,可以无限制添加数据,不会抛出
MissingBackpressureException
异常,但会导致 OOM。 - DROP:如果 Flowable 的异步缓存池满了,会丢掉将要放入缓存池中的数据。
- LATEST:如果缓存池满了,会丢掉将要放入缓存池中的数据。这一点跟 DROP 策略一样,不同的是,不管缓存池的状态如何,LATEST 策略会将最后一条数据强行放入缓存池中。
而在Flow代码块中,每当有一个处理结果 我们就可以收到,但如果处理结果也是耗时操作。就有可能出现,发送的数据太多了,处理不及时的情况。
Flow 的 Backpressure
是通过 suspend 函数实现的。
buffer
缓冲
buffer 对应 Rxjava 的 BUFFER 策略。 buffer 操作指的是设置缓冲区。当然缓冲区有大小,如果溢出了会有不同的处理策略。
- 设置缓冲区,如果溢出了,则将当前协程挂起,直到有消费了缓冲区中的数据。
- 设置缓冲区,如果溢出了,丢弃最新的数据。
- 设置缓冲区,如果溢出了,丢弃最老的数据。
缓冲区的大小可以设置为 0,也就是不需要缓冲区。
看一个未设置缓冲区的示例,假设每产生一个数据然后发射出去,要耗时 100ms ,每次处理一个数据需要耗时 700ms,代码如下:
1 | fun main() = runBlocking { |
结果如下:
1 | produce data: 1 |
由于流是惰性的,且是连续的,所以整个流中的数据处理完成大约需要 4000ms
下面,我们使用 buffer() 设置一个缓冲区。buffer()
,接收两个参数,第一个参数是 size, 表示缓冲区的大小。第二个参数是 BufferOverflow
, 表示缓冲区溢出之后的处理策略,其值为下面的枚举类型,默认是 BufferOverflow.SUSPEND
处理策略源码如下:
1 | public enum class BufferOverflow { |
设置缓冲区,并采用挂起的策略
修改,代码,我们设置缓冲区大小为 2 :
1 | fun main() = runBlocking { |
结果如下:
1 | produce data: 1 |
可见整体用时大约为 3713ms。buffer 操作符可以使发射和收集的代码并发运行,从而提高效率。
下面简单分析一下执行流程:
这里要注意的是,buffer 的容量是从 0 开始计算的。
首先,我们收集第一个数据,产生第一个数据,然后 2、3、4 存储在了缓冲区。第5个数据发射时,缓冲区满了,会挂起。等到第1个数据收集完成之后,再发射第5个数据。
设置缓冲区,丢弃最新的数据
如果上述代码处理缓存溢出策略为 BufferOverflow.DROP_LATEST
,代码如下:
1 | fun main() = runBlocking { |
输出如下:
1 | produce data: 1 |
可以看到,第4个数据和第5个数据因为缓冲区满了直接被丢弃了,不会被收集。
设置缓冲区,丢弃旧的数据
如果上述代码处理缓存溢出策略为 BufferOverflow.DROP_OLDEST
,代码如下:
1 | fun main() = runBlocking { |
输出结果如下:
1 | produce data: 1 |
可以看到,第4个数据进入缓冲区时,会把第2个数据丢弃掉,第5个数据进入缓冲区时,会把第3个数据丢弃掉。
conflate
合并
当流代表部分操作结果或操作状态更新时,可能没有必要处理每个值,而是只处理最新的那个。conflate
操作符可以用于跳过中间值:
1 | fun main() = runBlocking { |
输出结果:
1 | produce data: 1 |
conflate
操作符是不设缓冲区,也就是缓冲区大小为 0,丢弃旧数据,也就是采取 DROP_OLDEST 策略,其实等价于 buffer(0, BufferOverflow.DROP_OLDEST)
。
Flow 线程切换
Flow 是基于 CoroutineContext
进行线程切换的。因为 Collect 是一个 suspend 函数,必须在 CoroutineScope
中执行,所以响应线程是由 CoroutineContext
决定的。比如,在 Main 线程总执行 collect, 那么响应线程就是 Dispatchers.Main
。
flowOn 切换线程
Rxjava 通过 subscribeOn
和 ObserveOn
来决定发射数据和观察者的线程。并且,上游多次调用 subscribeOn
只会以最后一次为准。
而 Flows 通过 flowOn
方法来切换线程,多次调用,都会影响到它上游的代码。举个例子:
1 | private val mDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher() |
输出结果如下:
1 | thread id: 13, thread name: DefaultDispatcher-worker-1 ---> produce data: 1 |
可以看到,发射数据是在 Dispatchers.IO
线程执行的, map 操作时在我们自定义的线程池中进行的,collect 操作在 Dispatchers.Main
线程进行。
StateFlow
和 SharedFlow
请确保导入了依赖implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4'
StateFlow
和 SharedFlow
是用来替代 BroadcastChannel
的新的 API。用于上游发射数据,能同时被多个订阅者收集数据。
StateFlow
官方文档解释:StateFlow
是一个状态容器式可观察数据流,可以向其收集器发出当前状态更新和新状态更新。还可通过其 value 属性读取当前状态值。如需更新状态并将其发送到数据流,请为 MutableStateFlow
类的 value 属性分配一个新值。
在 Android 中,StateFlow
非常适合需要让可变状态保持可观察的类。
StateFlow
有两种类型: StateFlow
和 MutableStateFlow
:
1 | public interface StateFlow<out T> : `SharedFlow`<T> { |
状态由其值表示。任何对值的更新都会反馈新值到所有流的接收器中。
StateFlow
基本使用
使用示例:
1 | class Test { |
结果输出如下,并且程序是没有停下来的。
1 | thread id: 14, thread name: DefaultDispatcher-worker-3 ---> unKnown |
StateFlow
的使用方式与 LiveData
类似。
MutableStateFlow
是可变类型的,即可以改变 value 的值。 StateFlow
则是只读的。这与 LiveData
、MutableLiveData
一样。为了程序的封装性。一般对外暴露不可变的只读变量。
输出结果证明:
StateFlow
是发射的数据可以被在不同的协程中,被多个接受者同时收集的。StateFlow
是热流,只要数据发生变化,就会发射数据。
程序没有停下来,因为在 StateFlow
的收集者调用 collect
会挂起当前协程,而且永远不会结束。
StateFlow
与 LiveData
的不同之处:
StateFlow
必须有初始值,LiveData
不需要。LiveData
会与 Activity 声明周期绑定,当 View 进入STOPED
状态时,LiveData.observer()
会自动取消注册,而从StateFlow
或任意其他数据流收集数据的操作并不会停止。
为什么使用 StateFlow
我们知道 LiveData
有如下特点:
- 只能在主线程更新数据,即使在子线程通过
postValue()
方法,最终也是将值 post 到主线程调用的setValue()
LiveData
是不防抖的LiveData
的transformation
是在主线程工作LiveData
需要正确处理 “粘性事件” 问题。
鉴于此,使用 StateFlow
可以轻松解决上述场景。
防止任务泄漏
使用代码手动追踪一千个协程的确是很困难的。你可以尝试去追踪它们,并且手动保证它们最后会完成或者取消,但是这样的代码冗余,而且容易出错。如果你的代码不够完美,你将失去对一个协程的追踪,我把它称之为任务泄露。Flow也是如此。
手动取消 StateFlow
的订阅者的协程,在 Android 中,可以从 Lifecycle.repeatOnLifecycle
块收集数据流。
对应代码如下:
1 | lifecycleSope.launch { |
SateFlow 只会发射最新的数据给订阅者。
我们修改上面代码:
1 | class Test { |
现在的场景是,先请求 getApi1()
, 一秒之后再次请求 getApi2()
, 这样 StateFlow
的值加上初始值,一共被赋值过 3 次。确保,三次赋值都完成后,我们再收集 StateFlow
中的数据。
输出结果如下:
1 | thread id: 13, thread name: DefaultDispatcher-worker-2 ---> hello, kotlin |
结果显示了,StateFlow
只会将最新的数据发射给订阅者。对比 LiveData
, LiveData
内部有 version 的概念,对于注册的订阅者,会根据 version 进行判断,将历史数据发送给订阅者。即所谓的“粘性”。我不认为 “粘性” 是 LiveData
的设计缺陷,我认为这是一种特性,有很多场景确实需要用到这种特性。StateFlow
则没有此特性。
那总不能需要用到这种特性的时候,我又使用 LiveData
吧?下面要说的 SharedFlow
就是用来解决此种场景的。
SharedFlow
如果只是需要管理一系列状态更新(即事件流),而非管理当前状态.则可以使用 SharedFlow
共享流。如果对发出的一连串值感兴趣,则这API十分方便。相比 LiveData
的版本控制,SharedFlow
则更灵活、更强大。
SharedFlow
也有两种类型:SharedFlow
和 MutableSharedFlow
:
1 | public interface `SharedFlow`<out T> : Flow<T> { |
SharedFlow
是一个流,其中包含可用作原子快照的 replayCache
。每个新的订阅者会先从replay cache中获取值,然后才收到新发出的值。
MutableSharedFlow
可用于从挂起或非挂起的上下文中发射值。顾名思义,可以重置 MutableSharedFlow
的 replayCache
。而且还将订阅者的数量作为 Flow 暴露出来。
实现自定义的 MutableSharedFlow
可能很麻烦。因此,官方提供了一些使用 SharedFlow
的便捷方法:
1 | public fun <T> Mutable`SharedFlow`( |
MutableSharedFlow
的参数解释在上面对应的注释中。
SharedFlow
基本使用
1 | class `SharedFlow`Test { |
输出结果如下:
1 | send data: 0 |
分析一下该结果:SharedFlow
每 200ms 发射一次数据,总共发射 21 个数据出来,耗时大约 4s。SharedFlow
的 replay 设置为 3, extraBufferCapacity
设置为2, 即 SharedFlow
的缓存为 5 。缓存溢出的处理策略是默认挂起的。
订阅者是在 3s 之后开始手机数据的。此时应该已经发射了 14 个数据,即 0-13, SharedFlow
的缓存为 8, 缓存的数据为 9-13, 但是,只给订阅者发送 3 个旧数据,即订阅者收集到的值是从 11 开始的。
MutableSharedFlow
的其它接口
MutableSharedFlow
还具有 subscriptionCount
属性,其中包含处于活跃状态的收集器的数量,以便相应地优化业务逻辑。MutableSharedFlow
还包含一个 resetReplayCache
函数,在不想重放已向数据流发送的最新信息的情况下使用。
安卓中使用协程
依赖:implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4
Android中使用协程的一些最佳做法
注入调度器
在创建新协程或调用 withContext
时,请勿对 Dispatchers
进行硬编码。
1 | // DO inject Dispatchers |
这种依赖项注入模式可以降低测试难度,因为您可以使用 TestCoroutineDispatcher
替换单元测试和插桩测试中的这些调度程序,以提高测试的确定性。
挂起函数应该保证线程安全
挂起函数应该保证对任意线程安全,不应该由挂起函数的调用方来切换线程。
1 | class NewsRepository(private val ioDispatcher: CoroutineDispatcher) { |
此模式可以提高应用的可伸缩性,因为调用挂起函数的类无需担心使用哪个 Dispatcher 来处理哪种类型的工作。该责任将由执行相关工作的类承担。
ViewModel 应创建协程
ViewModel 类应首选创建协程,而不是公开挂起函数来执行业务逻辑。如果只需要发出一个值,而不是使用数据流公开状态,ViewModel 中的挂起函数就会非常有用。
1 | // DO create coroutines in the ViewModel |
视图不应直接触发任何协程来执行业务逻辑,而应将这项工作委托给 ViewModel。这样一来,业务逻辑就会变得更易于测试,因为可以对 ViewModel 对象进行单元测试,而不必使用测试视图所必需的插桩测试。
此外,如果工作是在 viewModelScope 中启动,您的协程将在配置更改后自动保留。如果您改用 lifecycleScope 创建协程,则必须手动进行处理该操作。如果协程的存在时间需要比 ViewModel 的作用域更长,请查看“在业务和数据层中创建协程”部分。
直白点理解就是业务逻辑,应该在 ViewModel 中启动协程处理,而不是在 View 中。
注意:视图应对与界面相关的逻辑启动协程。例如,从互联网提取映像或设置字符串格式。
安卓Kotlin开发常见知识
SAM 转换
您可以通过实现 OnClickListener
接口来监听 Android 中的点击事件。Button
对象包含一个 setOnClickListener()
函数,该函数接受 OnClickListener
的实现。
OnClickListener
具有单一抽象方法 onClick()
,您必须实现该方法。因为 setOnClickListener()
始终接受 OnClickListener
作为参数,又因为 OnClickListener
始终都有相同的单一抽象方法,所以此实现在 Kotlin 中可以使用匿名函数来表示。此过程称为单一抽象方法转换,简称 SAM 转换。
例如,有这样一个 Kotlin 函数式接口:
1 | fun interface IntPredicate { |
如果不使用 SAM 转换,那么你需要像这样编写代码:
1 | // 创建一个类的实例 |
通过利用 Kotlin 的 SAM 转换,可以改为以下等效代码:
1 | // 通过 lambda 表达式创建一个实例 |
可以通过更短的 lambda 表达式替换所有不必要的代码。
1 | fun interface IntPredicate { |
SAM 转换可使代码明显变得更简洁。以下示例展示了如何使用 SAM 转换来为 Button
实现 OnClickListener
:
1 | loginButton.setOnClickListener { |
当用户点击 loginButton
时,系统会执行传递给 setOnClickListener()
的匿名函数中的代码。