鸿蒙原生应用开发实战(四):电影详情与评分评价 — 电影清单App

【鸿蒙原生应用开发实战(四)】电影详情与评分评价 --- 电影清单App

前言

前三篇文章我们完成了项目搭建、添加电影和电影列表页面的开发。当用户在列表页点击某部电影时,需要进入详情页面查看完整信息、打分、写影评以及管理观影状态。

电影详情页是 App 中交互密度最高的页面,它聚合了:

  • 路由传参与数据回显
  • 多状态切换(想看/在看/已看)
  • 收藏功能(Toggle 开关)
  • 自定义星级评分组件
  • 影评编辑(TextArea 展开/收起)
  • 数据持久化与删除

本文将带完整代码和架构分析,帮你彻底掌握鸿蒙详情页的开发模式。


一、页面布局总览

详情页的视觉结构从上到下分为 9 个区域:

复制代码
┌──────────────────────────────────────┐
│  ← 返回          电影详情            │  ← 导航栏
├──────────────────────────────────────┤
│                                      │
│           🎬  (分类图标)               │
│         星际穿越                      │
│     2014 · 科幻                      │
│     导演: 克里斯托弗·诺兰             │  ← 电影信息头部
│                                      │
├──────────────────────────────────────┤
│  [想看] [▶️ 在看] [✅ 已看]        │  ← 状态切换
├──────────────────────────────────────┤
│  ⭐ 已收藏                    [🗹] │  ← 收藏 Toggle
├──────────────────────────────────────┤
│  我的评分                            │
│  ★★★★★   ← 当前评分展示            │  ← 只读星级
│  ★ ★ ★ ★ ★  ← 点击评分            │  ← 可点击评分
├──────────────────────────────────────┤
│  我的影评                      编辑  │
│  时间、空间与爱的史诗...              │  ← 影评摘要
│  ┌────────────────────────────┐      │
│  │ 写下你的影评...             │      │  ← TextArea(编辑态展开)
│  │                            │      │
│  └────────────────────────────┘      │
│  [     保存影评     ]                │  ← 保存按钮
├──────────────────────────────────────┤
│  添加日期: 2025-01-12                │  ← 元信息
├──────────────────────────────────────┤
│  [      删除这部电影      ]          │  ← 删除按钮(红色边框)
└──────────────────────────────────────┘

二、数据模型与枚举

2.1 Movie 接口

typescript 复制代码
// 观影状态枚举
enum MovieStatus {
  WANT_TO_WATCH = 'WANT_TO_WATCH',  // 想看
  WATCHING = 'WATCHING',             // 在看
  WATCHED = 'WATCHED'                // 已看
}

// 电影分类
interface Genre {
  id: string;
  name: string;
  icon: string;
}

// 核心数据模型
interface Movie {
  id: string;            // 唯一标识(UUID)
  title: string;         // 电影名称
  year: number;          // 上映年份
  director: string;      // 导演
  rating: number;        // 用户评分 0-5
  status: MovieStatus;   // 观影状态枚举
  isFavorite: boolean;   // 是否收藏
  review: string;        // 影评内容
  dateAdded: string;     // 添加日期
  genreId: string;       // 分类 ID(外键)
}

2.2 分类数据与查询函数

typescript 复制代码
// 分类数据源
const GENRES: Genre[] = [
  { id: '1', name: '科幻', icon: '🚀' },
  { id: '2', name: '剧情', icon: '🎭' },
  { id: '3', name: '喜剧', icon: '😂' },
  { id: '4', name: '动作', icon: '💥' },
  { id: '5', name: '动画', icon: '🐭' }
];

// 根据 ID 查询分类
function getGenreById(id: string): Genre | undefined {
  for (let i = 0; i < GENRES.length; i++) {
    if (GENRES[i].id === id) return GENRES[i];
  }
  return undefined;
}

三、路由传参与数据加载

3.1 跳转传参

从列表页跳转到详情页,传递电影 ID:

typescript 复制代码
// ListPage.ets
import router from '@ohos.router';

// 点击电影卡片时
Column() {
  // ... 电影卡片 UI
}
.onClick(() => {
  router.pushUrl({
    url: 'pages/DetailPage',
    params: { movieId: item.id }
  });
})

