【譯】kotlin 協程官方文檔(5)- 異步流

最近一直在了解關于 Kotlin協程 的知識,那最好的學習資料自然是官方提供的學習文檔了,看了看后我就萌生了翻譯官方文檔的想法。前后花了要接近一個月時間,一共九篇文章,在這里也分享出來,希望對讀者有所幫助。個人知識所限,有些翻譯得不是太順暢,也希望讀者能提出意見

協程官方文檔:coroutines-guide

掛起函數可以異步返回單個值,但如何返回多個異步計算值呢?這就是 kotlin Flows(流) 的用處了

一、表示多個值

可以使用集合在 kotlin 中表示多個值。例如,有一個函數 foo(),它返回包含三個數字的 List,然后使用 forEach 打印它們

fun foo(): List<Int> = listOf(1, 2, 3)
 
fun main() {
    foo().forEach { value -> println(value) } 
}

輸出結果:

1
2
3

1.1、序列

如果我們使用一些 CPU 消耗型 的阻塞代碼(每次計算需要100毫秒)來計算數字,那么我們可以使用一個序列(Sequence)來表示數字:

fun foo(): Sequence<Int> = sequence {
    // sequence builder
    for (i in 1..3) {
        Thread.sleep(100) // pretend we are computing it
        yield(i) // yield next value
    }
}

fun main() {
    foo().forEach { value -> println(value) }
}

這段代碼輸出相同的數字列表,但每打印一個數字前都需要等待100毫秒

1.2、掛起函數

上一節的代碼的計算操作會阻塞運行代碼的主線程。當這些值由異步代碼計算時,我們可以用 suspend 修飾符標記函數 foo,以便它可以在不阻塞的情況下執行其工作,并將結果作為列表返回

import kotlinx.coroutines.*                 
                           
//sampleStart
suspend fun foo(): List<Int> {
    delay(1000) // pretend we are doing something asynchronous here
    return listOf(1, 2, 3)
}

fun main() = runBlocking<Unit> {
    foo().forEach { value -> println(value) } 
}
//sampleEnd

這段代碼在等待一秒后輸出數字

1.3、Flows

使用 List< Int > 作為返回值類型,意味著我們只能同時返回所有值。為了表示異步計算的值流,我們可以使用 Flow< Int > 類型,就像同步計算值的 Sequence< Int > 類型一樣

//sampleStart
fun foo(): Flow<Int> = flow { // flow builder
    for (i in 1..3) {
        delay(100) // pretend we are doing something useful here
        emit(i) // emit next value
    }
}

fun main() = runBlocking<Unit> {
    // Launch a concurrent coroutine to check if the main thread is blocked
    launch {
        for (k in 1..3) {
            println("I'm not blocked $k")
            delay(100)
        }
    }
    // Collect the flow
    foo().collect { value -> println(value) }
}
//sampleEnd

此代碼在打印每個數字前等待100毫秒,但不會阻塞主線程。通過從主線程中運行的單獨協程中每隔100毫秒打印了一次 “I'm not blocked”,可以驗證這一點:

I'm not blocked 1
1
I'm not blocked 2
2
I'm not blocked 3
3

請注意,代碼與前面示例中的 Flow 有以下不同:

  • Flow 類型的構造器函數名為 flow
  • flow{...} 中的代碼塊可以掛起
  • foo 函數不再標記 suspend 修飾符
  • 值通過 emit 函數從流中發出
  • 通過 collect 函數從 flow 中取值

我們可以用 Thread.sleep 來代替 flow{...} 中的 delay,可以看到在這種情況下主線程被阻塞住了

二、流是冷的

Flows 是冷流(cold streams),類似于序列(sequences),flow builder 中的代碼在開始收集流值之前不會運行。在下面的示例中可以清楚地看到這一點:

import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*

//sampleStart      
fun foo(): Flow<Int> = flow { 
    println("Flow started")
    for (i in 1..3) {
        delay(100)
        emit(i)
    }
}

fun main() = runBlocking<Unit> {
    println("Calling foo...")
    val flow = foo()
    println("Calling collect...")
    flow.collect { value -> println(value) } 
    println("Calling collect again...")
    flow.collect { value -> println(value) } 
}
//sampleEnd

運行結果:

Calling foo...
Calling collect...
Flow started
1
2
3
Calling collect again...
Flow started
1
2
3

這是 foo() 函數(返回了 flow)未標記 suspend 修飾符的一個關鍵原因。foo() 本身返回很快,不會進行任何等待。flow 每次收集時都會啟動,這就是我們再次調用 collect 時會看到“flow started”的原因

三、取消流

