Android登录模块设计:别让“大门”变成“破篱笆”

各位朋友们,大家好。

我正在参加 2025 年博客之星评选,麻烦你抽空帮我投票,我会非常感激。

投票地址:投票链接,谢谢(投票截至 23 号,每天都可以投一次,可以拉到最大投票数)

前言

你以为登录模块就是"两个输入框+一个按钮"?太年轻!曾有同事写的登录模块,密码直接明文存在SP里------相当于把家门钥匙插在锁孔上;还有人没处理Token过期,用户用着用着突然闪退,以为APP"罢工"了;更别提忘记加防重复点击,用户手快点三下,发了三次登录请求,后端直接把账号临时锁了......

登录模块是APP的"大门",既要让合法用户顺畅进门,又要把"小偷"(恶意攻击、数据泄露)挡在外面。今天咱们就从"需求分析"到"安全落地",手把手教你设计一个"既安全又抗揍"的登录模块,代码示例管够,坑点提前预警!

先搞懂:登录模块要解决哪些"麻烦事"?

在敲代码前,得先想清楚"用户需要啥""产品要啥""安全要啥"------不然写出来的登录模块,要么功能残缺,要么漏洞百出。咱们先列个"需求清单",一个合格的登录模块至少要覆盖这些场景:

场景类型 具体需求
基础登录 账号密码登录(支持手机号/邮箱)、输入校验(格式错误提示)
便捷登录 第三方登录(微信/QQ/微博)、生物识别(指纹/人脸)、记住密码、自动登录
安全防护 密码加密存储、Token防泄露、防暴力破解(输错锁定)、验证码(图形/短信)
状态管理 登录状态保持、Token自动刷新、退出登录(清理数据)、多账号切换
异常处理 网络错误、账号密码错误、Token过期、服务器故障、生物识别失败

这些需求不是"全选必做",比如工具类APP可能不需要第三方登录,但"密码加密"和"状态管理"是底线------就像家门再简单,也得装个锁吧?

架构设计:拒绝"Activity堆代码",用MVVM分层

很多新手写登录,把接口调用、SP存储、输入校验全堆在LoginActivity里,代码上千行,改个"记住密码"逻辑要翻半天------这叫"面条代码",难维护、难测试。

正确的做法是用MVVM架构分层,把"显示""逻辑""数据"拆开,就像家里分"客厅(View)""书房(ViewModel)""仓库(Repository)",各司其职,不乱套:

  • View层LoginActivity/LoginFragment,只负责"显示界面"和"接收用户点击",比如显示加载动画、提示错误,不处理业务逻辑。
  • ViewModel层LoginViewModel,作为"中间人",接收View的请求(比如"用户点了登录按钮"),调用Repository处理,再把结果通知给View。
  • Repository层LoginRepository,处理"数据来源",比如调用远程登录接口、读写本地SP(存储Token/密码),统一管理本地和远程数据。

分层的好处很明显:比如以后要把"账号密码登录"改成"短信验证码登录",只需要改Repository层,View和ViewModel几乎不用动------这就是"解耦"的魅力!

先搭好"分层骨架"(代码示例)

1. 定义数据模型(Data Class)

首先明确"登录需要啥数据""后端返回啥数据",用数据类封装:

kotlin 复制代码
// 登录请求参数(账号密码登录)
data class LoginRequest(
    val account: String,    // 账号(手机号/邮箱)
    val password: String,   // 密码(加密后的值)
    val deviceId: String    // 设备ID(防多设备登录,可选)
)

// 登录响应(后端返回)
data class LoginResponse(
    val accessToken: String,  // 访问令牌(临时,比如2小时过期)
    val refreshToken: String, // 刷新令牌(长期,比如7天,用于换accessToken)
    val expiresIn: Long,      // accessToken过期时间(秒)
    val userInfo: UserInfo    // 用户基本信息(昵称、头像等)
)

// 用户信息
data class UserInfo(
    val userId: String,
    val nickname: String,
    val avatarUrl: String
)

// 登录状态密封类(ViewModel给View的结果)
sealed class LoginState {
    object Idle : LoginState()                  // 初始状态
    object Loading : LoginState()               // 加载中
    data class Success(val userInfo: UserInfo) : LoginState() // 成功
    data class Error(val msg: String) : LoginState() // 失败(带错误信息)
}
2. Repository层:处理数据读写