为什么传 ID 而非整个对象?

方式 优点 缺点
传 ID 数据只有一份源头 需要二次查询
传整个对象 无需查询 数据可能过期、参数体积大

在实际项目中,传 ID 是更规范的做法------数据以存储层为准,页面只保存引用。

3.2 接收参数与数据加载

typescript 复制代码
@Entry
@Component
struct DetailPage {
  @State movie: Movie = {
    id: '', title: '', year: 0, director: '', rating: 0,
    status: MovieStatus.WANT_TO_WATCH, isFavorite: false,
    review: '', dateAdded: '', genreId: ''
  };
  @State movies: Movie[] = [];
  @State editReview: string = '';
  @State showReviewInput: boolean = false;

  aboutToAppear(): void {
    // 1. 获取路由参数
    const params: Record<string, Object> = router.getParams() as Record<string, Object>;
    const movieId: string = params['movieId'] as string;

    // 2. 从 AppStorage 读取数据
    const stored: Movie[] | undefined = AppStorage.get<Movie[]>('movies');
    if (stored) {
      this.movies = stored;
      // 3. 遍历查找匹配的电影
      for (let i = 0; i < stored.length; i++) {
        if (stored[i].id === movieId) {
          this.movie = stored[i];
          this.editReview = stored[i].review;  // 初始化编辑草稿
          break;
        }
      }
    }
  }
}

关键知识点

  1. router.getParams() 返回类型Record<string, Object>,需要 as 断言转换
  2. 判空安全AppStorage.get() 可能返回 undefined,必须用 if (stored) 守卫
  3. 深拷贝问题this.movie = stored[i] 是引用赋值,修改 this.movie 会直接修改原数组中的对象
  4. editReview 独立存储 :编辑中的影评用一个独立变量 editReview 保存,避免直接修改数据源

3.3 数据模型初始化

在 ArkTS 严格模式下,@State 变量必须显式初始化:

typescript 复制代码
@State movie: Movie = {
  id: '', title: '', year: 0, director: '', rating: 0,
  status: MovieStatus.WANT_TO_WATCH, isFavorite: false,
  review: '', dateAdded: '', genreId: ''
};

如果不写这个完整的初始化对象,编译器会报:

复制代码
arkts-no-untyped-obj-literals: Object literals used in typed context must have explicit type annotations.

四、电影信息头部

4.1 实现

typescript 复制代码
@Builder buildMovieHeader() {
  Column() {
    // 分类图标
    Text(getGenreById(this.movie.genreId)?.icon ?? '🎬')
      .fontSize(56).margin({ bottom: 8 })

    // 电影标题
    Text(this.movie.title)
      .fontSize(24).fontWeight(FontWeight.Bold)
      .fontColor('#333333').textAlign(TextAlign.Center)

    // 年份 · 分类
    Text(this.movie.year + ' · '
      + (getGenreById(this.movie.genreId)?.name ?? '未知'))
      .fontSize(14).fontColor('#999999').margin({ top: 4 })

    // 导演
    Text('导演: ' + (this.movie.director ? this.movie.director : '未知'))
      .fontSize(14).fontColor('#999999').margin({ top: 2 })
  }
  .width('100%').padding(20)
  .backgroundColor('#FFFFFF')
  .alignItems(HorizontalAlign.Center)
}

4.2 可选链操作符 ?.

typescript 复制代码
getGenreById(this.movie.genreId)?.icon ?? '🎬'

这一行用了两个 ES2020 特性:

  • ?. 可选链 :如果 getGenreById() 返回 undefined,则不访问 .icon,直接返回 undefined
  • ?? 空值合并 :如果左侧是 undefinednull,使用右侧的默认值 '🎬'

为什么不直接用 || 因为 || 会把 ''(空字符串)、0false 也视为假值,而 ?? 只针对 null/undefined


五、状态切换

5.1 状态按钮 Builder

typescript 复制代码
@Builder statusBtn(label: string, status: MovieStatus) {
  Text(label)
    .fontSize(13)
    .fontColor(this.movie.status === status ? '#FFFFFF' : '#666666')
    .backgroundColor(this.movie.status === status ? '#6C63FF' : '#F0F0F0')
    .padding({ left: 12, right: 12, top: 6, bottom: 6 })
    .borderRadius(14)
    .margin({ right: 6 })
    .onClick(() => { this.changeStatus(status); })
}

