Kotlin 协程 (八) ——— Flow 简介

2,820 阅读6分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

通过前面的学习,我们知道,当需要执行异步任务并获取其结果时,可以通过开启一个协程来实现。那么,当我们需要执行多个异步任务并获得每个异步任务的结果时,我们应该怎么做呢?

没错,我们可以通过开启多个协程来实现。但更好的方式是使用 Flow 来实现。Flow 指的是异步流,它可以很方便地获取多个异步任务的结果。

一、Flow 的创建

Flow 有三种构建器:

  • flow:通用流构建器。
  • flowOf:用于发射固定值。
  • asFlow:这是一个扩展函数,用来将集合或序列转换为流。

flow 是一个构造函数,用来生成一个 Flow 对象。在 Flow 中,使用 emit() 可以发射一个值,使用 collect() 可以收集发射的值。

举个简单使用 Flow 的例子,构造一个每隔一秒发射一个 Int 值的 Flow,并用 collect() 收集发射的值:

fun main() {
    runBlocking {
        flow {
            repeat(3) {
                delay(1000)
                emit(it)
            }
        }.collect {
            println(it)
        }
    }
}

运行程序,输出如下:

0
1
2

Flow 被构建出来后,可以多次执行,获得相同的数据:

fun main() {
    runBlocking {
        val flow = simpleFlow()
        println("collect")
        flow.collect { println(it) }
        println("collect again")
        flow.collect { println(it) }
    }
}

fun simpleFlow() = flow<Int> {
    println("Flow started")
    repeat(3) {
        delay(1000)
        emit(it)
    }
}

运行程序,输出如下:

collect
Flow started
0
1
2
collect again
Flow started
0
1
2

从输出中我们可以看出,Flow 在构建时并没有立即执行,是在收集后才开始执行的。

这说明 Flow 是一种类似于序列的冷流,冷流和热流的区别是:

  • 冷流中的代码直到流被收集的时候才会执行。
  • 热流中的代码会立即执行,不管有没有被收集。

可以看出,冷流与热流的区别和懒汉式与饿汉式的区别很类似,冷流只会在需要时才开始运行,而热流会立即运行。在 Kotlin 中,与 Flow 对应的热流是 Channel,关于 Channel 的内容我们将在之后的文章中学习,本文暂且不提。

二、Flow 的生命周期

一般来讲,Flow 会经过 onStart()、collect()、onCompletion() 三个生命周期:

注:严格来说,collect() 并不属于生命周期,笔者这么讲只是为了便于理解。

fun main() {
    runBlocking {
        flow {
            for (i in 1..5) {
                delay(100)
                emit(i)
            }
        }.onStart {
            println("onStart")
        }.onCompletion {
            println("onCompletion")
        }.collect {
            println(it)
        }
    }
}

运行程序,输出如下:

onStart
1
2
3
4
5
onCompletion

在发生异常时,onComplete() 中可以收到异常,也可以通过 catch() 操作捕捉到异常信息:

fun main() {
    runBlocking {
        flow {
            for (i in 1..5) {
                delay(100)
                emit(i)
                if (i == 3) throw Exception("my error")
            }
        }.onStart {
            println("onStart")
        }.onCompletion { exception ->
            println("onCompletion: $exception")
        }.catch { exception ->
            println("catch: $exception")
        }.collect {
            println(it)
        }
    }
}

运行程序,输出如下:

onStart
1
2
3
onCompletion: java.lang.Exception: my error
catch: java.lang.Exception: my error

需要注意的是,catch() 和 onCompletion() 一起使用时,两者的顺序会影响运行结果:

  • 如果在 onCompletion() 之前调用 catch(),只有 catch() 中能收到异常信息,onCompletion() 中将收不到异常信息。
  • 如果在 onCompletion() 之后调用 catch(),onCompletion() 和 catch() 中都可以收到异常信息。

顺序之所以会影响结果,是因为流具有连续性:流的每次单独收集都是按顺序执行的(除非使用特殊操作符),从上游到下游的每个过渡操作符都会处理每个发射出的值,然后再交给末端操作符。

onCompletion() 能收到异常。但它并不会捕获这个异常,而是处理完后把异常抛出去。所以在 onCompletion() 之后的 catch() 能够捕获到异常信息。

catch() 能收到异常,也会捕获异常。异常被捕获之后,就不会再往外抛了,所以如果 onCompletion() 写在 catch() 之后,将收不到异常信息。

三、Flow 上下文切换

默认情况下,流的收集和流的构建共用同一个协程上下文,流的这个属性称为上下文保存。

但这种应用场景较少,通常我们需要的是 Flow 在子线程中构建,在主线程中收集。比如网络请求、文件读写,都需要在子线程中执行任务,在主线程中更新任务执行状态。