Repository是"数据管家",既要调用登录接口,也要把Token存到本地(用加密存储,后面讲):

kotlin 复制代码
class LoginRepository @Inject constructor(
    private val apiService: ApiService,          // 远程接口( Retrofit 实例)
    private val secureStorage: SecureStorage,    // 加密存储(自定义,存Token/密码)
    private val deviceIdManager: DeviceIdManager // 设备ID管理(自定义,获取设备唯一标识)
) {
    // 1. 账号密码登录
    suspend fun loginWithAccount(account: String, password: String): LoginResponse {
        // 步骤1:密码加密(不能明文传!这里用MD5+盐,实际推荐更安全的方式)
        val encryptedPwd = encryptPassword(password)
        // 步骤2:构造请求参数(加设备ID,后端可做设备绑定)
        val request = LoginRequest(
            account = account,
            password = encryptedPwd,
            deviceId = deviceIdManager.getDeviceId()
        )
        // 步骤3:调用远程登录接口( Retrofit 协程方法)
        val response = apiService.login(request)
        // 步骤4:登录成功,存储Token和用户信息到本地
        secureStorage.saveAccessToken(response.accessToken)
        secureStorage.saveRefreshToken(response.refreshToken)
        secureStorage.saveUserInfo(response.userInfo)
        // 步骤5:返回结果
        return response
    }

    // 2. 检查是否已登录(判断本地是否有有效Token)
    fun isLoggedIn(): Boolean {
        val accessToken = secureStorage.getAccessToken()
        val tokenExpired = secureStorage.isAccessTokenExpired()
        // Token存在且未过期,算已登录
        return !accessToken.isNullOrEmpty() && !tokenExpired
    }

    // 3. 退出登录(清理本地数据)
    fun logout() {
        secureStorage.clearAccessToken()
        secureStorage.clearRefreshToken()
        secureStorage.clearUserInfo()
        secureStorage.clearSavedAccountPassword() // 清除记住的密码
    }

    // 辅助:密码加密(示例,实际推荐用SHA-256+动态盐,盐从后端获取)
    private fun encryptPassword(password: String): String {
        val salt = "your_app_salt_123" // 盐值(实际不要硬编码,可后端返回)
        return MD5Utils.encode("$password:$salt") // MD5工具类(自定义)
    }
}
3. ViewModel层:处理业务逻辑

ViewModel负责"逻辑调度",比如用户点登录后,先校验输入,再调用Repository,最后把结果用StateFlow通知View:

kotlin 复制代码
class LoginViewModel @Inject constructor(
    private val loginRepository: LoginRepository,
    private val accountValidator: AccountValidator // 账号密码校验器(自定义)
) : ViewModel() {
    // 登录状态(View层观察这个Flow)
    private val _loginState = MutableStateFlow<LoginState>(LoginState.Idle)
    val loginState: StateFlow<LoginState> = _loginState.asStateFlow()

    // 记住密码状态(双向绑定,View层的CheckBox可观察)
    private val _isRememberPwd = MutableStateFlow(false)
    val isRememberPwd: StateFlow<Boolean> = _isRememberPwd.asStateFlow()

    // 1. 用户点击登录按钮
    fun login(account: String, password: String) {
        viewModelScope.launch {
            // 步骤1:切换到加载状态(View层显示Progress)
            _loginState.value = LoginState.Loading
            try {
                // 步骤2:校验输入(账号格式、密码长度)
                val validateResult = accountValidator.validate(account, password)
                if (!validateResult.isValid) {
                    // 校验失败,通知View显示错误
                    _loginState.value = LoginState.Error(validateResult.errorMsg)
                    return@launch
                }
                // 步骤3:调用Repository登录
                val response = loginRepository.loginWithAccount(account, password)
                // 步骤4:如果需要记住密码,存储账号密码到本地(加密)
                if (_isRememberPwd.value) {
                    loginRepository.secureStorage.saveSavedAccountPassword(account, password)
                } else {
                    // 不记住,清除之前存储的
                    loginRepository.secureStorage.clearSavedAccountPassword()
                }
                // 步骤5:登录成功,通知View跳转主页
                _loginState.value = LoginState.Success(response.userInfo)
            } catch (e: Exception) {
                // 步骤6:处理异常(网络错、账号密码错等)
                val errorMsg = when (e) {
                    is IOException -> "网络不给力,请检查WiFi/流量"
                    is ApiException -> e.message ?: "登录失败,请重试" // 后端返回的错误(自定义异常)
                    else -> "登录出了点小问题,请稍后再试"
                }
                _loginState.value = LoginState.Error(errorMsg)
            }
        }
    }

    // 2. 切换"记住密码"状态
    fun toggleRememberPwd(checked: Boolean) {
        _loginState.value = LoginState.Idle // 重置状态
        _isRememberPwd.value = checked
    }

    // 3. 初始化:如果之前记住了密码,自动填充到输入框
    fun initSavedAccount(): Pair<String, String>? {
        return loginRepository.secureStorage.getSavedAccountPassword()
    }
}
4. View层:只负责显示和交互

