任务五 保存登录状态

一、任务目标

使用登录接口返回的 token 获取用户信息,并保存登录状态。登录成功后进入主页面;再次启动应用时,根据本地登录状态进入主页面或登录页。

二、查看用户信息接口

任务四取得的 token 只能证明账号已通过验证。要取得用户名等信息,还需调用用户信息接口:

项目内容
请求方式GET
请求地址/order-account/info
请求头token:登录接口返回的凭证
请求体无
响应体格式application/json

请求头中的 token 使用登录接口返回的 data.token。成功时,服务器返回:

用户信息接口成功响应示例
{
"data": {
"accountId": 1,
"name": "示例用户",
"imagePath": ""
},
"code": 200,
"msg": "成功"
}

code 和 msg 的含义与登录响应相同;data 中保存账号编号、用户名和头像地址。

三、创建用户信息模型

用 UserInfoData 接收用户信息,并在保存前补上登录接口返回的 token。

1. 创建 UserInfoData

在任务四创建的 com.android.practice.food.data.model 包中创建 UserInfoData.kt,写入以下完整内容:

app/src/main/java/com/android/practice/food/data/model/UserInfoData.kt
package com.android.practice.food.data.model
import com.google.gson.annotations.SerializedName
data class UserInfoData(
@SerializedName("accountId") val accountId: Int = 0,
@SerializedName("name") val username: String = "",
@SerializedName("imagePath") val avatarPath: String = "",
// 用户信息接口不返回 token,保存前再补入。
val token: String = "",
)

@SerializedName 指出每个属性对应的 JSON 字段。例如,接口返回的 name 保存到 username。用户信息接口不返回 token,这里先用空字符串,登录完成时再填入。

2. 运行验证

构建并运行应用,确认能够进入登录页。此时只添加了数据类,页面不会出现新变化。

四、获取用户信息

先让 FoodApiClient 通过 token 请求用户信息,再在登录页检查返回结果。

1. 添加用户信息请求方法

打开 FoodApiClient.kt,导入 UserInfoData,并在 login() 方法后添加 getUserInfo():

app/src/main/java/com/android/practice/food/core/network/FoodApiClient.kt
import com.android.practice.food.data.model.UserInfoData
object FoodApiClient {
// ... 省略服务器地址、OkHttp 客户端、Gson 对象和 login()。
fun getUserInfo(token: String): Response<UserInfoData> {
val request = Request.Builder()
.url("$SERVER_HOST/order-account/info")
.get()
.addHeader("token", token)
.build()
return try {
httpClient.newCall(request).execute().use { response ->
val json = response.body.string()
gson.fromJson(
json,
object : TypeToken<Response<UserInfoData>>() {}.type
)
}
} catch (throwable: Throwable) {
Response(Response.CODE_EXCEPTION, throwable.message.orEmpty())
}
}
}

get() 表示读取数据,addHeader("token", token) 把登录凭证放进请求头。Gson 将响应中的 data 读成 UserInfoData;请求失败时返回与任务四相同的异常结果。

2. 登录成功后请求用户信息

登录成功后,还要继续获取用户信息。打开 LoginActivity.kt,在 handleLogin() 中删除登录请求后立即关闭对话框的 dialog.dismiss(),并将成功时的 Toast 替换为用户信息请求。

app/src/main/java/com/android/practice/food/feature/account/login/LoginActivity.kt
class LoginActivity : BaseActivity() {
// ... 省略其他成员和方法。
private fun handleLogin() {
// ... 省略输入校验和加载对话框的创建。
lifecycleScope.launch {
val response = withContext(Dispatchers.IO) {
FoodApiClient.login(username, password)
}
dialog.dismiss()
val token = response.data?.token
if (!response.success || token.isNullOrEmpty()) {
// 登录失败时立即关闭加载提示。
dialog.dismiss()
Toast.makeText(
this@LoginActivity,
response.msg,
Toast.LENGTH_SHORT
).show()
return@launch
}
// 删除临时 Toast。
Toast.makeText(
this@LoginActivity,
response.msg,
Toast.LENGTH_SHORT
).show()
// 账号验证通过后,继续获取用户信息。
val userInfoResponse = withContext(Dispatchers.IO) {
FoodApiClient.getUserInfo(token)
}
dialog.dismiss()
val userInfo = userInfoResponse.data
if (!userInfoResponse.success || userInfo == null) {
Toast.makeText(
this@LoginActivity,
userInfoResponse.msg,
Toast.LENGTH_SHORT
).show()
return@launch
}
// 临时显示用户信息对象,确认接口返回的数据。
Toast.makeText(
this@LoginActivity,
"用户信息获取成功:\n$userInfo",
Toast.LENGTH_LONG
).show()
}
}
}