Flow 采用和協程取同樣的協作取消。但是,Flow 實現基礎并沒有引入額外的取消點,它對于取消操作是完全透明的。通常,流的收集操作可以在當流在一個可取消的掛起函數(如 delay)中掛起的時候取消,否則不能取消

以下示例展示了在 withTimeoutOrNull 塊中流如何在超時時被取消并停止執行

//sampleStart
fun foo(): Flow<Int> = flow {
    for (i in 1..3) {
        delay(100)
        println("Emitting $i")
        emit(i)
    }
}

fun main() = runBlocking<Unit> {
    withTimeoutOrNull(250) {
        // Timeout after 250ms
        foo().collect { value -> println(value) }
    }
    println("Done")
}
//sampleEnd

注意,foo() 函數中的 Flow 只傳出兩個數字,得到以下輸出:

Emitting 1
1
Emitting 2
2
Done

相對應的,可以注釋掉 flow 中的 delay 函數,并增大 for 循環的循環范圍,此時可以發現 flow 沒有被取消,因為 flow 中沒有引入額外的掛起點

//sampleStart
fun foo(): Flow<Int> = flow {
    for (i in 1..Int.MAX_VALUE) {
//        delay(100)
        println("Emitting $i")
        emit(i)
    }
}

fun main() = runBlocking<Unit> {
    withTimeoutOrNull(250) {
        // Timeout after 250ms
        foo().collect { value -> println(value) }
    }
    println("Done")
}
//sampleEnd

四、流構建器

前面例子中的 flow{...} 是最基礎的一個流構建器,還有其它的構建器可以更容易地聲明流:

  • flowOf() 定義了一個發出固定值集的流構建器
  • 可以使用擴展函數 .asFlow() 將各種集合和序列轉換為流

因此,從流中打印從 1 到 3 的數字的例子可以改寫成:

fun main() = runBlocking<Unit> {
    //sampleStart
    // Convert an integer range to a flow
    (1..3).asFlow().collect { value -> println(value) }
    //sampleEnd
}   

五、中間流運算符

可以使用運算符來轉換流,就像使用集合和序列一樣。中間運算符應用于上游流并返回下游流。這些運算符是冷操作符,和流一樣。此類運算符本身不是掛起函數,它工作得很快,其返回一個新的轉換后的流,但引用僅包含對新流的操作定義,并不馬上進行轉換

基礎運算符有著熟悉的名稱,例如 map 和 filter。流運算符和序列的重要區別在于流運算符中的代碼可以調用掛起函數

例如,可以使用 map 運算符將傳入請求流映射為結果值,即使執行請求是由掛起函數實現的長時間運行的操作:

//sampleStart
suspend fun performRequest(request: Int): String {
    delay(1000) // imitate long-running asynchronous work
    return "response $request"
}

fun main() = runBlocking<Unit> {
    (1..3).asFlow() // a flow of requests
        .map { request -> performRequest(request) }
        .collect { response -> println(response) }
}
//sampleEnd

運行結果共有三行,每一秒打印一行輸出

response 1
response 2
response 3

5.1、轉換操作符

在流的轉換運算符中,最常用的一個稱為 transform。它可以用來模擬簡單的數據轉換(就像 map 和 filter),以及實現更復雜的轉換。使用 transform 運算符,我們可以發出任意次數的任意值

例如,通過使用 transform,我們可以在執行長時間運行的異步請求之前發出一個字符串,并在該字符串后面跟隨一個響應:

suspend fun performRequest(request: Int): String {
    delay(1000) // imitate long-running asynchronous work
    return "response $request"
}

fun main() = runBlocking<Unit> {
    //sampleStart
    (1..3).asFlow() // a flow of requests
        .transform { request ->
            emit("Making request $request")
            emit(performRequest(request))
        }
        .collect { response -> println(response) }
    //sampleEnd
}

輸出值:

Making request 1
response 1
Making request 2
response 2
Making request 3
response 3

5.2、限長運算符

限長中間運算符在達到相應限制時取消流的執行。協程中的取消總是通過拋出異常來實現,這樣所有的資源管理函數(例如 try { ... } finally { ... } )就可以在取消時正常執行

//sampleStart
fun numbers(): Flow<Int> = flow {
    try {
        emit(1)
        emit(2)
        println("This line will not execute")
        emit(3)
    } finally {
        println("Finally in numbers")
    }
}

fun main() = runBlocking<Unit> {
    numbers()
        .take(2) // take only the first two
        .collect { value -> println(value) }
}
//sampleEnd

這段代碼的輸出清楚地顯示了 numbers() 函數中的 flow{...} 函數體在發出第二個數字后就停止了:

1
2
Finally in numbers

六、流運算符

