流與響應式編程
1. 函數式副作用的處理
之前有説過函數式編程中儘量要編寫純函數,但是實際的程序中不可能如此理想的都是純函數,異常、用户交互、時間、變量等等這些所謂的“副作用”是一定會也一定需要存在的,那程序應該如何編寫?
首先我們需要回到“純函數”的定義上:對於相同的輸入,總是產生相同的輸出,可以用返回值替換函數執行。
比如:
var count = 0
fun increase(a: Int): Int {
return count + a
}
根據之前的説法,這個函數肯定不是純函數(對於相同的輸入,不能用返回值替代函數的執行)。但是我們做一點改變:
fun increase(a: Int): () -> Int = {
count + a
}
這個函數是純函數嗎?
要判斷的話我們按照上面相同的規則去帶入:
val b = increase(1)
這裏,b和increase(1)是否是等價的?是否可以用b替代之後任何地方、任何時間的increase(1)函數調用?
可以,所以新的increase就是一個純函數。
1.1 IO類型(包裝管道)
如果我們用一個類型包裝一下:
class IO<A>(run: () -> A)
fun increase(a: Int): IO<Int> = IO {
count + a
}
這就是專門用於隔離副作用的IO類型
IO類型是Haskell中的起名,原因是副作用大部分是“交互 Input/Output”操作(和文件系統交互、用户交互、數據庫交互、網絡交互)
如果這個類型中的值我們需要進行一定的計算,我們可以往裏面添加一些操作:
fun <A, B> IO<A>.map(f: (A) -> B): IO<B> = IO { f(this()) }
fun <A, B> IO<A>.flatMap(f: (A) -> IO<B>): IO<B> = IO { f(this()).run() }
大家再分析一下這些函數是否也是純函數?
一樣,我們按照代換法:
val c = b.map { it + 2 }
val d = b.flatMap { IO { 5 } }
c和d是否能替換(等效)之後任何位置、任何時間的b.map { it + 2 }和b.flatMap { IO { 5 } }函數調用?
可以,所以它們都是純函數。
實際的函數式的程序就像這樣,用一個類型包裹住副作用,然後通過一系列操作符去操作中間的值。
可以把這些用於包裹副作用的類型想象成“管道”,各種操作符就是在操作和組合管道,管道本身是“堅固“(不可變)的。中間流淌的就是”髒水“(副作用)。如果整個程序都是由管道構成的,那麼這些“髒水”並不會流得到處都是。
反過來,如果要按照函數式編程來構建程序,那些執行副作用的地方,就是這些髒水的“泄漏口”,所以這些“泄漏口”都要非常小心來處理,並且儘量少。
對於真正純粹的函數式編程程序來説,就只有一個泄漏口(副作用)執行點:main函數
Android的副作用執行口很複雜,後面結合實例説明
1.2 類型的傳染性
通過這些副作用包裹類型,除了將副作用包裹在“管道”內,還有一個好處。
之前提到過,異常可以通過類型來傳遞,這種類型也可以將“副作用”的聲明通過類型一層層往外面傳遞。
回過頭來看上面操作IO類型的方法,它返回的類型也是IO。這是因為如果你要在維持純函數的情況下操作副作用類型,那麼就必須繼續用副作用類型將自己的結果包裹起來;反之,如果要返回值,那麼就必須從副作用類型中取值,那就必須執行副作用,那就不是純函數了。
所以為了保持純函數,副作用類型一定會一層層地傳遞,這就是類型的傳染性。
1.3 協程
上面借用Haskell的命名方式構建了一個虛構的類型:IO。早期確實會這樣做,也有一些庫提供了這樣的IO類型(比如 arrow)
早期YRoute庫就是用Arrow的IO類型來表示副作用
但其實Kotlin本身語法上就提供了一個很類似的功能:協程
協程的設計在有意或無意間實際上實現了類似IO的功能:包裹副作用、惰性計算、類型傳染性
而由於協程是語言語法,所以性能上相比第三方庫的IO類型要好,而且也要更易讀。
後期YRoute改為用suspend替代IO類型的其中一個原因就是性能更好,當時進行的測試,替換為suspend後性能在各種情況下最高提升了15%
所以在項目中可以認為suspend方法就是IO類型的實現
RxJava中的Single、Completable、Maybe都是副作用類型
2. 流
2.1 再談變量與函數
説了副作用,再回到另一個重點:變量。函數式編程強調“不可變”,推崇使用不可變。
但實際程序中不可能沒有變量,因為程序的狀態一定會根據時間、交互而改變,那變量應該如何處理。
如果單看變量本身,它是不確定的,但是換個視角,增加時間的參數,又可以將變量看為:變量就是以時間為參數的函數。
x: Time -> Value
我們獲取某個變量當前的值,可以看為將當前的時間傳入函數後獲得當前的值。
以這種視角,可以將變量看為橫座標為時間、縱座標為值的一個折線圖。就像水流一樣,從左邊流到右邊,所以稱為“流”。
2.2 流
類似上面提供了時間軸上不同值點的類型就可以看為“流”
提問:IO類型是流嗎?
所以RxJava的Observable、Flowable,Kotlin的Flow,Android的LiveData都是流。
RxJava的註釋中對於操作符的解釋就是最好的時間軸模型的描述:
https://reactivex.io/documentation/observable.html
“流”相比“副作用類型”是完全不同的:流提供的是時間軸上持續變化的值,而副作用類型提供的是單個“值”是沒有變化的。
“流”因為提供了整個生命週期中值的整個變化,我們面對它不再只能獲取到它的值,而是對整個生命週期的變化進行處理。
這為我們實現某些隱含和時間有關的功能的實現提供了可能(比如防抖Debounce、fold、GroupBy、Buffer等等)
換句話説,如果我們將一個變量用流來描述,我們可以得到一個變量更為完整的生命信息。
正如上面所説,流,是為了描述變量的變化,所以對於上游來説,只需要準確描述變量是如何變化的即可,因為變量的任何變化,這種“變化”本身也是一種“信息”。注意!這裏的“變化”不是簡單指“值的變化”,“在某個時間點發出了一個值”本身也是一種變化。所以標準的流應該準確將這些信息都保留下來(聲明式編程),而得到這些信息後應該如何處理,則是實際使用的下游來決定的。
2.3 操作符(常用操作符)
mapflatMap、concatMap、switchMapdistinctUntilChangedcompose
2.4 流的組合
正如上面所説,函數式編程就是組合“管道”,所以除了流本身的變化,流相互之間組合也是非常常見的操作。
Fragment的生命是事件流、App的生命週期是事件流、畫面的狀態是狀態流、Widget的生命週期是事件流、用户的操作(點擊、滑動)是事件流等等,對於函數式程序而言,不需要在Fragment的生命週期回調中編寫邏輯,而是組合不同的事件流即可。
class AFragment {
init {
bindFragmentLife()
.ofType<FragmentLifeEvent.OnViewCreated>()
.flatMapCompletable {
RxCompose.mergeAll<Unit> {
binding.button.clicks()
.flatMap { callApi() }
.flatMap { newData -> subStore.dispatch { it.copy(data = newData) } }
.bindLife()
}
}
.bindLife()
...
}
}
fun <T> Observable<T>.throttleBy(switcher: ObservableSource<Boolean>): Observable<T> =
withLatestFrom(switcher, BiFunction { t, u -> Pair(t, u) }).filter { it.second }.map { it.first }
N004_01_RankingFragment
N003_01_MyPageFragment
refreshEvents: 當選擇第四個tab的時候、從後台返回前台的時候、接收到全局事件需要刷新第四個畫面時
ItemData(rankingNewsWidget(Observable.merge(pageState.bindPageSwitchEvent(4, true),
backToForeEvents().throttleBy(pageState.isSelected(4)),
Observable.defer { globalRxBusCB.bindEvent<SwitchRankingPageEvent>(this@N004_01_RankingFragment) }
.filter { it.pageNum == 4 }
.compose(pageState.pageHasInitedFilter(4))
).replace(Unit)),
Eval.later { "ニュース" })
2.5 狀態流與事件流
提問:LiveData是什麼類型的流?
流本身也有分類:
-
狀態(State):“狀態”是在時間軸上連續變化的線(因為狀態是隨時都有值的),它在任意時刻都是有值的。
- 連續性:任何時候都可以獲得一個最新的值
- 記憶性:會保留最新的值,任何訂閲者都可以立刻獲得最新的狀態
-
事件(Event):“事件”是描述特定時間發生的短暫事件,所以它是在時間軸上離散的數據點。它只在特定時刻才有事件發生
- 離散性:只在事件發生的時候發出數據,期間是沒有數據的
- 瞬時性:事件只在發生的瞬間,如果此刻沒有訂閲,則不會捕獲到這個事件
這是兩種處理方式不同的流:狀態流和事件流
- 狀態流:
BehaviorSubject、StateFlow、LiveData、MutableState - 事件流:
PublishSubject、SharedFlow
正如他們的特點,對應的實現類型的功能上也有區別(狀態流都會有value、並且訂閲後馬上可以獲得值;而事件流是沒有value屬性的,訂閲後也不會馬上返回值)。
實際使用中一定要按需使用對應的類型來描述對應的值。
通過一定的手段,兩種流可以一定程度上相互換用(比如將事件用特殊的Event類型包裝,然後將LiveData使用為事件流),但這種轉換很彆扭、也很容易造成誤解,最好不要這樣做。
狀態流,因為更注重“值的變化”,所以一般而言對“變化事件”不是特別在意,這也是為什麼LiveData設計為默認會進行去重。但事件流,對“值的發生事件”尤其注重,所以狀態流使用為事件流的時候一定要注意去掉有的狀態流的去重操作。
2.6 流的啓動與副作用
流雖然和副作用類型不太相同,但有一點是一致的:只要實際去獲取內部的值就會發生副作用。
所以流我們並不會頻繁去訂閲,而且訂閲本身也要非常注意。
理想的程序是隻在最外層的系統入口處才會執行副作用進行訂閲。
Fintos項目確實就是這樣做的,一個畫面中的所有邏輯都構建為流,並且只在Fragment的系統回調中進行訂閲。
訂閲的時候會通過bindLife方法捕獲所有異常並綁定生命週期,訂閲時的生命週期訂閲也就自動地讓整個畫面的流都具有了生命週期綁定。
所有啓動流的方法都有副作用。需要注意的是,啓動流並不只有subscribe方法,任何嘗試獲取內部值的方法都是“啓動”方法,比如toList,不要隨便使用它們!(正如上面所説,凡是會丟棄掉副作用包裹類型的方法都是副作用方法)
2.7 響應式編程
正如其名,在值變化的時候實時反應在畫面上。實時響應值的變化。
如果瞭解了上面流的概念,就可以發現,響應式編程其實是在程序中應用了“流”的概念替代所有變量後自然而然的結果。
因為本身訂閲的就是值的變化流,那麼值的變化自然就會實時反應在訂閲者上。
重要:符合響應式的函數不僅返回的是值,而且也是接收“執行完成”的信號,所以響應式中一般並不推薦使用回調來接收返回值。
3. 實例
3.1 響應式狀態機 StateMachine
狀態機:
Event -> StateMachine(OldState) -> StateMachine(NewState)
- 狀態機中保持有當前的狀態
- 當有Event發生的時候,會觸發狀態機中的邏輯:
(Event, OldState) -> NewState,會更新狀態機中的狀態
// 定義狀態
sealed class State {
object StateA : State() // 初始狀態
object StateB : State()
object StateC : State()
object StateD : State()
}
// 定義事件
sealed class Event {
object Event1 : Event()
object Event2 : Event()
object Event3 : Event()
object Event4 : Event()
}
// 狀態機類
class StateMachine(var currentState: State = State.StateA) {
// 根據當前狀態和事件進行狀態轉換
fun transition(event: Event) {
currentState = when (currentState) {
is State.StateA -> when (event) {
Event.Event1 -> State.StateB
else -> currentState
}
is State.StateB -> when (event) {
Event.Event2 -> State.StateD
else -> currentState
}
is State.StateD -> when (event) {
Event.Event4 -> State.StateC
else -> currentState
}
is State.StateC -> when (event) {
Event.Event3 -> State.StateA
else -> currentState
}
}
println("After ${event.javaClass.simpleName}: $currentState")
}
}
// 演示使用
fun main() {
val stateMachine = StateMachine()
println("Initial state: ${stateMachine.currentState}")
stateMachine.transition(Event.Event1) // 從 StateA 轉換到 StateB
stateMachine.transition(Event.Event2) // 從 StateB 轉換到 StateD
stateMachine.transition(Event.Event4) // 從 StateD 轉換到 StateC
stateMachine.transition(Event.Event3) // 從 StateC 轉換回 StateA
}
但可以注意到這種狀態機是普通狀態機,按照函數式的概念,其中的轉換函數是不能有副作用的,就無法執行API等耗時操作。所以需要將它改寫為響應式狀態機。
interface MEvent<R>
class StateMachine<S : Any>(
initState: S,
val run: suspend (S, event: MEvent<*>) -> Option<Tuple2<S, Any?>>,
val changedDeal: ChangedDeal = ChangedDeal.Ignore) {
suspend fun <R : Any, E: MEvent<R>> change(event: E): Option<R>
suspend fun <E: MEvent<*>> changeWithState(event: E): S
fun bindState(): Observable<S>
}
StateMachine本身的構建只專注於狀態根據函數來進行更新。而StateMachineBuilder則專注於run函數的構建,構建了一套DSL來定義狀態機邏輯
BaseWidget中的defaultLifeStore展示了這種狀態機的使用。它構建了Widget系統的流式邏輯。