【鸿蒙原生应用开发实战(四)】电影详情与评分评价 --- 电影清单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;
}
}
}
}
}
关键知识点:
router.getParams()返回类型 :Record<string, Object>,需要as断言转换- 判空安全 :
AppStorage.get()可能返回undefined,必须用if (stored)守卫 - 深拷贝问题 :
this.movie = stored[i]是引用赋值,修改this.movie会直接修改原数组中的对象 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??空值合并 :如果左侧是undefined或null,使用右侧的默认值'🎬'
为什么不直接用 ||? 因为 || 会把 ''(空字符串)、0、false 也视为假值,而 ?? 只针对 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.isFavoriteonChange中更新数据后调用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();
}
交互反馈:
- 用户点击某颗星 →
setRating(r)触发 this.movie.rating更新 → 所有starBtn中的三目运算重新计算- 上面的
starsString(this.movie.rating)也重新计算 - 两个区域同时更新 → 用户看到评分变化
这就是数据驱动 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();
}
三步走:
- 将草稿
editReview写入正式字段movie.review - 收起编辑区
showReviewInput = false - 持久化到 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 可读到最新数据
│
└── 数据闭环完成
关键数据流特点:
- 单向数据流:AppStorage → @State → UI
- 单一数据源:所有数据以 AppStorage 为准
- 即时持久化:每次修改立即保存,不怕页面意外关闭
十三、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
源码路径:请补充你的项目地址

(完)