1 | fun isPrime(n: Int): Boolean { |
02/80 统计素数个数-埃氏筛选法
埃拉托斯特尼筛法(简称埃氏筛)是一种高效的查找素数的算法,它通过排除从2开始到给定数n的所有非素数来找出所有小于或等于n的素数。使用埃氏筛选法改进统计数组中素数元素的个数的算法,我们可以先通过筛法找出数组中所有可能的素数,然后再统计数组中这些素数的个数。
这里是如何用Kotlin实现基于埃氏筛选法来统计一个数组中素数元素的个数:
- 构建埃氏筛: 根据数组中的最大值构建埃氏筛,以找到所有可能的素数。
- 统计素数: 遍历数组,利用构建的埃氏筛判断每个元素是否为素数,并计算素数的总数。
1 | fun countPrimesInArrayWithSieve(arr: IntArray): Int { |
在这个实现中,countPrimesInArrayWithSieve
函数首先找出数组中的最大值,以此来确定筛的大小。接着,它通过遍历2到sqrt(max)
的数来构建筛,标记所有这些数的倍数为非素数。最后,它遍历数组,统计那些标记为素数的元素个数。
埃氏筛选法的时间复杂度通常为 O(n log log n),相比暴力法的 O(n^2),它在处理大量数据时更加高效。
03/80 删除排序数组中的重复项
1 | fun removeDuplicates(nums: IntArray): Int { |
04/80 寻找数组的中心下标
要在Kotlin中实现寻找数组的中心下标的算法,你可以遵循这个基本思路:遍历数组,对于每个下标,计算其左侧所有元素的和与右侧所有元素的和。如果在某个下标位置,这两个和相等,那么这个下标就是数组的中心下标。
下面是一个具体的实现示例:
1 | fun findPivotIndex(nums: IntArray): Int { |
这个函数findPivotIndex
接受一个整数数组nums
作为参数,并返回中心下标。它首先计算数组的总和,然后遍历数组。在遍历过程中,它更新一个名为leftSum
的变量,该变量存储当前索引左侧所有元素的和。对于每个元素,它检查2 * leftSum + value
是否等于totalSum
。如果等于,这意味着左侧所有元素的和等于右侧所有元素的和,因此当前索引是中心下标,函数返回该索引。如果遍历完整个数组都没有找到这样的索引,函数返回-1。
要使用这个函数,你可以像这样调用它:
1 | fun main() { |
这个示例会输出数组[1, 7, 3, 6, 5, 6]
的中心下标。
Kotlin中的惰性操作容器——Sequence
Sequence序列
Sequence 是Kotlin标准库提供的一种容器类型。它和Iterable一样具备对集合进行多步骤操作能力,但是却是采用了一种完全不同于Iterable的实现方式:
1 | val map = (0..3).filter { |
上面的代码用来演示Iterable进行连续操作的情况。它的输出如下:
1 | filter:0 |
像map
和filter
这些链式集合函数它们都会立即执行并创建中间临时集用来保存数据。当原始数据不多时,这并不会有什么影响。但是,当原始数据量非常大的时候。这就会变的非常低效。而此时,就可以借助Sequence
提高效率。
1 | val sequence = (0..3).asSequence().filter { |
上面的代码执行结果如下:
1 | 准备开始执行 |
对比Iterable和Sequence:
Iterable是即时的、Sequence是惰性的:前者会要求尽早的计算结果,因此在多步骤处理链的每一环都会有中间产物也就是新的集合产生;后者会尽可能的延迟计算结果,Sequence处理的中间函数不进行任何计算。相反,他们返回一个新Sequence的,用新的操作装饰前一个,所有的这些计算都只是在类似toList的终端操作期间进行。
区分中间操作符和末端操作符的方式也很简单:如果操作符返回的是一个Sequence类型的数据,它就是中间操作符。
在操作的执行方式上也有所不同:Iterable每次都是在整个集合执行完操作后再进行下一步操作——采用第一个操作并将其应用于整个集合,然后移动到下一个操作,官方将其称呼为急切式或者按步骤执行(Eager/step-by-step);而Sequence则是逐个对每个元素执行所有操作。是一种惰性顺序——取第一个元素并应用所有操作,然后取下一个元素,依此类推。官方将其称呼为惰性式或者按元素执行(Lazy/element-by-element)
序列的惰性会带来一下几个优点:
- 它们的操作按照元素的自然顺序进行;
- 只做最少的操作;
- 元素可以是无限多个;
- 不需要在每一步都创建集合
Sequence可避免生成中间步骤的结果,从而提高了整个集合处理链的性能。但是,惰性性质也会带来一些运行开销。所以在使用时要权衡惰性开销和中间步骤开销,在Sequence和Iterable中选择更加合适的实现方式。
执行的顺序
1 | sequenceOf(1,2,3) |
sequence的执行时按照元素进行的,依次对元素执行所有的操作,对一个元素而言,所有操作时依次全部执行的。而普通集合操作则是以操作步骤进行的,当所有的元素执行完当前操作后才会进入下一个操作。
只做最少的操作
试想一下我们有一千万个数字,我们要经过几次变换取出20个,使用下面的代码对比一下序列和不同集合操作的性能:
1 | fun main(){ |
输出的结果如下:
1 | demoSequence:20ms:[-1, 1, 3, 5, 7, 9, 11, 13, 15, 17, 19, 21, 23, 25, 27, 29, 31, 33, 35, 37] |
这就是只执行最少操作的意思,序列按照元素顺序执行,当取够29个元素之后便会立即停止计算。而不同的集合则不同,没有中间操作的概念。它的每次操作都会对整个数组中的所有元素执行完才会进行下一个——也就是前两个map都要执行一千万次。
序列可以是无限的
看如下代码:
1 | var list = emptyArray<Int>() |
很明显,这段代码是没法正常运行的,因为这里有一个死循环。我们也无法创建一个无限长度的集合。但是:因为序列式按步骤依照需求进行处理的,所哟我们可以创建无限序列:
1 | val noEnd = sequence { |
但是一定要注意,我们虽然可以这么写,但是务必不能真的让while一直循环。我们不能直接使用toList。必须提供一个能结束循环的操作符,也就是不能取出所有元素(无限个)——要么使用类似take的操作来限制它们的数量,要么使用不需要所有元素的终端操作,例如first, find, any, all, indexOf等。
序列不会在每个步骤创建集合
普通的集合会在每次变换之后都会创建新的集合取存储所有变换后的元素。而每次创建集合和填入数据都会带来不小的性能开销。尤其是当我们处理大量或大量的集合时,性能问题会愈发凸显。而序列的按元素操作,则不会有这个问题。除非手动调用了终端操作符,否则不会生成新的集合。
Sequence的基本使用
Sequence序列的使用和普通的Iterable极其相似,实际上其内部也还是借助Iterable实现的。在研究它的内部实现原理之前,想从Sequence的创建和基本的序列操作来演示Sequence的基本用法。
序列的创建
创建Sequence的方式大概可以分为。分别是由元素创建、通过Iterable、借助函数以及由代码块创建。
由元素创建:通过调用顶级函数sequenceOf
实现:
1 | val ints = sequenceOf(1, 2, 3, 4, 5, 6, 7) |
通过Iterable转化:借助Iterable的扩展函数asSequence
实现:
1 | val ints = listOf(1, 2, 3, 4, 5, 6, 7).asSequence() |
通过generateSequence实现:该方法有三个:
1 | generateSequence(seedFunction: () -> T?, nextFunction: (T) -> T?): Sequence<T> |
最终都是通过GeneratorSequence
实现的,这里先不进行源码分析。只讨论使用方式:
- 其中三个函数都有的形参nextFunction可以理解为元素生成器,序列里的元素都通过调用该函数生成,当它返回为null是,序列停止生成(所以,nextFunction必须要在某个情况下返回null,否则会因为序列元素是无限多个触发java.lang.OutOfMemoryError: Java heap space异常)。
- 而另外两个的seedFunction和seed形参都是为了确定数据初始值的。区别在于一个直接指明,一个通过调用函数获取。
分别用这三个函数生成0~100的序列,代码如下:
1 | val generateSequenceOne = generateSequence { |
由代码块生成:借助sequence(block: suspend SequenceScope.() -> Unit)函数。改函数接受一个挂起函数,该函数会接受一个SequenceScope
实例,这个实例无需我们创建(后面源码分析会讲到)。SequenceScope提供了yield
和yieldAll
方法复杂返回序列元素给调用者,并暂停序列的执行,直到使用者请求下一个元素。
用该函数生成0~100的序列,代码如下:
1 | val ints = sequence { |
序列的操作
对序列的操作可以分为中间操作和末端操作两种。它们只要有一下另种区别:
- 中间操作返回惰性生成的一个新的序列,而末端序列则为其他普通的数据类型;
- 中间操作不会立刻执行代码,仅当执行了末端操作序列才会开始执行。
常见的中间操作包括:map、fliter、first、last、take等;它们会序列提供数据变化过滤等增强功能基本上和kotlin提供的集合操作符有着相同的功能。
常见的末端操作有:toList、toMutableList、sum、count等。它们在提供序列操作功能的同时,还会触发序列的运行。
Sequence源码分析
上文对序列做了简单的入门介绍。接下来深入源码去了解一下Sequence的实现方式。
Sequence是什么?
Kotlin对的定义Sequence很简单:
1 | public interface Sequence <out T> { |
就是一个接口,定义了一个返回Iterator的方法。接口本身只定义了Sequence具有返回一个迭代器的能力。具体的功能实现还是靠它的实现类完成。
可以概括一些:序列就是一个具备提供了迭代器能力的类。
序列的创建方式分析
结合上文中提到的序列的四种创建方式,我们依次分析一下它的创建流程。
我们首先以比较常用的通过Iterable转化获取序列,它需要借助asSequence
方法分析一下,使用listOf("a","b","c","d","e").asSequence()
生成一个序列。调用链如下:
1 | public fun <T> Iterable<T>.asSequence(): Sequence<T> { |
流程很简单,一个扩展函数加一个内联函数。最终通过匿名内部类的方式创建一个Sequence并返回。代码很好理解,实际上它的实现逻辑等同于下面的代码:
1 | val sequence = MySequence(listOf("a","b","c","d","e").iterator()) |
接着看一下通过调用顶级函数sequenceOf
实现,以sequenceOf("a","b","c","d","e")
为例,它的调用逻辑如下:
1 | public fun <T> sequenceOf(vararg elements: T): Sequence<T> = if (elements.isEmpty()) emptySequence() else elements.asSequence() |
可以看到依旧是借助asSequence实现的。
接下来看一下代码块和generateSequence的实现方式,这两个方式会比较复杂一点,毕竟前面两个都是借助List进行转换,而List本身就能提供迭代器Iterator。后面两个明显需要提供新的迭代器。 首先看一下代码看的实现方式:
1 | val ints = sequence { |
其中sequence的调用逻辑如下:
1 | public fun <T> sequence(@BuilderInference block: suspend SequenceScope<T>.() -> Unit): Sequence<T> = Sequence { iterator(block) } |
可以发现:该方法和asSequence一样最终也是通过匿名内部类的方式创建了一个Sequence。不过区别在于,该方法需要创建一个新的迭代器,也就是SequenceBuilderIterator
。同样以MySequence为例,它的创建流程等同于一下代码:
1 | fun mian(){ |
当然,这是不可能实现的,因为SequenceBuilderIterator是被private修饰了,我们是无法直接访问的。这里强制写出来演示一下它的流程。
最后看一下通过generateSequence方法创建序列的源码,一共有三个:
1 | public fun <T : Any> generateSequence(seedFunction: () -> T?, nextFunction: (T) -> T?): Sequence<T> = |
最终都是创建了GeneratorSequence
的一个实例并返回,而GeneratorSequence
实现了Sequence
接口并重写了iterator()
方法:
1 | private class GeneratorSequence<T : Any>(private val getInitialValue: () -> T?, private val getNextValue: (T) -> T?) : Sequence<T> { |
总结一下Sequence的创建大致可以分为三类:
- 使用List自带的迭代器通过匿名的方式创建Sequence实例,
sequenceOf("a","b","c","d","e")
和listOf("a","b","c","d","e").asSequence()
就是这种方式; - 创建新的
SequenceBuilderIterator
迭代器,并通过匿名的方式创建Sequence实例。例如使用代码块的创建方式。 - 创建
GeneratorSequence
,通过重写iterator()方法,使用匿名的方式创建Iterator。GeneratorSequence方法就是采用的这种方式。
看完创建方式,也没什么奇特的,就是一个提供迭代器的普通类。还是看不出是如何惰性执行操作的。接下来就分析一下惰性操作的原理。
序列的惰性原理
以最常用的map操作符为例:普通的集合操作源码如下:
1 | public inline fun <T, R> Iterable<T>.map(transform: (T) -> R): List<R> { |
可以看到:当List.map被调用后,便会立即创建新的集合,然后遍历老数据并进行变换操作。最后返回一个新的数据。这印证了上面提到的普通集合的操作时按照步骤且会立刻执行的理论。
接下来看一下序列的map方法,它的源码如下:
1 | public fun <T, R> Sequence<T>.map(transform: (T) -> R): Sequence<R> { |
代码并不复杂,它接收用户提供的变换函数和序列,然后创建了一个TransformingSequence并返回。TransformingSequence本身和上文中提到的序列没什么区别,唯一的区别在于它的迭代器:在通过next依次取数据的时候,并不是直接返回元素。而是先调用调用者提供的函数进行变换。返回变换后的数据——这也没什么新鲜的,和普通集合的map操作符和RxJava的Map都是同样的原理。
但是,这里却又有点不一样。操作符里没有任何开启迭代的代码,它只是对序列以及迭代进行了嵌套处理,并不会开启迭代.如果用户不手动调用(后者间接调用)迭代器的next函数,序列就不会被执行——这就是惰性执行的机制的原理所在。
而且,由于操作符返回的是一个Sequence类型的值,当你重复不断调用map时,例如下面的代码:
1 | (0..10).asSequence().map{add(it)}.map{add(it)}.map{add(it)}.toList() |
最终,序列sequence3的结构持有如下:sequence3-> sequence2 -> sequence1。而它们都有各自的迭代器。迭代器里都重写了各自的变换逻辑:
1 | override fun next(): R { |
而当我们通过sequence3.toList
执行代码时,它的流程如下:
1 | public fun <T> Sequence<T>.toList(): List<T> { |
经过几次扩展函数调用,最终在toCollection里开始执行迭代(Iterator的典型的操作),也就是获取了sequence3的iterator实例,并不断通过next取出数据。而在上文中的TransformingSequence源码里可以看到,TransformingSequence会持有上一个迭代器的实例(代码注释一)。
并且在迭代开始后,在进行transformer操作(也就是执行+1操作)前,会调用上一个迭代器的next方法进行迭代(代码注释二)。这样不断的迭代,最终,最终会调用到sequence1的next方法。再结合上文中的序列创建里的分析——sequence1里所持有的迭代器就是就是原始数据里的迭代器。
那么当最终执行toList方法时,它会循环sequence3.iterator
方法。而在每次循环内,都会首先执行sequence3所持有的sequence2.iterator的next方法。sequence2依次类推执行到sequence1的sequence1.iterator`方法,最终执行到我们原始数组的迭代器next方法:
整个流程如下:
原理就是这么简单:中间操作符通过序列嵌套,实现对迭代器iterator的嵌套。这样在进行迭代的时候,会依次调用各个iterator迭代器直到调用到原始集合数据里的迭代器开始并返回元素。而当元素返回时,会依次执行各个迭代器持有变换操作方法实现对数据的变换。
而其他操作符,也是遵循这个基本的规则。无非就是增加一些其他的操作。
总结
- 序列通过中间操作符对迭代器进行嵌套和复写,以此实现按元素操作执行变换;
- 中间操作符只负责根据需求创建并嵌套迭代器,并不负责开启迭代器。以此实现惰性操作且不产生临时集合;
- 末端操作符负责开启迭代,按照嵌套顺序执行迭代操作。依次获取操作后的数据,并且会创建新的集合用来存储最终数据;
- 序列不是万能的,因为要引入新的对象。在带来惰性和顺序执行的优势时,这些对象必然会带来性能开销。所以要依需求在集合和序列之间进行选择,使用合适的方式进行迭代。
Kotlin中的自动拆装箱
在 Kotlin 中,对于基本数据类型的包装类,比如 Integer
、Boolean
等,Kotlin 设计了一套特殊的类,被称为原生类型的包装类或者叫做原生类型的对象,例如 Int
、Boolean
等。这些类的行为表现得如同 Java 的基本类型,同时它们具备了对象的一些特性。在编译阶段,Kotlin 会尽量使用 JVM 的原生类型来提高性能,但在需要时(例如作为泛型参数时),这些原生类型会自动装箱。
自动装箱与拆箱
Kotlin 处理原生类型和装箱类型的自动转换,以保证性能同时提供丰富的类库支持。这个过程包括两个部分:自动装箱(boxing)和自动拆箱(unboxing)。
- 装箱(Boxing):当一个原生类型的值需要作为对象处理时,它会自动被装入对应的包装类。例如,当你将一个
int
值放入一个泛型集合如List<Int>
时,这个值会自动被装箱成Integer
。 - 拆箱(Unboxing):当从对象中需要一个原生类型的值时,这个包装对象会自动被拆箱。例如,从
List<Int>
中取出一个元素时,它会自动从Integer
转换为int
。
示例
1 | val list: List<Int> = listOf(1, 2, 3) // 装箱 |
在上面的例子中,整数列表中的数字自动被装箱成 Integer
类型的对象以存入 List<Int>
。当从列表中检索一个整数时,它自动拆箱回 Int
类型。
注意事项
虽然 Kotlin 试图隐藏装箱和拆箱的复杂性,但在某些情况下,装箱对象的身份不会保留。例如,两个独立装箱的整数可能不会在内存中具有相同的引用:
1 | val a: Int = 1000 |
在上面的代码中,boxedA
和 anotherBoxedA
是相同原始值的两个独立的装箱实例。使用 ===
比较它们的引用时可能得到 false
,因为它们可能指向不同的对象。
总结
Kotlin 在编写代码时提供了类似于基本数据类型的简洁性和效率,同时也保留了对象的灵活性。通过自动装箱和拆箱,Kotlin 旨在提供无缝的集合操作和泛型支持,同时减少需要程序员关注的底层细节。
Kotlin中有哪些类
在 Kotlin 中,类的概念是非常广泛的,包括各种类型的类设计用于不同的目的和场景。Kotlin 提供了丰富的类类型以支持现代软件开发的需要。下面是一些在 Kotlin 中常见的类类型:
1. 数据类(Data Class)
数据类是专门用于存储数据的类。Kotlin 的数据类通过 data
关键字定义,它自动从所声明的属性中派生出 equals()
、hashCode()
和 toString()
等方法,以及 copy()
函数和 componentN()
函数(按声明顺序对应于所有属性)。
1 | data class User(val name: String, val age: Int) |
2. 枚举类(Enum Class)
枚举类用于定义一组命名常量。Kotlin 中的枚举不仅可以有属性,还可以有自己的方法。
1 | enum class Direction { |
3. 密封类(Sealed Class)
密封类用于表示受限的类层次结构,即一个值只能是有限集合中的某个类型,而不能是任何其他类型。这对于当你在使用 when
表达式时,想要确保覆盖所有可能的类型非常有用。
1 | sealed class Expr |
4. 抽象类(Abstract Class)
抽象类是不能被实例化的类,通常用作其他类的基类。抽象类可以包含抽象方法(没有实现的方法)和非抽象方法。
1 | abstract class Vehicle { |
5. 内部类(Inner Class)
内部类是定义在另一个类内部的类。内部类持有其外部类的一个引用,因此可以访问其成员。
1 | class Outer { |
6. 嵌套类(Nested Class)
与内部类相比,嵌套类没有对外部类的隐式引用。
1 | class Outer { |
7. 对象声明(Object Declaration)
Kotlin 支持对象声明,这是实现单例模式的一种方式。对象声明的实例自动成为一个单例。
1 | object DataProviderManager { |
8. 伴生对象(Companion Object)
在 Kotlin 中,没有静态方法,但可以用伴生对象来模拟静态方法的效果。伴生对象的成员可以通过类名直接访问。
1 | class MyClass { |
9. 接口(Interface)
虽然不是类,但接口在 Kotlin 中用于定义可以由类实现或继承的协定。
1 | interface Drivable { |
这些类类型展示了 Kotlin 语言的灵活性和现代特性,旨在提供简洁而强大的语法来支持各种编程范式和设计模式。
密封类
在 Kotlin 中,密封类(sealed class)是一种特殊的类,它用于表示严格的类层次结构。使用密封类,你可以定义一个类的可能的子类集合,而且这些子类只能在与密封类相同的文件中定义。这种限制确保了除文件内定义的子类之外,无法有其他子类存在,从而使得使用时更加安全和维护更加方便。
密封类的主要特点和优势:
受限的继承:
- 密封类本身是抽象的,不能直接实例化,只能通过其子类进行实例化。
- 所有的子类必须与密封类在同一个文件中声明,这提高了可维护性,因为所有扩展都在一个集中的位置。
类型安全:
- 密封类非常适合用在
when
表达式中,因为它们可以确保覆盖所有可能的情况,不需要再添加一个else
子句。这是因为编译器能够检测到所有定义的子类。
- 密封类非常适合用在
更精确的控制:
- 使用密封类可以精确控制类的继承结构,这对于构建不可变数据类型和状态管理非常有用。
密封类的用法示例:
首先,定义一个密封类,然后在同一个文件中定义其所有子类:
1 | sealed class Expr { |
在这个例子中,Expr
是一个密封类,有三个子类:Const
、Sum
和 NotANumber
。这使得 eval
函数可以安全地使用 when
表达式来处理所有可能的 Expr
类型,而不需要 else
分支,因为编译器知道所有可能的子类。
使用密封类的场景:
- 状态管理:在应用程序状态管理或者在处理有限状态机(FSM)时,密封类提供了一种清晰的方式来表示所有可能的状态。
- 返回类型的多样性:在函数需要返回多种类型的结果时,可以使用密封类来封装这些不同类型的返回值。
- 在模式匹配中增强类型安全:如上面示例中的
eval
函数,使用密封类可以确保when
表达式已经处理了所有可能的情况,这在处理复杂的逻辑分支时非常有帮助。
通过这种方式,Kotlin 的密封类增加了代码的安全性和清晰度,特别是在需要表达一个有限的类层次结构时。
内联类
Kotlin 1.3 引入了内联类,主要目的是提供一种无开销的抽象方式。内联类允许你创建一个包含单个属性的类,当这个类被使用时,它会在编译时被内联,即直接替换为它包含的那个值,从而避免了额外的内存分配和间接访问。
内联类的定义和使用
内联类定义时需要使用 inline
关键字,且必须有一个主构造函数,该构造函数恰好接收一个参数:
1 | inline class Password(val value: String) |
这里的 Password
类包裹了一个字符串,但在编译后,Kotlin 编译器会尽可能将 Password
类的实例替换为简单的 String
类型,从而减少对象创建的开销。当你在代码中使用 Password
类型时,例如将它作为函数参数或从函数中返回时,实际上传递的将是一个 String
类型。
内联类的特点和优势
- 性能优化:内联类主要用于性能优化,可以避免对象分配,并减少方法调用的层次。
- 类型安全:虽然内联类在运行时表现为它们包装的类型(例如
String
或Int
),但在编译时,它们是不同的类型。这意味着你可以用它们来实现类型安全的操作,例如防止将普通字符串与经过验证的密码字符串混淆。 - 限制:内联类不能有初始化块 (
init
块),它们也不能包含其他属性或构造函数。此外,内联类可以实现接口,但不能从其他类继承。
示例代码
1 | inline class Password(val value: String) |
在这个例子中,尽管我们定义了一个名为 Password
的内联类,并在函数 takePassword
中使用它,实际上,在编译后,这些函数调用会直接使用 String
类型,而不会有任何包装和解包的性能开销。
结论
内联类是 Kotlin 提供的一种非常有用的特性,特别适合那些需要通过类型来提供更丰富语义但又不想引入运行时开销的场景。通过内联类,Kotlin 开发者可以在享受类型安全的同时,保持代码的高性能。
Kotlin 的协程本质到底什么
正文
几乎就是用阻塞的写法来完成非阻塞的任务。
Kotlin-JVM中所谓的协程是假协程
Kotlin-JVM中所谓的 协程挂起 ,就是开启了一个子线程去执行任务
对于Java来说,不管你用什么方法,只要你没有魔改JVM,那么最终你代码里start几个线程,操作系统就会创建几个线程,是1比1的关系。
Kotlin官网中那个创建10w个Kotlin协程没有oom的例子其实有误导性,本质上那10w个Kotlin协程就是10w个并发任务仅此而已,他下面运行的就是一个单线程的线程池。你往一个线程池里面丢多少个任务都不会OOM的(前提是你的线程池创建的时候设定了对应的拒绝策略,否则无界队列下,任务过多一定会OOM),因为在运行的始终是那几个线程。
创建协程的方式有五种:
1 | GlobalScope.launch{} |
协程中的取消和异常 (取消操作详解)
正文
在开发中,我们要避免不必要的的任务来节约设备的内存和电量的使用,协程也是如此。在使用的过程我们需要控制好它的生命周期,在不需要它的取消它。
调用cancel方法
取消作用域会取消它的子协程
当启动了很多个协程,我们一个个协程的取消比较麻烦,我们可以通过取消整个作用域来解决这个问题,因为取消作用域可以取消该作用域创建的所有协程。
1 | / 假设我们已经定义了一个作用域 |
假设我们创建了一个作用域scope,并创建了两个协程job1和job2。我们通过调用scope.cancel(),取消作用域,将会把job1 和job2两个协程都取消。
单独取消某个协程,不会影响他的兄弟协程
我们创建了两个协程,job1和job2.我们单独取消job1,不会影响到job2
1 | // 假设我们已经定义了一个作用域 |
协程通过抛出一个特殊的异常 CancellationException 来处理取消操作
在调用cancel函数的时候,我们需要传入一个CancellationException对象,如果我们没有传入,那就用默认的defaultCancellationException。
1 | // external cancel with cause, never invoked implicitly from internal machinery |
一旦抛出了CancellationException,我们就可以通过这一机制来处理协程的取消。在底层的实现中,子协程会通过抛出异常的方式将取消的情况通知它的父级,父协程通过传入的取消原因决定是否处理该异常。
不能在已取消的作用域中再次启动新的协程
调用了 cancel 方法为什么协程处理的任务没有停止?
不同的Diapatcher不同的区别,下一篇文章将介绍。
我们以Dispatchers.Default为例子
1 | import kotlinx.coroutines.* |
1 | 这是第0次 |
调用cancel方法之后,协程的任务依然在运行。调用cancel方法的时候,此时协程处于cancelling正在取消的状态,接着我们打印了2,3,4,处理任务结束之后,协程变成cancelled已经取消的状态,这是以Default举例,Default调度会等待协程任务处理完毕才取消。
让协程可以被取消
协程处理任务都是协作式的,协作的意思就是我们的处理任务要配合协程取消做处理。因此在执行任务期间我们要定时检查协程的状态是否已经取消,例如我们从磁盘读取文件之前我们先检查协程是否被取消了。
1 | val job = launch { |
协程中的挂起函数都是可取消的,使用他们的时候,我们不需要检查协程是否已取消。例如withContext,delay 。如果没有这些挂起函数,为了让我们的代码配合协程取消,可以使用一下两种方法:
- 检查 job.isActive 或者使用 ensureActive()
- 使用 yield() 来让其他任务进行
检查 job 的活跃状态
先看一下第一种方法,在我们的 while(i<5) 循环中添加对于协程状态的检查:
1 | // 因为处于 launch 的代码块中,可以访问到 job.isActive 属性 |
使用 yield() 函数运行其他任务
Job.join 和 Deferred.await cancellation
等待协程处理结果有两种方法,launch启动的job可以调用join,async 返回的Deferred 可以调用await方法
- job.join会让协程挂起,直到等待协程处理任务完毕,我们可以配合cancel使用
- deferred.await()如果我们关心协程的处理结果,我们可以使用deferred。结果由deferred.await返回。也是job类型,也可以被取消。
处理协程取消的副作用
当我们需要在协程取消 后处理一些清理的工作,或者做一些打印日志。我们有几种办法:
- 通过检查协程的状态
1 | while (i < 5 && isActive) { |
当判断协程不是isActive状态的时候,我们可以做一些清理
- try catch finally
我们知道协程的取消会抛出CancellationException 异常,我们可以在协程提中使用try catch finally,在finally中做我们的一些清理的工作,或者打印日志
1 | val job = launch { |
已经取消的协程,不能再被挂起
已经取消的协程,不能再被挂起,但是当我们需要在取消的协程中调用挂起函数,那么我们可以在finally中使用NonCancellable ,意思是让协程挂起,直到处理挂起函数中的代码完毕,协程才会取消。
1 | val job = launch { |
在jetpack中使用viewModelScope 或者lifecycleScope 中定义的作用域,他们在scope完成后取消他们的处理任务。如果我们手动创建自己的作用域CoroutineScope,我们需要协作协程,将我们的作用域和job绑定,在需要的时候取消。
协程核心概念的理解与应用(一)--翻译翻译什么是 CoroutineScope
作者:朕小猫与GPT4
Android 如何在一个非 LifecycleOwner 的类中使用协程
在非LifecycleOwner
的类中使用协程,你不能直接使用与生命周期相关的作用域,如lifecycleScope
或viewModelScope
。不过,你可以使用CoroutineScope
来创建自己的作用域。使用CoroutineScope
,你可以定义一个作用域,并指定一个Dispatcher
来控制协程的执行线程。此外,你还需要手动管理协程的生命周期,以避免内存泄漏。
以下是如何在非LifecycleOwner
类中使用协程的一个基本示例:
定义协程作用域:首先,定义一个协程作用域,通常是类的成员变量。这个作用域应当与你希望协程执行的生命周期绑定。例如,如果你在一个普通类中工作,你可能需要在类被销毁时取消所有协程。
1
2
3
4
5
6
7
8import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
class MyClass {
private val job = Job()
private val coroutineScope = CoroutineScope(Dispatchers.Main + job)
}在协程作用域内启动协程:使用定义的协程作用域启动协程,执行异步操作。
1
2
3
4
5
6
7
8
9
10
11import kotlinx.coroutines.launch
class MyClass {
// ...前面的代码
fun doSomething() {
coroutineScope.launch {
// 在这里执行协程代码
}
}
}管理协程生命周期:为避免内存泄漏,确保在不需要协程运行时取消它们。如果你的类有一个明确的生命周期结束点(如自定义视图的
onDetachedFromWindow
或者一个对象的close
方法),你应当在那里取消作用域中的所有协程。1
2
3fun onDestroy() {
job.cancel() // 取消与这个作用域相关的所有协程
}
通过以上步骤,你可以在不依赖于LifecycleOwner
的情况下,安全地在任何类中使用协程。记住,手动管理协程的生命周期是避免内存泄漏的关键。
如何理解 CoroutineContext
CoroutineContext
是Kotlin协程中用于持有协程相关配置的接口。它由一组元素组成,每个元素都实现了CoroutineContext.Element
接口,并且通过一个唯一的Key
进行标识。CoroutineContext
的实现并不限于某些固定的子类,而是通过组合不同的Element
实例来扩展其功能。这意味着CoroutineContext
的“子类”实际上是指一系列标准库或自定义库中定义的实现了CoroutineContext.Element
接口的类,这些类代表了不同的协程配置和特性。以下是一些常见的CoroutineContext.Element
实现,它们在Kotlin协程的使用中扮演着关键角色:
Job
: 控制协程的生命周期,允许启动、取消、和查询协程的状态。Job
是协程工作的基本单位,可以被用来取消协程。CoroutineDispatcher
: 决定协程在哪个线程或线程池上执行。Dispatchers.Main
、Dispatchers.IO
、和Dispatchers.Default
是最常用的几种调度器。CoroutineName
: 提供协程的名称,有助于调试。CoroutineExceptionHandler
: 定义了协程如何处理未捕获的异常。可以用来全局或局部地处理异常。
除了上述提到的标准元素外,开发者还可以实现自定义的CoroutineContext.Element
来扩展协程的功能。每个元素都可以通过其Key
加入到CoroutineContext
中,同时保证CoroutineContext
中的每个Key
都是唯一的。
在实际应用中,这些元素可以通过+
操作符组合在一起,形成一个包含多个配置的CoroutineContext
,用于启动和管理协程。这种设计允许协程上下文在保持灵活性的同时,能够非常精确地控制协程的行为和环境。
协程的 + 号代表什么
1 | /** |
这个plus
函数是CoroutineContext
的一个操作符函数,用于合并两个协程上下文(CoroutineContext
),返回一个包含两者元素的新上下文。如果两个上下文中有相同键(key
)的元素,那么原上下文(this
)中的元素会被丢弃。这个函数的主要逻辑可以分解为几个关键步骤:
快速路径检查:如果要添加的上下文(
context
)是一个空的协程上下文(EmptyCoroutineContext
),则直接返回当前上下文(this
),因为没有新元素需要添加。遍历合并:使用
fold
方法遍历context
中的每个元素。fold
接收一个初始累加值(这里是this
,即当前上下文)和一个操作函数。操作函数定义了如何将context
中的元素合并到累加值中。去除相同键的元素:对于
context
中的每个元素,先从累加值(即目前合并的结果)中移除具有相同键的元素。这一步确保了如果有相同键的元素,那么this
中的元素会被context
中的元素替换。合并上下文:如果移除相同键元素后的上下文是空的(
EmptyCoroutineContext
),则只添加当前遍历到的元素。否则,检查是否存在ContinuationInterceptor
元素(协程拦截器),因为拦截器应当始终位于上下文中的最后,以便快速获取。调整拦截器位置:如果存在
ContinuationInterceptor
,将其移动到合并后的上下文的最后。这涉及到在没有拦截器的剩余上下文中添加当前元素,然后再添加拦截器。返回新的合并上下文:最终返回包含了
this
和context
元素(且context
中的元素优先)的新上下文。如果存在ContinuationInterceptor
,确保它位于最后。
简而言之,这个函数通过合并两个协程上下文并调整元素顺序,确保了新上下文中不会有重复的键,且ContinuationInterceptor
(如果存在)总是位于最后,以优化性能。
如何理解其中的 CoroutineContext 与 key,为什么context中的元素优先,为什么如果存在ContinuationInterceptor,确保它位于最后。
CoroutineContext
在Kotlin协程中是一个关键概念,它是一组各种元素的集合,这些元素定义了协程的行为。每个元素都有一个Key
,用于唯一标识该元素的类型,这样CoroutineContext
就可以通过键值对的方式存储和检索协程的配置和状态信息。这里的“相同键(key)的元素”意味着两个元素属于同一类型,或者说执行相同的功能或角色。
CoroutineContext
和 key
**
CoroutineContext
:是一个集合,包含了控制协程行为的各种元素,例如调度器(Dispatcher
)、协程名称、协程的异常处理器等。每个元素通过一个唯一的键(Key
**)来识别,键通常由元素类型本身提供。相同键的元素:如果两个
CoroutineContext
元素具有相同的键,它们被视为同一类型的配置或状态信息。在合并上下文时,不能有重复键的元素,否则会产生冲突。
为什么context
中的元素优先
在合并两个CoroutineContext
时,如果存在相同键的元素,context
参数中的元素会替换当前上下文(this
)中的元素。这个设计选择是基于函数式编程中常见的不可变性原则,以及配置更新的需求:新提供的配置(即context
参数中的)被认为是最新的,应优先于旧的配置。这样,开发者可以灵活更新协程的行为,而无需担心旧上下文的状态。
为什么ContinuationInterceptor
位于最后
ContinuationInterceptor
是CoroutineContext
中的一个特殊元素,用于拦截协程的执行。它是协程调度器(如Dispatchers.Main
)的基础,控制着协程的执行线程。将ContinuationInterceptor
放在合并后的CoroutineContext
的最后,是出于性能考虑:
快速访问:协程在执行时频繁查询
ContinuationInterceptor
以确定执行线程。将其置于上下文的末尾可以优化这一查询过程,因为在CoroutineContext
的实现中,较后的元素可以更快地被访问到。避免冲突:确保
ContinuationInterceptor
总是最后一个被添加,意味着即使合并多个上下文,也能保证只有一个有效的调度器在最终的上下文中生效,这避免了多个调度器可能导致的潜在冲突。
综上所述,CoroutineContext
及其键的概念允许协程行为的灵活配置和更新,而在合并上下文时优先考虑新的元素以及保持ContinuationInterceptor
在最后,是出于更新优先级和性能优化的考虑。