MVI 架构模式中的 UiState 设计:单一、嵌套与 `sealed class` 的应用
在 MVI (Model-View-Intent) 架构模式中,UiState 是核心概念之一,它代表了用户界面在任何给定时刻的完整且不可变的状态。理解如何有效设计 UiState 对于构建清晰、可预测且易于维护的应用至关重要。
1. MVI 中 ViewModel 的 UiState:单一性与最佳实践
在 MVI 架构中,推荐每个屏幕(或功能模块)只对应一个 ViewModel,并且该 ViewModel 暴露一个单一的 UiState。
为什么推荐单一 UiState?
MVI 的核心理念是单向数据流(Unidirectional Data Flow)和状态的不可变性(Immutability)。单一 UiState 具有以下显著优势:
- 清晰且可预测: 整个 UI 的状态被封装在一个对象中,使得状态变化更容易理解和预测 UI 行为。
- 简化调试: 当问题出现时,可以更轻松地追踪状态变化历史,快速定位问题。
- 确保一致性: 避免了 UI 不同部分状态不一致的情况,因为所有组件都响应同一个状态。
- 易于测试: 单一、不可变的
UiState简化了单元测试和 UI 测试。 - 时间旅行调试: 有助于实现时间旅行调试,回溯到任意
UiState来查看 UI 在那一刻的样子。
尽管技术上可以在 ViewModel 中管理多个独立的 UiState 对象(例如,使用不同的 StateFlow 或 LiveData),但这通常不符合 MVI 的最佳实践,并可能导致状态分散、同步问题以及调试复杂性。
2. 嵌套的 UiState:管理复杂 UI 状态的有效方式
当 UI 状态变得复杂时,一个巨大的 UiState 可能难以管理。此时,推荐使用嵌套的 UiState,即将大的 UiState 拆分成更小、更具体的不可变的数据类,并作为属性嵌套在主 UiState 中。
示例代码:用户资料页面的嵌套 UiState
以下是一个用户资料页面的示例,展示了如何使用嵌套 UiState 来组织页面状态:
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
// 1. 定义子状态数据类
data class LoadingState(
val isLoading: Boolean = false,
val errorMessage: String? = null
)
data class UserDataState(
val userId: String? = null,
val userName: String? = null,
val userEmail: String? = null,
val profileImageUrl: String? = null
)
// 2. 定义主 UiState,包含嵌套的子状态
data class UserProfileUiState(
val loading: LoadingState = LoadingState(), // 嵌套 LoadingState
val userData: UserDataState = UserDataState(), // 嵌套 UserDataState
val isEditing: Boolean = false // 页面特有的其他状态
)
// 3. ViewModel 示例
class UserProfileViewModel : ViewModel() {
private val _uiState = MutableStateFlow(UserProfileUiState())
val uiState: StateFlow<UserProfileUiState> = _uiState.asStateFlow()
init {
fetchUserProfile()
}
private fun fetchUserProfile() {
viewModelScope.launch {
// 设置加载状态
_uiState.update { currentState ->
currentState.copy(loading = currentState.loading.copy(isLoading = true, errorMessage = null))
}
try {
// 模拟网络请求和数据获取
delay(2000)
val fetchedUser = User(id = "123", name = "张三", email = "zhangsan@example.com", imageUrl = "https://example.com/avatar.jpg")
// 更新用户数据和加载状态
_uiState.update { currentState ->
currentState.copy(
loading = currentState.loading.copy(isLoading = false),
userData = UserDataState(
userId = fetchedUser.id,
userName = fetchedUser.name,
userEmail = fetchedUser.email,
profileImageUrl = fetchedUser.imageUrl
)
)
}
} catch (e: Exception) {
// 处理错误
_uiState.update { currentState ->
currentState.copy(loading = currentState.loading.copy(isLoading = false, errorMessage = "加载失败: ${e.message}"))
}
}
}
}
// 示例:更新用户姓名
fun updateUserName(newName: String) {
_uiState.update { currentState ->
currentState.copy(
userData = currentState.userData.copy(userName = newName)
)
}
}
// 示例:切换编辑模式
fun toggleEditMode() {
_uiState.update { currentState ->
currentState.copy(isEditing = !currentState.isEditing)
}
}
}
// 模拟数据模型
data class User(
val id: String,
val name: String,
val email: String,
val imageUrl: String
)
代码解析:
LoadingState和UserDataState: 这些是独立的不可变data class,分别封装了加载过程和用户数据相关的状态。UserProfileUiState: 这是ViewModel暴露的主UiState。它通过组合的方式,将LoadingState和UserDataState的实例作为其属性。此外,它还可以包含页面特有的其他状态,如isEditing。UserProfileViewModel: 在更新状态时,使用copy()方法创建一个新的UserProfileUiState实例,并只更新需要改变的部分。例如,currentState.copy(loading = currentState.loading.copy(isLoading = true))仅更新了loading子状态中的isLoading字段。
这种方法保持了单一 UiState 的概念,但通过内部结构使其更易于管理和扩展。
3. UiState 与 sealed class 的应用场景差异
在 MVI 架构中,UiState 通常使用 data class 而不是直接使用 sealed class,这主要是因为它们的设计目的和表示方式不同。
data class 用于表示组合的状态
- 组合性:
data class能够轻松地将多个独立的数据点(如用户名、加载状态、错误信息、列表数据等)组合成一个单一的、内聚的对象,表达 UI 在任何给定时刻的完整状态。 - 可变性(通过
copy方法): MVI 强调状态的不可变性,即每次状态更新都会生成一个新的UiState实例。data class提供的copy()方法是实现这一点的最佳方式,它允许高效地创建新对象,只改变需要更新的属性,而保持其他属性不变。 - 表达连续性:
UiState代表了 UI 的连续演变,在一个统一的状态模型中进行微调。
sealed class 用于表示互斥的、离散的状态
sealed class (或 sealed interface) 通常用于表示有限的、互斥的离散状态,这些状态之间不能同时存在。它们最常见的应用场景包括:
事件(Events)/意图(Intents)/动作(Actions): 在 MVI 中,
Intent(或Action) 通常会用sealed class来定义,因为用户的每个意图都是一个独立的、互斥的动作。1 2 3 4 5
sealed class UserIntent { object LoadUser : UserIntent() data class UpdateName(val newName: String) : UserIntent() object ToggleEditMode : UserIntent() }
一次性操作(One-Time Events): 对于那些只发生一次、不需要在 UI 中持久表示的操作(例如显示 Toast 消息、导航),也常使用
sealed class包裹在Effect或Event层中。表示数据加载的不同阶段(作为
UiState的子属性):sealed class可以用于表示某个特定数据流的加载状态,并作为UiState的一个属性存在。1 2 3 4 5 6 7 8 9 10 11
sealed class DataStatus { object Idle : DataStatus() object Loading : DataStatus() data class Success<T>(val data: T) : DataStatus() data class Error(val message: String) : DataStatus() } data class MyScreenUiState( val productListStatus: DataStatus = DataStatus.Idle, // 这里的DataStatus就是sealed class val selectedProduct: Product? = null )
在这种情况下,
DataStatus本身是互斥的(要么是 Idle,要么是 Loading,要么是 Success,要么是 Error),但它只是整个UiState中的一个组件。
总结
data class适合作为主UiState,用于组合所有 UI 所需的属性,表达 UI 的完整且连续的状态。sealed class适合表示互斥的、离散的类型,如用户的意图、一次性操作,或者作为UiState内部某个属性的互斥阶段。
因此,UserProfileUiState 使用 data class 是因为它要表示用户资料页面的整体、可组合的状态,而不是几个互斥的阶段。而如果我们需要表示加载过程中的互斥阶段,我们会将其作为 UiState 内的一个 data class 属性,或在更复杂的场景下,在该属性内部使用 sealed class 来表示更细粒度的互斥状态。