如果登录请求失败,立即关闭加载对话框并显示失败原因。如果登录请求成功,保持加载对话框,继续请求用户信息;收到用户信息接口的结果后,再关闭对话框。

只有用户信息请求成功且 data 不为空,才在临时 Toast 中显示“用户信息获取成功”和 userInfo。字符串中的 $userInfo 会调用数据类的 toString(),列出各属性;此时用户信息接口不返回 token,该属性仍为空字符串,不会显示登录凭证。

3. 运行验证

在用户名输入框中填写 sjse,在密码输入框中填写 123456,勾选用户协议,然后点击“登录”按钮。

加载对话框关闭后,Toast 应先显示“用户信息获取成功”,下一行显示 UserInfoData(...),其中包含账号编号、用户名和头像地址等属性值。应用暂时停留在登录页。

用户信息接口验证结果

五、保存登录状态并进入主页面

登录页已取得用户信息和 token。接下来把它们保存到本地,成功后进入主页面。

1. 创建账号管理类

在已有的 com.android.practice.food.feature.account 包中创建 AccountManager.kt,写入以下完整内容:

app/src/main/java/com/android/practice/food/feature/account/AccountManager.kt
package com.android.practice.food.feature.account
import android.content.Context
import android.content.SharedPreferences
import com.android.practice.food.data.model.UserInfoData
import com.google.gson.Gson
/**
* 管理本地账号信息和登录状态。
*/
object AccountManager {
private const val KEY_USER_INFO = "key_user_info"
private lateinit var preferences: SharedPreferences
private val gson = Gson()
// 保存当前进程中的用户信息,避免重复读取本地文件。
private var userInfo: UserInfoData? = null
fun init(context: Context) {
preferences = context.applicationContext.getSharedPreferences(
"account_data",
Context.MODE_PRIVATE
)
}
fun getUserInfo(): UserInfoData? {
// 应用重新启动后,内存中没有数据时再从本地恢复用户信息。
if (userInfo == null) {
val json = preferences.getString(KEY_USER_INFO, "")
if (!json.isNullOrEmpty()) {
userInfo = gson.fromJson(json, UserInfoData::class.java)
}
}
return userInfo
}
fun updateUserInfo(newUserInfo: UserInfoData) {
userInfo = newUserInfo
preferences.edit()
.putString(KEY_USER_INFO, gson.toJson(newUserInfo))
.apply()
}
fun hasLogin(): Boolean = getUserInfo() != null
fun logout() {
preferences.edit()
.remove(KEY_USER_INFO)
.apply()
userInfo = null
}
}

SharedPreferences 可以把少量数据保存在设备上,应用重启后仍可读取。userInfo 则只保存在当前运行的应用中,重启后需要从本地重新读取。AccountManager 只负责本地账号数据:

  • updateUserInfo():将用户信息转换为 JSON 并保存。
  • getUserInfo():读取用户信息,内存中没有时从本地恢复。
  • hasLogin():判断本地是否保存了用户信息。
  • logout():清除本地和内存中的账号数据,供之后退出登录时使用。

2. 初始化账号管理类

AccountManager 读取登录状态前,需要先通过 init() 取得本地存储。打开 FoodApplication.kt,导入 AccountManager,并在 onCreate() 中调用 init():