带参数的 @Builder :这里 statusBtn 接收两个参数------显示的文本和对应的枚举值。调用时传入不同参数即可复用。

5.2 调用方式

typescript 复制代码
Row() {
  this.statusBtn('🎬 想看', MovieStatus.WANT_TO_WATCH)
  this.statusBtn('▶️ 在看', MovieStatus.WATCHING)
  this.statusBtn('✅ 已看', MovieStatus.WATCHED)
}
.width('94%').padding(14)
.backgroundColor('#FFFFFF').borderRadius(12)

5.3 状态变更方法

typescript 复制代码
changeStatus(newStatus: MovieStatus): void {
  this.movie.status = newStatus;
  this.saveMovieData();
}

为什么只有一行代码?

  • this.movie.status@State 变量的一部分
  • 修改后立即触发 UI 刷新(按钮高亮自动切换)
  • 调用 saveMovieData() 将变更持久化

5.4 状态切换的视觉反馈

状态 按钮高亮 颜色
想看 「🎬 想看」高亮 #6C63FF 紫色
在看 「▶️ 在看」高亮 #6C63FF 紫色
已看 「✅ 已看」高亮 #6C63FF 紫色

三个按钮是互斥关系,同一时间只有一个处于高亮态。


六、收藏功能(Toggle 组件)

6.1 实现

typescript 复制代码
Row() {
  Text(this.movie.isFavorite ? '⭐ 已收藏' : '☆ 收藏')
    .fontSize(16)
    .fontColor(this.movie.isFavorite ? '#FFA502' : '#999999')
  Blank()
  Toggle({ type: ToggleType.Checkbox, isOn: this.movie.isFavorite })
    .onChange((v: boolean) => {
      this.movie.isFavorite = v;
      this.saveMovieData();
    })
}
.width('94%').padding(14)
.backgroundColor('#FFFFFF').borderRadius(12)
.margin({ top: 10 })

6.2 Toggle 组件详解

HarmonyOS 的 Toggle 组件有三种类型:

类型 外观 适用场景
ToggleType.Checkbox ☑ 方框勾选 开关类选项
ToggleType.Switch 滑动开关 设置项开关
ToggleType.Button 按钮态 按钮式切换

onChange 回调 :当用户切换状态时触发,接收一个 boolean 参数 v,表示当前是否被选中。

关键细节

  • isOn 控制初始状态,这里绑定 this.movie.isFavorite
  • onChange 中更新数据后调用 saveMovieData() 持久化

6.3 为什么不直接用 Text 的 onClick?

typescript 复制代码
// ❌ 不推荐:只点文本有反馈
Text('⭐ 已收藏').onClick(() => { this.movie.isFavorite = false; })

// ✅ 推荐:Toggle 提供原生开关交互
Toggle({...})

Toggle 提供的是系统级开关交互,有:

  • 原生的点击反馈动画
  • 无障碍支持(TalkBack 可识别)
  • 视觉上更符合用户预期

七、星级评分组件

7.1 辅助函数

typescript 复制代码
// 生成星级字符串
export function starsString(rating: number): string {
  let full = '★'.repeat(rating);
  let empty = '☆'.repeat(5 - rating);
  return full + empty;
}

rating 取值范围 0-5,例如:

  • starsString(0)"☆☆☆☆☆"
  • starsString(3)"★★★☆☆"
  • starsString(5)"★★★★★"

7.2 只读展示区

typescript 复制代码
Column() {
  Text('我的评分')
    .fontSize(14).fontColor('#999999').width('100%')

  // ★★★★★ 展示当前评分
  Row() {
    Text(starsString(this.movie.rating))
      .fontSize(32).fontColor('#FFA502').letterSpacing(4)
  }.width('100%').margin({ top: 6 })

  // 可点击评分区
  Row() {
    this.starBtn(1)
    this.starBtn(2)
    this.starBtn(3)
    this.starBtn(4)
    this.starBtn(5)
  }
  .width('100%').margin({ top: 6 })
}
.width('94%').padding(14)
.backgroundColor('#FFFFFF').borderRadius(12)
.margin({ top: 10 })

