跳转到内容

编程实验:开发点餐 APP 页面框架

本实验将基于 02_framework 模板工程,按步骤完成一个点餐 APP 的页面框架,主要包含以下功能:

  • 应用启动后展示 2 秒启动页,根据登录状态跳转到登录页或首页
  • 登录页支持模拟账号登录,登录成功后跳转首页
  • 首页通过 Tab 导航支持「点餐」「订单」「我的」三个子页面切换
  • 个人中心页显示登录用户信息并支持退出登录

页面框架结构示意

下图展示了本单元将要搭建的完整页面框架与导航关系:

点餐 APP 页面框架与导航关系

本实验涉及的知识点

知识点使用场景
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.etsTab 导航数据模型(新建)
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 文字
  • 无启动页、无登录流程

✅ 预期效果:工程能正常编译启动,便于后续逐步完善。

初始运行效果:直接显示 Hello World

3.1 AppStorage 与 PersistentStorage

AppStorage 是 HarmonyOS 提供的应用级全局状态存储,可以在任意页面读写数据。PersistentStorage 则在此基础上实现持久化:即使应用退出重启,数据依然保留。

两者的典型使用流程如下:

⚠️ 本工程所用 SDK 中,AppStoragePersistentStorage全局 API,使用时不需要 import 语句,可直接调用。

entry/src/main/ets/utils/StorageDemo.ets
// AppStorage 和 PersistentStorage 无需 import,直接使用
// 第一步:注册需要持久化的 key(通常在 Ability 初始化阶段执行)
PersistentStorage.persistProp('userInfo', null)
// 第二步:在任意位置读写 AppStorage
AppStorage.set('userInfo', { username: '张三' })
const info = AppStorage.get('userInfo')

在组件中使用 @StorageLink 可以让组件状态自动与 AppStorage 中对应的 key 保持双向同步:

entry/src/main/ets/pages/DemoStorageLink.ets
@Entry
@Component
struct DemoPage {
// 与 AppStorage 中 'username' 的值双向绑定
@StorageLink('username') username: string = ''
build() {
Text(this.username)
}
}

3.2 页面跳转:router.replaceUrl

本实验中所有页面切换都使用 router.replaceUrl,它会将当前页从路由栈中替换掉,使用户无法通过返回键回到上一页(适合登录/启动页等场景)。

⚠️ router.replaceUrl 在当前 SDK 中会产生 deprecated 警告,但不影响本实验的完成。本实验沿用该 API 用于展示跨页跳转原理。

entry/src/main/ets/pages/DemoRouter.ets
import router from '@ohos.router'
// 跳转后,当前页从路由栈中移除
router.replaceUrl({ url: 'pages/Index' })

3.3 定时任务:setTimeout

启动页需要停留 2 秒后自动跳转,使用 setTimeout 实现:

entry/src/main/ets/pages/DemoTimeout.ets
@Component
struct 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,封装用户信息字段:

entry/src/main/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,定义类的基本结构:

entry/src/main/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 实例。

entry/src/main/ets/account/AccountManager.ets
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):将用户信息写入 AppStorage
  • removeUserInfo():将 AppStorage 中用户信息重置为空对象(即退出登录)
entry/src/main/ets/account/AccountManager.ets
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 判断)
entry/src/main/ets/account/AccountManager.ets
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):更新用户名后重新写回 AppStorage
  • updateAvatarUrl(url):更新头像链接后重新写回 AppStorage
entry/src/main/ets/account/AccountManager.ets
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 文件无编译报错,具备 saveUserInforemoveUserInfogetUserInfoisLoggedInupdateUsernameupdateAvatarUrl 共 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.jsonsrc 数组中,否则运行时路由跳转会失败。

5.2 修改 EntryAbility 的启动页

默认起始的是 pages/Index,需要改为 pages/Splash

entry/src/main/ets/entryability/EntryAbility.ets
onWindowStageCreate(windowStage: window.WindowStage) {
...
windowStage.loadContent(
'pages/Splash',
// 'pages/Index',
(err, data) => {
...
}
)
}

5.3 实现启动页 UI 布局

打开 Splash.ets,修改 build() 内容:

entry/src/main/ets/pages/Splash.ets
@Entry
@Component
struct 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%')
}

页面效果示意如下:

启动页 UI 效果

5.4 实现定时跳转逻辑

Splash.ets 中添加 aboutToAppearaboutToDisappear 生命周期,要求:

  • aboutToAppear:启动一个 Constants.SPLASH_DELAY_TIME 毫秒后触发的定时任务,到期时根据 AccountManager.isLoggedIn() 决定跳转到 pages/Index 还是 pages/Login
  • aboutToDisappear:若定时任务尚未触发,调用 clearTimeout 取消,避免内存泄漏
