山东大学创新实训项目个人博客——第二篇
本周是项目开始开发的第三周,记录一下工作进度:
一、登陆界面重构
第一周初学时创建的登陆界面所有的代码都集成在LoginActivity中,包括页面组件、viewModel、Activity等模块,重构后的login界面采用MVVM架构,由LoginScreen、LoginActivity、LoginViewModel和LoginUiState四个文件组成。其中Activity负责导航、窗口管理、系统交互;ViewModel持有并处理 UI 状态,承载业务逻辑;UiState:定义状态的不可变数据模型;Screen:纯 UI 渲染(Compose绑定),修改某一块时,不影响其他块。
功能示例:发送验证码的完整流程:
场景描述:
用户在登录界面输入手机号 13800000001,然后点击"发送验证码"按钮。
第 1 步:UI 层接收用户输入
//LoginScreen.kt,用户在 PhoneInput 组件中输入手机号 PhoneInput( value = uiState.phoneNumber, // 从状态读取显示 onValueChange = viewModel::updatePhone, // 输入变化时调用 ViewModel error = uiState.phoneError // 显示错误信息 )用户每输入一个字符,onValueChange都会触发,调用viewModel.updatePhone("13800000001")
第 2 步:ViewModel 更新状态
//LoginViewModel.kt fun updatePhone(phone: String) { _uiState.update { it.copy(phoneNumber = phone) } }ViewModel 接收到新的手机号 "13800000001",使用 MutableStateFlow.update() 更新 _uiState
创建新的 LoginUiState 对象(不可变数据类),状态从 phoneNumber = "" 变为 phoneNumber = "13800000001"。
第 3 步:状态自动通知 UI 刷新
//LoginScreen.kt val uiState by viewModel.uiState.collectAsState()collectAsState() 自动观察到 _uiState 的变化,Compose 框架检测到状态改变,自动重组(Recompose)相关的 UI 组件,使手机输入框中显示最新的手机号。
第 4 步:用户点击"发送验证码"按钮
//LoginScreen.kt SmsLoginSection( verifyCode = uiState.verifyCode, countdown = uiState.countdown, isLoading = uiState.isLoading, onVerifyCodeChange = viewModel::updateVerifyCode, onSendCode = { viewModel.sendVerifyCode(context) }, // ← 点击触发 onToggleMode = viewModel::toggleLoginMode, error = uiState.codeError, codeMessage = uiState.codeMessage )用户点击发送按钮,调用 viewModel.sendVerifyCode(context),传入 context 用于后续可能的 Toast 提示
第 5 步:ViewModel 执行业务逻辑
//LoginViewModel.kt fun sendVerifyCode(context: Context) { val phone = _uiState.value.phoneNumber // ① 获取当前手机号 // ② 清空之前的错误和消息 _uiState.update { it.copy( phoneError = "", codeMessage = "" ) } // ③ 校验手机号是否为空 if (phone.isBlank()) { _uiState.update { it.copy(phoneError = "请输入手机号") } return } // ④ 校验手机号格式 if (!NetworkUtil.validatePhone(phone)) { _uiState.update { it.copy(phoneError = "请输入正确的手机号") } return } // ⑤ 发起网络请求 NetworkUtil.request( ApiService.api.sendCode(phone), object : NetworkCallback<BaseResponseWithoutData> { override fun onSuccess(data: BaseResponseWithoutData) { if (data.code == "0") { startCountdown() // 启动倒计时 _uiState.update { it.copy(codeMessage = "验证码已发送!") } } else { _uiState.update { it.copy(phoneError = data.message?.ifEmpty { "发送失败" } ?: "发送失败") } } } override fun onFailure(error: String) { _uiState.update { it.copy(phoneError = "网络不佳,请稍后重试") } } } ) }依次进行了以下事务:
① 读取状态:从 _uiState.value 获取当前手机号 "13800000001";
② 重置错误:清空之前的错误提示,避免干扰;
③ 空值校验:如果手机号为空,更新 phoneError 并提前返回;
④ 格式校验:调用工具类验证手机号格式(正则表达式匹配);
⑤ 网络请求:调用 ApiService.api.sendCode(phone) 创建网络请求,通过 NetworkUtil.request() 封装的请求方法发送,传入回调接口处理成功/失败结果。
第 6 步:网络层执行请求
//ApiService.kt + NetworkUtil.kt //ApiService 定义接口 interface ApiInterface { @GET("sms/send") fun sendCode(@Query("phone") phone: String): Call<BaseResponseWithoutData> } // NetworkUtil 封装网络请求 object NetworkUtil { fun <T> request(call: Call<T>, callback: NetworkCallback<T>) { call.enqueue(object : Callback<T> { override fun onResponse(call: Call<T>, response: Response<T>) { if (response.isSuccessful) { callback.onSuccess(response.body()!!) } else { callback.onFailure("Error: ${response.code()}") } } override fun onFailure(call: Call<T>, t: Throwable) { callback.onFailure(t.message ?: "网络错误") } }) } }Retrofit 发起 HTTP GET 请求:/sms/send?phone=13800000001,服务器处理请求并返回响应,
NetworkUtil 根据响应状态调用相应的回调方法。
第 7 步A:请求成功的处理流程
ViewModel 处理成功:
override fun onSuccess(data: BaseResponseWithoutData) { if (data.code == "0") { startCountdown() // ← 启动倒计时 _uiState.update { it.copy(codeMessage = "验证码已发送!") } } }启动倒计时:
private fun startCountdown() { countdownJob?.cancel() // 取消之前的倒计时任务 countdownJob = viewModelScope.launch { for (i in 60 downTo 0) { // 从 60 秒开始倒数 _uiState.update { it.copy(countdown = i) } delay(1000) // 每秒更新一次 } } }UI层反应:
// LoginScreen.kt 中的发送按钮 IconButton( onClick = onSendCode, enabled = countdown == 0 && !isLoading // 倒计时期间禁用 ) { if (countdown > 0) { Text(text = "${countdown}秒") // 显示倒计时 } else { Image(painter = painterResource(id = R.drawable.varifycode_send)) } }用户将看到蓝色提示文字:"验证码已发送!",而且发送按钮变成灰色,显示 "60秒"、"59秒"...
倒计时结束后,按钮恢复可点击状态。
第 7 步B:请求失败的处理流程
假设服务器返回失败或网络错误:
override fun onFailure(error: String) { val errorMessage = when { error.contains("Response body is null") -> "服务器响应异常" error.startsWith("Error:") -> "服务器错误,请稍后重试" else -> "网络不佳,请稍后重试" } _uiState.update { it.copy(phoneError = errorMessage) } }UI 层的反应:
// LoginScreen.kt 中的错误提示区域 Box(modifier = Modifier.fillMaxWidth().height(20.dp)) { if (error.isNotEmpty()) { Text( text = error, color = colorResource(id = R.color.attention), // 红色 fontSize = 12.sp ) } }手机号输入框下方将显示红色错误提示:"网络不佳,请稍后重试",用户可以重新点击发送按钮。
整个过程中,UI 层只负责渲染和收集用户输入;ViewModel 处理所有验证、网络请求、状态转换;State 作为唯一的数据源,保证 UI 和数据的一致性。MVVM架构清晰,便于测试和维护,将应用于之后所有页面的搭建工作。
二、搭建主界面
使用前文提到的MVVM架构构建主页面,创建的文件如下:
主界面的结构为底部一个导航栏用于切换对话界面和“我的”界面。
对话界面则分为三个阶段,如上图所示:问诊、导航、康复。由于三个阶段的对话界面各有侧重所以需要分别创建自己的对话界面文件,但是后端的接口尚未完成,暂时使用一样的三个文件和写死的模拟数据模拟三个阶段的对话界面,如下图所示。目前的按钮逻辑还有问题,顶部的三个按钮应该在ConversationScreen中重新布置,中间的聊天信息和输入框在各自的文件中描述。也就是说这些文件在项目目录中的相对路径结构,应与其所描述的界面组件在视图层次结构中的包含关系(即父组件包裹子组件的嵌套顺序)保持一致。
个人信息和健康画像部分的页面,后端的Api已经实现,这里先说明页面的搭建,大致的结构与效果如下:
Column (垂直滚动) ├── 标题 "个人中心" ├── 错误提示(可选) ├── 加载指示器 / 内容区域 │ ├── 用户信息卡片 (Card) │ │ ├── 头像 (AsyncImage/Image) │ │ ├── 用户名 + 手机号 │ │ ├── 编辑/保存按钮 │ │ └── 账号状态 │ └── 健康画像卡片 (Card) │ ├── 年龄、性别、体重(横向排列) │ ├── 过敏史 │ ├── 既往病史 │ ├── 家族病史 │ └── 保存按钮(编辑模式显示)该界面以双卡片布局展示(用户信息卡片 + 健康画像卡片),支持查看/编辑双模式切换,利用 LaunchedEffect 在初始化时加载数据并同步到本地编辑状态,使用 StateFlow + collectAsState 实现响应式数据绑定,结合 Coil 库加载网络头像,并通过条件渲染处理加载状态、错误提示和编辑模式的动态切换,最终形成一个具备完整 CRUD 功能的用户资料管理页面。
三、适配真正的后端api
本周后端的小伙伴已经完成了登录注册请求接口以及个人信息和健康的查看和修改接口,我将后端在本地跑起来后进行了适配。
首先修改baseurl为本地后端地址:
private const val BASE_URL = "http://10.0.2.2:8080"在 WenKangApplication.kt 中完成全局初始化,确保整个应用生命周期内该服务处于就绪状态:
//WenKangApplication.kt class WenKangApplication : Application() { override fun onCreate() { super.onCreate() ApiService.init(this) // 传入 Context } }OkHttp和Retrofit部分已经完成,再根据后端返回的信息适配Response模板:比如LoginResponse,后端小伙伴给的返回模板为:
{ "code": "0", "message": "login success", "data": { "userId": 1, "username": "android_user_01", "phone": "13800000001", "accessToken": "1f0c2d65e0d347ec8e7f72ea4e4f1fcb", "tokenType": "Bearer", "expiresAt": "2026-04-20T17:00:00" }, "requestId": null }那么LoginResponse适配为:
package com.example.wenkang.data.model.response data class LoginResponse( val code: String, val message: String, val data: LoginData, val requestId: String? = null ) data class LoginData( val userId: Int, val username: String, val phone: String, val accessToken: String, val tokenType: String, val expiresAt: String )同理,调整相关请求需要的参数以及路径后,安卓前端就可以与后端交互了。测试登录功能和个人信息界面,信息正常显示,表示适配成功:
接下来的工作:
1.完善conversation界面以及美化整个主界面,使其色调与登录界面统一、看上去更有质感;
2.完成注册界面,完成对后端注册api的适配。