app/src/main/java/com/android/practice/food/app/FoodApplication.kt
import com.android.practice.food.feature.account.AccountManager
class FoodApplication : Application() {
override fun onCreate() {
super.onCreate()
AccountManager.init(this)
}
}

这里的 this 是 FoodApplication 对象,init(this) 用它取得应用的本地存储。

3. 根据登录状态选择启动页面

当前 MainActivity 启动后会直接打开登录页,尚未检查本地是否保存了用户信息。现在加入登录状态判断:没有保存用户信息时打开登录页,已保存时继续显示主页面。

打开 MainActivity.kt,导入 AccountManager。在 onCreate() 中,保留原有的 startActivity() 和 finish(),在外面添加 if (!AccountManager.hasLogin()) 判断。

app/src/main/java/com/android/practice/food/feature/main/MainActivity.kt
import com.android.practice.food.feature.account.AccountManager
class MainActivity : BaseActivity() {
// ... 省略成员变量。
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = DataBindingUtil.setContentView(this, R.layout.activity_main)
binding.lifecycleOwner = this
// 没有本地登录记录时,才打开登录页。
if (!AccountManager.hasLogin()) {
startActivity(Intent(this, LoginActivity::class.java))
finish()
}
}
}

首次启动应用时,本地没有用户信息,hasLogin() 返回 false,程序打开登录页并结束当前主页面。登录状态已经保存时,条件不成立,程序继续显示主页面。

4. 保存用户信息并进入主页面

打开 LoginActivity.kt,导入 Intent、AccountManager 和 MainActivity。在 handleLogin() 中,删掉用户信息获取成功时的临时 Toast,改为保存用户信息并进入主页面:

app/src/main/java/com/android/practice/food/feature/account/login/LoginActivity.kt
import android.content.Intent
import com.android.practice.food.feature.account.AccountManager
import com.android.practice.food.feature.main.MainActivity
class LoginActivity : BaseActivity() {
// ... 省略其他成员和方法。
private fun handleLogin() {
// ... 省略输入校验和加载对话框的创建。
lifecycleScope.launch {
// ... 省略登录请求和用户信息请求。
val userInfo = userInfoResponse.data
if (!userInfoResponse.success || userInfo == null) {
// ... 保留用户信息获取失败时的提示。
return@launch
}
Toast.makeText(
this@LoginActivity,
"用户信息获取成功:\n$userInfo",
Toast.LENGTH_LONG
).show()
// 补上登录凭证并保存,再打开主页面。
AccountManager.updateUserInfo(userInfo.copy(token = token))
startActivity(Intent(this@LoginActivity, MainActivity::class.java))
finish()
}
}
}

用户信息接口的响应中不包含 token。copy(token = token) 以现有用户信息为基础,补充登录接口返回的凭证,再交给 AccountManager.updateUserInfo() 保存。

用户信息保存后,程序打开 MainActivity。此时 hasLogin() 返回 true,主页面不再打开登录页。finish() 关闭当前登录页,避免用户按返回键再次回到登录页面。

5. 运行验证

在用户名输入框中填写 sjse,在密码输入框中填写 123456,勾选用户协议,然后点击“登录”按钮。

登录请求和用户信息请求完成后,应用应关闭登录页并进入初始主页面。

登录成功后进入主页面

六、验证登录状态恢复

登录成功后,用户信息和 token 已经保存到 SharedPreferences。重新启动应用时,AccountManager 从本地恢复用户信息,MainActivity 不再打开登录页。

1. 重新启动应用

停止当前应用,再次运行项目。应用应直接显示初始主页面,不再经过登录页。

2. 检查未登录状态

在设备的应用设置中清除 Food 的应用数据,然后重新运行项目。由于本地用户信息已经删除,应用应再次进入登录页。

清除应用数据会删除本地保存的账号信息。完成检查后,可重新使用测试账号登录。

这里恢复的是本地保存的登录状态,并未向服务器检查 token 是否仍然有效。

任务小结

本任务完成了用户信息获取、账号数据保存和页面跳转。AccountManager 管理本地账号信息,MainActivity 根据本地登录状态选择启动页面,使应用重新启动后能够恢复登录状态。