entry/src/main/ets/pages/Splash.ets
import router from '@ohos.router'
import { Constants } from '../utils/Constants'
import { AccountManager } from '../account/AccountManager'
@Entry
@Component
struct 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

entry/src/main/ets/account/login/LoginView.ets
import router from '@ohos.router'
import { AccountManager } from '../AccountManager'
import { UserInfoData } from '../../model/UserInfoData'
@Preview
@Component
export 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 组件:

entry/src/main/ets/pages/Login.ets
import { LoginView } from '../account/login/LoginView'
@Entry
@Component
struct 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 包含一段文字标签和一对图标(选中/未选中)。动手之前,先观察一下它们的规律——

首页 TabBar 结构示意

这三个 Tab 的结构完全一样,区别只是数据不同:

Tab文字未选中图标选中图标
点餐'点餐'ic_tab_food_normalic_tab_food_selected
订单'订单'ic_tab_order_normalic_tab_order_selected
我的'我的'ic_tab_personal_normalic_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 接口,声明 FoodOrderPersonal 三个属性,类型均为 TabBarItem
  • 导出 TabBarConfig 对象,分别定义「点餐」「订单」「我的」三个 Tab 的数据
entry/src/main/ets/portal/viewmodel/TabBarModel.ets
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 组件搭建导航框架:

entry/src/main/ets/portal/PortalView.ets
import { TabBarConfig, TabBarItem } from './viewmodel/TabBarModel'
@Component
export 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 按钮样式:

entry/src/main/ets/portal/PortalView.ets
@Component
export 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() { ... }
}
点击查看参考实现
@Builder
TabBarBuilder(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 子页面

PortalViewTabs 组件内添加三个 TabContent,暂时用占位文字代替真实子页面,每个 TabContent 通过 .tabBar() 绑定步骤 7.3 中定义的 TabBarBuilder

entry/src/main/ets/portal/PortalView.ets
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 组件:

entry/src/main/ets/pages/Index.ets
import { PortalView } from '../portal/PortalView'
@Entry
@Component
struct 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 图标和文字颜色变为橙色。

首页 Tab 导航效果

目标:实现个人中心页,显示登录用户的头像和用户名,并提供退出登录按钮。

8.1 创建 PersonalView 组件骨架

ets/account/personal 目录下新建 PersonalView.ets(目录不存在则逐层创建):

entry/src/main/ets/account/personal/PersonalView.ets
import { UserInfoData } from '../../model/UserInfoData'
import { APP_SCOPE_KEY_USER_INFO_DATA } from '../../utils/Constants'
@Preview
@Component
export 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 布局

PersonalViewbuild 中完成 UI 布局:

entry/src/main/ets/account/personal/PersonalView.ets
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
@Component
export 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 组件:

entry/src/main/ets/portal/PortalView.ets
// 我的 Tab
TabContent() {
// TODO: 将 Text('个人中心') 替换为 PersonalView 组件
}
.tabBar(this.TabBarBuilder(TabBarConfig.Personal, 2))

记得在文件顶部补充 import:

entry/src/main/ets/portal/PortalView.ets
import { TabBarConfig, TabBarItem } from './viewmodel/TabBarModel'
import { PersonalView } from '../account/personal/PersonalView'

✅ 预期效果:进入「我的」Tab 后,页面显示已登录用户的头像和用户名;点击「退出登录」后,用户信息清除,跳转到登录页;再次模拟登录后,重新进入首页。

个人中心效果

9.1 Tab 选中态持久化存储

需求:退出 APP 时选中的是哪个 Tab,下次打开 APP 时仍然默认选中该 Tab。

关键 APIPersistentStorageAppStorage@StorageLink

实现思路

  1. AccountManager 的静态初始化块中,追加注册 Tab 下标的持久化 key(默认值 0
  2. PortalView 中的 @State currentIndex 改为 @StorageLink 绑定到该 key
  3. @StorageLink 会自动完成双向同步,onChange 中赋值操作无需变更
entry/src/main/ets/portal/PortalView.ets
@Component
export struct PortalView {
// TODO: 将 @State currentIndex 替换为 @StorageLink('lastTabIndex')
// 注意:需要在 AccountManager 的静态初始化块中先注册该 key
@State currentIndex: number = 0
@StorageLink('lastTabIndex') currentIndex: number = 0
...
}
点击查看 AccountManager 中的补充代码
entry/src/main/ets/account/AccountManager.ets
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 提交方式

请将以下内容提交到雨课堂课后作业

  1. 演示视频:录制上述演示流程的屏幕录像,视频中需能看出你的个人信息(如设备上显示的学号/姓名,或口头说明均可)
  2. 视频格式不限,文件大小建议控制在合理范围内