7.3 可点击星星

typescript 复制代码
@Builder starBtn(rating: number) {
  Text(rating <= this.movie.rating ? '★' : '☆')
    .fontSize(30).fontColor('#FFA502').margin({ right: 4 })
    .onClick(() => { this.setRating(rating); })
}

三目运算逻辑

typescript 复制代码
rating (按钮值) <= this.movie.rating (当前评分)  ? '★' : '☆'

举个例子:当前评分为 3:

  • starBtn(1)1 <= 3'★'
  • starBtn(2)2 <= 3'★'
  • starBtn(3)3 <= 3'★'
  • starBtn(4)4 <= 3'☆'
  • starBtn(5)5 <= 3'☆'

用户点击第 4 颗星时:

typescript 复制代码
setRating(4)  // 将评分更新为 4

UI 自动变为 ★★★★☆

7.4 评分保存

typescript 复制代码
setRating(r: number): void {
  this.movie.rating = r;
  this.saveMovieData();
}

交互反馈

  1. 用户点击某颗星 → setRating(r) 触发
  2. this.movie.rating 更新 → 所有 starBtn 中的三目运算重新计算
  3. 上面的 starsString(this.movie.rating) 也重新计算
  4. 两个区域同时更新 → 用户看到评分变化

这就是数据驱动 UI 的核心魅力:修改一个状态,多个依赖该状态的 UI 片段自动刷新。


八、影评编辑(TextArea 组件)

8.1 影评区域

typescript 复制代码
Column() {
  Row() {
    Text('我的影评').fontSize(14).fontColor('#999999')
    Blank()
    Text('编辑')
      .fontSize(13).fontColor('#6C63FF')
      .onClick(() => {
        this.editReview = this.movie.review;  // 加载现有影评到草稿
        this.showReviewInput = !this.showReviewInput;  // 切换编辑态
      })
  }.width('100%')

  // 编辑态:TextArea 输入 + 保存按钮
  if (this.showReviewInput) {
    TextArea({ placeholder: '写下你的影评...', text: this.editReview })
      .fontSize(15).height(100).width('100%')
      .placeholderColor('#CCCCCC').backgroundColor('#F9F9F9')
      .margin({ top: 8 })
      .onChange((v: string) => { this.editReview = v; })

    Button('保存影评')
      .width('100%').height(36)
      .backgroundColor('#6C63FF').borderRadius(18)
      .fontSize(14).fontColor('#FFFFFF')
      .margin({ top: 6 })
      .onClick(() => { this.saveReview(); })
  } else {
    // 阅读态:显示影评文本
    Text(this.movie.review
      ? this.movie.review
      : '暂无影评,点击编辑添加...')
      .fontSize(15)
      .fontColor(this.movie.review ? '#333333' : '#CCCCCC')
      .width('100%').margin({ top: 6 })
  }
}
.width('94%').padding(14)
.backgroundColor('#FFFFFF').borderRadius(12)
.margin({ top: 10 })

8.2 编辑态/阅读态切换

这个模块的核心在于 条件渲染

复制代码
用户点击「编辑」
  → showReviewInput = true
  → 阅读态隐藏,编辑态显示
  → TextArea 出现,预填现有影评

用户点击「保存影评」
  → saveReview()
  → showReviewInput = false
  → 编辑态隐藏,阅读态显示
  → 显示新保存的影评

两个状态的 UI 对比

状态 showReviewInput 显示内容
阅读态 false Text 显示影评 或 灰色占位文字
编辑态 true TextArea + 保存按钮

8.3 TextArea 组件详解

typescript 复制代码
TextArea({ placeholder: '写下你的影评...', text: this.editReview })
  .fontSize(15)
  .height(100)          // 固定高度 100vp
  .width('100%')        // 撑满父容器
  .placeholderColor('#CCCCCC')   // 占位文字颜色
  .backgroundColor('#F9F9F9')    // 浅灰背景
  .onChange((v: string) => { this.editReview = v; })

TextArea 属性一览

属性 说明 示例值
placeholder 占位提示文字 '写下你的影评...'
text 初始文本内容 this.editReview
placeholderColor 占位文字颜色 '#CCCCCC'
maxLength 最大字符数 500
onChange 文本变化回调 (v) => { this.editReview = v; }

