前言
学鸿蒙开发有一段时间了,之前做过掷骰子、天气、音乐播放器这些单页面App。这些项目虽然帮我熟悉了ArkUI的基础语法和状态管理,但总觉得还缺点什么——没有多页面跳转,没有数据传递,没有搜索功能。
一个真正的App至少要有这些能力:
- 多页面 — 不可能所有功能都塞在一个页面里
- 页面间传递数据 — 从列表页跳到详情页,要把数据带过去
- 搜索过滤 — 用户要能快速找到想要的内容
- 数据持久化 — 收藏状态不能每次打开都重置
带着这些目标,我决定做一款电影信息App。这个项目涵盖了我之前学到的所有技能,还新增了路由导航、参数传递、@StorageLink持久化等新知识。
先看最终效果:


一、项目概述与功能规划
1.1 这是一款什么样的App?
MovieApp是一款电影信息浏览应用,内置20部中外经典电影的数据,支持分类浏览、搜索、收藏和评分。
1.2 功能全景图
| 功能模块 | 功能点 | 技术实现 | 页面 |
|---|---|---|---|
| 首页推荐 | 热门推荐(Top5) | Row + ForEach | Index |
| 分类浏览 | 11个电影类型标签 | Row + ForEach | Index |
| 全部影片 | 20部电影列表 | List + ForEach | Index |
| 搜索 | 搜索电影名/导演/类型 | TextInput + 计算属性过滤 | Index |
| 收藏 | 收藏/取消收藏电影 | @StorageLink 持久化 | Index |
| 我的收藏 | 展示已收藏电影 | 计算属性过滤 | Index |
| 详情页 | 电影详细信息展示 | router.pushUrl 传参 | MovieDetail |
| 评分 | 用户星级评分 | 点击星星切换 | MovieDetail |
| 导航栏 | 4个Tab切换 | @State + 条件渲染 | Index |
1.3 页面结构
MovieApp
├── Index.ets ← 首页(主页面)
│ ├── 首页Tab (热门推荐 + 分类 + 影片列表)
│ ├── 分类Tab (影片列表,按类型筛选)
│ ├── 搜索Tab (搜索框 + 结果列表)
│ └── 我的Tab (个人信息 + 收藏列表)
│
└── MovieDetail.ets ← 详情页(从首页跳转进入)
├── 电影海报/标题/基本信息
├── 评分展示 + 用户评分
├── 剧情简介
├── 演职人员
└── 影片信息
1.4 技术栈
| 技术点 | 说明 |
|---|---|
| 多页面路由 | router.pushUrl / router.back |
| 参数传递 | router.getParams() 接收页面参数 |
| 数据持久化 | @StorageLink 跨组件/跨页面共享数据 |
| 计算属性 | getter 实现搜索过滤和收藏列表 |
| 条件渲染 | if-else 根据tab显示不同内容 |
| 接口定义 | interface 定义MovieData数据结构 |
| @Builder | 全局构建函数封装复用UI组件 |
二、项目创建
2.1 创建步骤
- 打开 DevEco Studio
- Create Project → 选择 Empty Ability 模板
- 填写项目信息:
| 配置项 | 值 |
|---|---|
| Project name | MovieApp |
| Bundle name | com.example.movieapp |
| Save location | E:\HMproject\Project\MovieApp |
| Language | ArkTS |
| Model | Stage |
- 点击 Finish
2.2 配置路由
这个项目有两个页面,需要在 main_pages.json 中注册路由:
{
"src": [
"pages/Index",
"pages/MovieDetail"
]
}
2.3 项目结构
MovieApp/
├── AppScope/
│ └── app.json5 # bundleName: com.example.movieapp
├── entry/
│ ├── src/main/
│ │ ├── ets/
│ │ │ ├── entryability/
│ │ │ │ └── EntryAbility.ets
│ │ │ ├── entrybackupability/
│ │ │ │ └── EntryBackupAbility.ets
│ │ │ └── pages/
│ │ │ ├── Index.ets # 首页(约200行)
│ │ │ └── MovieDetail.ets # 详情页(约100行)
│ │ └── resources/
│ ├── src/main/resources/base/profile/
│ │ └── main_pages.json # 路由配置
│ └── module.json5
└── oh_modules/
三、数据模型设计
3.1 接口定义
interface MovieData {
id: number // 唯一标识
title: string // 电影标题
year: number // 上映年份
rating: number // 评分(0-10)
genre: string // 类型
poster: string // 封面(emoji)
desc: string // 简介
director: string // 导演
cast: string // 主演
runtime: number // 片长(分钟)
isFav: boolean // 是否收藏
}
为什么用interface?
和之前的音乐播放器不同,这次电影数据字段多达10个,如果用平行数组的话太冗长了。用interface定义一个结构化的数据类型,代码更清晰、更易维护。
3.2 内置的20部电影
| # | 🎬 电影 | 📅 年份 | ⭐ 评分 | 🎭 类型 | 🎬 导演 | 👥 主演 | ⏱️ 片长 |
|---|---|---|---|---|---|---|---|
| 1 | 流浪地球3 | 2026 | 8.9 | 科幻 | 郭帆 | 吴京/刘德华/李雪健 | 148min |
| 2 | 封神第二部 | 2026 | 8.5 | 动作 | 乌尔善 | 黄渤/于适/费翔 | 155min |
| 3 | 年会不能停2 | 2026 | 7.8 | 喜剧 | 董润年 | 大鹏/白客/庄达菲 | 128min |
| 4 | 蛟龙行动 | 2026 | 8.2 | 动作 | 林超贤 | 黄轩/于适/杜江 | 142min |
| 5 | 哪吒之魔童降世 | 2019 | 9.2 | 动画 | 饺子 | 吕艳婷/囧森瑟夫 | 110min |
| 6 | 消失的她 | 2023 | 8.4 | 悬疑 | 陈思诚 | 朱一龙/倪妮/文咏珊 | 135min |
| 7 | 孤注一掷 | 2023 | 7.9 | 剧情 | 申奥 | 张艺兴/金晨/王传君 | 138min |
| 8 | 星际穿越 | 2014 | 9.4 | 科幻 | 诺兰 | 马修·麦康纳/安妮·海瑟薇 | 169min |
| 9 | 泰坦尼克号 | 1997 | 9.5 | 爱情 | 卡梅隆 | 莱昂纳多/凯特·温斯莱特 | 194min |
| 10 | 长津湖 | 2021 | 8.7 | 战争 | 陈凯歌/徐克/林超贤 | 吴京/易烊千玺/段奕宏 | 176min |
| 11 | 侏罗纪世界3 | 2022 | 7.5 | 冒险 | 科林·特雷沃罗 | 克里斯·帕拉特/布莱丝 | 147min |
| 12 | 你好,李焕英 | 2021 | 8.3 | 喜剧 | 贾玲 | 贾玲/张小斐/沈腾 | 128min |
| 13 | 战狼2 | 2017 | 8.5 | 动作 | 吴京 | 吴京/吴刚/张翰 | 126min |
| 14 | 寻龙诀 | 2015 | 8.0 | 冒险 | 乌尔善 | 陈坤/黄渤/舒淇 | 137min |
| 15 | 盗梦空间 | 2010 | 9.3 | 悬疑 | 诺兰 | 莱昂纳多/渡边谦 | 148min |
| 16 | 你的名字 | 2016 | 8.8 | 动画 | 新海诚 | 神木隆之介/上白石萌音 | 106min |
| 17 | 红海行动 | 2018 | 8.6 | 动作 | 林超贤 | 张译/海清/黄景瑜 | 138min |
| 18 | 阿甘正传 | 1994 | 9.5 | 剧情 | 泽米吉斯 | 汤姆·汉克斯 | 142min |
| 19 | 千与千寻 | 2001 | 9.4 | 动画 | 宫崎骏 | 柊瑠美/入野自由 | 125min |
| 20 | 唐探3 | 2021 | 7.1 | 喜剧 | 陈思诚 | 王宝强/刘昊然 | 136min |
数据覆盖了2019-2026年的国产热门电影和1994-2022年的国际经典,类型包含科幻、动作、喜剧、悬疑、爱情、战争、冒险、剧情、动画等9大类。
3.3 分类标签数据
private readonly GENRES: string[][] = [
['🎬', '全部'], ['🔥', '动作'], ['😂', '喜剧'], ['😱', '恐怖'],
['💕', '爱情'], ['🤖', '科幻'], ['🔍', '悬疑'], ['📜', '历史'],
['🎨', '动画'], ['📖', '剧情'], ['⚔️', '战争'], ['🚀', '冒险'],
]
用二维数组存储,每个元素包含emoji图标和类型名称。
四、状态管理与数据持久化
4.1 状态变量
@Entry
@Component
struct Index {
@State tab: number = 0 // 当前Tab索引(0-3)
@State searchText: string = '' // 搜索关键词
@StorageLink('movie_fav') savedFav: string = '' // 收藏持久化数据
}
4.2 变量说明
| 变量 | 装饰器 | 类型 | 作用 |
|---|---|---|---|
| tab | @State | number | 控制底部导航栏高亮和页面内容切换 |
| searchText | @State | string | 存储搜索框输入的关键词 |
| savedFav | @StorageLink | string | 持久化收藏的电影ID列表(如 “1,5,8,”) |
4.3 @StorageLink——数据持久化
这是本项目的一个重要技术点。
什么是@StorageLink?
@StorageLink 是ArkUI提供的状态装饰器,它能将组件的状态变量与App的持久化存储(AppStorage)绑定。当变量改变时,自动保存到存储中;App重启后,自动从存储恢复。
@StorageLink('movie_fav') savedFav: string = ''
'movie_fav'是存储的键名savedFav是组件内的变量名- 变量类型是
string,存储的是收藏的电影ID列表,格式为"1,5,8,"
收藏数据格式:
savedFav = "" → 没有收藏任何电影
savedFav = "1," → 收藏了ID为1的电影
savedFav = "1,5,8," → 收藏了ID为1、5、8的电影
用逗号分隔的ID字符串来存储,简单直观。
4.4 收藏功能的完整实现
// 切换收藏状态
private toggleFav(id: number): void {
// 第一步:遍历电影列表,找到目标电影并切换isFav
for (let i = 0; i < this.MOVIES.length; i++) {
if (this.MOVIES[i].id === id) {
this.MOVIES[i].isFav = !this.MOVIES[i].isFav
break
}
}
// 第二步:重新生成收藏ID字符串
let ids = ''
for (let i = 0; i < this.MOVIES.length; i++) {
if (this.MOVIES[i].isFav) {
ids = ids + String(this.MOVIES[i].id) + ','
}
}
this.savedFav = ids // 赋值给@StorageLink变量,自动持久化
}
执行流程:
用户点击❤️/🤍
↓
遍历MOVIES找到目标电影
↓
切换isFav状态
↓
遍历MOVIES,收集所有isFav=true的ID
↓
拼成 "1,5,8," 格式的字符串
↓
赋值给savedFav(@StorageLink自动保存)
4.5 收藏状态加载
aboutToAppear(): void {
this.loadFav()
}
private loadFav(): void {
if (this.savedFav && this.savedFav !== '') {
for (let i = 0; i < this.MOVIES.length; i++) {
this.MOVIES[i].isFav = false // 先全部重置
if (this.savedFav.indexOf(String(this.MOVIES[i].id)) !== -1) {
this.MOVIES[i].isFav = true // 匹配到的设为已收藏
}
}
}
}
加载逻辑:
- App启动时
aboutToAppear()触发 - 检查
savedFav是否有内容(@StorageLink会自动从存储加载) - 先把所有电影的
isFav重置为false - 用
indexOf检查每部电影的ID是否在保存的字符串中 - 匹配到的设为
true
注意: indexOf 的匹配方式意味着ID不能是另一个ID的子串。比如如果存在ID为11的电影,而保存了 "1,",那么 indexOf("11") 在 "1," 中找不到(因为 "1," 里没有连续的"11"),所以不会误匹配。但如果ID是单数且另一个ID是它的多位扩展(如1和11),就可能有问题。本项目ID是1-20,不存在嵌套问题。
五、计算属性——搜索与收藏列表
5.1 什么是计算属性?
ArkTS支持 getter 语法,可以在获取变量值时动态计算:
private get searchResults(): MovieData[] {
// 每次访问这个属性时都会重新计算
return this.MOVIES.filter(...)
}
当依赖的状态变量(如 searchText)改变时,下次访问这个 getter 就会得到新的结果。UI会自动更新。
5.2 搜索过滤
private get searchResults(): MovieData[] {
if (this.searchText === '') return this.MOVIES
const r: MovieData[] = []
for (let i = 0; i < this.MOVIES.length; i++) {
const m = this.MOVIES[i]
if (m.title.indexOf(this.searchText) !== -1 ||
m.director.indexOf(this.searchText) !== -1 ||
m.genre.indexOf(this.searchText) !== -1) {
r.push(m)
}
}
return r
}
搜索规则: 支持按电影名、导演名、类型三个维度模糊搜索。
匹配示例:
| 搜索词 | 匹配结果 | 匹配维度 |
|---|---|---|
| “星际” | 星际穿越 | 电影名 |
| “诺兰” | 星际穿越、盗梦空间 | 导演 |
| “科幻” | 流浪地球3、星际穿越 | 类型 |
| “吴京” | 流浪地球3、长津湖、战狼2 | 导演/主演 |
| “9.” | 星际穿越(9.4)、泰坦尼克号(9.5)… | 评分(数字字符) |
5.3 收藏列表
private get favList(): MovieData[] {
const r: MovieData[] = []
for (let i = 0; i < this.MOVIES.length; i++) {
if (this.MOVIES[i].isFav) r.push(this.MOVIES[i])
}
return r
}
返回所有 isFav 为 true 的电影。当用户点击收藏/取消收藏后,这个 getter 会自动返回最新的收藏列表。
六、页面路由——从列表跳转到详情
6.1 为什么需要路由?
之前的音乐播放器是单页面应用,所有内容都在一个页面里。但电影App需要两步操作:
- 浏览电影列表 → 在首页
- 查看电影详情 → 在详情页
这就需要页面跳转,也叫路由。
6.2 从首页跳转到详情页
private openDetail(m: MovieData): void {
router.pushUrl({
url: 'pages/MovieDetail',
params: {
id: m.id,
title: m.title,
year: m.year,
rating: m.rating,
genre: m.genre,
poster: m.poster,
desc: m.desc,
director: m.director,
cast: m.cast,
runtime: m.runtime
}
})
}
参数传递分析:
| 参数 | 类型 | 说明 |
|---|---|---|
| url | string | 目标页面的路由路径 |
| params | object | 传递给目标页面的参数对象 |
传递了9个参数:id、title、year、rating、genre、poster、desc、director、cast、runtime。这就是这部电影的全部信息。
为什么用 pushUrl 而不是 replaceUrl?
pushUrl:将新页面压入导航栈,用户可以返回上一页replaceUrl:替换当前页面,用户无法返回
电影详情页显然需要能返回列表,所以用 pushUrl。
6.3 详情页接收参数
interface MovieParams {
id?: number; title?: string; year?: number; rating?: number
genre?: string; poster?: string; desc?: string
director?: string; cast?: string; runtime?: number
}
@Entry
@Component
struct MovieDetail {
@State movieTitle: string = ''
@State movieYear: number = 0
@State movieRating: number = 0
@State movieGenre: string = ''
@State moviePoster: string = ''
@State movieDesc: string = ''
@State movieDirector: string = ''
@State movieCast: string = ''
@State movieRuntime: number = 0
@State userRating: number = 0
aboutToAppear(): void {
const p = router.getParams() as MovieParams
if (p) {
if (p.title !== undefined) { this.movieTitle = p.title }
if (p.year !== undefined) { this.movieYear = p.year }
if (p.rating !== undefined) { this.movieRating = p.rating }
// ... 其他参数同理
}
}
}
逐行分析:
- 定义
MovieParams接口,所有参数都是可选的(加了?) - 为每个字段定义独立的
@State变量 - 在
aboutToAppear()中用router.getParams()获取参数 - 逐个检查参数是否存在(
!== undefined),再赋值
为什么每个参数都要检查 undefined?
因为TypeScript的类型系统要求——接口中标记了 ? 的字段可能是 undefined,直接赋值给 @State 变量可能导致类型不匹配。保险起见,先检查再赋值。
6.4 返回上一页
private goBack(): void {
router.back()
}
就这么简单,一行代码返回上一页。
七、首页UI实现
7.1 整体结构
Column (全屏)
├── Row (标题栏: "🎬 影院" + 🔍图标)
├── if tab === 0 (首页)
│ ├── Column (热门推荐 Top5)
│ ├── Row (分类标签 第一行)
│ ├── Row (分类标签 第二行)
│ └── List (全部影片20部)
├── else if tab === 1 (分类)
│ └── List (影片列表)
├── else if tab === 2 (搜索)
│ ├── TextInput (搜索框)
│ └── List (搜索结果) / 空状态
├── else if tab === 3 (我的)
│ ├── Row (用户信息卡片)
│ └── List (收藏列表) / 空状态
└── Row (底部导航栏: 首页/分类/搜索/我的)
7.2 条件渲染——多Tab切换
if (this.tab === 0) {
// 首页内容...
} else if (this.tab === 1) {
// 分类内容...
} else if (this.tab === 2) {
// 搜索内容...
} else if (this.tab === 3) {
// 我的内容...
}
通过 if-else if 根据当前 tab 值显示不同的内容区域。当用户点击底部导航时,tab 值改变,UI自动切换。
为什么不创建4个独立页面?
因为这些Tab之间共享数据(MOVIES数组、收藏状态),放在同一个页面里更方便共享状态。详情页因为需要独立展示且有返回操作,所以做成独立页面。
7.3 热门推荐
Column() {
Text('🌟 热门推荐').fontSize(18).fontWeight(FontWeight.Bold).fontColor(Color.White)
Row() {
ForEach(this.MOVIES, (m: MovieData, idx: number) => {
if (idx < 5) {
Column() {
Text(m.poster).fontSize(36).width(60).height(60)
.backgroundColor('#FF9F0A').borderRadius(12)
Text(m.title).fontSize(12).fontColor(Color.White).maxLines(1)
Text(String(m.rating)).fontSize(11).fontColor('#FF9F0A')
}.onClick(() => { this.openDetail(m) })
}
})
}
}
取前5部电影作为推荐。海报用橙色背景区分(和列表中的灰色区分),点击可跳转详情页。
7.4 分类标签
Row() {
ForEach(this.GENRES, (g: string[], i: number) => {
if (i > 0 && i < 7) {
Column() {
Text(g[0]).fontSize(26)
Text(g[1]).fontSize(11).fontColor('#8E8E93')
}.layoutWeight(1).alignItems(HorizontalAlign.Center)
.onClick(() => { this.tab = 1 })
}
})
}
分两行显示:第一行显示索引1-6(动作、喜剧、恐怖、爱情、科幻、悬疑),第二行显示索引7-11(历史、动画、剧情、战争、冒险)。索引0是"全部",不在分类标签中显示。
点击标签切换到分类Tab。
7.5 影片列表
List() {
ForEach(this.MOVIES, (m: MovieData) => {
ListItem() {
Row() {
// 封面
Text(m.poster).fontSize(32).width(52).height(60)
.backgroundColor('#2C2C2E').borderRadius(10)
// 信息
Column() {
Text(m.title).fontSize(15).fontWeight(FontWeight.Bold).fontColor(Color.White)
Text(m.genre + ' · ' + String(m.year))
.fontSize(12).fontColor('#8E8E93').margin({ top: 2 })
Row() {
Text('⭐').fontSize(12)
Text(String(m.rating)).fontSize(13).fontColor('#FF9F0A')
}.margin({ top: 2 })
}.layoutWeight(1).margin({ left: 10 })
// 收藏按钮
Text(m.isFav ? '❤️' : '🤍').fontSize(20)
.onClick(() => { this.toggleFav(m.id) })
}
.padding(10).backgroundColor('#1C1C1E').borderRadius(10)
.onClick(() => { this.openDetail(m) })
}
}, (m: MovieData) => String(m.id))
}
每部电影显示:封面emoji + 标题 + 类型/年份 + 评分 + 收藏按钮。
布局特点:
- 整行可点击跳转详情
- 收藏按钮单独绑定onClick,不影响整行点击
- 卡片式设计(圆角+背景色)
7.6 搜索页
else if (this.tab === 2) {
Column() {
// 搜索框
TextInput({ placeholder: '搜索电影/导演/类型...', text: this.searchText })
.onChange((v: string) => { this.searchText = v })
// 有搜索内容 → 显示结果
if (this.searchText.length > 0) {
List() {
ForEach(this.searchResults, ...)
}
}
// 无搜索内容 → 显示空状态
else {
Column() {
Text('🔍').fontSize(48)
Text('输入关键词搜索电影').fontColor('#8E8E93')
}.justifyContent(FlexAlign.Center)
}
}
}
搜索交互流程:
用户输入关键词
↓
onChange触发 → searchText更新
↓
searchResults getter 重新计算
↓
有内容 → ForEach渲染搜索结果
无内容 → 显示空状态提示
7.7 "我的"页面
else if (this.tab === 3) {
Column() {
// 用户信息卡片
Row() {
Text('👤').fontSize(48).backgroundColor('#2C2C2E').borderRadius(32)
Column() {
Text('影迷').fontSize(20).fontWeight(FontWeight.Bold)
Text('已收藏 ' + String(this.favList.length) + ' 部电影')
.fontSize(14).fontColor('#8E8E93')
}
}
// 收藏列表
if (this.favList.length === 0) {
// 空状态
Text('还没有收藏的电影')
} else {
// 收藏列表
List() {
ForEach(this.favList, ...)
}
}
}
}
动态显示收藏数量。没有收藏时显示空状态提示。
7.8 底部导航栏
Row() {
ForEach([['🎬', '首页'], ['📂', '分类'], ['🔍', '搜索'], ['👤', '我的']],
(a: string[], i: number) => {
Column() {
Text(a[0]).fontSize(20)
Text(a[1]).fontSize(11)
.fontColor(i === this.tab ? '#FF9F0A' : '#8E8E93')
}.layoutWeight(1).alignItems(HorizontalAlign.Center)
.onClick(() => { this.tab = i })
}, (a: string[]) => a[1])
}
.height(58).backgroundColor('#1C1C1E')
当前Tab的文字用橙色(#FF9F0A)高亮,其他用灰色(#8E8E93)。
八、详情页UI实现
8.1 页面结构
Column (全屏)
├── Row (顶部导航: 返回按钮 + "电影详情")
├── Scroll (可滚动内容)
│ ├── Column (海报区域)
│ ├── Text (电影标题)
│ ├── Text (类型/年份/片长)
│ ├── Row (评分展示: ⭐ 9.5 /10)
│ ├── Text + Row (我的评分: 5颗星)
│ ├── Column (剧情简介卡片)
│ ├── Column (演职人员卡片)
│ └── Column (影片信息卡片)
└── (底部无边距,Scroll可滚动)
8.2 顶部导航
Row() {
Button('‹ 返回').fontSize(17).fontColor('#FF9F0A')
.backgroundColor(Color.Transparent)
.onClick(() => { this.goBack() })
Blank()
Text('电影详情').fontSize(18).fontWeight(FontWeight.Bold).fontColor(Color.White)
Blank()
}
.backgroundColor('#1C1C1E')
返回按钮用透明背景的Button,左侧橙色文字。
8.3 评分展示
Row() {
Text('⭐').fontSize(24)
Text(String(this.movieRating)).fontSize(36).fontWeight(FontWeight.Bold)
.fontColor('#FF9F0A').margin({ left: 8 })
Text('/10').fontSize(16).fontColor('#555555').margin({ left: 4 })
}
大字体显示评分,橙色突出。
8.4 用户评分
Text('我的评分').fontSize(14).fontColor('#8E8E93')
Row() {
ForEach([1, 2, 3, 4, 5], (r: number) => {
Text(r <= this.userRating ? '⭐' : '☆')
.fontSize(28).margin({ left: 2, right: 2 })
.onClick(() => { this.rate(r) })
}, (r: number) => String(r))
}
private rate(r: number): void {
this.userRating = r
}
点击星星设置评分。已选中的显示⭐,未选中的显示☆。
8.5 信息卡片
剧情简介、演职人员、影片信息分别放在三个独立的卡片中,使用Column + backgroundColor + borderRadius 实现圆角卡片效果。
8.6 @Builder全局构建函数
@Builder
function infoRow(label: string, value: string) {
Row() {
Text(label).fontSize(14).fontColor('#8E8E93').width(60)
Text(value).fontSize(15).fontColor(Color.White).layoutWeight(1)
}.width('100%').margin({ top: 4, bottom: 4 })
}
什么是@Builder?
@Builder 是ArkUI的构建函数装饰器,可以将一段UI代码封装成可复用的函数。
- 在
@Component外部定义的是全局构建函数,不需要this即可调用 - 在
@Component内部定义的需要用this.infoRow()调用
影片信息卡片中用了4次 infoRow:
infoRow('类型', this.movieGenre)
infoRow('年份', String(this.movieYear) + '年')
infoRow('片长', String(this.movieRuntime) + '分钟')
infoRow('评分', String(this.movieRating) + '/10')
如果不封装,就要写4遍相同的Row结构,代码重复度高。
九、色彩体系与视觉设计
9.1 整体色调
采用暗色主题,现代感强:
| 区域 | 颜色 | 用途 |
|---|---|---|
| 页面背景 | #000000 | 主背景 |
| 标题栏/控制栏 | #1C1C1E | 固定区域背景 |
| 封面/图标背景 | #2C2C2E | 列表项封面容器 |
| 热门推荐封面 | #FF9F0A | 推荐区海报背景 |
| 主文字 | White | 电影标题等 |
| 辅助文字 | #8E8E93 | 年份、类型等 |
| 评分/高亮 | #FF9F0A | 评分数字、Tab高亮 |
| 占位文字 | #555555 | "/10"等弱化文字 |
9.2 设计亮点
| 设计点 | 实现方式 | 效果 |
|---|---|---|
| 橙色推荐 | #FF9F0A背景 | 热门推荐视觉突出 |
| 灰色列表封面 | #2C2C2E背景 | 和推荐区区分 |
| 评分高亮 | #FF9F0A字体 | 关键信息一眼可见 |
| Tab高亮 | 当前Tab橙色/其他灰色 | 清晰指示当前位置 |
| 卡片圆角 | borderRadius(10-16) | 现代卡片风格 |
| 空状态 | 大emoji+提示文字 | 无数据时友好提示 |
十、完整代码
10.1 Index.ets(首页)
import router from '@ohos.router';
interface MovieData {
id: number; title: string; year: number; rating: number; genre: string; poster: string
desc: string; director: string; cast: string; runtime: number; isFav: boolean
}
@Entry
@Component
struct Index {
@State tab: number = 0
@State searchText: string = ''
@StorageLink('movie_fav') savedFav: string = ''
private readonly GENRES: string[][] = [
['🎬', '全部'], ['🔥', '动作'], ['😂', '喜剧'], ['😱', '恐怖'],
['💕', '爱情'], ['🤖', '科幻'], ['🔍', '悬疑'], ['📜', '历史'],
['🎨', '动画'], ['📖', '剧情'], ['⚔️', '战争'], ['🚀', '冒险'],
]
private readonly MOVIES: MovieData[] = [
// ... 20部电影的完整数据
]
aboutToAppear(): void { this.loadFav() }
private loadFav(): void {
if (this.savedFav && this.savedFav !== '') {
for (let i = 0; i < this.MOVIES.length; i++) {
this.MOVIES[i].isFav = false
if (this.savedFav.indexOf(String(this.MOVIES[i].id)) !== -1) {
this.MOVIES[i].isFav = true
}
}
}
}
private toggleFav(id: number): void {
for (let i = 0; i < this.MOVIES.length; i++) {
if (this.MOVIES[i].id === id) {
this.MOVIES[i].isFav = !this.MOVIES[i].isFav; break
}
}
let ids = ''
for (let i = 0; i < this.MOVIES.length; i++) {
if (this.MOVIES[i].isFav) { ids += String(this.MOVIES[i].id) + ',' }
}
this.savedFav = ids
}
private openDetail(m: MovieData): void {
router.pushUrl({
url: 'pages/MovieDetail',
params: { id: m.id, title: m.title, year: m.year, rating: m.rating,
genre: m.genre, poster: m.poster, desc: m.desc,
director: m.director, cast: m.cast, runtime: m.runtime }
})
}
private get searchResults(): MovieData[] {
if (this.searchText === '') return this.MOVIES
const r: MovieData[] = []
for (let i = 0; i < this.MOVIES.length; i++) {
const m = this.MOVIES[i]
if (m.title.indexOf(this.searchText) !== -1 ||
m.director.indexOf(this.searchText) !== -1 ||
m.genre.indexOf(this.searchText) !== -1) { r.push(m) }
}
return r
}
private get favList(): MovieData[] {
const r: MovieData[] = []
for (let i = 0; i < this.MOVIES.length; i++) {
if (this.MOVIES[i].isFav) r.push(this.MOVIES[i])
}
return r
}
build() {
Column() {
// 标题栏
// Tab内容(首页/分类/搜索/我的)
// 底部导航栏
}
.width('100%').height('100%').backgroundColor('#000000')
}
}
10.2 MovieDetail.ets(详情页)
import router from '@ohos.router';
interface MovieParams {
id?: number; title?: string; year?: number; rating?: number;
genre?: string; poster?: string; desc?: string;
director?: string; cast?: string; runtime?: number
}
@Entry
@Component
struct MovieDetail {
@State movieTitle: string = ''
@State movieYear: number = 0
@State movieRating: number = 0
@State movieGenre: string = ''
@State moviePoster: string = ''
@State movieDesc: string = ''
@State movieDirector: string = ''
@State movieCast: string = ''
@State movieRuntime: number = 0
@State userRating: number = 0
aboutToAppear(): void {
const p = router.getParams() as MovieParams
if (p) {
if (p.title !== undefined) this.movieTitle = p.title
if (p.year !== undefined) this.movieYear = p.year
// ... 其他参数
}
}
private goBack(): void { router.back() }
private rate(r: number): void { this.userRating = r }
build() {
Column() {
// 顶部导航(返回按钮)
// Scroll可滚动区域
// 海报、标题、评分、用户评分
// 剧情简介、演职人员、影片信息
}
.width('100%').height('100%').backgroundColor('#000000')
}
}
@Builder
function infoRow(label: string, value: string) {
Row() {
Text(label).fontSize(14).fontColor('#8E8E93').width(60)
Text(value).fontSize(15).fontColor(Color.White).layoutWeight(1)
}.width('100%').margin({ top: 4, bottom: 4 })
}
十一、运行效果
11.1 构建运行
在DevEco Studio中点击运行。
11.2 效果截图
首页——热门推荐和影片列表:

详情页——电影详细信息:

十二、踩坑记录
坑1:@State数组修改不触发UI更新
现象: 在首页点击收藏后,列表中的爱心图标没有变化。
原因: 直接修改对象数组中对象的属性(this.MOVIES[i].isFav = true)不会触发ForEach的重新渲染。
处理: 本项目中通过改变 savedFav(@StorageLink变量)间接触发UI刷新。@StorageLink变量的改变会触发依赖它的UI部分更新。但在实际使用中,可能需要结合其他策略(如使用@Observed和@ObjectLink)来确保对象属性变化能被监听。
坑2:路由参数类型不匹配
现象: 从详情页返回后再进入,评分等数据异常。
原因: MovieParams接口的字段都是可选的,如果直接赋值可能产生undefined。
解决: 对每个参数逐一检查 !== undefined 再赋值。
坑3:@Builder函数位置
现象: 在@Component内部定义@Builder函数调用时报错。
原因: 内部@Builder需要用 this.functionName() 调用,全局@Builder直接调用函数名。
解决: 将 infoRow 定义在@Component外部,作为全局构建函数。
坑4:收藏持久化格式
现象: 收藏ID为11的电影时,保存的字符串 "1,11," 中用 indexOf("1") 也会匹配到"11"。
原因: indexOf 做的是子串匹配,不是精确匹配。
处理: 本项目ID为1-20,不存在ID嵌套问题(如1和11、2和12等)。但如果ID范围更大,建议使用分隔符包裹(如 ",1,11," + indexOf(",1,"))来精确匹配。
十三、技术要点总结
| 知识点 | 实现方式 | 重要性 |
|---|---|---|
| 多页面路由 | router.pushUrl + router.back | ⭐⭐⭐ |
| 参数传递 | router.getParams() + 接口类型 | ⭐⭐⭐ |
| 数据持久化 | @StorageLink 绑定 AppStorage | ⭐⭐⭐ |
| 计算属性 | getter 语法实现搜索过滤 | ⭐⭐⭐ |
| 条件渲染 | if-else if 多Tab切换 | ⭐⭐ |
| 接口定义 | interface 定义数据结构 | ⭐⭐ |
| @Builder | 全局构建函数封装UI | ⭐⭐ |
| 列表渲染 | List + ForEach | ⭐⭐ |
| 用户评分 | 点击星星切换状态 | ⭐⭐ |
| 搜索功能 | TextInput + onChange + 过滤 | ⭐⭐⭐ |
十四、后续可以做的
14.1 功能扩展
| 扩展项 | 技术方案 | 说明 |
|---|---|---|
| 分类筛选 | 根据选中类型过滤列表 | 分类Tab点击类型标签后只显示该类型 |
| 进度拖动 | Slider组件 | 评分改为可拖动 |
| 数据持久化 | @StorageLink 保存用户评分 | 用户评分不丢失 |
| 真实海报 | Image组件 + 网络图片 | 用真实电影海报替换emoji |
| 网络数据 | HTTP请求 | 从API获取电影数据 |
| 排序功能 | 评分/年份/片长排序 | 列表支持排序切换 |
| 评论功能 | TextInput + 列表 | 用户可以写评论 |
| 分享功能 | 系统分享接口 | 分享电影给朋友 |
14.2 架构优化
| 优化项 | 方案 |
|---|---|
| 组件拆分 | MovieItem、PlayerBar拆为独立组件 |
| 数据管理 | 用MVVM模式管理电影数据 |
| 网络层 | 封装HTTP请求工具类 |
| 图片缓存 | 使用Image组件的缓存策略 |
| LazyForEach | 大量数据时懒加载列表 |
十五、总结
这个MovieApp项目是我做过的最完整的一个鸿蒙实战项目。相比之前的单页面应用,它新增了很多重要的技术点:
1. 多页面路由 — router.pushUrl 跳转、router.back 返回、router.getParams 传参。这三个方法覆盖了绝大多数页面跳转场景。
2. 数据持久化 — @StorageLink 让收藏状态在App重启后依然保留。不需要手动读写文件,装饰器帮你处理一切。
3. 搜索过滤 — 通过 getter 计算属性实现实时搜索,输入即过滤,体验流畅。
4. 条件渲染 — 一个页面内通过 if-else if 切换4个Tab的内容,共享数据状态。
5. @Builder — 全局构建函数封装重复UI,减少代码冗余。
6. 接口定义 — 用interface定义MovieData和MovieParams,代码更规范。
做这个项目最大的收获是理解了多页面开发的全流程:路由配置 → 页面跳转 → 参数传递 → 参数接收 → 返回上一页。这是任何真实App都必须掌握的技能。
项目虽然只有两个页面,但涵盖了导航、搜索、收藏、评分、持久化等核心功能,代码约300行,结构清晰,适合作为鸿蒙进阶实战的学习项目。
如果这篇文章对你有帮助,欢迎点赞、收藏、评论! ⭐

5557

被折叠的 条评论
为什么被折叠?