LoginActivity里不写业务逻辑,只观察ViewModel的loginState,处理用户点击:

kotlin 复制代码
class LoginActivity : AppCompatActivity() {
    @Inject
    lateinit var loginViewModel: LoginViewModel // Hilt注入ViewModel

    private lateinit var binding: ActivityLoginBinding // ViewBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityLoginBinding.inflate(layoutInflater)
        setContentView(binding.root)

        // 1. 观察登录状态(ViewModel -> View)
        lifecycleScope.launchWhenStarted {
            loginViewModel.loginState.collect { state ->
                when (state) {
                    LoginState.Idle -> {
                        binding.btnLogin.isEnabled = true
                        binding.progressBar.isVisible = false
                    }
                    LoginState.Loading -> {
                        binding.btnLogin.isEnabled = false // 防止重复点击
                        binding.progressBar.isVisible = true
                    }
                    is LoginState.Success -> {
                        // 登录成功,跳转主页(清除返回栈,避免按返回键回到登录页)
                        val intent = Intent(this@LoginActivity, MainActivity::class.java)
                        intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
                        startActivity(intent)
                        finish()
                    }
                    is LoginState.Error -> {
                        // 显示错误提示(Toast/Snackbar)
                        Toast.makeText(this@LoginActivity, state.msg, Toast.LENGTH_SHORT).show()
                    }
                }
            }
        }

        // 2. 观察"记住密码"状态,同步CheckBox
        lifecycleScope.launchWhenStarted {
            loginViewModel.isRememberPwd.collect { checked ->
                binding.cbRememberPwd.isChecked = checked
            }
        }

        // 3. 初始化:自动填充记住的账号密码
        val savedAccountPwd = loginViewModel.initSavedAccount()
        savedAccountPwd?.let { (account, pwd) ->
            binding.etAccount.setText(account)
            binding.etPassword.setText(pwd)
            loginViewModel.toggleRememberPwd(true) // 同步CheckBox状态
        }

        // 4. 登录按钮点击事件
        binding.btnLogin.setOnClickListener {
            val account = binding.etAccount.text.toString().trim()
            val password = binding.etPassword.text.toString().trim()
            loginViewModel.login(account, password) // 调用ViewModel的登录方法
        }

        // 5. "记住密码"CheckBox点击事件
        binding.cbRememberPwd.setOnCheckedChangeListener { _, isChecked ->
            loginViewModel.toggleRememberPwd(isChecked)
        }

        // 6. 密码可见/隐藏切换(眼睛图标)
        binding.ivPwdToggle.setOnClickListener {
            val isVisible = binding.etPassword.inputType != InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD
            binding.etPassword.inputType = if (isVisible) {
                InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD // 可见
            } else {
                InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD // 隐藏
            }
            // 切换后光标移到末尾
            binding.etPassword.setSelection(binding.etPassword.text.length)
        }
    }
}

到这里,基础的分层架构就搭好了------View层像"演员",只负责"演"(显示);ViewModel像"导演",指挥"演员"怎么动;Repository像"道具组",提供需要的"道具"(数据)。

安全第一:登录模块的"防盗门窗"

登录模块最容易出安全漏洞,比如密码明文存储、Token被窃取------这些问题一旦出现,用户数据可能被泄露,APP甚至会被攻击。咱们得给登录模块装"防盗门窗",重点解决这几个问题:

1. 密码存储:绝对不能"明文躺平"

很多新手图省事,把密码存在普通SharedPreferences里------用ADB命令就能导出SP文件,密码直接"裸奔"。正确的做法是用加密存储 ,Android推荐用EncryptedSharedPreferences(AndroidX Security库),它会把数据加密后存在SP里,即使文件被扒走,也解不开。