終端流運算符是用于啟動流的掛起函數。collect 是最基本的終端流運算符,但還有其它終端運算符,可以使得操作更加簡便:

  • 轉換為各種集合,如 toList 和 toSet 函數
  • first 運算符用于獲取第一個值,single 運算符用于確保流發出單個值
  • 使用 reduce 和 fold 將流還原為某個值

例如:

import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*

fun main() = runBlocking<Unit> {
//sampleStart         
    val sum = (1..5).asFlow()
        .map { it * it } // squares of numbers from 1 to 5                           
        .reduce { a, b -> a + b } // sum them (terminal operator)
    println(sum)
//sampleEnd     
}

輸出單個值:

55

七、流是連續的

除非使用對多個流進行操作的特殊運算符,否則每個流的單獨集合都是按順序執行的。集合直接在調用終端運算符的協程中工作,默認情況下不會啟動新的協程。每個發出的值都由所有中間運算符從上游到下游進行處理,然后在之后傳遞給終端運算符

請參閱以下示例,該示例過濾偶數并將其映射到字符串:

import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*

fun main() = runBlocking<Unit> {
//sampleStart         
    (1..5).asFlow()
        .filter {
            println("Filter $it")
            it % 2 == 0              
        }              
        .map { 
            println("Map $it")
            "string $it"
        }.collect { 
            println("Collect $it")
        }    
//sampleEnd                  
}

輸出:

Filter 1
Filter 2
Map 2
Collect string 2
Filter 3
Filter 4
Map 4
Collect string 4
Filter 5

八、流上下文

流的收集總是在調用協程的上下文中執行。例如,如果存在 foo 流,則無論 foo 流的實現詳細信息如何,以下代碼都將在該開發者指定的上下文中執行:

withContext(context) {
    foo.collect { value ->
        println(value) // run in the specified context 
    }
}

流的這個特性稱為上下文保留

所以,默認情況下,flow{...} 中的代碼在相應流的收集器提供的上下文中運行。例如,觀察 foo 的實現,它打印調用它的線程并發出三個數字:

fun log(msg: String) = println("[${Thread.currentThread().name}] $msg")

//sampleStart
fun foo(): Flow<Int> = flow {
    log("Started foo flow")
    for (i in 1..3) {
        emit(i)
    }
}

fun main() = runBlocking<Unit> {
    foo().collect { value -> log("Collected $value") }
}
//sampleEnd

運行結果:

