MVI 架构核心原理
MVI(Model-View-Intent)是一种基于单向数据流的架构模式,其核心组件关系如下:
scss
[View] -- Intents --> [ViewModel] -- States --> [View]
| |
用户交互事件 处理业务逻辑
| |
[View] <-- Effects ---- [ViewModel]
(一次性事件)
MVI 核心组件解析:
-
Model (状态)
- 不可变数据类表示UI的所有状态
- 包含数据、加载状态、错误信息等
- 使用Kotlin的
data class
确保状态不可变
-
View (Compose UI)
- 声明式UI组件
- 职责:
- 发送Intents(用户交互)
- 渲染State
- 处理Effects(导航、Toast等)
-
Intent (用户意图)
- 密封类表示所有可能的用户操作
- 每个交互对应一个Intent子类
- 示例:
AddTodoIntent
,DeleteTodoIntent
-
单向数据流
graph LR A[用户操作] --> B[发送Intent] B --> C[ViewModel处理] C --> D[更新State] D --> E[UI渲染新State] C --> F[发送Effect] F --> G[UI处理Effect]
MVI 开发流程详解
-
状态定义
- 创建包含所有UI状态的数据类
- 使用密封类管理不同状态(Loading/Success/Error)
-
Intent定义
- 创建密封类表示所有用户操作
- 每个操作对应一个Intent子类
-
ViewModel实现
- 使用
StateFlow
管理状态 - 使用
SharedFlow
管理副作用(Effects) - 创建处理Intent的函数
- 使用状态缩减器(Reducer)模式
- 使用
-
UI层实现
- 使用
collectAsState()
观察状态 - 使用
LaunchedEffect
收集Effects - 将用户操作包装为Intents发送
- 使用
-
副作用处理
- 导航事件
- 显示Toast/Snackbar
- 权限请求
- 使用单独的数据流管理
Todo 应用 MVI 最佳实践
1. 添加依赖 (app/build.gradle)
gradle
dependencies {
// Compose
implementation 'androidx.activity:activity-compose:1.8.0'
implementation "androidx.compose.material3:material3:1.1.2"
// Lifecycle
implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.6.2"
implementation "androidx.lifecycle:lifecycle-runtime-compose:2.6.2"
// Coroutines
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
// Room
implementation "androidx.room:room-runtime:2.6.0"
implementation "androidx.room:room-ktx:2.6.0"
kapt "androidx.room:room-compiler:2.6.0"
}
2. 状态定义
kotlin
// 状态管理
data class TodoState(
val todos: List<Todo> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null,
val newTodoTitle: String = ""
) {
// 派生状态
val completedTodos: List<Todo>
get() = todos.filter { it.isCompleted }
val activeTodos: List<Todo>
get() = todos.filterNot { it.isCompleted }
}
// 一次性事件(副作用)
sealed interface TodoEffect {
data class ShowMessage(val message: String) : TodoEffect
data class NavigateToDetail(val todoId: Long) : TodoEffect
}
3. Intent定义
kotlin
// 用户意图
sealed interface TodoIntent {
// 数据加载
object LoadTodos : TodoIntent
// 用户操作
data class AddTodo(val title: String) : TodoIntent
data class UpdateTodoTitle(val title: String) : TodoIntent
data class ToggleTodoCompletion(val todoId: Long) : TodoIntent
data class DeleteTodo(val todoId: Long) : TodoIntent
data class EditTodo(val todo: Todo) : TodoIntent
// UI事件
object ShowAddDialog : TodoIntent
object HideAddDialog : TodoIntent
}
4. ViewModel实现
kotlin
class TodoViewModel(
private val repository: TodoRepository
) : ViewModel() {
// 状态管理
private val _state = MutableStateFlow(TodoState(isLoading = true))
val state: StateFlow<TodoState> = _state.asStateFlow()
// 副作用管理
private val _effects = MutableSharedFlow<TodoEffect>()
val effects: SharedFlow<TodoEffect> = _effects.asSharedFlow()
init {
processIntent(TodoIntent.LoadTodos)
}
// 处理所有用户意图
fun processIntent(intent: TodoIntent) {
when (intent) {
TodoIntent.LoadTodos -> loadTodos()
is TodoIntent.AddTodo -> addTodo(intent.title)
is TodoIntent.UpdateTodoTitle -> updateTitle(intent.title)
is TodoIntent.ToggleTodoCompletion -> toggleCompletion(intent.todoId)
is TodoIntent.DeleteTodo -> deleteTodo(intent.todoId)
TodoIntent.ShowAddDialog -> showAddDialog()
TodoIntent.HideAddDialog -> hideAddDialog()
is TodoIntent.EditTodo -> editTodo(intent.todo)
}
}
private fun loadTodos() {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
repository.getTodos()
.catch { e ->
_state.update { it.copy(error = e.message, isLoading = false) }
_effects.emit(TodoEffect.ShowMessage("加载失败: ${e.message}"))
}
.collect { todos ->
_state.update { it.copy(todos = todos, isLoading = false) }
}
}
}
private fun addTodo(title: String) {
if (title.isBlank()) {
viewModelScope.launch {
_effects.emit(TodoEffect.ShowMessage("标题不能为空"))
}
return
}
viewModelScope.launch {
val newTodo = Todo(title = title)
repository.addTodo(newTodo)
_state.update {
it.copy(newTodoTitle = "", todos = it.todos + newTodo)
}
_effects.emit(TodoEffect.ShowMessage("添加成功"))
}
}
private fun toggleCompletion(todoId: Long) {
viewModelScope.launch {
val todo = _state.value.todos.find { it.id == todoId } ?: return@launch
val updated = todo.copy(isCompleted = !todo.isCompleted)
repository.updateTodo(updated)
_state.update { state ->
state.copy(todos = state.todos.map {
if (it.id == todoId) updated else it
})
}
}
}
private fun updateTitle(title: String) {
_state.update { it.copy(newTodoTitle = title) }
}
private fun showAddDialog() {
_state.update { it.copy(showAddDialog = true) }
}
private fun hideAddDialog() {
_state.update { it.copy(showAddDialog = false) }
}
private fun deleteTodo(todoId: Long) {
viewModelScope.launch {
val todo = _state.value.todos.find { it.id == todoId } ?: return@launch
repository.deleteTodo(todo)
_state.update { state ->
state.copy(todos = state.todos.filterNot { it.id == todoId })
}
_effects.emit(TodoEffect.ShowMessage("已删除: ${todo.title}"))
}
}
private fun editTodo(todo: Todo) {
viewModelScope.launch {
repository.updateTodo(todo)
_state.update { state ->
state.copy(todos = state.todos.map {
if (it.id == todo.id) todo else it
})
}
_effects.emit(TodoEffect.ShowMessage("更新成功"))
}
}
}
5. Compose UI实现
kotlin
@Composable
fun TodoScreen(
viewModel: TodoViewModel = viewModel()
) {
val state by viewModel.state.collectAsState()
// 收集副作用
val context = LocalContext.current
LaunchedEffect(Unit) {
viewModel.effects.collect { effect ->
when (effect) {
is TodoEffect.ShowMessage -> {
Toast.makeText(context, effect.message, Toast.LENGTH_SHORT).show()
}
is TodoEffect.NavigateToDetail -> {
// 处理导航
}
}
}
}
TodoScreenContent(
state = state,
onIntent = viewModel::processIntent
)
}
@Composable
private fun TodoScreenContent(
state: TodoState,
onIntent: (TodoIntent) -> Unit
) {
Scaffold(
topBar = { TopAppBar(title = { Text("Todo MVI") }) },
floatingActionButton = {
FloatingActionButton(
onClick = { onIntent(TodoIntent.ShowAddDialog) }
) {
Icon(Icons.Filled.Add, "添加")
}
}
) { padding ->
when {
state.isLoading -> LoadingView()
state.error != null -> ErrorView(state.error, onIntent)
else -> TodoListView(state, onIntent, Modifier.padding(padding))
}
// 添加对话框
if (state.showAddDialog) {
AddTodoDialog(
title = state.newTodoTitle,
onTitleChange = { onIntent(TodoIntent.UpdateTodoTitle(it)) },
onDismiss = { onIntent(TodoIntent.HideAddDialog) },
onConfirm = { onIntent(TodoIntent.AddTodo(state.newTodoTitle)) }
)
}
}
}
@Composable
fun TodoListView(
state: TodoState,
onIntent: (TodoIntent) -> Unit,
modifier: Modifier = Modifier
) {
Column(modifier = modifier) {
// 活动任务
Text("待完成 (${state.activeTodos.size})", style = MaterialTheme.typography.titleMedium)
LazyColumn {
items(state.activeTodos, key = { it.id }) { todo ->
TodoItem(
todo = todo,
onToggle = { onIntent(TodoIntent.ToggleTodoCompletion(todo.id)) },
onDelete = { onIntent(TodoIntent.DeleteTodo(todo.id)) },
onEdit = { onIntent(TodoIntent.EditTodo(it)) }
)
}
}
// 已完成任务
if (state.completedTodos.isNotEmpty()) {
Text("已完成 (${state.completedTodos.size})", style = MaterialTheme.typography.titleMedium)
LazyColumn {
items(state.completedTodos, key = { it.id }) { todo ->
TodoItem(
todo = todo,
onToggle = { onIntent(TodoIntent.ToggleTodoCompletion(todo.id)) },
onDelete = { onIntent(TodoIntent.DeleteTodo(todo.id)) },
onEdit = { onIntent(TodoIntent.EditTodo(it)) }
)
}
}
}
}
}
@Composable
fun TodoItem(
todo: Todo,
onToggle: () -> Unit,
onDelete: () -> Unit,
onEdit: (Todo) -> Unit
) {
var showEditDialog by remember { mutableStateOf(false) }
Card(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(16.dp)
) {
Checkbox(
checked = todo.isCompleted,
onCheckedChange = { onToggle() }
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = todo.title,
style = MaterialTheme.typography.titleMedium,
textDecoration = if (todo.isCompleted) LineThrough else None
)
if (todo.description.isNotBlank()) {
Text(text = todo.description, style = MaterialTheme.typography.bodySmall)
}
}
IconButton(onClick = { showEditDialog = true }) {
Icon(Icons.Default.Edit, "编辑")
}
IconButton(onClick = onDelete) {
Icon(Icons.Default.Delete, "删除")
}
}
}
if (showEditDialog) {
EditTodoDialog(
todo = todo,
onDismiss = { showEditDialog = false },
onSave = { updated ->
onEdit(updated)
showEditDialog = false
}
)
}
}
@Composable
fun EditTodoDialog(
todo: Todo,
onDismiss: () -> Unit,
onSave: (Todo) -> Unit
) {
var title by remember { mutableStateOf(todo.title) }
var description by remember { mutableStateOf(todo.description) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("编辑任务") },
text = {
Column {
TextField(
value = title,
onValueChange = { title = it },
label = { Text("标题") }
)
Spacer(Modifier.height(8.dp))
TextField(
value = description,
onValueChange = { description = it },
label = { Text("描述") }
)
}
},
confirmButton = {
Button(
onClick = {
onSave(todo.copy(title = title, description = description))
}
) {
Text("保存")
}
},
dismissButton = {
Button(onClick = onDismiss) {
Text("取消")
}
}
)
}
MVI 最佳实践要点
-
严格单向数据流
- UI只能发送Intents,不能直接修改状态
- ViewModel是唯一能修改状态的地方
- 使用
copy()
确保状态不可变
-
状态设计原则
- 包含UI所需全部数据
- 使用派生属性减少冗余
- 区分持久状态和临时状态
-
副作用处理
graph TD A[ViewModel] -->|发送| B[Effect] B --> C[UI层通过LaunchedEffect收集] C --> D[执行一次性操作] -
测试策略
-
ViewModel测试 :
kotlin@Test fun `添加任务应更新状态`() = runTest { val vm = TodoViewModel(mockRepository) vm.processIntent(TodoIntent.AddTodo("New Task")) val state = vm.state.value assertEquals(1, state.todos.size) assertEquals("New Task", state.todos[0].title) }
-
UI测试 :
kotlincomposeTestRule.onNodeWithText("添加").performClick() composeTestRule.onNodeWithText("标题").performTextInput("New Task") composeTestRule.onNodeWithText("确认").performClick() composeTestRule.onNodeWithText("New Task").assertExists()
-
-
性能优化
- 使用
key
参数优化LazyColumn重组 - 使用
derivedStateOf
处理复杂状态计算 - 避免在状态中包含大对象
- 使用
-
高级模式
-
Reducer模式 :
kotlinfun reduce(state: TodoState, intent: TodoIntent): TodoState { return when (intent) { is AddTodo -> state.copy(todos = state.todos + intent.todo) // 其他状态转换... } }
-
中间件:处理日志、分析等横切关注点
-
此MVI实现通过严格的状态管理和单向数据流,提供了比MVVM更高的可预测性和可测试性。每个用户操作都明确表示为Intent,状态变化完全可追踪,特别适合复杂业务场景。