Kotlin協程(5)Flow

0,引子

看下面的例子:

fun fooAsync(p: Params): CompletableFuture<Value> = 
    CompletableFuture.supplyAsync { bar(p) } 

可以使用Future來獲取需要長時間運行的異步返回的值。

當調用fooAsync(p)時,它會承諾將來會提供一個值,並且後臺會運行一個操作欄來計算該值。現在您必須小心不要丟失對這個Future的引用,因爲這個Future實際上是一種資源,就像一個打開的文件。您必須等待它或者如果不再需要它的值時則取消它。

這被稱爲熱數據源。與常規函數不同,常規函數僅在調用期間才處於活動狀態,而熱源甚至在調用相應函數之外也處於活動狀態,它可能在調用該函數之前就已在後臺處於活動狀態,而在調用該函數之後仍可以處於活動狀態,就像我們在這裏看到的那樣。

在kotlin中可以使用掛起函數避免熱數據源的麻煩

suspend fun foo(p: Params): Value =
    withContext(Dispatchers.Default) { bar(p) }

在執行bar操作時,foo的調用方被掛起。無需擔心會意外丟失對正在運行的後臺操作的引用,並且使用掛起函數編寫的代碼看起來很熟悉-就像常規的同步的代碼一樣。這個foo函數定義很冷-在調用之前它沒有做任何事情,在返回值之後也不會做任何事情。

如果我們想獲取一個流式返回怎麼辦?

fun foo(p: Params): Sequence<Value> =
    sequence { while (hasMore) yield(nextValue) }

這樣?如果您使用sequence作爲返回類型來表示流式API,則等待傳入值必須阻塞調用者的線程。這不利於UI應用程序。對於異步編程,我們要掛起的協程,對於協程間的通信kotlin提供了Channel。

fun fooProducer(p: Params): ReceiveChannel<Value> =
    GlobalScope.produce { while (hasMore) send(nextValue) }

但是如果這樣做的化我們就遇到到Futer同樣的問題,Channel代表着熱值流。通道的另一端正在協力生成值,因此我們不能只刪除對ReceiveChannel的引用,因爲生產者將永遠被掛起,以等待使用者,浪費內存資源,開放網絡連接等。

結構化併發在某種程度上緩解了這一問題。觀察到fooProducer啓動了一個協程,該協程與其餘代碼同時工作。我們可以通過將fooProducer函數聲明爲CoroutineScope的擴展來使併發性明確化:

fun CoroutineScope.fooProducer(p: Params): ReceiveChannel<Value> =
    produce { while (hasMore) send(nextValue) }

通過結構化的併發,丟失的通道會阻止外部協程作用域的完成,從而有效地“掛起”正在進行的操作。但是,它不能完全解決問題,而只是改變了我們的錯誤的影響,我們仍然不能這樣寫。

總而言之,使用Channel並不像使用掛起函數使用單個值或使用同步值序列那樣簡單,並且由於併發性而涉及細微的問題和約定。

Channel非常適合對本質上很熱的數據源進行建模,這些數據源在沒有應用程序要求的情況下就存在:傳入的網絡連接,事件流等。

就像Future一樣,Channel也是同步原語。當您需要以相同或不同的過程將數據從一個協程發送到另一個協程時,您應該使用一個通道,因爲不同的協程是併發的,並且需要同步才能在存在併發的情況下處理任何數據。但是,同步總是以性能爲代價。

但是,如果我們不需要併發或同步,而只需要非阻塞數據流,該怎麼辦?Flow是一個不錯的選擇。

fun foo(p: Params): Flow<Value> =
    flow { while (hasMore) emit(nextValue) }

就像sequence一樣,flow代表着一個冷的數據流。 foo的調用者獲得對flow實例的引用,但是flow{...}構建器中的代碼未激活,尚無資源綁定到該實例。與sequence相似,可以使用各種通用運算符(如map,filter等)來轉換flow。與sequence不同,flow是異步的,並允許在其生成器和運算符中的任何位置掛起函數。例如,以下代碼定義十個整數的flow,每個整數的延遲時間爲100毫秒:

val ints: Flow<Int> = flow { 
    for (i in 1..10) {
        delay(100)
        emit(i)
    }
}

 flow的終端operators收集該流程發出的所有值,僅在相應操作期間激活流程代碼。它使流程變冷-在調用終端操作之前它是不活動的,在調用返回之前釋放所有資源後,它是不活動的。最基本的終端操作稱爲收集。它是一個掛起函數,用於在收集流時掛起調用協程:

ints.collect { println(it) } 

與通道不同,flow本質上不涉及任何併發。它是非阻塞的,但是是順序的。flow的目標是使異步數據流的懸浮功能成爲異步操作,即方便,安全,易學且易於使用。

前面說了這麼多都是爲了引出今天的主角Flow。

1,Flow

flow主要涉及2個接口:

interface Flow<out T> {
    suspend fun collect(collector: FlowCollector<T>)
}

flow的唯一功能是單個collect函數,該函數使用單個emit方法接受FlowCollector接口的實例:

interface FlowCollector<in T> {
    suspend fun emit(value: T)
}

流構建器的簽名還使用FlowCollector接口作爲接收器,以便我們可以直接從相應的lambda的主體中發出:

fun <T> flow(block: suspend FlowCollector<T>.() -> Unit): Flow<T>

對於流的簡單用法,在收集流時,如下所示:

val ints: Flow<Int> = flow { 
    for (i in 1..10) {
        delay(100)
        emit(i)
    }
}

ints.collect { println(it) }

這其中進行了什麼操作呢?基於傳遞來collect{…}函數的lambda創建了FlowCollector的實例,然後將該實例傳遞給了follow{…}的body。

public suspend inline fun <T> Flow<T>.collect(crossinline action: suspend (value: T) -> Unit): Unit =
    collect(object : FlowCollector<T> {
        override suspend fun emit(value: T) = action(value)
    })

可以看到FlowCollector實例的有一個emit方法,方法體就是lamda表達式action.


因此, flow emitter和flow collector之間的交互是一個簡單的函數調用-調用emit函數。如果我們在頭腦中內聯此函數調用,我們可以立即瞭解在運行此代碼時發生的情況,它等同於:

for (i in 1..10) {
    delay(100)
    println(i) // <-- emit was called here => FlowCollector.emit(value)
}

2,操作符

前面說過Flow可以像Sequence一樣運行各種操作符,如:

fun <T, R> Flow<T>.map(transform: suspend (value: T) -> R) = flow {
    collect { emit(transform(it)) }
}

由於Kotlin支持擴展函數,Flow定義操作也是非常簡單的,kotlinx.coroutines庫已經將map和許多其他通用運算符定義爲Flow類型的擴展。

爲了更好地瞭解流的順序性質,請看一下下面的示例流,該流發出十個整數,它們之間的延遲爲100毫秒:

suspend fun productor() = flow<Int> {
        for (i in 1..10) {
            emit(i)
            delay(100)
        }
    }

讓我們確認收集它大約需要一秒鐘:

   @Test
    fun consumer() = runBlocking {

        val time = measureTimeMillis {
            productor().collect {
                println(it)
            }
        }
        println(time)
    }

但是,如果收集器也很慢,並且在打印每個元素之前增加了自己的100 ms延遲,會發生什麼情況?看看這個:

   @Test
    fun consumer() = runBlocking {

        val time = measureTimeMillis {
            productor().collect {
                delay(100)
                println(it)
            }
        }
        println(time)
    }

完成此過程大約需要2秒鐘,因爲此處的發射器和收集器都是順序執行的一部分,並且在它們之間交替進行:

我們可以安排執行的結構,使整個操作更快地完成,而不更改發射者或收集者的代碼嗎?我們可以。我們需要分離發射器和收集器-在與收集器分開的協程中運行發射器,以便它們可以併發運行。但是,對於兩個獨立的協程,我們不能簡單地通過函數調用來發出元素。我們需要在兩個協程之間建立一些通信。這正是渠道的設計宗旨。您可以通過一個協程通過一個通道發送元素,並在另一個協程中接收它們,這樣整個執行過程將如下所示:

我們定義一個Flow的擴展操作符buffer:

fun <T> Flow<T>.buffer(size: Int = 0): Flow<T> = flow {
    coroutineScope {
        val channel = produce(capacity = size) {
            collect { send(it) }
        }
        channel.consumeEach { emit(it) }
    }
}

我們使用coroutineScope來構造併發結構,並避免將生產者協程泄漏到範圍之外.

   @Test
    fun consumer() = runBlocking {
        val time = measureTimeMillis {
            productor().buffer().collect {
                delay(100)
                println(it)
            }
        }
        println(time)
    }

運行與之前相同的發射器和收集器代碼,但在它們之間使用上面的buffer()運算符可以實現所需的執行速度加速。

3,上下文

在許多情況下,代碼的執行上下文很重要。在服務器端程序的上下文中可能會攜帶診斷信息;在UI應用程序中,小部件只能從特定的主線程進行觸摸。當您的代碼變大時,這可能會造成潛在的問題,尤其是在將數據生產者和數據使用者分離時。 Kotlin Flows旨在實現這種模塊化,因此讓我們看看它們在執行上下文方面的表現。

launch(Dispatchers.Main) { // launch in the main thread
    initDisplay() // prepare ui
    dataFlow().collect { // block of the collector begins
        updateDisplay(it) // update ui
    } 
}

UI應用程序可以在主線程中啓動協程,以從一些dataFlow()函數返回的流中收集元素,並使用其值更新顯示。

dataFlow必須執行一些消耗CPU的計算,該怎麼辦:

fun dataFlow(): Flow<Data> = flow { // create emitter
    withContext(Dispatchers.Default) {
        while (isActive) {
            emit(someDataComputation())
        }
    }
}

如果允許這種流發射器實現,則collect中的updateDisplay會嘗試從錯誤的線程更新UI。爲什麼?看1,Flow最後一段

每個流程實現都必須保留收集器的上下文。實際上,這意味着flow{...}構建器函數不會將值直接傳遞給發出時收集器的塊,而是包含檢查此上下文保留不變性的邏輯。

實現dataFlow()發射器函數的正確方法是什麼?首先,我們將刪除withContext並從收集器的上下文中發出:

fun dataFlow(): Flow<Data> = flow { // create emitter
    while (isActive) {
        emit(someDataComputation())
    }
}

但是,someDataComputation可能會阻止收集器的線程,從而凍結UI。有兩種解決方法。一種是將適當的上下文封裝在someDataComputation本身中:

fun someDataComputation(): Data = 
    withContext(Dispatchers.Default) { 
        // implementation here
    }

這對於隔離功能很好用,但是如果flow{...}中的整個代碼需要特定的上下文,則很不方便,也無法在每個值之間來回切換上下文。因此,還有一個對結果流使用flowOn運算符的解決方案:

fun dataFlow(): Flow<Data> = flow { // create emitter
    while (isActive) {
        emit(someDataComputation())
    }
}.flowOn(Dispatchers.Default) // ^ works on the flow before it

flowOn函數更改了所應用的流的上下文,同時確保爲將在其後應用的收集器保留上下文。 flowOn運算符的實現使用指定的Dispatchers.Default上下文創建一個單獨的協程,以收集someDataComputation流,同時在原始收集器的上下文中發出。

相同的上下文保留規則適用於流上的運算符。考慮以下流程:

dataFlow()
    .map { opA(it) } // in contextA
    .flowOn(contextA) 
    .map { opB(it) } // in collector's context

在這裏,opB在收集器的上下文中被調用,但是opA的上下文受flowOn運算符影響。

總而言之,Kotlin Flows執行上下文的規則很簡單。不在乎其執行上下文的非阻塞代碼無需採取任何特殊的預防措施。收集器始終可以確保保留其執行上下文。對於需要某些特定執行上下文的代碼,可以將flowOn運算符放在相應的上下文敏感代碼之後,以在指定的上下文中收集其上的流。

4,Reactive Streams 

Reactive Extensions(簡稱ReactiveX或Rx)最初由Erik Meijer爲.NET創建,並於2010年向公衆公開。這是一種用於異步數據流的API的新方法,該方法通用化了觀察者模式以及發射元素的回調(onNext) ,流完成(onCompleted)和錯誤(onError),並引入了流處理運算符(例如map和filter),使操作數據流與使用集合一樣容易。

