MVI架构实践

为啥要用MVI架构

为啥要从MVVM过渡到MVI,结合实际开发经验,个人总结以下几点:

1、从MVVM中的多个LiveData挨个观察改为一个State统一观察,当然如果我们需要观察State里面单独的某个字段,这个也是很方便做到的,下面的代码会讲到
2、之前MVVM架构中,VM会开放很多方法给View调用;在MVI架构中,只有一个SendIntent入口,因此在一开始我们就可以把所有支持的的Intent全部定义出来,这样看起来就一目了然,知道当前VM支持哪些Intent,而不用去看VM的具体实现细节
3、有了State和Intent之后,就相当于明确了VM的输出和输入,符合单向数据流的思想,方便封装和解耦,且非常方便写单元测试;并且这个State和Intent相当于是一个约定,这样就可以做到一个人写逻辑,一个人写UI,互不干扰,因为大家都是遵循这个约定来写,提升研发效率

Jetpack Compose官方文档里面也是遵循MVI架构来的:
https://developer.android.com/jetpack/compose/architecture?hl=zh-cn

主要思想是单向数据流。单向数据流 (UDF) 是一种设计模式,在该模式下状态向下流动,事件向上流动。通过采用单向数据流,您可以将在界面中显示状态的可组合项与应用中存储和更改状态的部分分离开来,如下所示:

使用 Jetpack Compose 时遵循此模式可带来下面几项优势:

可测试性:将状态与显示状态的界面分离开来,更方便单独对二者进行测试。
状态封装:因为状态只能在一个位置进行更新,并且可组合项的状态只有一个可信来源,所以不太可能由于状态不一致而出现 bug。
界面一致性:通过使用可观察的状态容器,例如 StateFlow 或 LiveData,所有状态更新都会立即反映在界面中。

State

State接口定义:

1
interface MviUiState

Intent

Intent接口定义:

1
interface MviUiIntent

BaseMviViewModel

1、State使用StateFlow来承载,类似于LiveData
2、Intent使用MutableShareFlow来承载,因为默认ShareFlow的replay = 0(一次性的,非粘性回调),符合Intent的思想(当然这里不使用Flow直接调用handleIntent方法也是可以的)
3、子类覆写handleIntent方法来实现具体的Intent逻辑
4、子类通过调用updateState来更新State
5、构造VM的时候必须传入一个initState

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
abstract class BaseMviViewModel<S : MviUiState, I : MviUiIntent>(initialState: S) :
ViewModel() {
private val mutableStateFlow = MutableStateFlow(initialState)
val uiStateFlow = mutableStateFlow.asStateFlow()
private val uiIntentFlow = MutableSharedFlow<I>()

val state
get() = uiStateFlow.value

protected abstract fun handleIntent(uiIntent: I)

init {
viewModelScope.launch {
uiIntentFlow.collect {
handleIntent(it)
}
}
}

protected fun updateState(reducer: S.() -> S) {
viewModelScope.launch {
mutableStateFlow.update {
it.reducer()
}
}
}

fun setIntent(uiIntent: I) {
viewModelScope.launch {
uiIntentFlow.emit(uiIntent)
}
}
}

MviView

实际上在UI层拿到了ViewModel之后,就可以直接监听State了:

1
2
3
4
5
lifecycleScope.launchWhenCreated {
viewModel.uiStateFlow.collect {

}
}

如果我们只想监听State里面某个字段的改变,可以这样写:

1
2
3
4
5
6
7
8
lifecycleScope.launchWhenCreated {
viewModel.uiStateFlow
.map { CounterUiState::count.get(it) }
.distinctUntilChanged()
.collect {

}
}

通过反射以及distinctUntilChanged来达到单独监听某个字段以及进行去重处理(如果是LiveData需要自己做特殊处理)

