文章

MVI 意图处理方式

MVI 意图处理方式

在 MVI (Model-View-Intent) 架构中,处理用户意图 (Intent) 是核心部分。你提到的两种方式:使用 Channel 处理 Intent直接调用 ViewModel 的 handleIntent 方法,在实现上确实存在区别,并且各自有其适用场景和优缺点。

1. 使用 Channel 处理 Intent

当你在 MVI 架构中使用 Channel 来处理 Intent 时,通常指的是 View 层(例如 Activity 或 Fragment)将用户操作封装成 Intent 对象,然后将这些 Intent 发送到一个 Channel 中,ViewModel 则从这个 Channel 接收并处理这些 Intent。

实现方式:

  • View 层

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    
    // 在 View 中
    lifecycleScope.launch {
        // 当用户点击按钮时
        sendIntent(MyIntent.ClickButton)
    }
      
    private val _intents = Channel<MyIntent>(Channel.UNLIMITED)
    fun sendIntent(intent: MyIntent) {
        _intents.trySend(intent) // 或者 launch { _intents.send(intent) }
    }
    
  • ViewModel 层

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    
    // 在 ViewModel 中
    init {
        // 在 ViewModel 启动时收集 Intent
        viewModelScope.launch {
            _intents.consumeAsFlow().collect { intent ->
                handleIntent(intent)
            }
        }
    }
      
    private fun handleIntent(intent: MyIntent) {
        when (intent) {
            is MyIntent.ClickButton -> reduceState(State.Loading)
            // ... 处理其他 Intent
        }
    }
    

优缺点:

优点:

  • 解耦:View 不需要直接持有 ViewModel 实例或知晓其内部方法。它只需知道如何将 Intent 发送到一个通道。
  • 批处理/限流:如果你需要对 Intent 进行批处理(例如,只处理一定时间间隔内的第一个点击事件),或者进行限流,Channel 提供了灵活的机制来实现这些高级功能。
  • 生命周期管理:当 View 层使用 lifecycleScope 发送 Intent 到 Channel 时,可以更好地管理发送操作的生命周期。
  • 事件队列Channel 天然地提供了一个事件队列,可以按顺序处理接收到的 Intent,这对于确保事件处理的顺序性很有用。
  • 测试性:在单元测试中,你可以更容易地模拟 Channel 来发送一系列 Intent,测试 ViewModel 对这些 Intent 的响应。

缺点:

  • 复杂性增加:引入 Channel 增加了额外的抽象层和代码量,对于简单的场景可能会显得过度设计。
  • 调试难度:由于多了一层间接性,调试 Intent 的发送和接收过程可能稍微复杂一些。

2. 直接调用 ViewModel 的 handleIntent

这是 MVI 架构中更常见、更直接的处理 Intent 的方式。View 层直接调用 ViewModel 暴露的公共方法(例如 handleIntent),将 Intent 对象传递给它。

实现方式:

  • View 层

    1
    2
    3
    4
    5
    6
    7
    8
    9
    
    // 在 View 中
    private lateinit var viewModel: MyViewModel // 通常通过 Hilt/Koin 或 ViewModelProvider 获取
      
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        binding.myButton.setOnClickListener {
            viewModel.handleIntent(MyIntent.ClickButton)
        }
    }
    
  • ViewModel 层

    1
    2
    3
    4
    5
    6
    7
    8
    9
    
    // 在 ViewModel 中
    fun handleIntent(intent: MyIntent) {
        viewModelScope.launch { // 通常在 ViewModelScope 中处理,以避免阻塞 UI 线程
            when (intent) {
                is MyIntent.ClickButton -> reduceState(State.Loading)
                // ... 处理其他 Intent
            }
        }
    }
    

优缺点:

优点:

  • 简洁明了:代码更直接,更容易理解 Intent 的流向,没有额外的中间层。
  • 上手简单:对于 MVI 初学者来说,这种方式更容易理解和实现。
  • 调试方便:由于是直接方法调用,调试堆栈更加清晰,追踪问题更方便。