代码示例:封装SecureStorage

首先添加依赖:

gradle 复制代码
dependencies {
    // AndroidX Security:用于加密存储
    implementation "androidx.security:security-crypto:1.1.0-alpha06"
}

然后封装加密存储工具类:

kotlin 复制代码
class SecureStorage @Inject constructor(context: Context) {
    // 加密SP实例
    private val sharedPreferences: SharedPreferences

    init {
        // 步骤1:创建主密钥(AndroidKeyStore生成,存在系统安全区域,APP无法直接获取)
        val masterKeyAlias = MasterKey.Builder(context)
            .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) // 加密算法
            .build()

        // 步骤2:创建加密SP
        sharedPreferences = EncryptedSharedPreferences.create(
            "SecureLoginStorage", // 文件名
            masterKeyAlias,
            context,
            EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, // 键加密
            EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM // 值加密
        )
    }

    // 存储AccessToken
    fun saveAccessToken(token: String) {
        sharedPreferences.edit().putString(KEY_ACCESS_TOKEN, token).apply()
        // 记录Token过期时间(当前时间+expiresIn秒)
        val expireTime = System.currentTimeMillis() + (EXPIRES_IN * 1000)
        sharedPreferences.edit().putLong(KEY_ACCESS_TOKEN_EXPIRE, expireTime).apply()
    }

    // 获取AccessToken
    fun getAccessToken(): String? {
        return sharedPreferences.getString(KEY_ACCESS_TOKEN, null)
    }

    // 检查AccessToken是否过期
    fun isAccessTokenExpired(): Boolean {
        val expireTime = sharedPreferences.getLong(KEY_ACCESS_TOKEN_EXPIRE, 0)
        return System.currentTimeMillis() > expireTime
    }

    // 存储记住的账号密码(密码这里是用户输入的原密码,加密后存)
    fun saveSavedAccountPassword(account: String, password: String) {
        val encryptedPwd = encryptPassword(password) // 再加密一次,双重保险
        sharedPreferences.edit()
            .putString(KEY_SAVED_ACCOUNT, account)
            .putString(KEY_SAVED_PASSWORD, encryptedPwd)
            .apply()
    }

    // 获取记住的账号密码(解密后返回)
    fun getSavedAccountPassword(): Pair<String, String>? {
        val account = sharedPreferences.getString(KEY_SAVED_ACCOUNT, null)
        val encryptedPwd = sharedPreferences.getString(KEY_SAVED_PASSWORD, null)
        if (account.isNullOrEmpty() || encryptedPwd.isNullOrEmpty()) {
            return null
        }
        val decryptedPwd = decryptPassword(encryptedPwd) // 解密
        return Pair(account, decryptedPwd)
    }

    // 清除所有数据
    fun clearAll() {
        sharedPreferences.edit().clear().apply()
    }

    // 辅助:密码加密(示例,实际用更安全的算法)
    private fun encryptPassword(password: String): String {
        return Base64.encodeToString(
            password.toByteArray(),
            Base64.NO_WRAP
        )
    }

    // 辅助:密码解密
    private fun decryptPassword(encryptedPwd: String): String {
        return String(
            Base64.decode(encryptedPwd, Base64.NO_WRAP)
        )
    }

    // 常量
    companion object {
        private const val KEY_ACCESS_TOKEN = "access_token"
        private const val KEY_ACCESS_TOKEN_EXPIRE = "access_token_expire"
        private const val KEY_REFRESH_TOKEN = "refresh_token"
        private const val KEY_USER_INFO = "user_info"
        private const val KEY_SAVED_ACCOUNT = "saved_account"
        private const val KEY_SAVED_PASSWORD = "saved_password"
        private const val EXPIRES_IN = 7200L // AccessToken过期时间(2小时,和后端一致)
    }
}

这样,即使有人拿到SP文件,里面的密码和Token也是加密后的乱码,无法破解------相当于把"钥匙"放进了"保险箱"。

2. Token管理:别让"门禁卡"失效或被盗

Token是用户登录后的"门禁卡",分为Access Token(临时门禁卡,2小时过期)和Refresh Token(补办门禁卡的凭证,7天过期)。如果Token管理不好,会出现"用户明明登录了,却提示未登录"的尴尬场景。

