编程实验:开发点餐 APP 页面框架
本实验将基于 02_framework 模板工程,按步骤完成一个点餐 APP 的页面框架,主要包含以下功能:
- 应用启动后展示 2 秒启动页,根据登录状态跳转到登录页或首页
- 登录页支持模拟账号登录,登录成功后跳转首页
- 首页通过 Tab 导航支持「点餐」「订单」「我的」三个子页面切换
- 个人中心页显示登录用户信息并支持退出登录
页面框架结构示意
下图展示了本单元将要搭建的完整页面框架与导航关系:
本实验涉及的知识点
| 知识点 | 使用场景 |
|---|---|
AppStorage | 将用户信息存储到应用全局状态,各页面均可访问 |
PersistentStorage | 将用户信息持久化,应用重启后仍保留登录状态 |
@StorageLink | 个人中心页与 AppStorage 中用户信息建立双向绑定 |
router.replaceUrl | 启动页 → 登录页 → 首页之间的页面替换跳转 |
setTimeout / clearTimeout | 启动页的定时跳转与取消 |
Tabs / TabContent | 首页多 Tab 页面导航容器 |
@Builder | 定义 Tab Bar 小项的 UI 构建函数 |
@State | 追踪当前选中的 Tab 下标 |
2.1 获取模板工程
📦 模板工程仓库:
https://cnb.cool/sziit-coding/harmony-coding/02_framework
请将以上工程克隆或下载后,导入到 DevEco Studio 中并运行,确认工程能正常启动。
2.2 认识需要修改和新建的文件
本实验主要涉及以下文件(部分需要新建目录):
| 文件路径 | 作用 |
|---|---|
entry/src/main/ets/model/UserInfoData.ets | 用户信息数据类(新建) |
entry/src/main/ets/account/AccountManager.ets | 账号管理工具类(新建) |
entry/src/main/ets/pages/Splash.ets | 启动页(新建) |
entry/src/main/ets/pages/Login.ets | 登录页(新建) |
entry/src/main/ets/account/login/LoginView.ets | 登录页 UI 组件(新建) |
entry/src/main/ets/portal/viewmodel/TabBarModel.ets | Tab 导航数据模型(新建) |
entry/src/main/ets/portal/PortalView.ets | 首页 Tab 导航组件(新建) |
entry/src/main/ets/account/personal/PersonalView.ets | 个人中心页 UI 组件(新建) |
entry/src/main/ets/pages/Index.ets | 首页入口(已有,需修改) |
entry/src/main/ets/entryability/EntryAbility.ets | 应用入口(已有,需修改) |
2.3 确认初始效果
运行基础工程后,页面应表现为:
- 应用启动直接显示
Index页面上的Hello World文字 - 无启动页、无登录流程
✅ 预期效果:工程能正常编译启动,便于后续逐步完善。
3.1 AppStorage 与 PersistentStorage
AppStorage 是 HarmonyOS 提供的应用级全局状态存储,可以在任意页面读写数据。PersistentStorage 则在此基础上实现持久化:即使应用退出重启,数据依然保留。
两者的典型使用流程如下:
⚠️ 本工程所用 SDK 中,
AppStorage和PersistentStorage是全局 API,使用时不需要 import 语句,可直接调用。
// AppStorage 和 PersistentStorage 无需 import,直接使用
// 第一步:注册需要持久化的 key(通常在 Ability 初始化阶段执行)PersistentStorage.persistProp('userInfo', null)
// 第二步:在任意位置读写 AppStorageAppStorage.set('userInfo', { username: '张三' })const info = AppStorage.get('userInfo')@StorageLink 装饰器
在组件中使用 @StorageLink 可以让组件状态自动与 AppStorage 中对应的 key 保持双向同步:
@Entry@Componentstruct DemoPage { // 与 AppStorage 中 'username' 的值双向绑定 @StorageLink('username') username: string = ''
build() { Text(this.username) }}3.2 页面跳转:router.replaceUrl
本实验中所有页面切换都使用 router.replaceUrl,它会将当前页从路由栈中替换掉,使用户无法通过返回键回到上一页(适合登录/启动页等场景)。
⚠️
router.replaceUrl在当前 SDK 中会产生 deprecated 警告,但不影响本实验的完成。本实验沿用该 API 用于展示跨页跳转原理。
import router from '@ohos.router'
// 跳转后,当前页从路由栈中移除router.replaceUrl({ url: 'pages/Index' })3.3 定时任务:setTimeout
启动页需要停留 2 秒后自动跳转,使用 setTimeout 实现:
@Componentstruct DemoPage { private timeoutId: number = -1
aboutToAppear() { this.timeoutId = setTimeout(() => { // 定时任务到期后执行 console.log('2秒到,准备跳转') }, 2000) }
aboutToDisappear() { // 页面销毁时如果定时任务还未触发,则取消,避免内存泄漏 if (this.timeoutId !== -1) { clearTimeout(this.timeoutId) } } ...}✅ 小结:理解以上三个知识点后,实验中的各步骤将更容易理解。
目标:封装用户信息数据类和账号管理工具类,为后续登录、退出、信息展示功能提供统一的数据层。
4.1 设计分析:账号状态放在哪里?
在动手写代码之前,先来分析一个核心问题:这个 APP 里哪些页面需要知道”用户是谁”?
梳理一下各页面的诉求:
- 启动页:需要判断用户是否已登录,从而决定跳转目标
- 登录页:登录成功后,需要将用户信息保存下来
- 个人中心页:需要读取用户信息来展示头像和用户名
- 退出登录:需要清除已保存的用户信息
这四处逻辑操作的都是同一份数据——用户账号信息。如果每个页面都各自维护一套存取逻辑,不仅代码重复,还容易出现数据不一致的问题。
更好的做法是:把所有账号相关操作集中封装成一个工具类,各页面只需调用它的方法,无需关心底层如何存储。这个工具类就是接下来要开发的 AccountManager:
┌──────────────┐ 保存用户信息 ┌────────────────────┐│ LoginView │ ──────────────────→ │ │├──────────────┤ │ AccountManager ││ SplashPage │ ──── 是否已登录 ────→ │ │├──────────────┤ │ AppStorage + ││ PersonalView │ ──── 读取用户信息 ───→ │ PersistentStorage │├──────────────┤ │ ││ 退出登录 │ ──── 清除用户信息 ───→ │ │└──────────────┘ └────────────────────┘4.2 接口设计分析
确定了”需要一个集中管理账号的工具类”之后,下一步是决定这个工具类需要对外暴露哪些方法。
回到上面四处使用场景,逐一推导:
| 使用方 | 需要什么操作 | 对应方法 |
|---|---|---|
| 登录页(登录成功后) | 把用户信息写进去 | saveUserInfo(userInfo) |
| 个人中心页(退出登录) | 把用户信息清除 | removeUserInfo() |
| 启动页(决定跳转目标) | 判断是否已登录 | isLoggedIn() |
| 个人中心页(显示信息) | 读取当前用户信息 | getUserInfo() |
| 未来可能:修改昵称/头像 | 局部更新字段 | updateUsername() / updateAvatarUrl() |
后两个方法(updateUsername / updateAvatarUrl)是为后续编辑个人资料功能预留的,本实验不会直接用到,但提前规划好更有利于后期扩展。
关于存储方式的选择:这些方法底层需要使用 AppStorage + PersistentStorage,原因如下:
AppStorage:应用级全局状态,任何组件都可以读写,可跨页面共享PersistentStorage:在AppStorage基础上加持久化,APP 重启后仍能保留登录状态
这也是为什么 AccountManager 里需要在静态初始化块中注册 PersistentStorage——它必须在第一次读写该 key 之前执行,而静态块在类被加载时自动运行,是最合适的时机。
这种”先建数据层,再建 UI 层”的开发顺序是一种常见的工程实践,有助于降低各页面之间的耦合度。
4.3 创建用户信息数据类 UserInfoData
在 ets/model 目录下新建 UserInfoData.ets,封装用户信息字段:
export class UserInfoData { // TODO: 定义用户信息字段 // 提示:至少包含 id(number)、username(string)、avatarUrl(string)、token(string)}export class UserInfoData { id: number = 0 username: string = '' avatarUrl: string = '' token: string = ''}如果
model目录不存在,请先手动创建(在ets目录上右键 → New → Directory)。
4.4 创建账号管理类 AccountManager
在 ets/account 目录下新建 AccountManager.ets,定义类的基本结构:
import { UserInfoData } from '../model/UserInfoData'import { APP_SCOPE_KEY_USER_INFO_DATA } from '../utils/Constants'
export class AccountManager {
// TODO: 在此类中依次实现以下功能: // · 静态初始化块(注册持久化 key) // · saveUserInfo / removeUserInfo / getUserInfo / isLoggedIn // · updateUsername / updateAvatarUrl
}💡
APP_SCOPE_KEY_USER_INFO_DATA已在模板工程的Constants.ets中定义,值为"key_user_info",可直接导入使用。
4.5 实现 PersistentStorage 持久化注册
在 AccountManager 类中添加静态初始化块。
💡
PersistentStorage.persistProp必须在第一次读写该 key 之前执行。static {}静态块在类被加载时自动运行,是初始化持久化存储最合适的位置。
要求:使用 PersistentStorage.persistProp 将用户信息 key 与持久化存储关联,默认值为一个空的 UserInfoData 实例。
export class AccountManager {
static { // TODO: 使用 PersistentStorage.persistProp 注册用户信息 key 与持久化存储的关联 // 参数一:APP_SCOPE_KEY_USER_INFO_DATA // 参数二:new UserInfoData()(空的默认值) }
}static { PersistentStorage.persistProp( APP_SCOPE_KEY_USER_INFO_DATA, new UserInfoData() )}4.6 实现保存和删除用户信息方法
在 AccountManager 中实现以下两个静态方法:
saveUserInfo(userInfo):将用户信息写入AppStorageremoveUserInfo():将AppStorage中用户信息重置为空对象(即退出登录)
export class AccountManager { ... static saveUserInfo(userInfo: UserInfoData): void { // TODO: 使用 AppStorage.set 将用户信息保存到 APP_SCOPE_KEY_USER_INFO_DATA 对应的 key }
static removeUserInfo(): void { // TODO: 使用 AppStorage.set 将用户信息重置为空的 UserInfoData 实例(相当于退出登录) } ...}static saveUserInfo(userInfo: UserInfoData): void { AppStorage.set(APP_SCOPE_KEY_USER_INFO_DATA, userInfo)}
static removeUserInfo(): void { AppStorage.set(APP_SCOPE_KEY_USER_INFO_DATA, new UserInfoData())}4.7 实现获取和判断登录状态方法
实现以下两个静态方法:
getUserInfo():从AppStorage读取当前用户信息,若未设置则返回空对象isLoggedIn():判断用户是否已登录(建议通过id > 0判断)
export class AccountManager { ... static getUserInfo(): UserInfoData { // TODO: 从 AppStorage.get 读取用户信息,使用空值合并运算符 ?? 在未设置时返回 new UserInfoData() }
static isLoggedIn(): boolean { // TODO: 调用 getUserInfo() 获取用户信息,通过 id > 0 判断是否已登录 } ...}static getUserInfo(): UserInfoData { return AppStorage.get<UserInfoData>(APP_SCOPE_KEY_USER_INFO_DATA) ?? new UserInfoData()}
static isLoggedIn(): boolean { return AccountManager.getUserInfo().id > 0}4.8 实现更新用户名和头像方法
实现以下两个静态方法:
updateUsername(username):更新用户名后重新写回AppStorageupdateAvatarUrl(url):更新头像链接后重新写回AppStorage
export class AccountManager { ... static updateUsername(username: string): void { // TODO: 先调用 getUserInfo() 获取当前用户信息 // 修改 username 字段后,再用 AppStorage.set 写回 }
static updateAvatarUrl(url: string): void { // TODO: 先调用 getUserInfo() 获取当前用户信息 // 修改 avatarUrl 字段后,再用 AppStorage.set 写回 }}static updateUsername(username: string): void { const userInfo = AccountManager.getUserInfo() userInfo.username = username AppStorage.set(APP_SCOPE_KEY_USER_INFO_DATA, userInfo)}
static updateAvatarUrl(url: string): void { const userInfo = AccountManager.getUserInfo() userInfo.avatarUrl = url AppStorage.set(APP_SCOPE_KEY_USER_INFO_DATA, userInfo)}✅ 预期效果:AccountManager.ets 文件无编译报错,具备 saveUserInfo、removeUserInfo、getUserInfo、isLoggedIn、updateUsername、updateAvatarUrl 共 6 个静态方法。
目标:新建启动页,展示 LOGO 和应用名,停留 2 秒后根据登录状态跳转到登录页或首页。
5.1 新建 Splash 页面
在 ets/pages 目录上右键,选择 New → Page → EmptyPage,输入页面名称 Splash,点击 Finish。
DevEco Studio 会自动:
- 在
ets/pages/目录下创建Splash.ets文件 - 在
resources/base/profile/main_pages.json中自动注册该页面路由
⚠️ 如果跨过向导手动创建了
.ets文件,需要手动将"pages/Splash"添加到resources/base/profile/main_pages.json的src数组中,否则运行时路由跳转会失败。
5.2 修改 EntryAbility 的启动页
默认起始的是 pages/Index,需要改为 pages/Splash:
onWindowStageCreate(windowStage: window.WindowStage) { ... windowStage.loadContent( 'pages/Splash', // 'pages/Index', (err, data) => { ... } )}5.3 实现启动页 UI 布局
打开 Splash.ets,修改 build() 内容:
@Entry@Componentstruct Splash { build() { Column() { // TODO: 放置应用图标(Image) // · 图片资源:$r('app.media.icon') // · 尺寸:宽高各 140
// TODO: 放置应用名称(Text) // · 文本资源:$r('app.string.splash_slogan') // · 字号 25,顶部外边距 20 // · 字体颜色:$r('app.color.black_70') } // TODO: 设置容器样式 // · 背景色:$r('app.color.page_background') // · 内容垂直居中:justifyContent(FlexAlign.Center) // · 内容水平居中:align(Alignment.Center) // · 宽高各 100% }}build() { Column() { Image($r('app.media.icon')) .size({ width: 140, height: 140 }) Text($r('app.string.splash_slogan')) .margin({ top: 20 }) .fontSize(25) .fontColor($r('app.color.black_70')) } .backgroundColor($r('app.color.page_background')) .justifyContent(FlexAlign.Center) .align(Alignment.Center) .width('100%') .height('100%')}页面效果示意如下:
5.4 实现定时跳转逻辑
在 Splash.ets 中添加 aboutToAppear 和 aboutToDisappear 生命周期,要求:
aboutToAppear:启动一个Constants.SPLASH_DELAY_TIME毫秒后触发的定时任务,到期时根据AccountManager.isLoggedIn()决定跳转到pages/Index还是pages/LoginaboutToDisappear:若定时任务尚未触发,调用clearTimeout取消,避免内存泄漏
import router from '@ohos.router'import { Constants } from '../utils/Constants'import { AccountManager } from '../account/AccountManager'
@Entry@Componentstruct Splash { private timeoutId: number = -1
aboutToAppear() { // TODO: 使用 setTimeout 启动延迟任务 // · 延迟时间:Constants.SPLASH_DELAY_TIME // · 已登录(AccountManager.isLoggedIn())→ 跳转 pages/Index // · 未登录 → 跳转 pages/Login // · 记得将 setTimeout 的返回值保存到 this.timeoutId // . 记得在延迟任务触发时,将 this.timeoutId 重置为 -1,表示定时器已完成 }
aboutToDisappear() { // TODO: 检查 this.timeoutId,若不为 -1,则取消定时任务并重置为 -1 }
build() { ... }}aboutToAppear() { this.timeoutId = setTimeout(() => { this.timeoutId = -1 if (AccountManager.isLoggedIn()) { router.replaceUrl({ url: 'pages/Index' }) } else { router.replaceUrl({ url: 'pages/Login' }) } }, Constants.SPLASH_DELAY_TIME)}
aboutToDisappear() { if (this.timeoutId !== -1) { clearTimeout(this.timeoutId) this.timeoutId = -1 }}✅ 预期效果:应用启动后展示启动页 2 秒。
📌 此时定时器到期会尝试跳转到
pages/Login,此时 Login 页面尚未创建(Step 6 完成前),模拟器会停留在启动页面,这是正常现象。完成 Step 6 后可完整验证启动页流程。
目标:创建登录页,提供模拟登录按钮,点击后将模拟用户数据写入 AccountManager,跳转到首页。
6.1 创建 LoginView 组件
在 ets/account/login 目录下新建 LoginView.ets(目录不存在则逐层创建):
注意:这一步中我们创建的
LoginView只是一个 UI 组件,不是页面。它会被Login.ets页面引用,后者才是真正的登录页。
头像链接:https://api.food2.sziit.top/ugc/file/order/6a562c4ac9007842d146aeaecc53ca60_1747287745509_299.jpg
import router from '@ohos.router'import { AccountManager } from '../AccountManager'import { UserInfoData } from '../../model/UserInfoData'
@Preview@Componentexport struct LoginView { build() { Column({ space: 20 }) { // TODO: 显示应用标题(Text 组件) // · 内容:'点餐 APP' // · 字号 32,粗体(FontWeight.Bold)
Button('模拟登录') .width(200) .onClick(() => { // TODO: 构造 UserInfoData 实例并赋值: // · id = 1 // · username = '张三' // · avatarUrl = 'https://api.food2.sziit.top/ugc/file/order/6a562c4ac9007842d146aeaecc53ca60_1747287745509_299.jpg' // · token = '' // TODO: 调用 AccountManager.saveUserInfo(userInfo) 保存用户信息 // TODO: 调用 router.replaceUrl({ url: 'pages/Index' }) 跳转首页 }) } .justifyContent(FlexAlign.Center) .size({ width: '100%', height: '100%' }) }}build() { Column({ space: 20 }) { Text('点餐 APP') .fontSize(32) .fontWeight(FontWeight.Bold)
Button('模拟登录') .width(200) .onClick(() => { const userInfo = new UserInfoData() userInfo.id = 1 userInfo.username = '张三' userInfo.avatarUrl = 'https://api.food2.sziit.top/ugc/file/order/6a562c4ac9007842d146aeaecc53ca60_1747287745509_299.jpg' userInfo.token = '' AccountManager.saveUserInfo(userInfo) router.replaceUrl({ url: 'pages/Index' }) }) } .justifyContent(FlexAlign.Center) .size({ width: '100%', height: '100%' })}6.2 新建 Login 页面并引用 LoginView
在 ets/pages 目录上右键,选择 New → Page,输入页面名称 Login,点击 Finish(DevEco Studio 会自动注册路由)。然后修改 build 内容,引用 LoginView 组件:
import { LoginView } from '../account/login/LoginView'
@Entry@Componentstruct Login { build() { Column() { // TODO: 引用 LoginView 组件并设置宽高为 100% } .size({ width: '100%', height: '100%' }) }}build() { Column() { LoginView() .size({ width: '100%', height: '100%' }) } .size({ width: '100%', height: '100%' })}✅ 预期效果:启动页跳转到登录页后,点击「模拟登录」,用户信息被保存,页面跳转到首页(当前首页仍显示 Hello World)。
目标:实现首页 Tab 导航,支持「点餐」「订单」「我的」三个子页面切换。
7.1 创建 TabBarModel 数据模型
首页底部需要展示三个 Tab 按钮,每个 Tab 包含一段文字标签和一对图标(选中/未选中)。动手之前,先观察一下它们的规律——
这三个 Tab 的结构完全一样,区别只是数据不同:
| Tab | 文字 | 未选中图标 | 选中图标 |
|---|---|---|---|
| 点餐 | '点餐' | ic_tab_food_normal | ic_tab_food_selected |
| 订单 | '订单' | ic_tab_order_normal | ic_tab_order_selected |
| 我的 | '我的' | ic_tab_personal_normal | ic_tab_personal_selected |
如果直接在 UI 里一个一个硬写,代码会高度重复,而且一旦要调整样式(比如改字号或图标尺寸),就要同时修改三处,容易遗漏:
// ❌ 硬编码写法:逻辑重复,样式分散TabContent() { ... }.tabBar(Column() { Image($r('app.media.ic_tab_food_normal')); Text('点餐') })TabContent() { ... }.tabBar(Column() { Image($r('app.media.ic_tab_order_normal')); Text('订单') })TabContent() { ... }.tabBar(Column() { Image($r('app.media.ic_tab_personal_normal')); Text('我的') })更好的做法是把”数据”和”样式逻辑”拆开:先用 TabBarModel 集中管理三个 Tab 的数据,再在 PortalView 里用一个 @Builder 方法统一渲染样式。这样将来增删 Tab,或者统一调整样式,只改一处就够了。
TabBarModel.ets(数据层) PortalView.ets(UI 层)┌─────────────────────┐ ┌──────────────────────────────┐│ TabBarConfig 对象 │ │ @Builder TabBarBuilder() ││ · Food: {...} │ ────────→ │ 统一渲染每个 Tab 的样式 ││ · Order: {...} │ │ 选中/未选中由 currentIndex ││ · Personal: {...} │ │ 和数据驱动,无需重复代码 │└─────────────────────┘ └──────────────────────────────┘在 ets/portal/viewmodel 目录下新建 TabBarModel.ets(目录不存在则逐层创建)。
要求:
- 定义
TabBarItem接口,包含label(string)、icon(Resource)、selectedIcon(Resource)三个字段 - 定义
TabBarConfigMap接口,声明Food、Order、Personal三个属性,类型均为TabBarItem - 导出
TabBarConfig对象,分别定义「点餐」「订单」「我的」三个 Tab 的数据
export interface TabBarItem { // TODO: 定义 label、icon、selectedIcon 三个字段及其类型 // 提示:label 的类型应为 ResourceStr(支持字符串字面量和字符串资源引用两种写法)}
export interface TabBarConfigMap { Food: TabBarItem Order: TabBarItem Personal: TabBarItem}
export const TabBarConfig: TabBarConfigMap = { Food: { // TODO: 填写点餐 Tab 的 label 和图标资源 // label:$r('app.string.tab_label_food') // 未选中图标:ic_tab_food_normal 选中图标:ic_tab_food_selected }, Order: { // TODO: 填写订单 Tab 的 label 和图标资源 // label:$r('app.string.tab_label_order') // 未选中图标:ic_tab_order_normal 选中图标:ic_tab_order_selected }, Personal: { // TODO: 填写我的 Tab 的 label 和图标资源 // label:$r('app.string.tab_label_personal') // 未选中图标:ic_tab_personal_normal 选中图标:ic_tab_personal_selected }}export interface TabBarItem { label: ResourceStr icon: Resource selectedIcon: Resource}
export interface TabBarConfigMap { Food: TabBarItem Order: TabBarItem Personal: TabBarItem}
export const TabBarConfig: TabBarConfigMap = { Food: { label: $r('app.string.tab_food'), icon: $r('app.media.ic_tab_food_normal'), selectedIcon: $r('app.media.ic_tab_food_selected') }, Order: { label: $r('app.string.tab_order'), icon: $r('app.media.ic_tab_order_normal'), selectedIcon: $r('app.media.ic_tab_order_selected') }, Personal: { label: $r('app.string.tab_personal'), icon: $r('app.media.ic_tab_personal_normal'), selectedIcon: $r('app.media.ic_tab_personal_selected') }}7.2 创建 PortalView 组件框架
在 ets/portal 目录下新建 PortalView.ets,使用 Tabs 组件搭建导航框架:
import { TabBarConfig, TabBarItem } from './viewmodel/TabBarModel'
@Componentexport struct PortalView { // TODO: 声明 @State 变量 currentIndex,记录当前选中的 Tab 下标,初始值为 0
build() { Tabs({ barPosition: BarPosition.End }) { // TODO: 在此处添加三个 TabContent(步骤 7.3、7.4 中完成) } .barHeight(65) .onChange((index: number) => { // TODO: 将 index 赋值给 currentIndex }) .width('100%') .height('100%') }}@State currentIndex: number = 0
build() { Tabs({ barPosition: BarPosition.End }) { ... } .barHeight(65) .onChange((index: number) => { this.currentIndex = index }) .width('100%') .height('100%')}7.3 使用 @Builder 定义 Tab 小项 UI
在 PortalView 中用 @Builder 装饰器定义 Tab 按钮样式:
@Componentexport struct PortalView { @State currentIndex: number = 0
@Builder TabBarBuilder(item: TabBarItem, index: number) { Column({ space: 4 }) { // TODO: Image 组件 // · 选中时显示 item.selectedIcon,未选中时显示 item.icon // · 判断条件:currentIndex === index // · 尺寸:宽高各 24
// TODO: Text 组件 // · 显示文本:item.label // · 字号 12 // · 颜色:选中时 '#F63440',未选中时 '#999999' } .justifyContent(FlexAlign.Center) .height(65) }
build() { ... }}@BuilderTabBarBuilder(item: TabBarItem, index: number) { Column({ space: 4 }) { Image(this.currentIndex === index ? item.selectedIcon : item.icon) .size({ width: 24, height: 24 }) Text(item.label) .fontSize(12) .fontColor(this.currentIndex === index ? '#FF6600' : '#999999') } .justifyContent(FlexAlign.Center) .height(65)}7.4 添加三个 TabContent 子页面
在 PortalView 的 Tabs 组件内添加三个 TabContent,暂时用占位文字代替真实子页面,每个 TabContent 通过 .tabBar() 绑定步骤 7.3 中定义的 TabBarBuilder:
build() { Tabs({ barPosition: BarPosition.End }) {
// TODO: 点餐 Tab - 内容区域暂时显示占位文字"商铺列表" // .tabBar(this.TabBarBuilder(TabBarConfig.Food, 0))
// TODO: 订单 Tab - 内容区域暂时显示占位文字"订单列表" // .tabBar(this.TabBarBuilder(TabBarConfig.Order, 1))
// TODO: 我的 Tab - 内容区域暂时显示占位文字"个人中心" // .tabBar(this.TabBarBuilder(TabBarConfig.Personal, 2))
} .barHeight(65) ... }TabContent() { Text('商铺列表').fontSize(40).fontWeight(FontWeight.Bold)}.tabBar(this.TabBarBuilder(TabBarConfig.Food, 0))
TabContent() { Text('订单列表').fontSize(40).fontWeight(FontWeight.Bold)}.tabBar(this.TabBarBuilder(TabBarConfig.Order, 1))
TabContent() { Text('个人中心').fontSize(40).fontWeight(FontWeight.Bold)}.tabBar(this.TabBarBuilder(TabBarConfig.Personal, 2))7.5 在 Index.ets 中引用 PortalView
修改 Index.ets,将原来的 Hello World 替换为 PortalView 组件:
import { PortalView } from '../portal/PortalView'
@Entry@Componentstruct Index { build() { Column() { // TODO: 引用 PortalView,替换掉原有的 Text('Hello World'),设置宽高为 100% } .justifyContent(FlexAlign.Center) .size({ width: '100%', height: '100%' }) }}build() { Column() { PortalView() .size({ width: '100%', height: '100%' }) } .size({ width: '100%', height: '100%' })}✅ 预期效果:登录成功后进入首页,底部显示三个 Tab,点击可切换「点餐」「订单」「我的」页面,选中 Tab 图标和文字颜色变为橙色。
目标:实现个人中心页,显示登录用户的头像和用户名,并提供退出登录按钮。
8.1 创建 PersonalView 组件骨架
在 ets/account/personal 目录下新建 PersonalView.ets(目录不存在则逐层创建):
import { UserInfoData } from '../../model/UserInfoData'import { APP_SCOPE_KEY_USER_INFO_DATA } from '../../utils/Constants'
@Preview@Componentexport struct PersonalView { // TODO: 使用 @StorageLink 将 userInfo 与 AppStorage 中的 APP_SCOPE_KEY_USER_INFO_DATA 双向绑定 // 类型为 UserInfoData,初始值为 new UserInfoData()
build() { Column({ space: 20 }) { // TODO: 在步骤 8.2 中实现具体 UI } .justifyContent(FlexAlign.Center) .size({ width: '100%', height: '100%' }) }}@StorageLink(APP_SCOPE_KEY_USER_INFO_DATA) userInfo: UserInfoData = new UserInfoData()8.2 实现 PersonalView UI 布局
在 PersonalView 的 build 中完成 UI 布局:
import router from '@ohos.router'import { AccountManager } from '../AccountManager'import { UserInfoData } from '../../model/UserInfoData'import { APP_SCOPE_KEY_USER_INFO_DATA } from '../../utils/Constants'
@Preview@Componentexport struct PersonalView { @StorageLink(APP_SCOPE_KEY_USER_INFO_DATA) userInfo: UserInfoData = new UserInfoData()
build() { Column({ space: 20 }) { // TODO: 头像(Image) // · 图片来源:this.userInfo.avatarUrl // · 尺寸:宽高各 100 // · 圆角:borderRadius(50) // · 填充方式:objectFit(ImageFit.Cover)
// TODO: 显示用户名(Text) // · 文本内容:'用户名:' + this.userInfo.username // · 字号 20
// TODO: 退出登录按钮(Button) // · 文本:'退出登录',宽度 200 // · 点击时:先调用 AccountManager.removeUserInfo() 清除用户信息 // 再调用 router.replaceUrl({ url: 'pages/Login' }) 跳转登录页 } .justifyContent(FlexAlign.Center) .size({ width: '100%', height: '100%' }) }}build() { Column({ space: 20 }) { Image(this.userInfo.avatarUrl) .size({ width: 100, height: 100 }) .borderRadius(50) .objectFit(ImageFit.Cover)
Text('用户名:' + this.userInfo.username) .fontSize(20)
Button('退出登录') .width(200) .onClick(() => { AccountManager.removeUserInfo() router.replaceUrl({ url: 'pages/Login' }) }) } .justifyContent(FlexAlign.Center) .size({ width: '100%', height: '100%' })}8.3 在 PortalView 中引用 PersonalView
将 PortalView 的「我的」TabContent 中的占位文字替换为 PersonalView 组件:
// 我的 Tab TabContent() { // TODO: 将 Text('个人中心') 替换为 PersonalView 组件 } .tabBar(this.TabBarBuilder(TabBarConfig.Personal, 2))记得在文件顶部补充 import:
import { TabBarConfig, TabBarItem } from './viewmodel/TabBarModel'import { PersonalView } from '../account/personal/PersonalView'✅ 预期效果:进入「我的」Tab 后,页面显示已登录用户的头像和用户名;点击「退出登录」后,用户信息清除,跳转到登录页;再次模拟登录后,重新进入首页。
9.1 Tab 选中态持久化存储
需求:退出 APP 时选中的是哪个 Tab,下次打开 APP 时仍然默认选中该 Tab。
关键 API:PersistentStorage、AppStorage、@StorageLink
实现思路:
- 在
AccountManager的静态初始化块中,追加注册 Tab 下标的持久化 key(默认值0) - 将
PortalView中的@State currentIndex改为@StorageLink绑定到该 key @StorageLink会自动完成双向同步,onChange中赋值操作无需变更
@Componentexport struct PortalView { // TODO: 将 @State currentIndex 替换为 @StorageLink('lastTabIndex') // 注意:需要在 AccountManager 的静态初始化块中先注册该 key @State currentIndex: number = 0 @StorageLink('lastTabIndex') currentIndex: number = 0 ...}static { PersistentStorage.persistProp(APP_SCOPE_KEY_USER_INFO_DATA, new UserInfoData()) PersistentStorage.persistProp('lastTabIndex', 0) // 追加这一行}✅ 预期效果:切换到「订单」Tab 后,退出并重新打开 APP,默认仍停留在「订单」Tab。
完成本实验后,请录制一段不超过 3 分钟的演示视频并提交。
10.1 必须涵盖的演示内容
请在视频中按顺序完整演示以下流程:
| 序号 | 演示内容 | 说明 |
|---|---|---|
| ① | 启动页展示 | 冷启动 APP,可以看到带有应用 LOGO 和名称的启动页停留约 2 秒 |
| ② | 自动跳转到登录页 | 未登录状态下,启动页时间到后自动跳转到登录页 |
| ③ | 模拟登录 | 点击「模拟登录」按钮,成功登录并跳转到点餐首页 |
| ④ | Tab 切换 | 在首页演示「点餐」「订单」「我的」三个 Tab 的切换 |
| ⑤ | 个人中心 | 在「我的」Tab 中可以看到已登录用户的头像和用户名 |
| ⑥ | 退出登录 | 点击「退出登录」按钮,跳转回登录页,用户信息已清除 |
| ⑦ | 再次启动验证 | 退出后重新冷启动 APP,确认启动页跳转到登录页(而非首页) |
10.2 加分项(可选)
若完成了拓展任务(Step 9),请额外演示:
- 在首页切换到任意非默认 Tab(如「订单」)
- 完全退出 APP 后重新启动
- 验证 APP 启动后默认停留在上次选中的 Tab
10.3 提交方式
请将以下内容提交到雨课堂课后作业:
- 演示视频:录制上述演示流程的屏幕录像,视频中需能看出你的个人信息(如设备上显示的学号/姓名,或口头说明均可)
- 视频格式不限,文件大小建议控制在合理范围内