[main @coroutine#1] Started foo flow
[main @coroutine#1] Collected 1
[main @coroutine#1] Collected 2
[main @coroutine#1] Collected 3

由于 foo().collect 是在主線程調用的,所以 foo 流也是在主線程中調用。對于不關心執行上下文且不阻塞調用方的快速返回代碼或者異步代碼,這是完美的默認設置

8.1、錯誤地使用 withContext

但是,可能需要在 Dispatchers 的上下文中執行長時間運行的占用 CPU 的代碼,可能需要在 Dispatchers.Main 的上下文中執行默認代碼和 UI 更新。通常,withContext 用于在使用 kotlin 協程時更改代碼中的上下文,但 fow{...} 中的代碼必須遵守上下文本保留屬性,并且不允許從其它上下文中觸發

嘗試運行以下代碼:

import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
                      
//sampleStart
fun foo(): Flow<Int> = flow {
    // The WRONG way to change context for CPU-consuming code in flow builder
    kotlinx.coroutines.withContext(Dispatchers.Default) {
        for (i in 1..3) {
            Thread.sleep(100) // pretend we are computing it in CPU-consuming way
            emit(i) // emit next value
        }
    }
}

fun main() = runBlocking<Unit> {
    foo().collect { value -> println(value) } 
}            
//sampleEnd

代碼會生成以下異常:

Exception in thread "main" java.lang.IllegalStateException: Flow invariant is violated:
        Flow was collected in [CoroutineId(1), "coroutine#1":BlockingCoroutine{Active}@5511c7f8, BlockingEventLoop@2eac3323],
        but emission happened in [CoroutineId(1), "coroutine#1":DispatchedCoroutine{Active}@2dae0000, DefaultDispatcher].
        Please refer to 'flow' documentation or use 'flowOn' instead
    at ...

8.2、flowOn 運算符

有個例外情況,flowOn 函數能用于改變流發送值時的上下文。改變流上下文的正確方式如下面的示例所示,該示例還打印了相應線程的名稱,以顯示所有線程的工作方式:

import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*

fun log(msg: String) = println("[${Thread.currentThread().name}] $msg")
           
//sampleStart
fun foo(): Flow<Int> = flow {
    for (i in 1..3) {
        Thread.sleep(100) // pretend we are computing it in CPU-consuming way
        log("Emitting $i")
        emit(i) // emit next value
    }
}.flowOn(Dispatchers.Default) // RIGHT way to change context for CPU-consuming code in flow builder

fun main() = runBlocking<Unit> {
    foo().collect { value ->
        log("Collected $value") 
    } 
}            
//sampleEnd

注意,flow{...} 在后臺線程中工作,而在主線程中進行取值

這里要注意的另一件事是 flowOn 操作符改變了流的默認順序性質。現在取值操作發生在協程 "coroutine#1" 中,而發射值的操作同時運行在另一個線程中的協程 "coroutine#2" 上。當必須在上游流的上下文中更改 CoroutineDispatcher 時,flowOn 運算符將為該上游流創建另一個協程

九、緩沖

從收集流所需的總時間的角度來看,在不同的協程中運行流的不同部分可能會有所幫助,特別是當涉及到長時間運行的異步操作時。例如,假設 foo() 流的發射很慢,生成元素需要100毫秒;收集器也很慢,處理元素需要300毫秒。讓我們看看用三個數字收集這樣的流需要多長時間:

import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlin.system.*

//sampleStart
fun foo(): Flow<Int> = flow {
    for (i in 1..3) {
        delay(100) // pretend we are asynchronously waiting 100 ms
        emit(i) // emit next value
    }
}

fun main() = runBlocking<Unit> { 
    val time = measureTimeMillis {
        foo().collect { value -> 
            delay(300) // pretend we are processing it for 300 ms
            println(value) 
        } 
    }   
    println("Collected in $time ms")
}
//sampleEnd

以上代碼會產生如下類似的結果,整個收集過程大約需要1200毫秒(三個數字,每個400毫秒)

1
2
3
Collected in 1220 ms

我們可以在流上使用 buffer 運算符,在運行取集代碼的同時運行 foo() 的發值代碼,而不是按順序運行它們

import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlin.system.*

fun foo(): Flow<Int> = flow {
    for (i in 1..3) {
        delay(100) // pretend we are asynchronously waiting 100 ms
        emit(i) // emit next value
    }
}

fun main() = runBlocking<Unit> { 
//sampleStart
    val time = measureTimeMillis {
        foo()
            .buffer() // buffer emissions, don't wait
            .collect { value -> 
                delay(300) // pretend we are processing it for 300 ms
                println(value) 
            } 
    }   
    println("Collected in $time ms")
//sampleEnd
}

這可以得到相同的輸出結果但運行速度更快,因為我們已經有效地創建了一個處理管道,第一個數字只需要等待100毫秒,然后只需要花費300毫秒來處理每個數字。這樣運行大約需要1000毫秒:

1
2
3
Collected in 1071 ms

請注意,flowOn 運算符在必須更改 CoroutineDispatcher 時使用相同的緩沖機制,但這里我們顯示地請求緩沖而不更改執行上下文

9.1、合并

當流用于表示操作或操作狀態更新的部分結果時,可能不需要處理每個值,而是只處理最近的值。在這種情況下,當取值器處理中間值太慢時,可以使用合并運算符跳過中間值。在前面的例子的基礎上再來修改下:

import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlin.system.*

fun foo(): Flow<Int> = flow {
    for (i in 1..3) {
        delay(100) // pretend we are asynchronously waiting 100 ms
        emit(i) // emit next value
    }
}

fun main() = runBlocking<Unit> { 
//sampleStart
    val time = measureTimeMillis {
        foo()
            .conflate() // conflate emissions, don't process each one
            .collect { value -> 
                delay(300) // pretend we are processing it for 300 ms
                println(value) 
            } 
    }   
    println("Collected in $time ms")
//sampleEnd
}

可以看到,雖然第一個數字仍在處理中,但第二個數字和第三個數字已經生成,因此第二個數字被合并(丟棄),只有最近的一個數字(第三個)被交付給取值器:

1
3
Collected in 758 ms

9.2、處理最新值

在發射端和處理端都很慢的情況下,合并是加快處理速度的一種方法。它通過丟棄發射的值來實現。另一種方法是取消慢速收集器,并在每次發出新值時重新啟動它。有一系列 xxxLatest 運算符與 xxx 運算符執行相同的基本邏輯,但是在新值產生的時候取消執行其塊中的代碼。在前面的示例中,我們嘗試將 conflate 更改為 collectLatest:

import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlin.system.*

fun foo(): Flow<Int> = flow {
    for (i in 1..3) {
        delay(100) // pretend we are asynchronously waiting 100 ms
        emit(i) // emit next value
    }
}

fun main() = runBlocking<Unit> { 
//sampleStart
    val time = measureTimeMillis {
        foo()
            .collectLatest { value -> // cancel & restart on the latest value
                println("Collecting $value") 
                delay(300) // pretend we are processing it for 300 ms
                println("Done $value") 
            } 
    }   
    println("Collected in $time ms")
//sampleEnd
}

由于 collectLatest 的主體需要延遲300毫秒,而每100毫秒會發出一個新值,因此我們可以看到 collectLatest 代碼塊得到了每一個發射值,但最終只完成了最后一個值:

Collecting 1
Collecting 2
Collecting 3
Done 3
Collected in 741 ms

十、組合多個流

有許多方法可以組合多個流

10.1、zip

與 Kotlin 標準庫中的 Sequence.zip 擴展函數一樣,流有一個 zip 運算符,用于組合兩個流的相應值:

import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*

fun main() = runBlocking<Unit> { 
//sampleStart                                                                           
    val nums = (1..3).asFlow() // numbers 1..3
    val strs = flowOf("one", "two", "three") // strings 
    nums.zip(strs) { a, b -> "$a -> $b" } // compose a single string
        .collect { println(it) } // collect and print
//sampleEnd
}

運行結果:

1 -> one
2 -> two
3 -> three

10.2、Combine

當 flow 表示變量或操作的最新值時(參閱有關 conflation 的相關章節),可能需要執行依賴于相應流的最新值的計算,并在任何上游流發出值時重新計算它。相應的運算符族稱為 combine

例如,如果上例中的數字每300毫秒更新一次,但字符串每400毫秒更新一次,則使用 zip 運算符壓縮它們仍會產生相同的結果,盡管結果是每400毫秒打印一次

在本例中,我們使用中間運算符 onEach 來延遲每個元素,并使發出樣本流的代碼更具聲明性,更加簡短

import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*

fun main() = runBlocking<Unit> { 
//sampleStart                                                                           
    val nums = (1..3).asFlow().onEach { delay(300) } // numbers 1..3 every 300 ms
    val strs = flowOf("one", "two", "three").onEach { delay(400) } // strings every 400 ms
    val startTime = System.currentTimeMillis() // remember the start time 
    nums.zip(strs) { a, b -> "$a -> $b" } // compose a single string with "zip"
        .collect { value -> // collect and print 
            println("$value at ${System.currentTimeMillis() - startTime} ms from start") 
        } 
//sampleEnd
}

但是,如果在此處使用 combine 運算符而不是 zip 時:

import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*

fun main() = runBlocking<Unit> { 
//sampleStart                                                                           
    val nums = (1..3).asFlow().onEach { delay(300) } // numbers 1..3 every 300 ms
    val strs = flowOf("one", "two", "three").onEach { delay(400) } // strings every 400 ms          
    val startTime = System.currentTimeMillis() // remember the start time 
    nums.combine(strs) { a, b -> "$a -> $b" } // compose a single string with "combine"
        .collect { value -> // collect and print 
            println("$value at ${System.currentTimeMillis() - startTime} ms from start") 
        } 
//sampleEnd
}

我們得到了完全不同的輸出:

1 -> one at 452 ms from start
2 -> one at 651 ms from start
2 -> two at 854 ms from start
3 -> two at 952 ms from start
3 -> three at 1256 ms from start

十一、展平流

流表示異步接收的值序列,因此在每個值觸發對另一個值序列的請求的情況下很容易獲取新值。例如,我們可以使用以下函數,該函數返回相隔500毫秒的兩個字符串流:

fun requestFlow(i: Int): Flow<String> = flow {
    emit("$i: First") 
    delay(500) // wait 500 ms
    emit("$i: Second")    
}

現在,如果我們有一個包含三個整數的流,并為每個整數調用 requestFlow,如下所示:

(1..3).asFlow().map { requestFlow(it) }

然后我們最終得到一個流(flow< flow< String >>),需要將其展平為單獨一個流以進行進一步處理。集合和序列對此提供了 flatten 和 flatMap 運算符。然而,由于流的異步特性,它們需要不同的展開模式,因此流上有一系列 flattening 運算符

11.1、flatMapConcat

flatMapConcat 和 flattencat 運算符實現了 Concatenating 模式,它們是與序列運算符最直接的類比。它們等待內部流完成,然后開始收集下一個流,如下例所示:

fun requestFlow(i: Int): Flow<String> = flow {
    emit("$i: First")
    delay(500) // wait 500 ms
    emit("$i: Second")
}

fun main() = runBlocking<Unit> {
    //sampleStart
    val startTime = System.currentTimeMillis() // remember the start time
    (1..3).asFlow().onEach { delay(100) } // a number every 100 ms
        .flatMapConcat { requestFlow(it) }
        .collect { value ->
            // collect and print
            println("$value at ${System.currentTimeMillis() - startTime} ms from start")
        }
//sampleEnd
}

flatMapConcat 的順序特性在輸出結果中清晰可見:

1: First at 121 ms from start
1: Second at 622 ms from start
2: First at 727 ms from start
2: Second at 1227 ms from start
3: First at 1328 ms from start
3: Second at 1829 ms from start

11.2、flatMapMerge

另一種 flattening 模式是同時收集所有傳入流并將其值合并到單個流中,以便盡快發出值。它由 flatMapMerge 和 flattenMerge 運算符實現。它們都接受一個可選的并發參數,該參數用于限制同時收集的并發流的數量(默認情況下等于 DEFAULT_CONCURRENCY)

import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*

fun requestFlow(i: Int): Flow<String> = flow {
    emit("$i: First") 
    delay(500) // wait 500 ms
    emit("$i: Second")    
}

fun main() = runBlocking<Unit> { 
//sampleStart
    val startTime = System.currentTimeMillis() // remember the start time 
    (1..3).asFlow().onEach { delay(100) } // a number every 100 ms 
        .flatMapMerge { requestFlow(it) }                                                                           
        .collect { value -> // collect and print 
            println("$value at ${System.currentTimeMillis() - startTime} ms from start") 
        } 
//sampleEnd
}

flatMapMerge 的并發性是顯而易見的:

1: First at 136 ms from start
2: First at 231 ms from start
3: First at 333 ms from start
1: Second at 639 ms from start
2: Second at 732 ms from start
3: Second at 833 ms from start

請注意,flatMapMerge 按順序調用其代碼塊({requestFlow(it)}),但同時收集結果流,這相當于先執行序列 map{requestFlow(it)},然后對返回值調用 flattenMerge

11.3、flatMapLatest

與“Processing the latest value(處理最新值)”章節介紹的 collectLatest 操作符類似,存在相應的 "Latest" flattening 模式。在該模式下,一旦發出新流,將取消先前已發出的流。這通過 flatMapLatest 運算符實現

import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*

fun requestFlow(i: Int): Flow<String> = flow {
    emit("$i: First") 
    delay(500) // wait 500 ms
    emit("$i: Second")    
}

fun main() = runBlocking<Unit> { 
//sampleStart
    val startTime = System.currentTimeMillis() // remember the start time 
    (1..3).asFlow().onEach { delay(100) } // a number every 100 ms 
        .flatMapLatest { requestFlow(it) }                                                                           
        .collect { value -> // collect and print 
            println("$value at ${System.currentTimeMillis() - startTime} ms from start") 
        } 
//sampleEnd
}

本例中的輸出很好的演示了 flatMapLatest 的工作原理

1: First at 142 ms from start
2: First at 322 ms from start
3: First at 425 ms from start
3: Second at 931 ms from start

請注意,當新值到來時,flatMapLatest 將取消其塊中的所有代碼({requestFlow(it)})。requestFlow 函數本身的調用是很快速的,并非掛起函數,如果其內部不包含額外的掛起點,那么它就不能被取消,所以此處就在其內部使用了 delay 函數,使其可以達到被取消的目的

十二、流異常

當發射器或運算符內部的代碼引發異常時,流收集器可以結束運行,但會出現異常。有幾種方法可以處理這些異常

12.1、收集器 try 與 catch

收集器可以使用 kotlin 的 try/catch 代碼塊來處理異常

import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*

//sampleStart
fun foo(): Flow<Int> = flow {
    for (i in 1..3) {
        println("Emitting $i")
        emit(i) // emit next value
    }
}

fun main() = runBlocking<Unit> {
    try {
        foo().collect { value ->         
            println(value)
            check(value <= 1) { "Collected $value" }
        }
    } catch (e: Throwable) {
        println("Caught $e")
    } 
}            
//sampleEnd

此代碼成功捕獲 collect 運算符中的異常,如我們所見,在此之后不再發出任何值:

Emitting 1
1
Emitting 2
2
Caught java.lang.IllegalStateException: Collected 2

12.2、一切都已捕獲

前面的示例實際上捕獲了發射器或任何中間或終端運算符中發生的任何異常。例如,讓我們更改代碼,以便將發出的值映射到字符串,但相應的代碼會產生異常:

import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*

//sampleStart
fun foo(): Flow<String> = 
    flow {
        for (i in 1..3) {
            println("Emitting $i")
            emit(i) // emit next value
        }
    }
    .map { value ->
        check(value <= 1) { "Crashed on $value" }                 
        "string $value"
    }

fun main() = runBlocking<Unit> {
    try {
        foo().collect { value -> println(value) }
    } catch (e: Throwable) {
        println("Caught $e")
    } 
}            
//sampleEnd

仍捕獲此異常并停止收集:

Emitting 1
string 1
Emitting 2
Caught java.lang.IllegalStateException: Crashed on 2

十三、異常透明性

但是發射器的代碼如何封裝其異常處理行為呢?

flows 對于異常必須是透明的,并且在 flow{...} 構建器中發射值有可能拋出異常時,異常必須顯式地從 try/catch 塊內部拋出。這保證了拋出異常的收集器始終可以使用 try/catch 來捕獲異常,如前一個示例所示

發射器可以使用 catch 運算符來保持此異常的透明性,并允許封裝其異常處理行為。catch 運算符可以分析異常并根據捕獲到的異常以不同的方式對其作出反應:

  • 可以使用 throw 重新引發異常
  • 使用 catch 的 emit 可以將異常轉換為值的 emission
  • 異常可以被其他代碼忽略、記錄或處理

例如,讓我們在捕獲異常時發出一段文本:

import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*

fun foo(): Flow<String> = 
    flow {
        for (i in 1..3) {
            println("Emitting $i")
            emit(i) // emit next value
        }
    }
    .map { value ->
        check(value <= 1) { "Crashed on $value" }                 
        "string $value"
    }

fun main() = runBlocking<Unit> {
//sampleStart
    foo()
        .catch { e -> emit("Caught $e") } // emit on exception
        .collect { value -> println(value) }
//sampleEnd
}            

示例代碼的輸出結果是與之前相同的,即使我們不再在代碼周圍使用 try/catch

13.1、透明捕獲

catch 中間運算符遵循異常透明性,只捕獲上游異常(即 catch 上所有運算符的異常,而不是 catch 下所有運算符的異常)。如果 collect{...}(放在 catch 下面)拋出異常,程序將退出:

import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*

//sampleStart
fun foo(): Flow<Int> = flow {
    for (i in 1..3) {
        println("Emitting $i")
        emit(i)
    }
}

fun main() = runBlocking<Unit> {
    foo()
        .catch { e -> println("Caught $e") } // does not catch downstream exceptions
        .collect { value ->
            check(value <= 1) { "Collected $value" }                 
            println(value) 
        }
}            
//sampleEnd

盡管存在 catch 運算符,但不會打印 “Caught ...” 日志

13.2、聲明式捕獲

我們可以將 catch 運算符的聲明性與處理所有異常的愿望結合起來,方法是將 collect 運算符原先所要做的操作移動到 onEach 中,并將其放在 catch 運算符之前。此流的取值操作必須由不帶參數的 collect() 函數來調用觸發:

import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*

fun foo(): Flow<Int> = flow {
    for (i in 1..3) {
        println("Emitting $i")
        emit(i)
    }
}

fun main() = runBlocking<Unit> {
//sampleStart
    foo()
        .onEach { value ->
            check(value <= 1) { "Collected $value" }                 
            println(value) 
        }
        .catch { e -> println("Caught $e") }
        .collect()
//sampleEnd
}            

現在我們可以看到打印了一條 “Caught ...” 消息,至此我們捕獲了所有異常,而無需顯式使用 try/catch

十四、流完成

當流收集完成時(正常或異常),它可能需要執行一個操作。正如你可能已經注意到的,它可以通過兩種方式完成:命令式或聲明式

14.1、命令式 finally 塊

除了 try/catch 外,收集器還可以使用 finally 在收集完成時執行操作

import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*

//sampleStart
fun foo(): Flow<Int> = (1..3).asFlow()

fun main() = runBlocking<Unit> {
    try {
        foo().collect { value -> println(value) }
    } finally {
        println("Done")
    }
}            
//sampleEnd

此代碼打印 fon() 流生成的三個數字,之后跟隨 "Done" 字符串

1
2
3
Done

14.2、聲明式處理

對于聲明性方法,flow 有一個 onCompletion 中間運算符,該運算符在流完全收集后調用

前面的示例可以使用 onCompletion 運算符重寫,并生成相同的輸出:

import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*

fun foo(): Flow<Int> = (1..3).asFlow()

fun main() = runBlocking<Unit> {
//sampleStart
    foo()
        .onCompletion { println("Done") }
        .collect { value -> println(value) }
//sampleEnd
}            

onCompletion 的主要優點是包含一個 lambda 參數,該 lambda 包含一個可空的 Throwable 參數,該 Throwable 參數可用于確定流收集是正常完成還是異常完成。在以下示例中,foo() 流在發出數字1后引發異常:

import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*

//sampleStart
fun foo(): Flow<Int> = flow {
    emit(1)
    throw RuntimeException()
}

fun main() = runBlocking<Unit> {
    foo()
        .onCompletion { cause -> if (cause != null) println("Flow completed exceptionally") }
        .catch { cause -> println("Caught exception") }
        .collect { value -> println(value) }
}            
//sampleEnd

如你所料,將打印:

1
Flow completed exceptionally
Caught exception

與 catch 運算符不同,onCompletion 運算符不處理異常。正如我們從上面的示例代碼中看到的,異常仍然會流向下游。它將被傳遞給其他完成 onCompletion 運算符,并可以使用 catch 運算符進行處理

14.3、僅限上游異常

就像 catch 操作符一樣,onCompletion 只看到來自上游的異常,而看不到下游的異常。例如,運行以下代碼:

import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*

//sampleStart
fun foo(): Flow<Int> = (1..3).asFlow()

fun main() = runBlocking<Unit> {
    foo()
        .onCompletion { cause -> println("Flow completed with $cause") }
        .collect { value ->
            check(value <= 1) { "Collected $value" }                 
            println(value) 
        }
}
//sampleEnd

我們可以看到 completion cause 為空,但流收集失敗并拋出異常:

1
Flow completed with null
Exception in thread "main" java.lang.IllegalStateException: Collected 2

十五、命令式還是聲明式

現在我們知道如何收集流,并以命令式和聲明式的方式處理它的完成和異常。這里很自然的就有了個問題,應該首選哪種方法呢?為什么?作為一個庫,我們不提倡任何特定的方法,并且相信這兩種方式都是有效的,應該根據你自己的偏好和代碼風格來選擇

十六、啟動流

很容易使用流來表示來自某個數據源的異步事件。在這種情況下,我們需要一個模擬的 addEventListener 函數,該函數將一段代碼注冊為對傳入事件的響應,并繼續進一步工作。onEach 運算符可以擔任此角色。然而,onEach 是一個中間運算符。我們還需要一個終端運算符來收集數據。否則,只注冊 onEach 是沒有效果的

如果在 onEach 之后使用 collect 終端運算符,則在 collect 之后的代碼將等待流被收集完成后再運行:

import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*

//sampleStart
// Imitate a flow of events
fun events(): Flow<Int> = (1..3).asFlow().onEach { delay(100) }

fun main() = runBlocking<Unit> {
    events()
        .onEach { event -> println("Event: $event") }
        .collect() // <--- Collecting the flow waits
    println("Done")
}            
//sampleEnd

如你所見,將打印

Event: 1
Event: 2
Event: 3
Done

launchIn 終端運算符在這里是很實用的。通過將 collect 替換為 launchIn,我們可以在單獨的協程中啟動收集流數據的操作,以便立即繼續執行下一步的代碼:

import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*

// Imitate a flow of events
fun events(): Flow<Int> = (1..3).asFlow().onEach { delay(100) }

//sampleStart
fun main() = runBlocking<Unit> {
    events()
        .onEach { event -> println("Event: $event") }
        .launchIn(this) // <--- Launching the flow in a separate coroutine
    println("Done")
}            
//sampleEnd

運行結果:

Done
Event: 1
Event: 2
Event: 3

launchIn 所需的參數用于指定啟動用于收集流的協程的作用域。在上面的示例中,此作用域來自 runBlocking,因此當流運行時,runBlocking 作用域等待其子協程完成,并阻止主函數返回和終止此示例代碼

在實際應用程序中,作用域將來自生命周期是有限的實體。一旦此實體的生命周期終止,相應的作用域將被取消,從而取消相應流的收集。onEach { ... }.launchIn(scope) 的工作方式與 addEventListener 類似。但是,不需要相應的 removeEventListener 函數,因為 cancellation 和結構化并發可以達到這個目的

請注意,launchIn 還返回一個 Job 對象,該 Job 僅可用于取消相應的流數據收集協程,而不取消整個作用域或加入它

十七、Flow and Reactive Streams

For those who are familiar with Reactive Streams or reactive frameworks such as RxJava and project Reactor, design of the Flow may look very familiar.

Indeed, its design was inspired by Reactive Streams and its various implementations. But Flow main goal is to have as simple design as possible, be Kotlin and suspension friendly and respect structured concurrency. Achieving this goal would be impossible without reactive pioneers and their tremendous work. You can read the complete story in Reactive Streams and Kotlin Flows article.

While being different, conceptually, Flow is a reactive stream and it is possible to convert it to the reactive (spec and TCK compliant) Publisher and vice versa. Such converters are provided by kotlinx.coroutines out-of-the-box and can be found in corresponding reactive modules (kotlinx-coroutines-reactive for Reactive Streams, kotlinx-coroutines-reactor for Project Reactor and kotlinx-coroutines-rx2 for RxJava2). Integration modules include conversions from and to Flow, integration with Reactor's Context and suspension-friendly ways to work with various reactive entities.

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,238評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,430評論 3 415
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,134評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,893評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,653評論 6 408
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,136評論 1 323
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,212評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,372評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,888評論 1 334
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,738評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,939評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,482評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,179評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,588評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,829評論 1 283
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,610評論 3 391
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,916評論 2 372

推薦閱讀更多精彩內容