鸿蒙实战记录:从零开发一款电影信息App

前言

学鸿蒙开发有一段时间了,之前做过掷骰子、天气、音乐播放器这些单页面App。这些项目虽然帮我熟悉了ArkUI的基础语法和状态管理,但总觉得还缺点什么——没有多页面跳转,没有数据传递,没有搜索功能

一个真正的App至少要有这些能力:

  1. 多页面 — 不可能所有功能都塞在一个页面里
  2. 页面间传递数据 — 从列表页跳到详情页,要把数据带过去
  3. 搜索过滤 — 用户要能快速找到想要的内容
  4. 数据持久化 — 收藏状态不能每次打开都重置

带着这些目标,我决定做一款电影信息App。这个项目涵盖了我之前学到的所有技能,还新增了路由导航、参数传递、@StorageLink持久化等新知识。

先看最终效果:

在这里插入图片描述
在这里插入图片描述


一、项目概述与功能规划

1.1 这是一款什么样的App?

MovieApp是一款电影信息浏览应用,内置20部中外经典电影的数据,支持分类浏览、搜索、收藏和评分。

1.2 功能全景图

功能模块功能点技术实现页面
首页推荐热门推荐(Top5)Row + ForEachIndex
分类浏览11个电影类型标签Row + ForEachIndex
全部影片20部电影列表List + ForEachIndex
搜索搜索电影名/导演/类型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 创建步骤

  1. 打开 DevEco Studio
  2. Create Project → 选择 Empty Ability 模板
  3. 填写项目信息:
配置项
Project nameMovieApp
Bundle namecom.example.movieapp
Save locationE:\HMproject\Project\MovieApp
LanguageArkTS
ModelStage
  1. 点击 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流浪地球320268.9科幻郭帆吴京/刘德华/李雪健148min
2封神第二部20268.5动作乌尔善黄渤/于适/费翔155min
3年会不能停220267.8喜剧董润年大鹏/白客/庄达菲128min
4蛟龙行动20268.2动作林超贤黄轩/于适/杜江142min
5哪吒之魔童降世20199.2动画饺子吕艳婷/囧森瑟夫110min
6消失的她20238.4悬疑陈思诚朱一龙/倪妮/文咏珊135min
7孤注一掷20237.9剧情申奥张艺兴/金晨/王传君138min
8星际穿越20149.4科幻诺兰马修·麦康纳/安妮·海瑟薇169min
9泰坦尼克号19979.5爱情卡梅隆莱昂纳多/凯特·温斯莱特194min
10长津湖20218.7战争陈凯歌/徐克/林超贤吴京/易烊千玺/段奕宏176min
11侏罗纪世界320227.5冒险科林·特雷沃罗克里斯·帕拉特/布莱丝147min
12你好,李焕英20218.3喜剧贾玲贾玲/张小斐/沈腾128min
13战狼220178.5动作吴京吴京/吴刚/张翰126min
14寻龙诀20158.0冒险乌尔善陈坤/黄渤/舒淇137min
15盗梦空间20109.3悬疑诺兰莱昂纳多/渡边谦148min
16你的名字20168.8动画新海诚神木隆之介/上白石萌音106min
17红海行动20188.6动作林超贤张译/海清/黄景瑜138min
18阿甘正传19949.5剧情泽米吉斯汤姆·汉克斯142min
19千与千寻20019.4动画宫崎骏柊瑠美/入野自由125min
20唐探320217.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@Statenumber控制底部导航栏高亮和页面内容切换
searchText@Statestring存储搜索框输入的关键词
savedFav@StorageLinkstring持久化收藏的电影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  // 匹配到的设为已收藏
      }
    }
  }
}

加载逻辑:

  1. App启动时 aboutToAppear() 触发
  2. 检查 savedFav 是否有内容(@StorageLink会自动从存储加载)
  3. 先把所有电影的 isFav 重置为 false
  4. indexOf 检查每部电影的ID是否在保存的字符串中
  5. 匹配到的设为 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
}

返回所有 isFavtrue 的电影。当用户点击收藏/取消收藏后,这个 getter 会自动返回最新的收藏列表。


六、页面路由——从列表跳转到详情

6.1 为什么需要路由?

之前的音乐播放器是单页面应用,所有内容都在一个页面里。但电影App需要两步操作:

  1. 浏览电影列表 → 在首页
  2. 查看电影详情 → 在详情页

这就需要页面跳转,也叫路由

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
    }
  })
}

参数传递分析:

参数类型说明
urlstring目标页面的路由路径
paramsobject传递给目标页面的参数对象

传递了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行,结构清晰,适合作为鸿蒙进阶实战的学习项目。


如果这篇文章对你有帮助,欢迎点赞、收藏、评论!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值