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

应用组件是否应访问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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.07 03:47:05