可以看到,如果需要单独监听某个字段还是需要写很多模板代码的,因此我们可以抽一个MviView的接口,在里面提供监听单个或者多个字段的方法,以及提供默认的订阅协程,这个默认协程MviView实现类可以覆写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
data class Tuple1<A>(val a: A)
data class Tuple2<A, B>(val a: A, val b: B)
data class Tuple3<A, B, C>(val a: A, val b: B, val c: C)
data class Tuple4<A, B, C, D>(val a: A, val b: B, val c: C, val d: D)
data class Tuple5<A, B, C, D, E>(val a: A, val b: B, val c: C, val d: D, val e: E)
data class Tuple6<A, B, C, D, E, F>(
val a: A,
val b: B,
val c: C,
val d: D,
val e: E,
val f: F
)
data class Tuple7<A, B, C, D, E, F, G>(
val a: A,
val b: B,
val c: C,
val d: D,
val e: E,
val f: F,
val g: G
)


interface MviView {

/**
* 收集状态的协程,子类可以自定义
*/
val subscriptionScope: CoroutineScope?
get() = try {
val lifecycleOwner = (this as? Fragment)?.viewLifecycleOwner ?: this as? LifecycleOwner
lifecycleOwner?.lifecycleScope
} catch (e: IllegalStateException) {
null
}

fun <S : MviUiState, I : MviUiIntent, T> BaseMviViewModel<S, I>.onEach(
block: (state: S) -> T,
action: suspend (T) -> Unit) {
uiStateFlow.map { Tuple1(block(it)) }.distinctUntilChanged()
.resolveSubscription(subscriptionScope) {
action(it.a)
}
}

fun <S : MviUiState, I : MviUiIntent, T> BaseMviViewModel<S, I>.onEach(prop1: KProperty1<S, T>,
action: suspend (T) -> Unit) {
uiStateFlow.map { Tuple1(prop1.get(it)) }.distinctUntilChanged()
.resolveSubscription(subscriptionScope) {
action(it.a)
}
}

fun <S : MviUiState, I : MviUiIntent, A, B> BaseMviViewModel<S, I>.onEach(
prop1: KProperty1<S, A>,
prop2: KProperty1<S, B>,
action: suspend (A, B) -> Unit) {
uiStateFlow.map { Tuple2(prop1.get(it), prop2.get(it)) }
.distinctUntilChanged().resolveSubscription(subscriptionScope) {
action(it.a, it.b)
}
}

fun <S : MviUiState, I : MviUiIntent, A, B, C> BaseMviViewModel<S, I>.onEach(
prop1: KProperty1<S, A>,
prop2: KProperty1<S, B>,
prop3: KProperty1<S, C>,
action: suspend (A, B, C) -> Unit) {
uiStateFlow.map { Tuple3(prop1.get(it), prop2.get(it), prop3.get(it)) }
.distinctUntilChanged().resolveSubscription(subscriptionScope) {
action(it.a, it.b, it.c)
}
}

fun <S : MviUiState, I : MviUiIntent, A, B, C, D> BaseMviViewModel<S, I>.onEach(
prop1: KProperty1<S, A>,
prop2: KProperty1<S, B>,
prop3: KProperty1<S, C>,
prop4: KProperty1<S, D>,
action: suspend (A, B, C, D) -> Unit) {
uiStateFlow.map { Tuple4(prop1.get(it), prop2.get(it), prop3.get(it), prop4.get(it)) }
.distinctUntilChanged().resolveSubscription(subscriptionScope) {
action(it.a, it.b, it.c, it.d)
}
}

fun <S : MviUiState, I : MviUiIntent, A, B, C, D, E> BaseMviViewModel<S, I>.onEach(
prop1: KProperty1<S, A>,
prop2: KProperty1<S, B>,
prop3: KProperty1<S, C>,
prop4: KProperty1<S, D>,
prop5: KProperty1<S, E>,
action: suspend (A, B, C, D, E) -> Unit) {
uiStateFlow.map { Tuple5(prop1.get(it), prop2.get(it), prop3.get(it), prop4.get(it), prop5.get(it)) }
.distinctUntilChanged().resolveSubscription(subscriptionScope) {
action(it.a, it.b, it.c, it.d, it.e)
}
}

fun <S : MviUiState, I : MviUiIntent, A, B, C, D, E, F> BaseMviViewModel<S, I>.onEach(
prop1: KProperty1<S, A>,
prop2: KProperty1<S, B>,
prop3: KProperty1<S, C>,
prop4: KProperty1<S, D>,
prop5: KProperty1<S, E>,
prop6: KProperty1<S, F>,
action: suspend (A, B, C, D, E, F) -> Unit) {
uiStateFlow.map {
Tuple6(prop1.get(it),
prop2.get(it),
prop3.get(it),
prop4.get(it),
prop5.get(it),
prop6.get(it))
}
.distinctUntilChanged().resolveSubscription(subscriptionScope) {
action(it.a, it.b, it.c, it.d, it.e, it.f)
}
}

fun <S : MviUiState, I : MviUiIntent, A, B, C, D, E, F, G> BaseMviViewModel<S, I>.onEach(
prop1: KProperty1<S, A>,
prop2: KProperty1<S, B>,
prop3: KProperty1<S, C>,
prop4: KProperty1<S, D>,
prop5: KProperty1<S, E>,
prop6: KProperty1<S, F>,
prop7: KProperty1<S, G>,
action: suspend (A, B, C, D, E, F, G) -> Unit) {
uiStateFlow.map {
Tuple7(prop1.get(it),
prop2.get(it),
prop3.get(it),
prop4.get(it),
prop5.get(it),
prop6.get(it),
prop7.get(it))
}.distinctUntilChanged().resolveSubscription(subscriptionScope) {
action(it.a, it.b, it.c, it.d, it.e, it.f, it.g)
}
}
}