缺点:

  • 紧耦合:View 直接依赖 ViewModel 的具体方法,造成了 View 和 ViewModel 之间一定程度的紧耦合。
  • 缺少内置的批处理/限流机制:如果需要实现批处理或限流,你需要在 ViewModel 内部或在调用前手动实现,而不是利用 Channel 的特性。
  • 潜在的并发问题:如果不对 handleIntent 方法内部进行适当的并发控制,快速连续的调用可能会导致竞争条件或状态不一致(尽管通常会在 viewModelScope 中处理以缓解此问题)。

总结与选择建议

特性使用 Channel 处理 Intent直接调用 ViewModel 的 handleIntent
耦合度:View 不直接依赖 ViewModel 具体方法较高:View 直接调用 ViewModel 具体方法
复杂性较高(引入 Channel 抽象)较低(直接方法调用)
功能扩展易于实现批处理、限流、序列化处理等高级功能需要手动在 ViewModel 或调用方实现这些功能
调试稍复杂(多一层间接性)简单(直接方法调用)
适用场景复杂的 Intent 交互、需要高级流控制、事件队列简单直接的 Intent 交互、快速原型开发
推荐程度适用于需要更强解耦流控制的复杂应用场景适用于大多数 MVI 场景,尤其是入门和中小型项目

何时选择哪种方式:

  • 对于大多数 MVI 应用,特别是初学者和中小型项目, 直接调用 ViewModel 的 handleIntent 是更推荐和更常见的选择。 它简单、直接,足以满足大部分需求,且易于理解和调试。
  • 当你遇到以下情况时,考虑使用 Channel 来处理 Intent:
    • 你需要非常高的解耦度,希望 View 对 ViewModel 的内部实现知之甚少。
    • 你需要对用户 Intent 进行批处理、限流或复杂的序列化处理,例如处理频繁的用户输入。
    • 你希望 View 发送的 Intent 能够自动进行背压处理,而不需要 View 关心 ViewModel 是否忙碌。
    • 你在实现一个复杂的事件总线,或者多个 View 可能向同一个 ViewModel 发送不同类型的 Intent,需要一个统一的入口和队列来处理。
  • 另一种现代 MVI 趋势是使用 SharedFlow 作为 Intent 的输入。 SharedFlow 结合了 StateFlow 的热流特性和 Channel 的消息传递能力,并且可以配置缓冲区和重放行为,使其成为处理一次性事件和多播事件的强大替代方案,在很多场景下比 Channel 更具优势。如果你正在寻找 Channel 的替代方案来处理 Intent,SharedFlow 也是一个值得深入研究的选择。

最终的选择取决于你的项目需求、团队偏好以及对复杂性与灵活性的权衡。

为什么使用 SharedFlow 更好?

  • 生命周期安全Channel 是一种“热”数据流,如果 View 在发送 Intent 的那一刻没有处于活跃的监听状态(例如,在屏幕旋转的瞬间),这个 Intent 可能会丢失。
  • 多订阅者支持SharedFlow 天生设计用于向多个订阅者广播数据,虽然在这个简单例子中只有一个订阅者(ViewModel 自身),但在更复杂的场景下这非常有用。
  • 可配置性SharedFlow 可以配置 replay 缓存,这在某些场景下可以确保新订阅者也能收到最近的事件。
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
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlin.random.Random

// 1. 定义 Contract: State 和 Intent
// =================================================================

/**
 * 定义 UI 状态 (Model)
 * @param isLoading 是否正在加载
 * @param quote 当前显示的引言
 * @param error 错误信息,如果没有错误则为 null
 */
data class MainUiState(
    val isLoading: Boolean = false,
    val quote: String = "点击按钮获取一句名言",
    val error: String? = null
)

/**
 * 定义用户意图 (Intent)
 * 使用 sealed interface/class 可以限制意图的类型
 */
sealed interface MainIntent {
    object FetchQuote : MainIntent
}


// 2. 创建 ViewModel
// =================================================================

class MainViewModel : ViewModel() {

    // _state 是可变的,且是私有的,只能在 ViewModel 内部修改
    private val _state = MutableStateFlow(MainUiState())
    // state 是暴露给外部的、不可变的 StateFlow,用于 UI 观察
    val state = _state.asStateFlow()

    // **【变更】** 使用 MutableSharedFlow 替代 Channel 来接收 Intent
    private val _intent = MutableSharedFlow<MainIntent>()

