遵循MVVM模式,如何将TextField状态整合到带密封类UiState的ViewModel中?
分析与最佳实践方案
现有代码的合理性
你的代码功能能正常运行,但不符合MVVM的核心设计原则,存在以下隐患:
- Composable持有业务输入状态(
name/alias),违反了View层仅负责展示和转发事件的定位,导致状态分散,后续扩展逻辑(如实时输入校验、防抖)时会被迫把逻辑写在UI层,难以维护; - 虽然用
rememberSaveable能处理配置变更(如旋转屏幕)的状态保存,但如果遇到进程重建(如系统回收内存后重启App),rememberSaveable的恢复能力有限,而ViewModel配合SavedStateHandle能更可靠地恢复状态; - ViewModel无法感知输入变化,所有输入相关的逻辑只能在Composable中处理,代码职责混乱。
是否需要迁移到ViewModel?
必须迁移,这是MVVM模式下处理业务输入状态的标准做法:
- 状态统一管理:所有与玩家创建/编辑相关的状态都集中在ViewModel,避免状态分散,便于后续维护和扩展;
- 业务逻辑内聚:ViewModel可以直接监听输入状态变化,实现实时校验(如
name为空时禁用按钮)、输入防抖、格式校验等逻辑,无需在UI层写冗余代码; - 可靠的状态恢复:ViewModel配合
SavedStateHandle,无论是配置变更还是进程重建,都能完整恢复输入状态,用户体验更连贯; - 测试友好:输入状态在ViewModel中,便于编写单元测试验证业务逻辑,而Composable的状态难以单独测试。
最佳实践方案
推荐将输入状态整合到PlayerUiState中,让UiState包含所有UI需要展示的状态,同时用ViewModel维护状态流转。以下是具体修改示例:
1. 修改UiState,整合输入状态
sealed class PlayerUiState { data object Loading : PlayerUiState() // 创建模式下的状态包含输入的name和alias data class Creation( val name: String = "", val alias: String = "" ) : PlayerUiState() // 编辑模式下保留原始Player,同时维护当前输入的name和alias data class Edition( val originalPlayer: Player, val name: String, val alias: String ) : PlayerUiState() data class Error(val message: String) : PlayerUiState() }
2. 修改ViewModel,维护状态并处理业务逻辑
@HiltViewModel class PlayerViewModel @Inject constructor( private val playerRepository: PlayerRepository, @ApplicationContext private val context: Context, private val savedStateHandle: SavedStateHandle // 用于进程重建恢复状态 ) : ViewModel() { private val _uiState = MutableStateFlow<PlayerUiState>(PlayerUiState.Loading) val uiState: StateFlow<PlayerUiState> = _uiState.asStateFlow() // 更新name输入状态 fun updateName(newName: String) { _uiState.update { currentState -> when (currentState) { is PlayerUiState.Creation -> currentState.copy(name = newName) is PlayerUiState.Edition -> currentState.copy(name = newName) else -> currentState } } } // 更新alias输入状态 fun updateAlias(newAlias: String) { _uiState.update { currentState -> when (currentState) { is PlayerUiState.Creation -> currentState.copy(alias = newAlias) is PlayerUiState.Edition -> currentState.copy(alias = newAlias) else -> currentState } } } // 获取玩家数据,初始化编辑状态 fun getPlayer(playerId: Int) { viewModelScope.launch { playerRepository.getPlayer(playerId).onStart { _uiState.value = PlayerUiState.Loading }.catch { e -> _uiState.value = PlayerUiState.Error( e.message ?: context.getString(R.string.msg_unknown_error) ) }.collect { player -> _uiState.value = PlayerUiState.Edition( originalPlayer = player, name = player.name, alias = player.alias ) } } } // 初始化创建模式状态,从SavedStateHandle恢复之前的输入 fun initCreationState() { val savedName = savedStateHandle.get<String>("creation_name") ?: "" val savedAlias = savedStateHandle.get<String>("creation_alias") ?: "" _uiState.value = PlayerUiState.Creation(savedName, savedAlias) } // 保存玩家数据(创建或更新统一处理) fun savePlayer() { viewModelScope.launch { when (val currentState = _uiState.value) { is PlayerUiState.Creation -> { playerRepository.insertPlayer( Player(name = currentState.name, alias = currentState.alias) ) // 保存当前输入到SavedStateHandle,进程重建时恢复 savedStateHandle["creation_name"] = currentState.name savedStateHandle["creation_alias"] = currentState.alias } is PlayerUiState.Edition -> { val updatedPlayer = currentState.originalPlayer.copy( name = currentState.name, alias = currentState.alias ) playerRepository.updatePlayer(updatedPlayer) } else -> {} } } } }
3. 修改Composable,变为无状态UI
@Composable fun PlayerScreen( modifier: Modifier = Modifier, titleRes: Int, viewModel: PlayerViewModel = hiltViewModel(), playerId: Int? = null, ) { val uiState by viewModel.uiState.collectAsState() LaunchedEffect(playerId) { if (playerId != null) { viewModel.getPlayer(playerId) } else { viewModel.initCreationState() } } PlayerScreenContent( modifier = modifier, titleRes = titleRes, uiState = uiState, onNameChange = viewModel::updateName, onAliasChange = viewModel::updateAlias, onSave = viewModel::savePlayer, ) } @Composable fun PlayerScreenContent( modifier: Modifier = Modifier, titleRes: Int, uiState: PlayerUiState = PlayerUiState.Loading, onNameChange: (String) -> Unit, onAliasChange: (String) -> Unit, onSave: () -> Unit, ) { Scaffold() { innerPadding -> Box( contentAlignment = Alignment.Center, modifier = Modifier .fillMaxSize() .padding(innerPadding), ) { when (uiState) { is PlayerUiState.Loading -> CircularProgressIndicator() is PlayerUiState.Error -> ErrorDialog(text = uiState.message) is PlayerUiState.Creation, is PlayerUiState.Edition -> { val name = (uiState as? PlayerUiState.Creation)?.name ?: (uiState as PlayerUiState.Edition).name val alias = (uiState as? PlayerUiState.Creation)?.alias ?: (uiState as PlayerUiState.Edition).alias PlayerForm( name = name, alias = alias, onNameChange = onNameChange, onAliasChange = onAliasChange, onSave = onSave, ) } } } } } @Composable fun PlayerForm( name: String, alias: String, onNameChange: (String) -> Unit, onAliasChange: (String) -> Unit, onSave: () -> Unit, ) { Box( modifier = Modifier .fillMaxSize() .padding(16.dp), contentAlignment = Alignment.BottomCenter, ) { Column( modifier = Modifier.align(Alignment.Center), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp), ) { OutlinedTextField( value = name, onValueChange = onNameChange, label = { Text(stringResource(id = R.string.text_field_name)) }, ) OutlinedTextField( value = alias, onValueChange = onAliasChange, label = { Text(stringResource(id = R.string.text_field_alias)) }, ) } Row( modifier = Modifier .fillMaxWidth() .padding(16.dp) .align(Alignment.BottomCenter), horizontalArrangement = Arrangement.SpaceEvenly, ) { Button( onClick = onSave, enabled = name.isNotEmpty(), ) { Text(stringResource(id = R.string.button_save)) } } } }
方案优势
- 职责清晰:Composable仅负责展示UI和转发用户事件,ViewModel负责状态管理和业务逻辑;
- 可扩展性强:后续要加输入校验、防抖等逻辑,直接在ViewModel中实现即可,无需修改UI层;
- 状态可靠:通过
SavedStateHandle确保进程重建时输入状态不丢失; - 测试便捷:ViewModel的状态和逻辑可以单独编写单元测试,无需依赖UI环境。
内容的提问来源于stack exchange,提问作者forvas
相关产品推荐
相关产品推荐