其中resolveSubscription定义在BaseMviViewModel里面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
fun <T> Flow<T>.resolveSubscription(subscriptionScope: CoroutineScope? = null, action: suspend (T) -> Unit): Job {
return (subscriptionScope as? LifecycleCoroutineScope)?.launchWhenCreated {
yield()
collectLatest(action)
} ?: run {
val scope = subscriptionScope ?: viewModelScope
scope.launch(start = CoroutineStart.UNDISPATCHED) {
// Use yield to ensure flow collect coroutine is dispatched rather than invoked immediately.
// This is necessary when Dispatchers.Main.immediate is used in scope.
// Coroutine is launched with start = CoroutineStart.UNDISPATCHED to perform dispatch only once.
yield()
collectLatest(action)
}
}
}

由于这些onEach方法是定义在MViView接口里面的,因此只有实现了MviView接口的UI才能调用这些onEach方法

实践

假设我们要实现如下所示的一个计数器:第一行是当前计数展示,下面俩个按钮分别是add和decrease:

首先是定义State和Intent:

1
2
3
4
5
6
data class CounterUiState(val count: Int = 0) : MviUiState

sealed class CounterUiIntent : MviUiIntent {
object Add : CounterUiIntent()
object Decrease : CounterUiIntent()
}

然后就是VM:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class CounterViewModel(initialState: CounterUiState) : BaseMviViewModel<CounterUiState, CounterUiIntent>(initialState) {
override fun handleIntent(uiIntent: CounterUiIntent) {
when (uiIntent) {
is CounterUiIntent.Add -> {
updateState {
copy(count = count + 1)
}
}

is CounterUiIntent.Decrease -> {
updateState {
copy(count = count - 1)
}
}
}
}
}

最后是UI:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
package com.easyliu.demo.mvi

import android.os.Bundle
import android.widget.TextView
import androidx.activity.ComponentActivity
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelLazy
import androidx.lifecycle.ViewModelProvider

@Suppress("UNCHECKED_CAST")
class CounterMVIActivity : ComponentActivity(), MviView {
private lateinit var counter: TextView
private lateinit var add: TextView
private lateinit var decrease: TextView

private val viewModel by ViewModelLazy(CounterViewModel::class,
{ viewModelStore }, {
object : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass == CounterViewModel::class.java) {
return CounterViewModel(CounterUiState(0)) as T
}
return super.create(modelClass)
}
}
})

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.layout_counter_mvi)
counter = findViewById(R.id.tv_counter)
add = findViewById<TextView?>(R.id.bt_add).apply {
setOnClickListener {
viewModel.setIntent(CounterUiIntent.Add)
}
}
decrease = findViewById<TextView?>(R.id.bt_decrease).apply {
setOnClickListener {
viewModel.setIntent(CounterUiIntent.Decrease)
}
}
viewModel.onEach(CounterUiState::count) {
counter.text = buildString {
append("计数:")
append(it)
}
}
}
}