核心原则:
  • Access Token:只存在内存(比如ViewModel或单例),不用时及时清空,避免被内存dump窃取。
  • Refresh Token:存在加密SP里,有效期 longer,用于Access Token过期后自动刷新。
  • 自动刷新:当调用接口返回401(Token过期)时,自动用Refresh Token换新的Access Token,用户无感知。
代码示例:Token自动刷新(拦截器实现)

用OkHttp的拦截器,在请求发出去前检查Token是否过期,过期则自动刷新:

kotlin 复制代码
class TokenRefreshInterceptor @Inject constructor(
    private val secureStorage: SecureStorage,
    private val apiService: ApiService
) : Interceptor {
    // 锁:防止多个请求同时刷新Token(并发问题)
    private val refreshLock = Any()
    // 是否正在刷新Token
    private var isRefreshing = false

    override fun intercept(chain: Interceptor.Chain): Response {
        val originalRequest = chain.request()
        // 1. 如果请求不需要Token(比如登录接口),直接放行
        if (!originalRequest.url.pathSegments.contains("api")) {
            return chain.proceed(originalRequest)
        }

        // 2. 检查Access Token是否过期
        val accessToken = secureStorage.getAccessToken()
        if (accessToken.isNullOrEmpty()) {
            // 没Token,直接返回401(触发登录)
            return Response.Builder()
                .request(originalRequest)
                .code(401)
                .message("未登录")
                .build()
        }

        // 3. Token未过期,给请求加Header
        val requestWithToken = originalRequest.newBuilder()
            .header("Authorization", "Bearer $accessToken") // Bearer Token格式
            .build()

        // 4. 发送请求,获取响应
        val response = chain.proceed(requestWithToken)

        // 5. 如果响应是401(Token过期),尝试刷新Token
        if (response.code == 401) {
            synchronized(refreshLock) { // 加锁,避免并发
                if (!isRefreshing) {
                    isRefreshing = true
                    try {
                        // 5.1 调用刷新Token接口
                        val refreshToken = secureStorage.getRefreshToken()
                        val refreshResponse = apiService.refreshToken(refreshToken)
                        // 5.2 刷新成功,存储新Token
                        secureStorage.saveAccessToken(refreshResponse.newAccessToken)
                        // 5.3 用新Token重新发起原请求
                        val newRequest = originalRequest.newBuilder()
                            .header("Authorization", "Bearer ${refreshResponse.newAccessToken}")
                            .build()
                        isRefreshing = false
                        return chain.proceed(newRequest)
                    } catch (e: Exception) {
                        // 5.4 刷新失败(比如Refresh Token也过期了),清除本地数据,返回401
                        secureStorage.clearAll()
                        isRefreshing = false
                        return Response.Builder()
                            .request(originalRequest)
                            .code(401)
                            .message("登录已过期,请重新登录")
                            .build()
                    }
                } else {
                    // 5.5 其他请求正在刷新Token,等待刷新完成后重试
                    while (isRefreshing) {
                        Thread.sleep(100) // 等待100ms
                    }
                    // 用新Token重试
                    val newAccessToken = secureStorage.getAccessToken()
                    val newRequest = originalRequest.newBuilder()
                        .header("Authorization", "Bearer $newAccessToken")
                        .build()
                    return chain.proceed(newRequest)
                }
            }
        }

        // 6. 非401响应,直接返回
        return response
    }
}

把这个拦截器添加到OkHttp:

kotlin 复制代码
val okHttpClient = OkHttpClient.Builder()
    .addInterceptor(TokenRefreshInterceptor) // Token刷新拦截器
    .addInterceptor(HttpLoggingInterceptor) // 日志拦截器(开发环境用)
    .connectTimeout(10, TimeUnit.SECONDS)
    .build()

这样,用户在使用过程中,即使Access Token过期,APP也会自动刷新,不用重新登录------体验无缝衔接!

3. 防暴力破解:别让"小偷"试出密码

如果有人拿着别人的账号,不停试密码,很容易试出简单密码(比如123456)。防暴力破解的核心是"限制尝试次数",比如连续输错3次,锁定10分钟,或要求输入图形验证码。

