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

Kotlin Compose中ViewModel绑定TextField无法输入及按钮无响应问题

问题描述

我有多年Python开发经验,刚入门Android Kotlin Compose开发,正在实现登录/注册界面,采用ViewModel与Composable组件交互。目前遇到两个问题:

  • 所有TextField无法输入内容
  • 空内容点击“登录”按钮时,理论应弹出Toast提示,但实际无响应

怀疑是LoginViewModel配置问题,排查多日无果,附上代码求助。开发环境为Ubuntu系统下的Android Studio Electric Eel,真机和模拟器均出现该问题。


代码片段

LoginScreen.kt

import android.content.Context
import android.graphics.drawable.Icon
import android.media.Image
import android.os.Bundle
import android.view.Surface
import com.ftc.nomadicworker.R
import org.w3c.dom.Text

/*
LoginScreen.kt file
This holds the composables that make up the signin and signup screens
*/

@Composable
fun LoginScreen(
    loginViewModel: LoginViewModel? = null,
    onNavToHomePage: () -> Unit,
    onNavToSignUpPage: () -> Unit,
) {
    val loginUiState = loginViewModel?.loginUiState
    println("LoginScreen: loginUiState: $loginUiState")
    val isError = loginUiState?.loginError != null
    val context = LocalContext.current

    Column(
        modifier = Modifier.fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally,
    ) {
        Image(
            painter = painterResource(id = R.drawable.ic_launcher_foreground),
            contentDescription = "Logo",
            modifier = Modifier.padding(16.dp)
        )
        Text(
            text = "Login",
            style = MaterialTheme.typography.h4,
            fontWeight = FontWeight.Black,
            color = MaterialTheme.colors.primary
        )

        if (isError) {
            Text(
                text = loginUiState?.loginError ?: "unknown error",
                color = Color.Red,
            )
        }

        OutlinedTextField(
            modifier = Modifier
                .fillMaxWidth()
                .padding(16.dp),
            value = loginUiState?.userName ?: "",
            onValueChange = { loginViewModel?.onUserNameChange(it) },
            leadingIcon = {
                Icon(
                    imageVector = Icons.Default.Person,
                    contentDescription = null,
                )
            },
            label = {
                Text(text = "Email")
            },
            isError = isError
        )
        OutlinedTextField(
            modifier = Modifier
                .fillMaxWidth()
                .padding(16.dp),
            value = loginUiState?.password ?: "",
            onValueChange = { loginViewModel?.onPasswordNameChange(it) },
            leadingIcon = {
                Icon(
                    imageVector = Icons.Default.Lock,
                    contentDescription = null,
                )
            },
            label = {
                Text(text = "Password")
            },
            visualTransformation = PasswordVisualTransformation(),
            isError = isError
        )

        Button(onClick = { loginViewModel?.loginUser(context) }) {
            Text(text = "Sign In")
        }
        Spacer(modifier = Modifier.size(16.dp))

        Row(
            modifier = Modifier.fillMaxWidth(),
            horizontalArrangement = Arrangement.Center,
        ) {
            Text(text = "Don't have an Account?")
            Spacer(modifier = Modifier.size(8.dp))
            TextButton(onClick = { onNavToSignUpPage.invoke() }) {
                Text(text = "SignUp")
            }
        }

        if (loginUiState?.isLoading == true) {
            CircularProgressIndicator()
        }

        LaunchedEffect(key1 = loginViewModel?.hasUserBeenLoggedIn) {
            if (loginViewModel?.hasUserBeenLoggedIn == true) {
                onNavToHomePage.invoke()
            }
        }
    }
}

LoginViewModel.kt

/*
LoginViewModel.kt file

This is the associated viewModel for the above login screens.
*/

import android.content.Context
import android.widget.Toast
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch

class LoginViewModel(
    private val repository: AuthRepository = AuthRepository(),
) : ViewModel() {
    val currentUser = repository.currentUser

    val hasUserBeenLoggedIn: Boolean
        get() = repository.hasUserBeenLoggedIn()

    var loginUiState by mutableStateOf(LoginUiState())
        private set

    fun onUserNameChange(userName: String) {
        println("onUserNameChange: $userName")
        loginUiState = loginUiState.copy(userName = userName)
    }

    fun onPasswordNameChange(password: String) {
        loginUiState = loginUiState.copy(password = password)
    }

    fun onUserNameChangeSignup(userName: String) {
        loginUiState = loginUiState.copy(userNameSignUp = userName)
    }

    fun onPasswordChangeSignup(password: String) {
        loginUiState = loginUiState.copy(passwordSignUp = password)
    }

    fun onConfirmPasswordChange(password: String) {
        loginUiState = loginUiState.copy(confirmPasswordSignUp = password)
    }

    private fun validateLoginForm() =
        loginUiState.userName.isNotBlank() &&
                loginUiState.password.isNotBlank()

    private fun validateSignupForm() =
        loginUiState.userNameSignUp.isNotBlank() &&
                loginUiState.passwordSignUp.isNotBlank() &&
                loginUiState.confirmPasswordSignUp.isNotBlank()

    fun createUser(context: Context) = viewModelScope.launch {
        try {
            if (!validateSignupForm()) {
                throw IllegalArgumentException("Email and password can not be empty")
            }
            loginUiState = loginUiState.copy(isLoading = true)
            if (loginUiState.passwordSignUp !=
                loginUiState.confirmPasswordSignUp
            ) {
                throw IllegalArgumentException(
                    "Password do not match"
                )
            }
            loginUiState = loginUiState.copy(signUpError = null)
            repository.createUser(
                loginUiState.userNameSignUp,
                loginUiState.passwordSignUp
            ) { isSuccessful ->
                loginUiState = if (isSuccessful) {
                    Toast.makeText(
                        context,
                        "success Login",
                        Toast.LENGTH_SHORT
                    ).show()
                    loginUiState.copy(isSuccessLogin = true)
                } else {
                    Toast.makeText(
                        context,
                        "Failed Login",
                        Toast.LENGTH_SHORT
                    ).show()
                    loginUiState.copy(isSuccessLogin = false)
                }
            }
        } catch (e: Exception) {
            loginUiState = loginUiState.copy(signUpError = e.localizedMessage)
            e.printStackTrace()
        } finally {
            loginUiState = loginUiState.copy(isLoading = false)
        }
    }

    fun loginUser(context: Context) = viewModelScope.launch {
        try {
            if (!validateLoginForm()) {
                throw IllegalArgumentException("email and password can not be empty")
            }
            loginUiState = loginUiState.copy(isLoading = true)
            loginUiState = loginUiState.copy(loginError = null)
            repository.login(
                loginUiState.userName,
                loginUiState.password
            ) { isSuccessful ->
                loginUiState = if (isSuccessful) {
                    Toast.makeText(
                        context,
                        "success Login",
                        Toast.LENGTH_SHORT
                    ).show()
                    loginUiState.copy(isSuccessLogin = true)
                } else {
                    Toast.makeText(
                        context,
                        "Failed Login",
                        Toast.LENGTH_SHORT
                    ).show()
                    loginUiState.copy(isSuccessLogin = false)
                }
            }
        } catch (e: Exception) {
            loginUiState = loginUiState.copy(loginError = e.localizedMessage)
            e.printStackTrace()
        } finally {
            loginUiState = loginUiState.copy(isLoading = false)
        }
    }
}

data class LoginUiState(
    val userName: String = "",
    val password: String = "",
    val userNameSignUp: String = "",
    val passwordSignUp: String = "",
    val confirmPasswordSignUp: String = "",
    val isLoading: Boolean = false,
    val isSuccessLogin: Boolean = false,
    val signUpError: String? = null,
    val loginError: String? = null,
)

MainActivity.kt

/* MainActivity.kt */

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.ui.Modifier
import androidx.lifecycle.viewmodel.compose.viewModel

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MyAndroidAppTheme {
                val loginViewModel = viewModel(modelClass = LoginViewModel::class.java)
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colors.background
                ) {
                    Navigation(loginViewModel = loginViewModel)
                }
            }
        }
    }
}

问题修复方案

1. TextField无法输入的问题

原因

Composable未正确监听ViewModel中loginUiState的变化,导致UI不会随着输入内容更新,TextField始终显示初始值。

修复步骤

步骤1:ViewModel改用StateFlow(解耦Compose依赖)

修改LoginViewModel.kt中的loginUiState定义,使用StateFlow替代mutableStateOf,避免ViewModel依赖Compose API:

import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow

