应用组件是否应访问ViewModel?密码验证最佳实践咨询
我想在应用中实现密码强度显示与验证功能,已将PasswordTextFieldComponent这类独立组件放在AppComponents.kt文件中,SignupScreen.kt作为页面组件。目前遇到的问题是:组件中的OutlinedTextField需要用到passwordError变量,但这个变量是SignupScreen通过val passwordError by viewModel.passwordError.collectAsStateWithLifecycle()获取的ViewModel状态。我认为普通组件不应依赖ViewModel,只有页面组件才需要关联ViewModel,因此想咨询:
OutlinedTextField的验证逻辑应该放在SignupScreen.kt还是AppComponents.kt中?- 如何让
PasswordTextFieldComponent获取到所需的验证状态参数?
相关代码
AppComponents.kt中的PasswordTextFieldComponent
@Composable fun PasswordTextFieldComponent( labelValue: String, painterResource: Painter, onTextSelected: (String) -> Unit, errorStatus: Boolean = false ) { val localFocusManager = LocalFocusManager.current val password = remember { mutableStateOf("") } val passwordVisible = remember { mutableStateOf(false) } OutlinedTextField( modifier = Modifier .fillMaxWidth() .clip(componentShapes.small), label = { Text(text = labelValue) }, colors = OutlinedTextFieldDefaults.colors( focusedBorderColor = Primary, focusedLabelColor = Primary, cursorColor = Primary, unfocusedContainerColor = BgColor ), keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Password, imeAction = ImeAction.Done ), singleLine = true, keyboardActions = KeyboardActions { localFocusManager.clearFocus() }, maxLines = 1, value = password.value, onValueChange = { password.value = it onTextSelected(it) }, leadingIcon = { Icon(painter = painterResource, contentDescription = "") }, trailingIcon = { val iconImage = if (passwordVisible.value) { Icons.Filled.Visibility } else { Icons.Filled.VisibilityOff } val description = if (passwordVisible.value) { stringResource(id = R.string.hide_password) } else { stringResource(id = R.string.show_password) } IconButton(onClick = { passwordVisible.value = !passwordVisible.value }) { Icon(imageVector = iconImage, contentDescription = description) } }, visualTransformation = if (passwordVisible.value) VisualTransformation.None else PasswordVisualTransformation(), isError = !errorStatus || !passwordError.status ) Spacer(modifier = Modifier.height(8.dp)) Column(verticalArrangement = Arrangement.spacedBy(5.dp)) { ConditionRow(condition = "Minimum 6 characters", check = passwordError.hasMinimum) ConditionRow(condition = "Has special character", check = passwordError.hasSpecialCharacter) ConditionRow(condition = "Has capitalized letter", check = passwordError.hasCapitalizedLetter) } }
SignupScreen.kt中的组件调用
PasswordTextFieldComponent( labelValue = stringResource(id = R.string.password), painterResource = painterResource(id = R.drawable.ic_lock), onTextSelected = { signupViewModel.onEvent(SignupUIEvent.PasswordChanged(it)) }, errorStatus = signupViewModel.registrationUIState.value.passwordError, )
解决方案
1. 验证逻辑的归属:放在ViewModel中
验证逻辑属于业务规则范畴,应该统一放在ViewModel里处理。这样做的好处:
- 符合单一职责原则:ViewModel负责状态管理和业务逻辑,UI组件只负责展示和用户交互
- 方便测试:验证逻辑可以独立于UI进行单元测试
- 逻辑集中:后续修改验证规则时,只需要调整ViewModel代码,无需改动多个UI组件
2. 组件获取状态的方式:传递具体状态参数而非ViewModel
不要直接把ViewModel传给PasswordTextFieldComponent,而是将组件需要的验证状态封装成数据类,作为参数传递给组件,实现组件与ViewModel解耦。
步骤1:定义验证状态数据类
创建一个数据类来封装密码验证的所有状态:
data class PasswordValidationState( val isError: Boolean, val hasMinimumLength: Boolean, val hasSpecialCharacter: Boolean, val hasCapitalizedLetter: Boolean )
步骤2:修改PasswordTextFieldComponent参数
更新组件的参数列表,接收PasswordValidationState,并在组件内使用这些状态:
@Composable fun PasswordTextFieldComponent( labelValue: String, painterResource: Painter, onTextSelected: (String) -> Unit, validationState: PasswordValidationState ) { val localFocusManager = LocalFocusManager.current val password = remember { mutableStateOf("") } val passwordVisible = remember { mutableStateOf(false) } OutlinedTextField( modifier = Modifier .fillMaxWidth() .clip(componentShapes.small), label = { Text(text = labelValue) }, colors = OutlinedTextFieldDefaults.colors( focusedBorderColor = Primary, focusedLabelColor = Primary, cursorColor = Primary, unfocusedContainerColor = BgColor ), keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Password, imeAction = ImeAction.Done ), singleLine = true, keyboardActions = KeyboardActions { localFocusManager.clearFocus() }, maxLines = 1, value = password.value, onValueChange = { password.value = it onTextSelected(it) }, leadingIcon = { Icon(painter = painterResource, contentDescription = "") }, trailingIcon = { val iconImage = if (passwordVisible.value) Icons.Filled.Visibility else Icons.Filled.VisibilityOff val description = if (passwordVisible.value) { stringResource(id = R.string.hide_password) } else { stringResource(id = R.string.show_password) } IconButton(onClick = { passwordVisible.value = !passwordVisible.value }) { Icon(imageVector = iconImage, contentDescription = description) } }, visualTransformation = if (passwordVisible.value) VisualTransformation.None else PasswordVisualTransformation(), isError = validationState.isError ) Spacer(modifier = Modifier.height(8.dp)) Column(verticalArrangement = Arrangement.spacedBy(5.dp)) { ConditionRow(condition = "至少6个字符", check = validationState.hasMinimumLength) ConditionRow(condition = "包含特殊字符", check = validationState.hasSpecialCharacter) ConditionRow(condition = "包含大写字母", check = validationState.hasCapitalizedLetter) } }
步骤3:在SignupScreen中传递状态
在页面组件中,将ViewModel的状态转换为PasswordValidationState,并传递给组件:
val registrationState by signupViewModel.registrationUIState.collectAsStateWithLifecycle() val passwordValidationState = remember(registrationState.passwordError) { PasswordValidationState( isError = !registrationState.passwordError.status, hasMinimumLength = registrationState.passwordError.hasMinimum, hasSpecialCharacter = registrationState.passwordError.hasSpecialCharacter, hasCapitalizedLetter = registrationState.passwordError.hasCapitalizedLetter ) } PasswordTextFieldComponent( labelValue = stringResource(id = R.string.password), painterResource = painterResource(id = R.drawable.ic_lock), onTextSelected = { signupViewModel.onEvent(SignupUIEvent.PasswordChanged(it)) }, validationState = passwordValidationState )
3. 额外优化:组件内部状态简化
注意到组件内的password状态可以去掉,直接通过onValueChange将输入值传递给ViewModel,由ViewModel管理输入状态,这样组件完全成为无状态的展示组件,复用性更强:
@Composable fun PasswordTextFieldComponent( labelValue: String, painterResource: Painter, passwordValue: String, onTextSelected: (String) -> Unit, validationState: PasswordValidationState ) { val localFocusManager = LocalFocusManager.current val passwordVisible = remember { mutableStateOf(false) } OutlinedTextField( modifier = Modifier .fillMaxWidth() .clip(componentShapes.small), label = { Text(text = labelValue) }, colors = OutlinedTextFieldDefaults.colors( focusedBorderColor = Primary, focusedLabelColor = Primary, cursorColor = Primary, unfocusedContainerColor = BgColor ), keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Password, imeAction = ImeAction.Done ), singleLine = true, keyboardActions = KeyboardActions { localFocusManager.clearFocus() }, maxLines = 1, value = passwordValue, onValueChange = onTextSelected, leadingIcon = { Icon(painter = painterResource, contentDescription = "") }, trailingIcon = { val iconImage = if (passwordVisible.value) Icons.Filled.Visibility else Icons.Filled.VisibilityOff val description = if (passwordVisible.value) { stringResource(id = R.string.hide_password) } else { stringResource(id = R.string.show_password) } IconButton(onClick = { passwordVisible.value = !passwordVisible.value }) { Icon(imageVector = iconImage, contentDescription = description) } }, visualTransformation = if (passwordVisible.value) VisualTransformation.None else PasswordVisualTransformation(), isError = validationState.isError ) Spacer(modifier = Modifier.height(8.dp)) Column(verticalArrangement = Arrangement.spacedBy(5.dp)) { ConditionRow(condition = "至少6个字符", check = validationState.hasMinimumLength) ConditionRow(condition = "包含特殊字符", check = validationState.hasSpecialCharacter) ConditionRow(condition = "包含大写字母", check = validationState.hasCapitalizedLetter) } }
对应的SignupScreen调用:
val registrationState by signupViewModel.registrationUIState.collectAsStateWithLifecycle() val passwordValidationState = remember(registrationState.passwordError) { PasswordValidationState( isError = !registrationState.passwordError.status, hasMinimumLength = registrationState.passwordError.hasMinimum, hasSpecialCharacter = registrationState.passwordError.hasSpecialCharacter, hasCapitalizedLetter = registrationState.passwordError.hasCapitalizedLetter ) } PasswordTextFieldComponent( labelValue = stringResource(id = R.string.password), painterResource = painterResource(id = R.drawable.ic_lock), passwordValue = registrationState.password, onTextSelected = { signupViewModel.onEvent(SignupUIEvent.PasswordChanged(it)) }, validationState = passwordValidationState )
内容的提问来源于stack exchange,提问作者Kaleab Woldemariam