代码示例:防暴力破解逻辑(ViewModel层)
kotlin 复制代码
class LoginViewModel @Inject constructor(
    private val loginRepository: LoginRepository,
    private val accountValidator: AccountValidator,
    private val secureStorage: SecureStorage
) : ViewModel() {
    // 记录错误次数和锁定时间
    private var errorCount: Int
    private var lockTime: Long

    init {
        // 从本地读取之前的错误次数和锁定时间
        errorCount = secureStorage.getLoginErrorCount() ?: 0
        lockTime = secureStorage.getLoginLockTime() ?: 0
    }

    fun login(account: String, password: String) {
        viewModelScope.launch {
            // 1. 检查是否被锁定
            val currentTime = System.currentTimeMillis()
            if (currentTime < lockTime) {
                // 还在锁定时间内,计算剩余锁定时间(分钟)
                val remainingTime = (lockTime - currentTime) / (1000 * 60)
                _loginState.value = LoginState.Error("账号已锁定,请${remainingTime}分钟后再试")
                return@launch
            }

            // 2. 其他逻辑(校验输入、调用登录接口)
            try {
                // ... 之前的校验和登录逻辑 ...
                // 登录成功,重置错误次数
                errorCount = 0
                secureStorage.saveLoginErrorCount(0)
            } catch (e: ApiException) {
                // 3. 登录失败(账号密码错),累加错误次数
                errorCount++
                secureStorage.saveLoginErrorCount(errorCount)
                when (errorCount) {
                    1, 2 -> {
                        _loginState.value = LoginState.Error("账号或密码错误,还剩${3 - errorCount}次机会")
                    }
                    3 -> {
                        // 输错3次,锁定10分钟
                        val newLockTime = currentTime + (10 * 60 * 1000)
                        lockTime = newLockTime
                        secureStorage.saveLoginLockTime(newLockTime)
                        _loginState.value = LoginState.Error("连续输错3次,账号已锁定10分钟")
                    }
                }
            }
        }
    }
}

这样,即使有人恶意试密码,也会被锁定------相当于给"大门"装了"电子锁",试错多了就自动锁死。

拓展功能:让登录模块更"贴心"

基础登录做好后,还可以加一些拓展功能,提升用户体验,比如第三方登录、生物识别登录、自动登录等。

1. 第三方登录:微信/QQ登录(以微信为例)

第三方登录的核心是"用第三方平台的身份,换自己APP的Token",流程是:

  1. 集成微信SDK,调用微信登录,获取code(临时授权码)。
  2. code传给自己的后端,后端用code换微信的openid(用户唯一标识)。
  3. 后端判断openid是否已绑定账号,绑定则返回Token,未绑定则引导用户注册。
代码示例:微信登录实现

首先集成微信SDK(参考微信开放平台文档),然后在Repository层加微信登录方法:

kotlin 复制代码
class LoginRepository @Inject constructor(
    private val apiService: ApiService,
    private val secureStorage: SecureStorage,
    private val wechatHelper: WechatHelper // 自定义微信登录助手
) {
    // 微信登录(挂起函数,配合协程)
    suspend fun loginWithWechat(): LoginResponse {
        // 步骤1:调用微信SDK,获取code(用CompletableDeferred包装回调)
        val code = wechatHelper.getWechatCode()
        if (code.isNullOrEmpty()) {
            throw ApiException("获取微信授权失败,请重试")
        }

        // 步骤2:把code传给后端,换Token
        val wechatLoginRequest = WechatLoginRequest(code = code)
        val response = apiService.loginWithWechat(wechatLoginRequest)

        // 步骤3:存储Token和用户信息(和账号密码登录一致)
        secureStorage.saveAccessToken(response.accessToken)
        secureStorage.saveRefreshToken(response.refreshToken)
        secureStorage.saveUserInfo(response.userInfo)

        return response
    }
}

// 微信登录助手(包装微信SDK回调)
class WechatHelper @Inject constructor(private val context: Context) {
    // 用CompletableDeferred把回调转成挂起函数
    suspend fun getWechatCode(): String? {
        val deferred = CompletableDeferred<String?>()
        // 调用微信登录
        val api = WXAPIFactory.createWXAPI(context, "你的微信APPID", true)
        api.registerApp("你的微信APPID")
        if (!api.isWXAppInstalled) {
            deferred.completeExceptionally(ApiException("未安装微信,请先安装"))
            return deferred.await()
        }

        // 构造授权请求
        val req = SendAuth.Req()
        req.scope = "snsapi_userinfo" // 获取用户信息的 scope
        req.state = "wechat_login_state" // 防CSRF攻击,随机字符串
        api.sendReq(req)

        // 接收微信回调(需要在WXEntryActivity里处理)
        WechatCallbackManager.setLoginCallback { result ->
            if (result.isSuccess) {
                deferred.complete(result.code)
            } else {
                deferred.completeExceptionally(ApiException(result.errorMsg))
            }
        }

        return deferred.await()
    }
}