    init {
        // **【变更】** 在 ViewModel 初始化时,启动一个协程来持续处理来自 View 的 Intent
        viewModelScope.launch {
            _intent.collectLatest { intent ->
                // 根据不同的 Intent 类型进行处理
                when (intent) {
                    is MainIntent.FetchQuote -> fetchQuote()
                }
            }
        }
    }

    /**
     * **【变更】** 这个公共方法用于让 View 发送 Intent
     */
    fun sendIntent(intent: MainIntent) {
        viewModelScope.launch {
            _intent.emit(intent)
        }
    }

    private fun fetchQuote() {
        viewModelScope.launch {
            // 步骤 1: 发出“加载中”的状态
            _state.value = _state.value.copy(isLoading = true, error = null)

            try {
                // 步骤 2: 模拟网络请求
                delay(1500) // 模拟 1.5 秒的网络延迟

                // 模拟成功或失败
                if (Random.nextBoolean()) {
                    val newQuote = mockQuotes.random()
                    // 步骤 3 (成功): 发出包含新数据的状态
                    _state.value = _state.value.copy(isLoading = false, quote = newQuote)
                } else {
                    // 步骤 3 (失败): 抛出异常
                    throw RuntimeException("网络连接失败!")
                }

            } catch (e: Exception) {
                // 步骤 4 (捕获异常): 发出包含错误信息的状态
                _state.value = _state.value.copy(isLoading = false, error = e.message)
            }
        }
    }

    // 模拟一些数据
    private val mockQuotes = listOf(
        "生活就像一盒巧克力,你永远不知道下一颗是什么味道。",
        "Stay hungry, stay foolish.",
        "代码就是最好的文档。",
        "你唯一需要回头的时候,是为了看自己走了多远。"
    )
}


// 3. 创建 View (Activity + Jetpack Compose)
// =================================================================

class MainActivity : ComponentActivity() {

    private val viewModel: MainViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MviExampleTheme {
                // **【变更】** onIntent 回调现在调用 sendIntent 方法
                MainScreen(
                    state = viewModel.state.collectAsState().value,
                    onIntent = { intent -> viewModel.sendIntent(intent) }
                )
            }
        }
    }
}

@Composable
fun MainScreen(state: MainUiState, onIntent: (MainIntent) -> Unit) {
    Surface(
        modifier = Modifier.fillMaxSize(),
        color = MaterialTheme.colorScheme.background
    ) {
        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(24.dp),
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.Center
        ) {
            // 根据 state.quote 显示文本
            Text(
                text = state.quote,
                style = MaterialTheme.typography.headlineSmall,
                textAlign = TextAlign.Center
            )

            Spacer(modifier = Modifier.height(40.dp))

            // 根据 state.isLoading 决定显示加载圈还是按钮
            if (state.isLoading) {
                CircularProgressIndicator()
            } else {
                Button(
                    onClick = { onIntent(MainIntent.FetchQuote) },
                    // 加载中时禁用按钮
                    enabled = !state.isLoading
                ) {
                    Text(text = "获取下一句")
                }
            }

            // 如果 state.error 不为 null,则显示错误信息
            state.error?.let {
                Spacer(modifier = Modifier.height(24.dp))
                Text(
                    text = it,
                    color = MaterialTheme.colorScheme.error,
                    style = MaterialTheme.typography.bodyMedium
                )
            }
        }
    }
}

// 主题和预览
@Composable
fun MviExampleTheme(content: @Composable () -> Unit) {
    MaterialTheme(
        colorScheme = darkColorScheme(), // 使用深色主题
        content = content
    )
}

@Preview(showBackground = true)
@Composable
fun DefaultPreview() {
    MviExampleTheme {
        MainScreen(
            state = MainUiState(quote = "这是一个预览"),
            onIntent = {}
        )
    }
}

@Preview(showBackground = true)
@Composable
fun LoadingPreview() {
    MviExampleTheme {
        MainScreen(
            state = MainUiState(isLoading = true),
            onIntent = {}
        )
    }
}

@Preview(showBackground = true)
@Composable
fun ErrorPreview() {
    MviExampleTheme {
        MainScreen(
            state = MainUiState(error = "加载失败了!"),
            onIntent = {}
        )
    }
}

本文由作者按照 CC BY 4.0 进行授权