编程实验:开发点餐 APP 账号登录功能
本实验将基于 03_login 模板工程,完成点餐 APP 的账号登录功能。模板工程已经具备上一章搭建的页面框架、登录页入口、首页 Tab 框架、账号信息持久化工具和基础资源。本章的核心任务是把 LoginView 中的“模拟登录”改造为“真实账号登录”。
完成后,应用将具备以下能力:
- 登录页展示完整的账号、密码、协议勾选、登录按钮和注册入口
- 点击登录时先完成本地表单校验,错误输入立即给出 Toast 提示
- 登录请求过程中显示通用 Loading 弹窗
- 调用后端登录接口获取
token - 使用
token继续获取用户信息 - 将用户信息保存到
AccountManager,并跳转到首页
功能流程示意
本实验涉及的知识点
| 知识点 | 使用场景 |
|---|---|
@State | 保存用户名、密码、协议勾选状态 |
TextInput | 构建账号和密码输入框 |
Checkbox | 构建协议勾选控件 |
promptAction.showToast() | 表单校验失败和登录失败提示 |
CustomDialogController | 控制 Loading 弹窗打开和关闭 |
Promise / async / await | 串联登录接口和用户信息接口 |
http.createHttp() | 发起 HTTP 请求 |
class-transformer | 将接口返回对象转换为数据类实例 |
AccountManager | 保存登录后的用户信息 |
router.replaceUrl() | 登录成功后替换跳转到首页 |
2.1 获取模板工程
模板工程仓库:https://cnb.cool/sziit-coding/harmony-coding/03_login
请从上面的 Git 仓库克隆或下载 03_login 模板工程,再导入 DevEco Studio。
2.2 认识模板工程的当前状态
03_login 模板不是空工程,它已经完成了上一章的页面框架。当前最重要的起点是:LoginView.ets 中只有一个简单的“模拟登录”按钮,点击后会手动创建一个 UserInfoData 对象并跳转首页。
这说明模板工程已经具备了以下基础:
| 已有能力 | 对应文件 | 本章是否需要重写 |
|---|---|---|
| 登录页路由入口 | entry/src/main/ets/pages/Login.ets | 不需要 |
| 首页路由入口 | entry/src/main/ets/pages/Index.ets | 不需要 |
| 首页 Tab 框架 | entry/src/main/ets/portal/PortalView.ets | 不需要 |
| 用户信息数据类 | entry/src/main/ets/model/UserInfoData.ets | 直接复用 |
| 账号信息保存工具 | entry/src/main/ets/account/AccountManager.ets | 直接复用 |
| 常量、颜色、字符串资源 | utils/Constants.ets、resources | 直接复用 |
| 登录页 UI 和真实登录逻辑 | account/login/LoginView.ets | 本章重点开发 |
2.3 本实验需要修改和新建的文件
| 文件路径 | 操作 | 作用 |
|---|---|---|
entry/src/main/ets/account/login/LoginView.ets | 修改 | 完成登录页 UI、校验、Loading 控制和真实登录流程 |
entry/src/main/ets/common/dialog/LoadingDialog.ets | 新建 | 封装通用加载弹窗 |
entry/src/main/ets/api/Response.ets | 新建 | 封装后端统一响应结构 |
entry/src/main/ets/model/LoginData.ets | 新建 | 封装登录接口返回的 token 数据 |
entry/src/main/ets/entryability/EntryAbility.ets | 修改 | 开启全屏布局并保存状态栏高度 |
entry/src/main/ets/api/ServerApi.ets | 新建 | 封装登录接口和用户信息接口 |
2.4 注册测试账号
在真实登录接口对接前,请先注册一个测试账号:
https://food2.sziit.top#register建议记录以下信息:
- 用户名
- 密码
后续测试时需要使用自己注册的账号登录。
如果注册链接打不开、访问较慢或被网络环境拦截,请按以下顺序处理:
- 先更换网络环境,例如切换到校园网、手机热点或浏览器无痕窗口后重试
- 如果仍无法注册,先继续完成本实验的 UI、表单校验、Loading 和接口封装步骤
- 到真实登录测试阶段时,使用课程提供的临时测试账号,或等待网络恢复后再完成最终登录验收
注册入口不可用不会阻止你完成前面的编码练习,但会影响最后“真实账号登录”的演示。
2.5 初始运行效果检查
运行模板工程后,进入登录页可以看到一个简化页面:
- 页面中显示
LoginView - 页面中有一个“模拟登录”按钮
- 点击按钮后可以跳转到首页
这一点很重要:后续每完成一小步,都可以和这个初始效果对比,确认本次修改是否生效。
3.1 为什么不能直接在按钮里写请求?
登录功能看起来只是一个按钮点击事件,但真正的业务流程包含多个职责:
- 读取用户输入
- 判断输入是否有效
- 展示加载状态
- 调用登录接口
- 处理接口失败
- 获取完整用户资料
- 保存用户信息
- 跳转首页
如果把这些逻辑全部堆在按钮的 onClick 中,代码会很快变得难以阅读,也不方便排查问题。因此,本实验采用分层拆解的方式:
| 层次 | 负责内容 |
|---|---|
| UI 层 | 输入框、按钮、协议勾选、Loading 弹窗 |
| 页面逻辑层 | 表单校验、登录流程编排、跳转 |
| 接口层 | ServerApi.login()、ServerApi.getUserInfo() |
| 数据层 | LoginData、Response<T>、UserInfoData |
| 账号状态层 | AccountManager.saveUserInfo() |
3.2 先做 UI,再做真实接口
本章建议按以下顺序开发:
- 先把登录页面布局做出来,立即看到页面变化
- 再完成本地校验,立即看到 Toast 提示
- 再加入 Loading 弹窗,立即看到异步过程反馈
- 最后接入真实接口,把模拟登录替换掉
这样的顺序可以减少一次性改动太多导致的问题。每个阶段都有可观察的运行结果,方便定位错误。
目标:在应用启动时完成两件事——让页面内容延伸到状态栏下方(全屏模式),同时把状态栏的实际高度存入全局,供登录页等各个页面用作顶部留白,避免内容被系统区域遮挡。
4.1 适配思路
为什么要开全屏?
默认情况下,HarmonyOS 会把页面内容限制在状态栏以下的安全区域,状态栏部分由系统管控。如果我们希望背景色或图片能铺满整个屏幕(包括状态栏区域),就需要主动调用 setWindowLayoutFullScreen(true) 开启全屏布局。
开全屏后为什么还要记高度?
开启全屏后,页面内容会从屏幕最顶端开始渲染,状态栏区域也属于页面范围。这意味着如果不做处理,登录页的关闭按钮等顶部组件会和状态栏中的时间、信号图标重叠。因此还需要读取状态栏的实际高度,用它作为顶部留白,把页面内容推到状态栏下方。
单位换算
系统接口 getWindowAvoidArea 返回的高度单位是 px(物理像素)。ArkTS 组件的 padding、margin 等布局属性默认接受 vp(虚拟像素)。两者不是同一单位,直接把 px 值赋给 padding 会导致偏移量在不同屏幕密度的设备上表现不一致。
正确的做法是用 px2vp() 把物理像素转换为虚拟像素后再赋值:
// px → vp 转换示例(实际代码见 4.2)const heightInVp = px2vp(avoidArea.topRect.height)AppStorage.setOrCreate(APP_SCOPE_STATUS_BAR_HEIGHT, heightInVp)这样存入 AppStorage 的值已经是 vp 单位,后续直接用作 padding 属性即可。
4.2 在 EntryAbility 中初始化全屏和状态栏高度
状态栏高度是全局配置,应该在应用窗口完全就绪后立即读取。HarmonyOS 提供的标准时机是 UIAbility.onWindowStageCreate()——此时窗口已创建完毕,可以安全读取窗口属性。这也是为什么应该把这段逻辑放在 EntryAbility 而不是某个具体页面里。
打开 entry/src/main/ets/entryability/EntryAbility.ets,在 onWindowStageCreate 中完成以下两件事:
import UIAbility from '@ohos.app.ability.UIAbility';import window from '@ohos.window';import { Logger } from '../utils/Logger';import { APP_SCOPE_STATUS_BAR_HEIGHT } from '../utils/Constants';
export default class EntryAbility extends UIAbility { onWindowStageCreate(windowStage: window.WindowStage) { Logger.info('Ability onWindowStageCreate');
// TODO: 设置全屏并获取状态栏高度 // - 调用 windowStage.getMainWindow() 获取主窗口对象 // - 在 then 回调中: // · 使用 win.setWindowLayoutFullScreen(true) 开启全屏布局 // · 调用 win.getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM) 获取系统避让区域 // · 读取 avoidArea.topRect.height,得到状态栏高度(单位:px) // · 用 px2vp() 将其转换为 vp 单位 // · 通过 AppStorage.setOrCreate(APP_SCOPE_STATUS_BAR_HEIGHT, heightInVp) 写入全局存储 // · 调用 console.log('状态栏高度(vp): ' + heightInVp) 打印日志,用于验证
windowStage.loadContent('pages/Splash', (err, data) => { if (err.code) { Logger.error('Failed to load the content. Cause: %{public}s', JSON.stringify(err) ?? ''); return; } }); }}4.3 验证状态栏高度
完成 4.2 的代码后,运行应用,在 DevEco Studio 的 Log 面板中搜索 状态栏高度,确认控制台输出了一个合理的正数。手机设备通常在 30–50 vp 之间,模拟器可能略有差异。
如果输出为 0,优先检查:getWindowAvoidArea 是否在 setWindowLayoutFullScreen 之后调用,以及 px2vp() 是否正确包裹了 topRect.height。
✅ 阶段检查:日志中能看到正确的状态栏高度数值,说明 AppStorage 中的值已就绪,后续登录页可以直接通过 @StorageProp 读取使用。
登录页的状态栏适配(@StorageProp + padding)将在步骤 5 中一并完成。
目标:将模板中的简化 LoginView 改造成完整登录表单。本阶段完成后,页面应该能看到关闭按钮、欢迎语、账号输入框、密码输入框、协议区域、登录按钮和注册入口。
颜色资源说明:本步骤及后续所有 UI 开发中涉及的颜色均已在模板工程中定义,常用值如下,后文不再重复说明:
用途 使用方式 主题浅红色(登录按钮、主题色、协议勾选色) '#F63440'黑色 70% 透明度(正文字体颜色) $r('app.color.black_70')页面背景色 $r('app.color.page_background')使用时直接把对应值赋给
.fontColor()、.backgroundColor()等属性即可。如需查看完整颜色定义,可打开entry/src/main/resources/base/element/color.json。
5.1 清理模拟页面,搭建表单骨架
打开 entry/src/main/ets/account/login/LoginView.ets。本步骤有两个任务:清理模板中的模拟登录内容,同时完成状态栏适配——声明 statusBarPadding 变量并设置给根容器的顶部 padding。
状态栏适配说明:步骤 4 已经在 EntryAbility 中把状态栏高度(vp 单位)写入了 AppStorage。登录页这里只需要用 @StorageProp 绑定这个值,并把它设置给根容器的 .padding({ top: ... }),就能让页面内容从状态栏下方开始显示。
import router from '@ohos.router'import { UserInfoData } from '../../model/UserInfoData'import { APP_SCOPE_STATUS_BAR_HEIGHT } from '../../utils/Constants'import { AccountManager } from '../AccountManager'
@Preview@Componentexport struct LoginView { // TODO: 用 @StorageProp 声明一个属性,读取状态栏高度
build() { Column({ space: 10 }) { Text('LoginView') /* 模板中的占位标题,删除 */ Button('模拟登录').onClick(_ => { /* 模板中的模拟登录逻辑,整体删除 */ }) } Column() { // 后续步骤会在这里依次加入: // 关闭按钮、欢迎语、输入框、协议区、登录按钮、注册入口 } // TODO: 给根容器设置顶部内边距,值为上面声明的状态栏高度 .alignItems(HorizontalAlign.Center) .size({ width: '100%', height: '100%' }) }}✅ 阶段检查:本步骤完成后,页面主体暂时没有可见内容是正常的,因为旧的”模拟登录”按钮已经移除,新的 UI 还没有添加。你需要重点确认两件事:
- DevEco Studio 编译没有报错
- 页面没有崩溃、白屏闪退或控制台异常
如果出现编译错误,优先检查 import 是否有多余或遗漏;如果出现运行后崩溃,优先检查 build() 中是否只保留了一个页面根容器。
5.2 添加页面状态变量
登录表单需要记录三类状态:
- 用户名输入内容
- 密码输入内容
- 是否勾选协议
在 LoginView 中添加 @State 状态变量:
import router from '@ohos.router'import { APP_SCOPE_STATUS_BAR_HEIGHT } from '../../utils/Constants'
@Preview@Componentexport struct LoginView { @StorageProp(APP_SCOPE_STATUS_BAR_HEIGHT) statusBarPadding: number = 0
// TODO: 添加三个 @State 状态变量 // - username:保存用户名输入框内容 // - password:保存密码输入框内容 // - agreementChecked:保存协议勾选状态
build() { Column() { // 4.1 已留下空骨架,本步骤不修改 build() } ... }}@State 的作用是让组件保存并响应自身状态变化。输入框内容变化后,登录按钮可以直接读取页面状态完成校验。
5.3 实现关闭按钮和欢迎语
关闭按钮使用模板已有的 ic_close 图片资源。欢迎语使用字符串资源 login_welcome,避免把展示文字写死在页面代码中。
import router from '@ohos.router'import { APP_SCOPE_STATUS_BAR_HEIGHT } from '../../utils/Constants'
@Preview@Componentexport struct LoginView { @StorageProp(APP_SCOPE_STATUS_BAR_HEIGHT) statusBarPadding: number = 0
...
build() { Column() { // TODO: 使用 Image 实现关闭按钮 // - 图片资源使用 app.media.ic_close // - 宽高设置为 50,内部留 5 的 padding,避免图标贴边 // - 填充色使用 $r('app.color.black_80') // - 背景色使用 $r('app.color.black_5'),圆角设置为 25 // - 通过 alignSelf(ItemAlign.Start) 靠左,外边距设置为 { top: 20, left: 20 } // - 点击时调用 router.back() 返回上一页
// TODO: 使用 Text 实现欢迎语 // - 文案使用 app.string.login_welcome // - 字号设置为 34,作为登录页视觉焦点 // - 颜色使用 $r('app.color.black_70') // - 外边距设置为 { top: 60 }
// 用户名/密码输入框、协议区、登录按钮、注册入口将在后续步骤实现 } ... }}✅ 阶段检查:运行后应能看到关闭按钮和“欢迎登录点餐系统”文字。
5.4 实现账号和密码输入框
账号输入框负责更新 username,密码输入框负责更新 password。两个输入框的结构类似,但密码输入框需要设置为密码类型。
@Preview@Componentexport struct LoginView { @StorageProp(APP_SCOPE_STATUS_BAR_HEIGHT) statusBarPadding: number = 0
...
build() { Column() { ...
// TODO: 实现用户名输入框 // - placeholder 使用 app.string.placeholder_username // - 高度 60,字号 20,便于触摸和阅读 // - 输入类型使用普通文本 // - 左右外边距 20,顶部外边距 40,和欢迎语拉开距离 // - 输入光标 caretColor 和文字颜色 fontColor 均使用 $r('app.color.black') // - onChange 中把输入值保存到 username 状态
// TODO: 实现密码输入框 // - placeholder 使用 app.string.placeholder_password // - 类型设置为密码输入,隐藏密码明文 // - 键盘完成按钮设置为 Done // - 和用户名输入框保持相同高度(60)、字号(20)和颜色 // - 左右外边距 20,顶部外边距 10(顶距小于用户名框,使两框形成一组) // - onChange 中把输入值保存到 password 状态
// 协议区、登录按钮、注册入口将在后续步骤实现 } ... }}✅ 阶段检查:运行后可以在页面输入账号和密码,密码框输入内容应以密码形式显示。
5.5 实现用户隐私协议区域
协议区域包含一个 Checkbox 和两段文字。前半段是普通说明文字,后半段可点击打开协议链接。
import common from '@ohos.app.ability.common'import router from '@ohos.router'import { APP_SCOPE_STATUS_BAR_HEIGHT } from '../../utils/Constants'import { APP_SCOPE_STATUS_BAR_HEIGHT, Constants } from '../../utils/Constants'
@Preview@Componentexport struct LoginView { @StorageProp(APP_SCOPE_STATUS_BAR_HEIGHT) statusBarPadding: number = 0
...
openUserAgreementPage() { // TODO: 构造 want 对象并通过系统浏览器打开协议页面 // want 对象的字段固定格式如下: // { // action: 'ohos.want.action.viewData', // 声明"打开数据",系统浏览器识别此 action // entities: ['entity.system.browsable'], // 声明目标是可浏览实体(网页) // uri: Constants.USER_AGREEMENT_URL, // 协议页面地址,来自 Constants // type: 'text/html' // 内容类型 // } // 通过 getContext(this) as common.UIAbilityContext 获取 context, // 调用 context.startAbility(want) 发起跳转 // 注意:Want 需要从 '@ohos.app.ability.Want' 导入 }
build() { Column() { ...
// TODO: 实现用户隐私协议区域 // - Row 内从左到右放置 Checkbox、普通说明文字、协议链接文字 // - Checkbox selectedColor 使用 $r('app.color.primary_color_light') // - Checkbox onChange 中同步 agreementChecked 状态 // - 普通说明文字 app.string.label_read_agreement_prefix,左外边距 5,字号 16,颜色 $r('app.color.black_80') // - 协议链接文字使用 app.string.label_read_agreement_postfix // - 协议链接文字颜色使用 $r('app.color.primary_color'),字号 16 // - 点击协议链接文字时调用 openUserAgreementPage // - Row 设置左右外边距 20,顶部外边距 20
// 登录按钮、注册入口将在 4.6 实现 } ... }}在部分模拟器环境中,外部浏览器可能无法正常拉起。只要点击逻辑没有导致应用崩溃即可继续后续实验。
✅ 阶段检查:运行后可以勾选协议;点击《用户隐私协议》文字时,满足以下任一情况都算本阶段通过:
- 能拉起浏览器或系统选择器,并尝试打开协议链接
- 模拟器没有可用浏览器,但应用不崩溃,控制台没有因
want字段错误或类型转换错误导致的异常
也就是说,本阶段验收重点是“点击事件、want 结构和 startAbility 调用逻辑正确”,不是强制要求每台模拟器都能成功打开外部网页。
5.6 实现登录按钮和注册入口
登录按钮只负责调用 handleLogin(),校验逻辑统一放在该方法中。注册入口本章不实现真实注册,点击后给出“暂未实现”提示即可。
import common from '@ohos.app.ability.common'import promptAction from '@ohos.promptAction'import router from '@ohos.router'import { APP_SCOPE_STATUS_BAR_HEIGHT, Constants } from '../../utils/Constants'
@Preview@Componentexport struct LoginView { @StorageProp(APP_SCOPE_STATUS_BAR_HEIGHT) statusBarPadding: number = 0
...
handleLogin() { // TODO: 在此实现表单校验和登录流程(后续步骤逐步完善) }
build() { Column() { ...
// TODO: 实现登录按钮区域 // - 外层 Row:padding 左右各 30 // - margin 顶部 40 // - size { width: '100%', height: 50 } // - Button 文案使用 app.string.label_login // - 按钮占满 Row 的宽高(size { width: '100%', height: '100%' }) // - 字号 25,背景色 $r('app.color.primary_color_light'),圆角 5,type 设为 ButtonType.Normal // - 点击时调用 handleLogin,让登录流程集中在方法中维护
// TODO: 实现注册入口 // - 文案使用 app.string.go_register // - 字号 15,颜色使用 $r('app.color.primary_color_light') // - 顶部外边距 30 // - 本章暂不实现注册页,点击时使用 Toast 提示暂未实现 } ... }}✅ 阶段检查:运行后应看到完整登录页外观。点击登录暂时没有实际效果,点击注册应显示“此功能暂未实现”。
目标:让登录按钮先完成本地校验。这样不需要访问网络,也能立即验证页面状态是否正确。
6.1 分析需要校验什么
登录前至少需要检查三件事:
| 校验项 | 为什么需要 |
|---|---|
| 用户名不能为空 | 后端无法识别空账号 |
| 密码不能为空 | 后端无法完成密码校验 |
| 必须勾选协议 | 登录前需要用户确认协议 |
校验逻辑应该放在 handleLogin() 的开头。只要某一项不满足,就显示 Toast 并 return,阻止继续执行后续登录流程。
6.2 校验用户名不能为空
先完成第一项校验:读取 username 状态,如果用户名为空,就提示“用户名不能为空”,并立即结束 handleLogin()。
@Preview@Componentexport struct LoginView { ...
private handleLogin() { // TODO: 校验用户名不能为空 // - 读取 username 状态 // - 为空时使用 app.string.toast_empty_username 在页面中下部提示用户 // - 命中校验失败后使用 return 阻止继续执行后续登录逻辑 }
build() { ... }}✅ 阶段检查:不输入用户名,直接点击登录,应看到“用户名不能为空”提示。
6.3 校验密码不能为空
用户名校验通过后,再检查 password 状态。如果密码为空,提示“密码不能为空”,同样立即结束登录流程。
@Preview@Componentexport struct LoginView { ...
private handleLogin() { ...
// TODO: 校验密码不能为空 // - 读取 password 状态 // - 为空时使用 app.string.toast_empty_password 提示用户,Toast 位置与用户名校验保持一致 // - 命中校验失败后使用 return 阻止继续执行后续登录逻辑 }
build() { ... }}✅ 阶段检查:输入用户名,不输入密码,点击登录,应看到“密码不能为空”提示。
6.4 校验用户隐私协议
用户名和密码都不为空后,再检查 agreementChecked。如果用户没有勾选协议,需要提示用户先阅读并同意协议。
@Preview@Componentexport struct LoginView { ...
private handleLogin() { ...
// TODO: 校验用户隐私协议必须勾选 // - 判断 agreementChecked 是否为 false // - 未勾选时使用 app.string.toast_agreement_not_checked 提示用户,Toast 位置与前两项校验保持一致 // - 命中校验失败后使用 return 阻止继续执行后续登录逻辑 }
build() { ... }}✅ 阶段检查:输入用户名和密码,但不勾选协议,点击登录,应看到“请先阅读并同意《用户隐私协议》”提示。
目标:实现 Loading 弹窗,在登录流程中给用户明确的等待反馈;使用模拟异步任务验证弹窗的打开和关闭。
7.1 自定义弹窗的开发范式
HarmonyOS 的自定义弹窗由两部分组成:
- 弹窗组件:用
@CustomDialog装饰的struct,负责弹窗的 UI 和内容。它通过controller: CustomDialogController成员与外部通信。 - 控制器:在调用方组件中用
new CustomDialogController({ builder: XxxDialog })创建,调用.open()和.close()控制弹窗的显示与隐藏。
使用时的基本模式如下:
// 弹窗组件@CustomDialogstruct MyDialog { controller: CustomDialogController // 必须声明,弹窗内部可以调用它来关闭自身 message: string = ''
build() { ... }}
// 调用方@Componentstruct SomePage { dialogController = new CustomDialogController({ builder: MyDialog({ message: '提示内容' }), autoCancel: false, // 点击弹窗外部不关闭 customStyle: true, // 使用弹窗组件自定义的样式 })
build() { Button('打开').onClick(() => this.dialogController.open()) }}7.2 创建 LoadingDialog.ets
在 entry/src/main/ets/common/dialog 目录下新建 LoadingDialog.ets。
@CustomDialogexport struct LoadingDialog { // TODO: 声明 CustomDialogController 成员 // TODO: 声明 message 参数,默认值使用 app.string.default_loading_message
build() { Column() { // TODO: 添加 LoadingProgress // - 宽高设置为 70,保证加载状态明显 // TODO: 添加 Text 显示 message // - 放在 LoadingProgress 下方 // - 字号 15,颜色使用 $r('app.color.black_80') // - 顶部外边距 4 } // TODO: 设置弹窗容器样式 // - 左右留白约 30,上方留白约 10,下方留白约 20 // - 圆角设置为 5,和登录按钮风格一致 // - 背景使用白色,保证在不同页面背景下都清晰 }}✅ 阶段检查:新建文件后先编译一次,确认 @CustomDialog 组件没有语法错误。
7.3 在 LoginView 中创建弹窗控制器
回到 LoginView.ets,导入 Loading 弹窗,并在组件中添加控制器。
请务必注意此处导入的LoadingDialog组件为 common/dialog/LoadingDialog 目录下的自定义组件,而不是 HarmonyOS 内置的 LoadingDialog 组件。
import { LoadingDialog } from '../../common/dialog/LoadingDialog'
@Preview@Componentexport struct LoginView { ...
// TODO: 定义 loadingDialogController // - 作为成员变量,建议放在三个 @State 状态变量之后 // - builder 使用 LoadingDialog // - message 传入 app.string.message_logging // - autoCancel 设置为 false,避免登录期间点击外部取消弹窗 // - customStyle 设置为 true,使用 LoadingDialog 自己定义的白底圆角样式
...}7.4 先用模拟异步任务验证流程
真实接口还没有接入前,可以先写一个模拟登录方法。这个方法只负责延迟一段时间后结束,用于检查 Loading 是否能打开和关闭。
@Preview@Componentexport struct LoginView { ...
private async doLogin(username: string, password: string): Promise<boolean> { return new Promise((resolve) => { // TODO: 使用 setTimeout 模拟约 1.5 秒的网络请求,延迟后 resolve(true) }) }
private handleLogin() { ... }
...}7.5 在 handleLogin() 中串联 Loading
在三项表单校验通过后,补充“打开 Loading → 调用异步登录 → 处理结果 → 关闭 Loading”的流程。
@Preview@Componentexport struct LoginView { ...
private handleLogin() { ... // 三项表单校验
// TODO: 打开 Loading 弹窗 // TODO: 调用 doLogin(username, password) // TODO: then 中使用 Toast 或日志提示“模拟登录成功”,用于验证异步流程 // TODO: catch 中读取 Error.message,并使用 Toast 提示失败原因 // TODO: finally 中关闭 Loading 弹窗 // TODO: 注意:关闭 Loading 应放在 finally 中,保证成功失败都会关闭 }
...}✅ 阶段检查:输入账号密码并勾选协议后,点击登录,应能看到“正在登录…”弹窗,约 1.5 秒后弹窗关闭。
目标:先设计接口返回数据的模型,再写请求代码。这样可以让网络层代码更清晰。
8.1 接口说明
真实登录需要两个接口串联完成:
登录接口
- 地址:
POST /order-account/login - 请求体(
application/x-www-form-urlencoded):
| 参数 | 类型 | 说明 |
|---|---|---|
name | string | 用户名 |
password | string | 密码 |
role | number | 固定传 1,表示点餐用户端 |
- 响应示例:
{ "code": 200, "msg": "success", "data": { "token": "eyJhbGciOiJIUzI1NiJ9..." }}用户信息接口
-
地址:
GET /order-account/info -
请求头:需携带登录接口返回的
token -
响应示例:
{ "code": 200, "msg": "success", "data": { "accountId": 101, "name": "testuser", "imagePath": "https://example.com/avatar.jpg" }}为什么不只保存 token?因为首页和个人中心需要展示用户名、头像等信息。登录成功后应该保存完整用户资料,而不仅是登录凭证。
8.2 引入 class-transformer
对比 8.1 中两个接口的响应字段,会发现后端字段名和本地类的属性名并不完全对应:
| 后端字段 | 本地属性 | 说明 |
|---|---|---|
msg | message | 响应消息 |
accountId | id | 用户 id |
name | username | 用户名 |
imagePath | avatarUrl | 头像地址 |
如果手动写字段映射(localObj.id = rawObj.accountId),每个模型都要重复这类代码,可维护性很差。
本项目引入了 class-transformer 解决这两个问题:
问题一:字段名不一致
它提供 @Expose({ name: '后端字段名' }) 装饰器,可以在类定义中直接声明字段映射关系,不需要手写 obj.id = raw.accountId 这类代码。
问题二:原始对象没有方法
后端返回的是普通 JSON 对象,不是类实例,无法调用类上定义的方法(如 isSuccess())。调用 plainToClassFromExist(new MyClass(), rawData) 可以把原始对象”升级”为真正的类实例,同时完成字段映射。
依赖关系
reflect-metadata 是 class-transformer 的运行时依赖,用于在程序运行时读取装饰器声明的元信息。只需在文件顶部 import 'reflect-metadata' 即可启用,通常不需要深入了解它的原理。
// 示例:声明后端字段 accountId 对应本地属性 id@Expose({ name: 'accountId' })id: number = 0模板工程的 oh-package.json5 已经包含这两个依赖,不需要手动安装。如果导入时报”找不到模块”,请在 DevEco Studio 中同步工程依赖(File → Sync and Refresh Project)。
plainToClassFromExist 调用方式
有了上面的背景,下面展示在本实验中完整的转换写法。步骤 9 的 ServerApi 中会用到这个模式:
// 假设 data.result 是 HTTP 响应体(字符串)const raw: Object = JSON.parse(String(data.result)) as Object // 先解析成原始对象
// 第一步:把原始响应转换为 Response<LoginData> 实例// - 第一个参数:new Response<LoginData>(LoginData) —— 提供目标实例,构造函数传入 data 字段的类型// - 第二个参数:raw —— 原始 JSON 对象const response = plainToClassFromExist( new Response<LoginData>(LoginData), raw)// 此时 response.isSuccess()、response.code、response.message 均可正常调用
// 第二步:response.data 目前还是普通对象,需要再转换一次才能变成 LoginData 实例if (response.data) { response.data = plainToClassFromExist( new LoginData(), response.data as Object )}// 此时 response.data.token 才能正确读取 @Expose 字段映射后的值两层
plainToClassFromExist是必要的:外层处理Response字段映射(msg→message),内层处理data字段里业务数据的字段映射(accountId→id等)。
8.3 创建通用响应模型 Response
后端接口通常会返回统一结构,例如:
{ "code": 200, "msg": "success", "data": {}}在 entry/src/main/ets/api 目录下新建 Response.ets。
import 'reflect-metadata'import { Exclude, Expose, Type } from 'class-transformer'
export class Response<T> { static CODE_SUCCESS = 200
code: number = 200
// 后端字段名是 msg,本地用 message @Expose({ name: 'msg' }) message: string = ''
// @Type 根据构造函数传入的类型,自动将 data 字段转换为对应的类实例 @Type((options) => { return (options?.newObject as Response<T>).type }) data: T | null = null
@Exclude() private type: Function
// 构造函数接收 data 字段对应的类型,供 @Type 使用 constructor(type: Function) { this.type = type }
isSuccess(): boolean { return this.code === Response.CODE_SUCCESS }}这里的关键思路是:Response<T> 不绑定具体业务数据。登录接口可以是 Response<LoginData>,用户信息接口可以是 Response<UserInfoData>。
8.4 创建登录结果模型 LoginData
登录接口的核心返回值是 token。在 entry/src/main/ets/model 目录下新建 LoginData.ets。
import { Expose } from 'class-transformer'
export class LoginData { @Expose() token: string = ''}8.5 复用已有 UserInfoData
模板工程已经提供 UserInfoData.ets,本步骤不需要新建也不需要修改,但需要打开该文件阅读字段映射,确保你理解各字段的含义,因为后续步骤会用到它们。
import { Expose } from 'class-transformer'
export class UserInfoData { @Expose({ name: 'accountId' }) id: number = 0 @Expose({ name: 'name' }) username: string = '' @Expose({ name: 'imagePath' }) avatarUrl: string = ''
// token 不来自用户信息接口,而是在登录成功后由登录流程手动赋值 token: string = ''}确认字段映射与 8.2 表格一致后,继续下一步。
✅ 阶段检查:完成 Response.ets 和 LoginData.ets 后先编译一次。此时还没有接口请求代码,编译通过即可。
目标:创建 ServerApi,把网络请求集中封装起来。登录页后续只调用方法,不直接拼接请求细节。
9.1 创建 ServerApi.ets
在 entry/src/main/ets/api 目录下新建 ServerApi.ets。
如果导入 class-transformer 或 @ohos.net.http 时报”找不到模块”,请参考 8.2 末尾的排查步骤。
import { plainToClassFromExist } from 'class-transformer'import 'reflect-metadata'import http from '@ohos.net.http'import { Response } from './Response'import { LoginData } from '../model/LoginData'import { UserInfoData } from '../model/UserInfoData'import { Constants } from '../utils/Constants'
export class ServerApi {}9.2 实现 login 方法
登录接口需要发送 POST 请求,提交用户名、密码和角色。role=1 是后端约定的点餐用户端角色值。
HTTP 请求的写法模式
@ohos.net.http 模块使用回调方式,本实验将其包装成 Promise,方便后续用 async/await 串联多个接口。以下是 POST 和 GET 请求的通用骨架:
static async somePostMethod(param: string): Promise<Response<SomeData>> { const req = http.createHttp() return new Promise((resolve, reject) => { req.request( Constants.SERVER_HOST + '/some-path', // 完整 URL = 服务器地址 + 接口路径 { method: http.RequestMethod.POST, header: { 'Content-Type': 'application/x-www-form-urlencoded' }, extraData: `key=${encodeURIComponent(param)}&role=1`, // 表单格式,用 & 拼接字段 connectTimeout: 10000, readTimeout: 10000, }, (err, data) => { req.destroy() // 无论成功失败都必须释放请求实例 if (err || data.responseCode !== 200) { reject(new Error('网络请求失败')) return } // 解析响应,转换为数据类实例(参考 8.2 的两层转换说明) const raw: Object = JSON.parse(String(data.result)) as Object const response = plainToClassFromExist(new Response<SomeData>(SomeData), raw) if (response.data) { response.data = plainToClassFromExist(new SomeData(), response.data as Object) } resolve(response) } ) })}static async someGetMethod(token: string): Promise<Response<SomeData>> { const req = http.createHttp() return new Promise((resolve, reject) => { req.request( Constants.SERVER_HOST + '/some-path', { method: http.RequestMethod.GET, header: { token: token }, // 后端约定使用 token 作为 Header Key connectTimeout: 10000, readTimeout: 10000, }, (err, data) => { req.destroy() if (err || data.responseCode !== 200) { reject(new Error('网络请求失败')) return } const raw: Object = JSON.parse(String(data.result)) as Object const response = plainToClassFromExist(new Response<SomeData>(SomeData), raw) if (response.data) { response.data = plainToClassFromExist(new SomeData(), response.data as Object) } resolve(response) } ) })}参考以上模式,完成 login 方法:
export class ServerApi { static async login(username: string, password: string): Promise<Response<LoginData>> { // TODO: 参考上方 POST 请求模式: // - 请求路径:Constants.SERVER_HOST + '/order-account/login' // - header: { 'Content-Type': 'application/x-www-form-urlencoded' }, // - extraData 格式:`name=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}&role=1` // - 数据转换:Response<LoginData>(LoginData) → response.data → LoginData 实例 }}9.3 实现 getUserInfo 方法
用户信息接口需要携带登录后获得的 token,否则后端无法判断当前请求属于哪个用户。在 ServerApi 中追加:
export class ServerApi { // ... login 方法省略
static async getUserInfo(token: string): Promise<Response<UserInfoData>> { // TODO: 参考上方 GET 请求模式: // - 请求路径:Constants.SERVER_HOST + '/order-account/info' // - header 中携带: // 'token': token // 'Content-Type': 'application/x-www-form-urlencoded', // - 数据转换:Response<UserInfoData>(UserInfoData) → response.data → UserInfoData 实例 }}9.4 确认网络权限
模板工程的 entry/src/main/module.json5 已经声明网络权限:
"requestPermissions": [ { "name": "ohos.permission.INTERNET", "reason": "$string:reason_for_internet" }]因此本章不需要再额外添加网络权限。
✅ 阶段检查:完成 ServerApi.ets 后编译工程。此时如果出现 class-transformer 或 reflect-metadata 找不到,检查根目录 oh-package.json5 中是否已有依赖,并在 DevEco Studio 中同步工程依赖。
目标:把之前的模拟异步登录替换为真实接口登录,并在成功后保存账号信息、跳转首页。
10.1 回到 LoginView 添加导入
真实登录流程需要调用 ServerApi 和 AccountManager:
import { ServerApi } from '../../api/ServerApi'import { AccountManager } from '../AccountManager'如果文件顶部还保留了模板中用于模拟登录的 UserInfoData 导入,可以移除。真实登录流程中,用户信息来自接口。
10.2 用真实接口替换模拟 doLogin()
真实登录流程要完成五件事:
- 调用登录接口获取
token - 判断登录接口是否成功
- 使用
token调用用户信息接口 - 判断用户信息接口是否成功
- 给用户信息补充
token,并保存到AccountManager
@Preview@Componentexport struct LoginView {
private async doLogin(username: string, password: string): Promise<boolean> { return new Promise((resolve) => { // 删除原来的模式实现 // TODO: 使用 setTimeout 模拟约 1.5 秒的网络请求,延迟后 resolve(true) }) // TODO: await ServerApi.login(username, password),检查响应是否成功,取出 token // TODO: await ServerApi.getUserInfo(token),检查响应是否成功,取出用户信息 // TODO: 将 token 补充到用户信息对象,调用 AccountManager.saveUserInfo 保存 // TODO: 成功后 return true;失败时 throw new Error(失败原因) }
private handleLogin() { }}10.3 登录成功后跳转首页
前面已经完成了校验、打开 Loading、调用 doLogin() 和关闭 Loading。现在需要把模拟成功 Toast 改成首页跳转。
@Preview@Componentexport struct LoginView {
private handleLogin() {
// 7.5 中 doLogin().then 里写的“模拟登录成功”提示要改为路由跳转: // TODO: 把 then 回调中的 Toast 提示替换为 router.replaceUrl({ url: 'pages/Index' }) // - 登录成功进入首页时不使用 pushUrl // - 原因:登录成功后不希望返回键回到登录页
}}页面跳转方式的选择规则可以这样记:
| 场景 | 建议方式 | 原因 |
|---|---|---|
| 启动页进入登录页或首页 | replaceUrl | 启动页不应该留在返回栈 |
| 登录成功进入首页 | replaceUrl | 不希望返回键回到登录页 |
| 从登录页进入注册页 | pushUrl | 注册页通常需要允许返回登录页 |
| 注册成功回到登录页 | replaceUrl 或事件通知后返回 | 不希望用户反复回到已完成的注册流程 |
✅ 阶段检查:使用注册好的账号密码登录。
- 输入正确账号密码并勾选协议,应显示 Loading,然后跳转首页
- 切换到“我的”Tab,应能看到接口返回的头像和用户名
- 点击退出后,应回到登录页
- 输入错误账号或密码,应显示失败提示,并停留在登录页
“我的”Tab 中显示的用户信息来自首页 Tab 组件。模板工程中可重点查看 entry/src/main/ets/portal/PortalView.ets:该组件通过全局账号状态读取当前用户信息,退出登录时也会清除 AccountManager 中保存的数据。如果登录成功但“我的”Tab 没有显示用户名或头像,优先排查 AccountManager.saveUserInfo() 是否保存了完整用户信息,以及 PortalView 是否正确绑定了用户信息状态。
当前模板工程中的部分 API 在新 SDK 下会出现 deprecated 警告(如
showToast、replaceUrl、getContext、router.back、px2vp等)。本章以功能完成和构建通过为准,暂不要求处理这些警告。
11.1 常见问题排查
| 现象 | 优先检查 |
|---|---|
| 点击登录没有任何反应 | onClick 是否调用了 handleLogin() |
| 一直提示用户名为空 | TextInput.onChange 是否把 value 赋值给 username 状态 |
| 一直提示未勾选协议 | Checkbox.onChange 是否更新了 agreementChecked 状态 |
| Loading 不显示 | CustomDialogController 是否正确创建,open() 是否执行 |
| Loading 不关闭 | finally() 中是否调用了 close() |
| 接口请求失败 | 账号密码是否正确,网络权限是否存在,服务器地址是否为 Constants.SERVER_HOST |
| 用户名头像不显示 | 是否调用了 AccountManager.saveUserInfo(userInfo),并给用户信息补充了 token |
11.2 基础验收标准
完成基础实验后,请按以下顺序验证:
- 打开应用进入登录页,展示完整登录 UI
- 不输入用户名点击登录,出现用户名为空提示
- 只输入用户名点击登录,出现密码为空提示
- 输入用户名和密码但不勾选协议,出现协议提示
- 输入错误账号或密码,显示登录失败提示
- 输入正确账号密码并勾选协议,显示 Loading 后进入首页
- 切换到“我的”Tab,展示当前登录用户信息
- 点击退出,返回登录页
本步骤为可选拓展,不影响基础实验评分。建议在完成基础登录功能并确认稳定后再尝试。
12.1 记住最近一次登录用户名
要实现的功能:用户登录成功后保存用户名,下次打开登录页时自动填充最近一次登录的用户名。
实现思路:
- 定义一个用于持久化最近登录用户名的 key
- 在登录成功后,把当前用户名写入
AppStorage/PersistentStorage LoginView初始化时读取该值,并赋值给usernameTextInput需要显示初始用户名
// TODO: 定义最近登录用户名的持久化 key// TODO: 使用 PersistentStorage 注册该 key
@Preview@Componentexport struct LoginView { // TODO: username 初始值改为从 AppStorage 中读取最近登录用户名
private handleLogin() { // TODO: 登录成功后,把当前用户名写入 AppStorage }}✅ 拓展验收:登录成功后退出账号,再次进入登录页时,用户名输入框中能看到上次登录的用户名。
12.2 输入框校验失败抖动动画
要实现的效果:用户点击登录时,如果用户名或密码输入框未通过校验,该输入框执行一次水平抖动动画(类似摇头效果),视觉上强调”这里有问题”,比单纯文字提示更直观。
效果参考:输入框在约 400ms 内左右快速震荡 3 次后恢复原位,过程流畅不生硬。
实现思路:
- 为用户名输入框和密码输入框各维护一个
@State偏移量状态(如usernameOffset: number = 0) - 校验失败时,调用 ArkUI 的
animateTo依次设置偏移量:+10 → -10 → +10 → -10 → 0,每次切换间隔约 80ms - 在输入框组件上通过
.translate({ x: this.usernameOffset })应用偏移 - 注意:抖动结束后偏移量需恢复为
0,避免输入框残留位移
可能用到的 API:
animateTo(value: AnimateParam, event: () => void):执行帧动画,curve建议使用Curve.Linear保证抖动节奏均匀.translate({ x: offsetVp }):按虚拟像素偏移组件位置
✅ 拓展验收:点击登录时输入框为空,可以看到对应输入框发生抖动效果;动画结束后输入框恢复原始位置,页面布局不发生变化。
完成本实验后,请录制一段不超过 3 分钟的演示视频并提交。
12.1 必须涵盖的演示内容
请在视频中按顺序完整演示以下流程:
| 序号 | 演示内容 | 说明 |
|---|---|---|
| ① | 登录页展示 | 打开 APP,进入登录页,可以看到完整账号登录 UI |
| ② | 用户名为空校验 | 不输入用户名点击登录,出现“用户名不能为空”提示 |
| ③ | 密码为空校验 | 只输入用户名点击登录,出现“密码不能为空”提示 |
| ④ | 协议未勾选校验 | 输入用户名和密码但不勾选协议,出现协议提示 |
| ⑤ | 登录失败提示 | 输入错误账号或密码,页面显示登录失败原因 |
| ⑥ | 真实账号登录 | 输入正确账号密码并勾选协议,显示 Loading 后进入首页 |
| ⑦ | 用户信息展示 | 切换到「我的」Tab,可以看到当前登录用户头像和用户名 |
| ⑧ | 退出登录 | 点击退出后返回登录页 |
12.2 加分项(可选)
若完成了 Step 12 中的拓展任务,请额外演示对应效果:
- 记住最近一次登录用户名:退出后再次进入登录页,用户名自动填充
- 输入框校验失败抖动动画:点击登录时未通过校验的输入框发生抖动,动画结束后恢复原位
12.3 提交方式
请将以下内容提交到雨课堂课后作业:
- 演示视频:录制上述演示流程的屏幕录像,视频中需能看出你的个人信息(如设备上显示的学号/姓名,或口头说明均可)
- 视频格式不限,文件大小建议控制在合理范围内