然后在View层加个"微信登录"按钮,点击调用viewModel.loginWithWechat()即可------用户不用记密码,一键登录,体验更好。

2. 生物识别登录:指纹/人脸登录

生物识别登录适合"已登录过,且开启了生物识别"的用户,流程是:

  1. 首次登录时,询问用户是否开启"指纹登录"。
  2. 开启后,将账号密码(加密)存到本地,下次登录时调用生物识别。
  3. 生物识别通过后,自动用存储的账号密码登录。
代码示例:生物识别登录(用AndroidX Biometric)

首先添加依赖:

gradle 复制代码
dependencies {
    implementation "androidx.biometric:biometric:1.2.0-alpha05"
}

然后在ViewModel层加生物识别登录方法:

kotlin 复制代码
class LoginViewModel @Inject constructor(
    private val loginRepository: LoginRepository,
    private val biometricHelper: BiometricHelper // 自定义生物识别助手
) : ViewModel() {
    // 生物识别登录
    fun loginWithBiometric(activity: AppCompatActivity) {
        viewModelScope.launch {
            _loginState.value = LoginState.Loading
            try {
                // 步骤1:检查设备是否支持生物识别
                val supportResult = biometricHelper.checkBiometricSupport(activity)
                if (!supportResult.isSupported) {
                    _loginState.value = LoginState.Error(supportResult.errorMsg)
                    return@launch
                }

                // 步骤2:发起生物识别验证
                val authResult = biometricHelper.authenticate(activity)
                if (authResult.isSuccess) {
                    // 步骤3:验证通过,获取本地存储的账号密码
                    val savedAccountPwd = loginRepository.secureStorage.getSavedAccountPassword()
                        ?: throw ApiException("未找到已保存的账号,请用密码登录")
                    // 步骤4:自动登录
                    val response = loginRepository.loginWithAccount(
                        account = savedAccountPwd.first,
                        password = savedAccountPwd.second
                    )
                    _loginState.value = LoginState.Success(response.userInfo)
                } else {
                    _loginState.value = LoginState.Error(authResult.errorMsg)
                }
            } catch (e: Exception) {
                _loginState.value = LoginState.Error(e.message ?: "生物识别登录失败")
            }
        }
    }
}

// 生物识别助手
class BiometricHelper @Inject constructor() {
    // 检查设备是否支持生物识别
    fun checkBiometricSupport(activity: AppCompatActivity): BiometricSupportResult {
        val biometricManager = BiometricManager.from(activity)
        return when (biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK or BiometricManager.Authenticators.DEVICE_CREDENTIAL)) {
            BiometricManager.BIOMETRIC_SUCCESS -> {
                BiometricSupportResult(isSupported = true)
            }
            BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> {
                BiometricSupportResult(isSupported = false, errorMsg = "设备不支持生物识别")
            }
            BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> {
                BiometricSupportResult(isSupported = false, errorMsg = "生物识别硬件不可用")
            }
            BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> {
                BiometricSupportResult(isSupported = false, errorMsg = "请先在系统设置中录入指纹/人脸")
            }
            else -> {
                BiometricSupportResult(isSupported = false, errorMsg = "生物识别功能不可用")
            }
        }
    }

    // 发起生物识别验证(挂起函数)
    suspend fun authenticate(activity: AppCompatActivity): BiometricAuthResult {
        val deferred = CompletableDeferred<BiometricAuthResult>()
        // 构造生物识别提示
        val promptInfo = BiometricPrompt.PromptInfo.Builder()
            .setTitle("生物识别登录")
            .setSubtitle("请验证指纹/人脸以登录")
            .setNegativeButtonText("取消")
            .build()

        // 创建BiometricPrompt
        val biometricPrompt = BiometricPrompt(
            activity,
            ContextCompat.getMainExecutor(activity),
            object : BiometricPrompt.AuthenticationCallback() {
                override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
                    // 验证成功
                    deferred.complete(BiometricAuthResult(isSuccess = true))
                }

                override fun onAuthenticationFailed() {
                    // 验证失败(比如指纹不匹配)
                    deferred.complete(BiometricAuthResult(isSuccess = false, errorMsg = "生物识别失败,请重试"))
                }

                override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
                    // 验证错误(比如用户取消)
                    if (errorCode != BiometricPrompt.ERROR_NEGATIVE_BUTTON) {
                        deferred.complete(BiometricAuthResult(isSuccess = false, errorMsg = errString.toString()))
                    } else {
                        deferred.complete(BiometricAuthResult(isSuccess = false, errorMsg = "已取消"))
                    }
                }
            }
        )

        // 显示生物识别弹窗
        biometricPrompt.authenticate(promptInfo)
        return deferred.await()
    }

    // 生物识别支持结果
    data class BiometricSupportResult(
        val isSupported: Boolean,
        val errorMsg: String = ""
    )

    // 生物识别验证结果
    data class BiometricAuthResult(
        val isSuccess: Boolean,
        val errorMsg: String = ""
    )
}

