各位朋友们,大家好。
我正在参加 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",流程是:
- 集成微信SDK,调用微信登录,获取
code(临时授权码)。 - 把
code传给自己的后端,后端用code换微信的openid(用户唯一标识)。 - 后端判断
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. 生物识别登录:指纹/人脸登录
生物识别登录适合"已登录过,且开启了生物识别"的用户,流程是:
- 首次登录时,询问用户是否开启"指纹登录"。
- 开启后,将账号密码(加密)存到本地,下次登录时调用生物识别。
- 生物识别通过后,自动用存储的账号密码登录。
代码示例:生物识别登录(用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的"第一道防线",也是用户体验的"第一印象"。设计登录模块时,要记住这几个核心原则:
- 分层架构:用MVVM拆分View、ViewModel、Repository,解耦代码,方便维护和测试。
- 安全优先:密码加密存储(EncryptedSharedPreferences)、Token安全管理(自动刷新、内存持有)、防暴力破解(限制试错次数)。
- 体验至上:第三方登录、生物识别、记住密码、自动登录,减少用户操作;友好的异常提示,不让用户"懵圈"。
- 灵活扩展:预留拓展点,比如以后加短信验证码登录、刷脸登录,只需在Repository层加方法,不改动现有逻辑。
最后给个小建议:刚开始不用追求"大而全",可以先实现"账号密码登录+加密存储+基础异常处理",然后根据产品需求逐步加功能。
祝你从此告别"登录模块踩坑记",用户登录体验节节高!