You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

遵循MVVM模式,如何将TextField状态整合到带密封类UiState的ViewModel中?

分析与最佳实践方案

现有代码的合理性

你的代码功能能正常运行,但不符合MVVM的核心设计原则,存在以下隐患:

  • Composable持有业务输入状态(name/alias),违反了View层仅负责展示和转发事件的定位,导致状态分散,后续扩展逻辑(如实时输入校验、防抖)时会被迫把逻辑写在UI层,难以维护;
  • 虽然用rememberSaveable能处理配置变更(如旋转屏幕)的状态保存,但如果遇到进程重建(如系统回收内存后重启App),rememberSaveable的恢复能力有限,而ViewModel配合SavedStateHandle能更可靠地恢复状态;
  • ViewModel无法感知输入变化,所有输入相关的逻辑只能在Composable中处理,代码职责混乱。

是否需要迁移到ViewModel?

必须迁移,这是MVVM模式下处理业务输入状态的标准做法:

  1. 状态统一管理:所有与玩家创建/编辑相关的状态都集中在ViewModel,避免状态分散,便于后续维护和扩展;
  2. 业务逻辑内聚:ViewModel可以直接监听输入状态变化,实现实时校验(如name为空时禁用按钮)、输入防抖、格式校验等逻辑,无需在UI层写冗余代码;
  3. 可靠的状态恢复:ViewModel配合SavedStateHandle,无论是配置变更还是进程重建,都能完整恢复输入状态,用户体验更连贯;
  4. 测试友好:输入状态在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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.18 08:00:54