简述一下 Compose 的状态管理
tldr
经典的 Kotlin Compose 代码结构自顶向下主要可以分为三层,每层的主要构成如下:
- UI 层:Compose、ViewModel(UiState、UiEvent)
- 逻辑层/业务层:Service/UseCase、Repository、Model
- 数据层:RepositoryImpl、DAO、Entity
一、常见目录结构和说明
---- ui/ 视图层
---- screens/ 页面级 Compose
---- navigation/ 页面路由
---- components/ 组件级 Compose
---- viewmodel/ 用于管理页面状态和事件
---- state/ 声明状态和事件枚举
---- domain/ 逻辑层 or 业务层
---- services/ 以某个 Model 为核心的业务逻辑
---- usecase/ 以某个场景为核心的业务逻辑
---- repository/ 声明 Model 对应的数据层接口
---- model/ 业务 Model、UI Model 定义和相互映射
---- data/ 数据层
---- database/ 数据库层
---- dao/ 数据库查询的具体实现
---- entities/ 数据库表、枚举、扩展类型的定义
---- converters/ 一些数据类型转换操作的实现
---- repository/ 调用 DAO 获取 Model 对应的数据层接口
---- mapper/ Entity 和 Model 的相互映射
二、数据流拆解
从数据层到 UI 层,数据流动时会经过很多的节点做转换和映射。在整个流程中,我们使用最多的数据类型是 Model 和 UiModel 着两种。在数据层中,虽然我们定义了 Entity 类型,但它主要是作为数据库表的定义存在,并不会在数据层外直接使用 Entity 类型的数据。在 Dao 中查询出的 Entity 会由 RepositoryImpl 转换为 Model 数据,提供给上层,也就是业务层使用。
DB --(query by)--> DAO --(return)->
Entity --(called by)-> RepositoryImpl --(return)->
Model --(called by)-> Service/UseCase --(called by)-> ui
UiModel 是提供给 UI 层使用的数据类型,和 Model 是并存的关系,支持相互转换。一般来说,就是在 Model 的基础上增加了一些扩展字段:
/**
* 待办事项领域模型
*/
data class TodoItem(
val id: String,
val planId: String,
val title: String,
val content: String,
val isCompleted: Boolean = false,
val triggerTime: LocalDateTime,
val completedAt: LocalDateTime? = null,
val createdAt: LocalDateTime
)
data class TodoItemUiModel(
val id: String,
val planId: String,
val title: String,
val content: String,
val isCompleted: Boolean,
val triggerTime: LocalDateTime,
val completedAt: LocalDateTime?,
val createdAt: LocalDateTime,
val formattedTime: String = "",
val formattedDate: String = "",
val timeAgo: String = "", // 相对时间显示,如"2小时前"
val isOverdue: Boolean = false
)
三、UI 层的状态管理
Compose 的状态管理和 Flutter 类似,Compose 本身默认是无状态的组件,输入静态数据并渲染 UI。有两种方法可以让 Compose 获得响应式状态,一种是直接在 Compose 内部创建 remember 变量,还有一种就是使用 ViewModel。
3.1 remember
// remember 搭配 MutableState 或者 State 可以创建响应式的局部变量
// rememberSaveable 的区别是会自动保存,当 Activity 或者进程重建时会恢复状态
// @Parcelize 让 data class 能够被序列化
@Parcelize
data class City(val name: String, val country: String) : Parcelable
@Composable
fun CityScreen() {
var selectedCity = rememberSaveable {
mutableStateOf(City("Madrid", "Spain"))
}
}
3.2 ViewModel
ViewModel 一般会和 Hilt 依赖注入一起使用,这样的好处是状态和 UI 的解耦更加彻底。一般 ViewModel 会包含 uiState 和 uiEvent 两部分,概念上像是 Vue 的 Data 和 Event。
在 Compose 当中,可以通过 LaunchedEffect 来注册 uiEvent 的监听器,使用 ViewModel.onEvent 来触发事件。在 ViewModel 当中,需要 onEvent 用于定义事件的监听器,也就是触发事件之后应该如何调整状态、调用 Service,并通过 uiEvent 来与 Componse 通信。
@Composable
fun HomeScreen(
viewModel: HomeViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsState()
LaunchedEffect(viewModel.uiEvent) {
viewModel.uiEvent.collect { event ->
when (event) {
is HomeUiEvent.ShowSnackbar -> {
snackbarHostState.showSnackbar(event.message)
}
is HomeUiEvent.ShowError -> {
snackbarHostState.showSnackbar("错误: ${event.error}")
}
else -> {}
}
}
}
}
@HiltViewModel
class HomeViewModel @Inject constructor() : ViewModel() {
private val _uiState = MutableStateFlow(HomeUiState())
val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()
private val _uiEvent = MutableSharedFlow<HomeUiEvent>()
val uiEvent: SharedFlow<HomeUiEvent> = _uiEvent.asSharedFlow()
fun onEvent(event: HomeUiEvent) {
when (event) {
is HomeUiEvent.DateSelected -> {
selectDate(event.date)
}
is HomeUiEvent.RefreshData -> {
loadData()
}
is HomeUiEvent.WeekChanged -> {
changeWeek(event.weekOffset)
}
}
}
所以 ViewModel 也可以称为多个不直接相关的 Compose 之间通信的桥梁,只要两个 Compose 都依赖同一个 ViewModel 即可。