class LoginViewModel(
    private val repository: AuthRepository = AuthRepository(),
) : ViewModel() {
    // ... 其他代码保持不变

    private val _loginUiState = MutableStateFlow(LoginUiState())
    val loginUiState: StateFlow<LoginUiState> = _loginUiState.asStateFlow()

    // 更新所有状态修改方法,改用_flow.value.copy
    fun onUserNameChange(userName: String) {
        println("onUserNameChange: $userName")
        _loginUiState.value = _loginUiState.value.copy(userName = userName)
    }

    fun onPasswordNameChange(password: String) {
        _loginUiState.value = _loginUiState.value.copy(password = password)
    }

    fun onUserNameChangeSignup(userName: String) {
        _loginUiState.value = _loginUiState.value.copy(userNameSignUp = userName)
    }

    fun onPasswordChangeSignup(password: String) {
        _loginUiState.value = _loginUiState.value.copy(passwordSignUp = password)
    }

    fun onConfirmPasswordChange(password: String) {
        _loginUiState.value = _loginUiState.value.copy(confirmPasswordSignUp = password)
    }

    // 在createUser和loginUser中,所有修改loginUiState的地方都替换为_loginUiState.value
    // 例如:
    fun loginUser(context: Context) = viewModelScope.launch {
        try {
            if (!validateLoginForm()) {
                throw IllegalArgumentException("email and password can not be empty")
            }
            _loginUiState.value = _loginUiState.value.copy(isLoading = true)
            _loginUiState.value = _loginUiState.value.copy(loginError = null)
            // ... 其他代码同理
        } catch (e: Exception) {
            _loginUiState.value = _loginUiState.value.copy(loginError = e.localizedMessage)
            // ... 其他代码
        } finally {
            _loginUiState.value = _loginUiState.value.copy(isLoading = false)
        }
    }
}

步骤2:Composable中监听StateFlow变化

修改LoginScreen.kt中loginUiState的获取方式,使用collectAsStateWithLifecycle()监听状态变化,确保UI自动重组:

import androidx.lifecycle.compose.collectAsStateWithLifecycle

@Composable
fun LoginScreen(
    loginViewModel: LoginViewModel, // 去掉nullable,确保传递非null实例
    onNavToHomePage: () -> Unit,
    onNavToSignUpPage: () -> Unit,
) {
    val loginUiState by loginViewModel.loginUiState.collectAsStateWithLifecycle()
    val isError = loginUiState.loginError != null
    val context = LocalContext.current

    // ... 后续UI代码保持不变,直接使用loginUiState即可
}

步骤3:确保ViewModel实例非null

在MainActivity.kt的Navigation组件中,确保传递给LoginScreen的loginViewModel是非null实例,避免空安全调用导致方法不执行。


2. 空内容点击登录无Toast的问题

原因

  • 表单验证失败抛出异常时,仅设置了loginError但未弹出Toast
  • Toast必须在主线程显示,而ViewModel的协程默认在IO线程执行,直接调用Toast会无效

修复步骤

修改LoginViewModel.kt中的loginUser方法,在catch块中添加主线程执行的Toast,并确保repository回调中的Toast也在主线程:

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

fun loginUser(context: Context) = viewModelScope.launch {
    try {
        if (!validateLoginForm()) {
            throw IllegalArgumentException("email and password can not be empty")
        }
        _loginUiState.value = _loginUiState.value.copy(isLoading = true)
        _loginUiState.value = _loginUiState.value.copy(loginError = null)
        repository.login(
            _loginUiState.value.userName,
            _loginUiState.value.password
        ) { isSuccessful ->
            withContext(Dispatchers.Main) { // 切换到主线程显示Toast
                if (isSuccessful) {
                    Toast.makeText(context, "success Login", Toast.LENGTH_SHORT).show()
                    _loginUiState.value = _loginUiState.value.copy(isSuccessLogin = true)
                } else {
                    Toast.makeText(context, "Failed Login", Toast.LENGTH_SHORT).show()
                    _loginUiState.value = _loginUiState.value.copy(isSuccessLogin = false)
                }
            }
        }
    } catch (e: Exception) {
        _loginUiState.value = _loginUiState.value.copy(loginError = e.localizedMessage)
        withContext(Dispatchers.Main) { // 主线程显示验证失败的Toast
            Toast.makeText(context, e.localizedMessage, Toast.LENGTH_SHORT).show()
        }
        e.printStackTrace()
    } finally {
        _loginUiState.value = _loginUiState.value.copy(isLoading = false)
    }
}

同理,createUser方法也需要做相同的主线程Toast处理。


内容的提问来源于stack exchange,提问作者Lazza

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.26 20:12:35