流与响应式编程
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(run: () -> A)
fun increase(a: Int): IO = IO {
count + a
}
这就是专门用于隔离副作用的IO
类型
IO
类型是Haskell中的起名,原因是副作用大部分是“交互 Input/Output”操作(和文件系统交互、用户交互、数据库交互、网络交互)
如果这个类型中的值我们需要进行一定的计算,我们可以往里面添加一些操作:
fun IO.map(f: (A) -> B): IO = IO { f(this()) }
fun IO.flatMap(f: (A) -> IO): IO = 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 操作符(常用操作符)
map
flatMap
、concatMap
、switchMap
distinctUntilChanged
compose
2.4 流的组合
正如上面所说,函数式编程就是组合“管道”,所以除了流本身的变化,流相互之间组合也是非常常见的操作。
Fragment的生命是事件流、App的生命周期是事件流、画面的状态是状态流、Widget的生命周期是事件流、用户的操作(点击、滑动)是事件流等等,对于函数式程序而言,不需要在Fragment的生命周期回调中编写逻辑,而是组合不同的事件流即可。
class AFragment {
init {
bindFragmentLife()
.ofType()
.flatMapCompletable {
RxCompose.mergeAll {
binding.button.clicks()
.flatMap { callApi() }
.flatMap { newData -> subStore.dispatch { it.copy(data = newData) } }
.bindLife()
}
}
.bindLife()
...
}
}
fun Observable.throttleBy(switcher: ObservableSource): Observable =
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(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
class StateMachine(
initState: S,
val run: suspend (S, event: MEvent<*>) -> Option>,
val changedDeal: ChangedDeal = ChangedDeal.Ignore) {
suspend fun > change(event: E): Option
suspend fun > changeWithState(event: E): S
fun bindState(): Observable
}
StateMachine本身的构建只专注于状态根据函数来进行更新。而StateMachineBuilder则专注于run函数的构建,构建了一套DSL来定义状态机逻辑
BaseWidget中的defaultLifeStore展示了这种状态机的使用。它构建了Widget系统的流式逻辑。