这时我们需要用到 flowOn() 操作符更改上下文。flowOn() 会将其之前的代码块设置为指定的线程。

fun main() {
    runBlocking {
        flow {
            println("start: ${Thread.currentThread().name}")
            repeat(3) {
                delay(1000)
                this.emit(it)
            }
            println("end: ${Thread.currentThread().name}")
        }.flowOn(Dispatchers.Default).collect {
            println("collect: $it, ${Thread.currentThread().name}")
        }
    }
}

运行程序,输出如下:

start: DefaultDispatcher-worker-1
collect: 0, main
collect: 1, main
end: DefaultDispatcher-worker-1
collect: 2, main

可以看出,数据的发射和数据的收集所在的线程是不一样的。因此 end: DefaultDispatcher-worker-1collect: 2, main 打印的先后顺序也不是固定的。

注:在 Flow 中不允许使用 withContext 切换上下文。

如果想要修改收集流的协程,可以通过 launchIn() 函数,使用 onEach() + launchIn() 替换 collect() 可以指定流的收集所在的协程。

例如:

viewModelScope.launch {
    flow {
        Log.d("~~~", "start: ${Thread.currentThread().name}")
        repeat(3) {
            delay(1000)
            this.emit(it)
        }
        Log.d("~~~", "end: ${Thread.currentThread().name}")
    }
        .flowOn(Dispatchers.Main)
        .onEach {
            Log.d("~~~", "collect: $it, ${Thread.currentThread().name}")
        }
        .launchIn(CoroutineScope(Dispatchers.IO))
        .join()
}

在这个例子中,我们将 Flow 的构建指定在主线程,收集指定在 IO 线程。运行程序,输出如下:

D/~~~: start: main
D/~~~: collect: 0, DefaultDispatcher-worker-1
D/~~~: collect: 1, DefaultDispatcher-worker-1
D/~~~: end: main
D/~~~: collect: 2, DefaultDispatcher-worker-1

launchIn() 函数的源码很简单:

public fun <T> Flow<T>.launchIn(scope: CoroutineScope): Job = scope.launch {
    collect() // tail-call
}

可以看到,launchIn() 会返回一个 Job 对象,有了 Job 对象,我们还可以在收集的中途取消收集。

四、Flow 的取消

Flow 在使用 emit() 发射数据时,会通过 ensureActive() 检测当前 Flow 是否处于活跃状态,如果不在活跃状态,则会直接抛出 CancellationException 异常并取消整个 Flow。

举个例子:

runBlocking {
    flow {
        (1..5).forEach {
            emit(it)
        }
    }.collect {
        println(it)
        if (it == 3) cancel()
    }
}

运行程序,输出如下:

1
2
3
Exception in thread "main" kotlinx.coroutines.JobCancellationException: BlockingCoroutine was cancelled; job=BlockingCoroutine{Cancelled}@365185bd

可以看到,正是因为这个特性,使得 Flow 在执行 CPU 密集型任务时也能及时取消。

但出于对性能的考虑,大多数其他操作不会通过 ensureActive() 检测 Flow 的状态,需要开发者明确指定是否检测。

不能被取消的例子:

runBlocking {
    (1..5).asFlow().collect {
        println(it)
        if (it == 3) cancel()
    }
}

运行程序,输出如下:

1
2
3
4
5
Exception in thread "main" kotlinx.coroutines.JobCancellationException: BlockingCoroutine was cancelled; job=BlockingCoroutine{Cancelled}@77ec78b9

这里我们没有通过 emit() 发射数据,而是通过 asFlow() 将 Kotlin 中的 Range 对象转成流发射,在收集的途中调用 cancel() 函数尝试取消流。

可以看到 Flow 并没有被及时取消,因为这时候 Flow 在发射数据时不会通过 ensureActive() 检测 Flow 的状态。

想要解决这个问题,需要使用 cancellable() 函数,这个函数表示此 Flow 需要执行取消检测:

fun main() {
    runBlocking {
        flow {
            (1..5).forEach {
                emit(it)
            }
        }.cancellable().collect {
            println(it)
            if (it == 3) cancel()
        }
    }
}

运行程序,输出如下:

1
2
3
Exception in thread "main" kotlinx.coroutines.JobCancellationException: BlockingCoroutine was cancelled; job=BlockingCoroutine{Cancelled}@42607a4f

可以看到,Flow 被及时取消了。

五、小结

Flow 可以用来收集多个异步任务的结果,本文我们学习了 Flow 的基本用法,包括 Flow 的构建,Flow 的生命周期,Flow 的上下文切换,Flow 的取消。Flow 在实际开发中非常实用,后续我们还会继续介绍 Flow 的一些特性。