這裏就得提提RxJava了。

   @Test
    fun rxJavaRs() {
     val subscription = Observable.fromCallable {
            {
                "${Thread.currentThread().name}:rxjava"
            }()
        }.subscribeOn(Schedulers.io())
            .observeOn(Schedulers.computation())
            .subscribe({
                println(it)
            }, {
                it.printStackTrace()
            }, {
                println("${Thread.currentThread().name}:Complete")
            })

        Thread.sleep(100)
    }

輸出:

RxCachedThreadScheduler-1:rxjava
RxComputationThreadPool-1:Complete

這個用Fllow怎麼寫?

 @Test
    fun fllowRx() = runBlocking {
        flow {
            emit("${Thread.currentThread().name}:fllow")
        }.flowOn(Dispatchers.IO)
            .onEach {
                println(it)
            }
            .catch {
                it.printStackTrace()
            }.onCompletion {
                println("${Thread.currentThread().name}:Complete")
            }.flowOn(Dispatchers.Default)
            .collect()
    }

輸出:

DefaultDispatcher-worker-3 @coroutine#3:fllow
DefaultDispatcher-worker-2 @coroutine#2:Complete

 tips:注意我上面RxJava用的fromCallable,可不可以用Just代替呢?答案是不可以,原因:擁抱RxJava(三):關於Observable的冷熱,常見的封裝方式以及誤區

可以看到上面RxJava持有一個Subscription對象引用,如果您希望能夠取消此訂閱,​​則必須仔細管理該對象,否則可能會泄漏它。這與結構化併發正在解決的問題非常相似,因此設計Flow是合理的,這樣您就不會意外泄漏訂閱。

Kotlin Flow完全沒有訂閱的概念。掛起和輕量協程來解救。Flow的collect操作是最類似於訂閱的概念,但是它只是一個掛起的函數調用,由於結構化的併發性而很難泄漏或以其他方式濫用。

基於掛起的collect操作設計也消除了對onError和onCompleted回調的單獨設置的需求,如果需要可以通過catch 和onCompletion 操作符來實現,當然Fllow也提供了一個onEach操作符來替換onNext回調。

Fllow爲subscriptionOn / observeOn設計了一個一致的機制,其中只有一個flowOn運算符。Rxjava中subscriptionOn / observeOn的用途是不一樣的,這不是本文的重點就不說了。

5,異常

假設我們正在編寫一個UI應用程序,該應用程序在UI中顯示值的更新流,從而從流中收集它們。此應用程序的uiScope是CoroutineScope,其生存期綁定到顯示數據的相應UI元素。有一個dataFlow()函數返回帶有要顯示的數據的流,因此可以像這樣激活數據顯示:

uiScope.launch { // launch a UI display coroutine
    dataFlow().collect { value -> updateUI(value) }
}

Flow確保updateUI始終在uiScope在此處定義的收集器的執行上下文中調用。即使dataFlow()在內部使用了其他上下文,該事實也不會以任何方式從中泄漏出來。

但是,如果dataFlow()中有錯誤怎麼辦?在這種情況下,collect調用將引發異常,從而導致協程異常完成,協程將傳播到uiScope,通常,最終將在其上下文中調用未捕獲的異常處理程序(CoroutineExceptionHandler)。如果異常確實是意外的,並且永遠都不會在正確的代碼中發生,那很好,但是例如,如果dataFlow()正在從網絡中讀取數據,並且當網絡出現問題時,很可能會發生故障?需要處理。失敗是通過異常報告的,可以像通常在Kotlin中處理異常一樣處理-使用try / catch塊:

uiScope.launch { 
    try {
        dataFlow().collect { value -> updateUI(value) }
    } catch (e: Throwable) {
        showErrorMessage(e)
    }
}

如果將這種異常處理邏輯封裝到流上的運算符中,那麼我們可以簡化此代碼,減少嵌套並使其更具可讀性:

uiScope.launch {
    dataFlow()
        .handleErrors() // handle dataFlow errors
        .collect { value -> updateUI(value) }
}

但是,我們如何實現handleErrors函數呢?如下所示:

fun <T> Flow<T>.handleErrors(): Flow<T> = flow {
    try {
        collect { value -> emit(value) }
    } catch (e: Throwable) {
        showErrorMessage(e)
    }
}

此實現從調用它的上游流中收集值,並向下遊發出它們,就像之前一樣,將collect調用包裝到try / catch塊中。它只是抽象了我們最初編寫的代碼。能行嗎?是的,對於這種特殊情況。那麼,對於通用的情況呢?

考慮一下handleErrors返回的流的屬性:

val flow = dataFlow().handleErrors()

它像其他任何流一樣發出一些值,但是它還具有其他流所沒有的其他屬性-任何錯誤是下游流被它捕獲。考慮以下帶有Kotlin錯誤功能的代碼作爲測試:

flow.collect { error("Failed") }

如果以簡單的流程運行它,則此代碼將在第一個發出的值上引發anIllegalStateException。但是使用handleError返回的流時,此異常將被捕獲並且不會出現,因此collect調用可以正常完成。爲什麼?看1,Flow最後一段

Kotlin流旨在允許對數據流進行模塊化推理。流的唯一假定影響是它們的發射值和完成,因此流規範不允許使用類似handleError的流運算符。每個流程實現都必須確保異常透明-下游異常必須始終傳播到收集器。

其實上面已經看到了kotlin已經提供catch操作符給我們用,不用我們自己定義一個,我們可以把handlerErrors()中用catch實現。

fun <T> Flow<T>.handleErrors(): Flow<T> = 
    catch { e -> showErrorMessage(e) }

但是,生成的代碼與我們使用try / catch編寫的原始代碼具有不同的行爲,因爲它沒有捕獲由於異常透明而可能在collect {value-> updateUI(value)}調用中發生的錯誤。我們可以通過重寫以下代碼來繼續處理updateUI中的錯誤:

uiScope.launch { 
    dataFlow()
        .onEach { value -> updateUI(value) }
        .handleErrors() 
        .collect()
}

將updateUI從collect移到onEach運算符中,我們已將其放置在handleErrors中的錯誤處理之前,因此現在也可以處理updateUI錯誤。最後,我們現在可以使用launchIn終端運算符合並launch和collect調用,進一步減少此代碼中的嵌套並將其轉換爲簡單的從左到右的運算符序列:

dataFlow()
    .onEach { value -> updateUI(value) }
    .handleErrors() 
    .launchIn(uiScope)

6,背壓

說到流就不得不討論一個背壓(Back-pressure)的問題了,背壓定義爲數據使用者無法跟上輸入數據的能力,無法將信號發送到數據生產者以減慢數據元素的速率。

傳統的反應流設計涉及一個反向通道,以根據需要向生產者請求更多數據。即使對於簡單的操作符,此請求協議的管理也會導致非常困難的實現。我們在Kotlin流程的設計中或在其操作員的實現中都沒有看到這種複雜性,但是Kotlin流程確實支持背壓。怎麼會?

通過使用Kotlin掛起功能,可以在Kotlin流程中實現透明的背壓管理。您可能已經注意到,Kotlin流程設計中的所有函數和功能類型都標有suspend修飾符-這些函數具有在不阻塞線程的情況下掛起調用程序執行的強大功能。因此,當流的收集器不堪重負時,它可以簡單地掛起發射器,並在準備好接受更多元素時稍後將其恢復。

7,RxJava互操作

Kotlin Flows在概念上仍然是反應性流。即使它們是基於懸浮的並且沒有直接實現相應的接口,它們的設計方式也使得與基於反應流的系統的集成變得簡單。我們提供了開箱即用的flow.asPublisher()擴展函數,可將Flow轉換爲反應流Publisher接口,並提供Publisher.asFlow()擴展以進行反向轉換。

  @Test
    fun fllowToRxjava(){
        fun <T> Publisher<T>.toFloweable() = Flowable.fromPublisher(this)
        flow {
            emit("${Thread.currentThread().name}:fllow")
        }.flowOn(Dispatchers.IO)
            .asPublisher()
            .toFloweable()
            .observeOn(Schedulers.computation())
            .subscribe({
                println(it)
            }, {
                it.printStackTrace()
            }, {
                println("${Thread.currentThread().name}:Complete")
            })

        Thread.sleep(100)
    }