在View层加个"指纹登录"按钮,点击调用viewModel.loginWithBiometric(this),用户验证指纹后就能自动登录------比输密码快多了!

异常处理:让登录模块"抗揍"

登录过程中会遇到各种"意外",比如网络断了、服务器崩了、用户手指湿了(指纹识别失败),这些都要友好处理,不能让APP闪退或显示"莫名其妙"的错误。

常见异常及处理方案:

异常类型 处理方案
网络错误(IOException) 提示"网络不给力,请检查WiFi/流量",显示"重试"按钮
账号密码错误(ApiException) 提示"账号或密码错误,请重试",清空密码输入框,聚焦密码输入框
Token过期(401) 自动刷新Token,失败则跳转登录页,提示"登录已过期,请重新登录"
服务器错误(500) 提示"服务器开小差了,请稍后再试",记录错误日志(方便排查)
生物识别失败 提示"指纹/人脸不匹配,请重试",提供"用密码登录"的选项
第三方登录取消 不提示(用户主动取消,无需打扰)

这些异常处理逻辑,我们在之前的ViewModel和Repository层已经分散实现了------核心是"不把异常抛给用户",而是转化为用户能看懂的提示。

总结

看到这里,你会发现登录模块远不止"输入框+按钮"------它是APP的"第一道防线",也是用户体验的"第一印象"。设计登录模块时,要记住这几个核心原则:

  1. 分层架构:用MVVM拆分View、ViewModel、Repository,解耦代码,方便维护和测试。
  2. 安全优先:密码加密存储(EncryptedSharedPreferences)、Token安全管理(自动刷新、内存持有)、防暴力破解(限制试错次数)。
  3. 体验至上:第三方登录、生物识别、记住密码、自动登录,减少用户操作;友好的异常提示,不让用户"懵圈"。
  4. 灵活扩展:预留拓展点,比如以后加短信验证码登录、刷脸登录,只需在Repository层加方法,不改动现有逻辑。

最后给个小建议:刚开始不用追求"大而全",可以先实现"账号密码登录+加密存储+基础异常处理",然后根据产品需求逐步加功能。

祝你从此告别"登录模块踩坑记",用户登录体验节节高!

相关推荐
2401_861277552 小时前
上海哪些海洋公园可以触摸海洋动物
经验分享
嵌入式-老费2 小时前
Android开发(总结)
android
Coder_Boy_2 小时前
基于SpringAI的在线考试系统-DDD(领域驱动设计)核心概念及落地架构全总结
java·大数据·人工智能·spring boot·架构·ddd·tdd
AI小怪兽2 小时前
YOLO26:面向实时目标检测的关键架构增强与性能基准测试
人工智能·yolo·目标检测·计算机视觉·目标跟踪·架构
为自己_带盐2 小时前
架构演进:从数据库“裸奔”到多级防护
数据库·架构
php_kevlin2 小时前
websocket实现站内信
android·websocket·网络协议
美团骑手阿豪2 小时前
Unity适配 安卓15+三键导航模式下的 底部UI被遮挡
android·智能手机
张海龙_China2 小时前
Android 上架Google Play ~16KB内存页机制适配指南
android
blackorbird2 小时前
Android Pixel 9 的零点击漏洞利用链全解析:从发送杜比音频解码到内核提权
android·音视频