注意TextArea 是受控组件,需要通过 onChange 将用户输入同步到 editReview

8.4 保存方法

typescript 复制代码
saveReview(): void {
  this.movie.review = this.editReview;
  this.showReviewInput = false;
  this.saveMovieData();
}

三步走

  1. 将草稿 editReview 写入正式字段 movie.review
  2. 收起编辑区 showReviewInput = false
  3. 持久化到 AppStorage

九、数据持久化(saveMovieData)

9.1 核心保存方法

typescript 复制代码
saveMovieData(): void {
  let updated = false;
  // 遍历列表,找到对应电影并更新
  for (let i = 0; i < this.movies.length; i++) {
    if (this.movies[i].id === this.movie.id) {
      this.movies[i] = this.movie;    // 引用赋值,直接替换
      updated = true;
      break;
    }
  }
  // 如果没找到(新电影),追加到列表
  if (!updated) {
    this.movies.push(this.movie);
  }
  // 写回 AppStorage
  AppStorage.set<Movie[]>('movies', this.movies);
}

9.2 为什么需要 updated 标记?

考虑两种场景:

场景一:已有电影修改(正常流程)

  • 详情页是从列表页跳转过来的
  • this.movies 中已经包含该电影
  • 遍历找到后直接替换数组元素

场景二:新增电影(异常/防御)

  • 如果用户通过非常规路径进入
  • this.movies 中没有该电影
  • 使用 push 追加到末尾

updated 标记确保了这两种场景都能正确处理。

9.3 AppStorage 使用要点

typescript 复制代码
// 读取
AppStorage.get<Movie[]>('movies')

// 写入
AppStorage.set<Movie[]>('movies', this.movies)
API 说明 注意
get<T>(key) 读取持久化数据 可能返回 undefined
set<T>(key, value) 写入持久化数据 会覆盖已有值
delete<T>(key) 删除键值 谨慎使用

数据流路径

复制代码
用户操作 → @State 更新 → saveMovieData() → AppStorage.set()
                                                                  ↓
其他页面(如列表页) → AppStorage.get() → 获取最新数据

十、删除功能

10.1 删除按钮

typescript 复制代码
Button('删除这部电影')
  .width('94%').height(44)
  .backgroundColor('#FFFFFF')
  .fontColor('#FF4757').fontSize(16)
  .borderRadius(22)
  .border({ width: 1, color: '#FF4757' })
  .margin({ top: 16, bottom: 30 })
  .onClick(() => { this.deleteMovie(); })

视觉设计

  • 白底红字红边框------这是标准的「危险操作」样式
  • 圆角 22(高度 44 的一半)------胶囊按钮
  • 与其他操作按钮形成视觉区分

10.2 删除逻辑

typescript 复制代码
deleteMovie(): void {
  // 构建新数组(排除当前电影)
  let newList: Movie[] = [];
  for (let i = 0; i < this.movies.length; i++) {
    if (this.movies[i].id !== this.movie.id) {
      newList.push(this.movies[i]);
    }
  }
  // 更新持久化数据
  AppStorage.set<Movie[]>('movies', newList);
  // 返回上一页
  router.back();
}

为什么不直接用 splice

ArkTS 严格模式下可以直接使用 splice,但在函数式编程风格中,构建新数组(不可变更新)是更安全的方式:

  • 不影响原数组
  • 避免潜在的引用问题
  • 逻辑更清晰

10.3 安全改进空间

当前版本没有二次确认弹窗,用户点击即删除。实际产品中应该增加确认弹窗:

typescript 复制代码
// 在 deleteMovie() 前增加确认
showDeleteConfirm(): void {
  // 使用 AlertDialog 或自定义弹窗
  AlertDialog.show({
    title: '确认删除',
    message: '确定要删除《' + this.movie.title + '》吗?此操作不可恢复。',
    primaryButton: {
      value: '取消',
      action: () => {}
    },
    secondaryButton: {
      value: '删除',
      action: () => { this.deleteMovie(); }
    }
  });
}

十一、页面组装与完整生命周期

11.1 build 方法