这样,一个简单的计数功能就完成了,整体开发下来是不是很顺畅,遵循state -> Intent -> VM -> View这样的一个开发流程

—————–2023.9.16 update————————
在上述MVI架构讲述中,定义了State以及Intent,State包含了Ui的状态。还是以上述的计数器为例,如果我们想在点击计数+1的时候弹出一个toast,如果我们把这个行为也定义到CounterState里边,就会由于State的粘性特性,页面发生横竖屏切换的时候又会弹出一次toast,这个很明显不是我们期望的结果。

那这里说明了一个问题:这个State和Event得进行分离,Event是代表一次性的行为,比如弹出Toast,且是一对一的行为,因此这里就引申出了Event的概念

Event

首先是Event基类定义:

1
interface MviEvent

然后是BaseMviViewModel新增Event约束:

1
abstract class BaseMviViewModel<S : MviUiState, I : MviUiIntent, E : MviEvent>(initialState: S) : ViewModel()

当然还需要一个承载Event的容器,这里使用Channel承载一对一的场景,并且这个Channel是没有buffer的,同时转成eventFlow方便UI进行观察:

1
2
private val eventChanel = Channel<E>()
val eventFlow = eventChanel.receiveAsFlow()

最后在BaseMviViewModel里面新增一个sendEvent方法供子类调用:

1
2
3
4
5
protected fun sendEvent(event: E) {
viewModelScope.launch {
eventChanel.send(event)
}
}

由于UI侧需要观察Event,因此在MviView里面新增onEvent扩展方法来观察Event:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
interface MviView {

/**
* 收集状态的协程,子类可以自定义
*/
val subscriptionScope: CoroutineScope?
get() = try {
val lifecycleOwner = (this as? Fragment)?.viewLifecycleOwner ?: this as? LifecycleOwner
lifecycleOwner?.lifecycleScope
} catch (e: IllegalStateException) {
null
}

fun <S : MviUiState, I : MviUiIntent, E : MviEvent> BaseMviViewModel<S, I, E>.onEvent(event: (e: E) -> Unit) {
eventFlow.distinctUntilChanged().resolveSubscription(subscriptionScope) {
event(it)
}
}

..........
}

改造计数器

Event能力定义好了之后,接下来我么就来改造上述计数器Demo。首先定义CounterEvent:

1
2
3
4
sealed class CounterEvent : MviEvent {
object ShowAddToast : CounterEvent()
object ShowDecreaseToast : CounterEvent()
}

然后就是在CounterViewModel里面发送对应的Event:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class CounterViewModel(initialState: CounterUiState) :
BaseMviViewModel<CounterUiState, CounterUiIntent, CounterEvent>(initialState) {
override fun handleIntent(uiIntent: CounterUiIntent) {
when (uiIntent) {
is CounterUiIntent.Add -> {
updateState {
copy(count = count + 1)
}
//showToast
sendEvent(CounterEvent.ShowAddToast)
}

is CounterUiIntent.Decrease -> {
updateState {
copy(count = count - 1)
}
//showToast
sendEvent(CounterEvent.ShowDecreaseToast)
}
}
}
}

最后在UI侧CounterMVIActivity里面进行event观察响应:

1
2
3
4
5
6
7
8
9
10
11
viewModel.onEvent {
when (it) {
is CounterEvent.ShowAddToast -> {
Toast.makeText(this, "计数+1", Toast.LENGTH_SHORT).show()
}

is CounterEvent.ShowDecreaseToast -> {
Toast.makeText(this, "计数-1", Toast.LENGTH_SHORT).show()
}
}
}

这样,整个功能就完美实现了,在点击add或者decrease按钮的时候计数会进行增减,并且弹出对应的toast,然后当进行横竖屏切换导致Activity销毁重建的时候,toast也不会再次弹出。

源码

https://github.com/EasyLiu-Ly/MVI

参考

https://developer.android.google.cn/jetpack/guide?hl=zh_cn
https://github.com/airbnb/mavericks
https://juejin.cn/post/7048980213811642382?searchId=202309161518351288648A2C013082F53D