依賴爲:

   implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactive:versionCode")

8,番外

考慮一個問題:

interface Operation<T> {
    fun performAsync(callback: (T?, Throwable?) -> Unit)
}

這個東西用協程怎麼改寫?

suspend fun <T> Operation<T>.perform(): T =
    suspendCoroutine { continuation ->
        performAsync { value, exception ->
            when {
                exception != null -> // operation had failed
                    continuation.resumeWithException(exception)
                else -> // succeeded, there is a value
                    continuation.resume(value as T)
            }
        }
    }

這樣對吧。

注意,這種表現是一種冷的數據源。它不會執行任何操作,直到它被調用爲止,並且在返回之後不會執行任何操作,因爲它會等待通過回調的操作完成。

那如果支持取消呢?

interface Operation<T> {
    fun performAsync(callback: (T?, Throwable?) -> Unit)
    fun cancel() // cancels ongoing operation
}

可以這樣改寫:

suspend fun <T> Operation<T>.perform(): T =
    suspendCancellableCoroutine { continuation ->
        performAsync { /* ... as before ... */ }
        continuation.invokeOnCancellation { cancel() }
    }

那如果我要這麼一個需求:如果Operation提供異步值流並多次調用指定的回調該怎麼辦?它也必須以某種方式表明它的完成。對於這個簡單的示例,我們假設它是通過調用具有null值的回調來實現的。怎麼改寫?

我們不能將此類Operation與suspendCoroutine之類的函數一起使用,以免在第二次嘗試恢復延續時得到IllegalStateException,因爲Kotlin的掛起和resume是single-shot.

Kotlin Flow進行救援!流被明確設計爲代表多個值的冷異步流。我們可以使用callbackFlow函數將多次回調轉換爲流:

fun <T : Any> Operation<T>.perform(): Flow<T> =
    callbackFlow {
        performAsync { value, exception ->
            when {
                exception != null -> // operation had failed
                    close(exception)
                value == null -> // operation had succeeded
                    close()
                else -> // there is a value
                    offer(value as T)
            }
        }
        awaitClose { cancel() }
    }

注意許多重要的區別。首先,perform不再是暫停功能。它本身不會等待任何東西。它返回冷流。直到終端操作的調用者收集了此流後,才調用callbackFlow {...}塊中的代碼。
和以前一樣,performAsync將安裝一個回調,但是現在,我們正在使用一個熱的SendChannel來代替Continuation,該熱SendChannel已開放以傳遞值。因此,將爲每個值調用offer函數,並調用close函數以表示失敗或成功完成。在這裏,awaitClose替換了invokeOnCancellation,並且還起到了重要的功能,即在傳入值時將塊暫停在callbackFlow中。

如何支持背壓?

如果performAsync將值傳遞給回調的速度快於收集協程可以處理它們的速度,會發生什麼?輸入在處理異步數據流時總是出現的背壓問題。有一個緩衝區可以保留一些值,但是當該緩衝區溢出時,提供的返回值將爲false且值將丟失。有幾種方法可以避免或控制損失。
一種是用sendBlocking(value)替換offer(value)。在這種情況下,調用回調的線程會在緩衝區溢出時被阻塞,直到緩衝區中有更多空間爲止。這是在大多數傳統的基於流回叫的API中向後壓表示信號的一種典型方法,它可以確保不會丟失任何價值。
如果期望值的數量受到限制或期望值不能太快到達,那麼我們可以使用緩衝區運算符來配置無限的緩衝區大小,方法是在callbackFlow {...}之後添加.buffer(Channel.UNLIMITED)調用。在這種情況下,offer始終返回true,因此不會丟失任何價值,也不會出現阻塞。但是,可能會耗盡緩衝值⁶的內存。

通常,值流表示部分操作結果或狀態更新,因此只有最新的值才真正有意義。這意味着可以使用conlate運算符對結果流進行安全地合併,這可以保證offer始終返回true,並且即使可以丟棄(合併)中間值,收集器也可以看到最新的值。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章