typescript 复制代码
build(): void {
  Column() {
    // 导航栏
    Row() {
      Text('← 返回')
        .fontSize(16).fontColor('#6C63FF')
        .onClick(() => { router.back(); })
      Blank()
      Text('电影详情')
        .fontSize(17).fontWeight(FontWeight.Bold).fontColor('#333333')
      Blank()
      Text('').width(60)  // 留白保持标题居中
    }
    .width('100%').padding({ left: 16, right: 16, top: 12, bottom: 12 })
    .backgroundColor('#FFFFFF')

    Scroll() {
      Column() {
        this.buildMovieHeader()        // 电影信息头部
        this.buildStatusSection()      // 状态切换
        this.buildFavoriteSection()    // 收藏
        this.buildRatingSection()      // 评分
        this.buildReviewSection()      // 影评
        this.buildMetaInfo()           // 元信息
        this.buildDeleteButton()       // 删除
      }
      .width('100%').padding({ bottom: 30 })
    }
    .scrollable(ScrollDirection.Vertical)
    .layoutWeight(1).width('100%')
  }
  .width('100%').height('100%').backgroundColor('#F5F5F5')
}

11.2 页面完整生命周期

复制代码
页面跳转
  └→ aboutToAppear()
      ├→ 读取路由参数
      ├→ 从 AppStorage 读取数据
      └→ 加载目标电影到 @State

用户交互
  └→ 状态切换 / 评分 / 收藏 / 编辑影评
      ├→ 更新 @State 变量
      ├→ UI 自动刷新
      └→ saveMovieData() 持久化

用户点击删除
  └→ deleteMovie()
      ├→ 从数组中移除
      ├→ 更新 AppStorage
      └→ router.back() 返回

页面销毁
  └→ aboutToDisappear()
      (无需额外操作,数据已持久化)

十二、完整数据流图

复制代码
AppStorage
  │
  ├── ListPage.ets
  │     │  aboutToAppear() → AppStorage.get('movies')
  │     │  渲染列表
  │     │  点击卡片 → router.pushUrl({ movieId })
  │     │
  │     └──→ DetailPage.ets
  │              aboutToAppear() → 接收 params
  │              → AppStorage.get('movies')
  │              → 查找匹配 → @State movie
  │              │
  │              ├── 状态切换 → changeStatus()
  │              ├── 收藏切换 → Toggle.onChange()
  │              ├── 评分点击 → setRating()
  │              └── 保存影评 → saveReview()
  │                    │
  │                    └── saveMovieData()
  │                          └── AppStorage.set('movies')
  │                                  │
  │                                  └── 列表页 aboutToAppear 可读到最新数据
  │
  └── 数据闭环完成

关键数据流特点

  1. 单向数据流:AppStorage → @State → UI
  2. 单一数据源:所有数据以 AppStorage 为准
  3. 即时持久化:每次修改立即保存,不怕页面意外关闭

十三、ArkTS 严格模式踩坑记录

13.1 路由参数的类型断言

typescript 复制代码
// ❌ 错误:缺少类型断言
const params = router.getParams();
const movieId = params['movieId'];

// ✅ 正确:完整类型断言 + 判空
const params = router.getParams() as Record<string, Object>;
if (params && params['movieId'] !== undefined) {
  const movieId: string = params['movieId'] as string;
}

13.2 AppStorage 的类型安全

typescript 复制代码
// ❌ 错误:不检查 undefined
this.movies = AppStorage.get<Movie[]>('movies');

// ✅ 正确:判空守卫
const stored = AppStorage.get<Movie[]>('movies');
if (stored) {
  this.movies = stored;
}

13.3 对象字面量初始化

typescript 复制代码
// ❌ 错误:空对象无法推断类型
@State movie: Movie = {};

// ✅ 正确:完整初始化
@State movie: Movie = {
  id: '', title: '', year: 0, director: '', rating: 0,
  status: MovieStatus.WANT_TO_WATCH, isFavorite: false,
  review: '', dateAdded: '', genreId: ''
};

13.4 枚举的使用

typescript 复制代码
// ✅ 枚举可以替代常量字符串,保证类型安全
this.movie.status = MovieStatus.WATCHING;

// ❌ 不推荐:裸字符串容易手误
this.movie.status = 'watching';  // 拼写错误不会编译报错

十四、性能优化与最佳实践

14.1 避免重复查找

每次 saveMovieData() 都遍历一次数组。如果列表很大(几千条),可以建立 ID 索引:

typescript 复制代码
// 优化:建立 ID → index 映射
saveMovieDataOptimized(): void {
  for (let i = 0; i < this.movies.length; i++) {
    if (this.movies[i].id === this.movie.id) {
      this.movies[i] = this.movie;
      break;  // 找到立即退出,不要继续循环
    }
  }
  AppStorage.set<Movie[]>('movies', this.movies);
}

这里已经使用了 break 优化,在找到目标后立即退出循环。

14.2 合理使用条件渲染

typescript 复制代码
// ✅ 推荐:使用 if 进行条件渲染
if (this.showReviewInput) {
  TextArea({...})
} else {
  Text({...})
}

// ❌ 不推荐:用 visibility 控制显隐(组件仍在内存中)
TextArea({...}).visibility(this.showReviewInput ? Visibility.Visible : Visibility.None)

if 条件渲染会彻底销毁/创建组件,而 visibility 只是隐藏。对于 TextArea 这种有状态的输入组件,销毁重建可以确保每次进入编辑态时都是干净的状态。

14.3 数据流最佳实践总结

操作 更新方式 持久化
状态切换 直接赋值 this.movie.status = x 立即 saveMovieData()
收藏 Toggle onChange 立即 saveMovieData()
评分 setRating(r) 立即 saveMovieData()
影评 点击「保存影评」时 saveReview()saveMovieData()

原则:每次修改都立即保存 ------因为详情页可能随时被返回或销毁,不能依赖 aboutToDisappear 做批量保存(用户可能意外退出)。


总结

本文完成了电影详情页的完整开发,涵盖:

模块 技术点 交互方式
路由传参 router.pushUrl + getParams 列表页点击跳转
状态切换 @Builder 带参复用 + enum 三选一互斥按钮
收藏 Toggle 组件 Checkbox 开关
星级评分 starsString() + 可点击 Builder 点击星星评分
影评编辑 TextArea + 条件渲染 展开/收起编辑态
数据持久化 AppStorage get/set 每次修改即时保存
删除 数组过滤 + router.back() 红色按钮操作

至此,一个电影的 CRUD(增删改查) 全流程已经打通:

  • Create:添加电影页
  • Read:列表页 + 详情页
  • Update:状态/评分/影评编辑
  • Delete:删除电影

下一篇将开发个人中心页面,实现数据统计、分类分析和全局设置!


系列目录

  • ✅ 第一篇:项目搭建与首页概览
  • ✅ 第二篇:添加电影与表单交互
  • ✅ 第三篇:电影列表与搜索筛选
  • 第四篇:电影详情与评分评价(本篇)
  • ⏳ 第五篇:个人中心与数据统计

项目信息 :API 23 (compatible) / API 24 (target) | Stage 模型 | ArkTS

源码路径请补充你的项目地址

(完)

相关推荐
风华圆舞1 小时前
鸿蒙语音播报功能 的 Flutter 侧封装思路
flutter·华为·harmonyos
风华圆舞2 小时前
鸿蒙 + Flutter 下美食探索场景为什么 AI 推荐比传统搜索更自然
flutter·harmonyos·美食
小博测试成长之路2 小时前
行业日报 | 2026年6月12日:Claude新模型、鸿蒙开发者大会与AI工程化加速
人工智能·harmonyos
互联网散修2 小时前
鸿蒙实战:从零实现自定义相机(上)——架构设计与核心实现
数码相机·华为·harmonyos·自定义相机
不羁的木木2 小时前
《HarmonyOS 6.1 新能力实战之智感握姿》第一篇:初识智感握姿——能力与场景全解析
华为·harmonyos
狼哥16862 小时前
《新闻资讯》八、产品定制层实现指南
ui·华为·harmonyos
浮芷.2 小时前
六星光芒阵:HarmonyOS API 24 Canvas 高级绘图实战
科技·华为·开源·harmonyos·鸿蒙
狼哥16863 小时前
《新闻资讯》一、应用分层模块化整体实现指南
ui·harmonyos
木咺吟3 小时前
鸿蒙原生应用实战(一):项目搭建与首页开发 — 游戏收藏夹
华为·harmonyos