书影鉴(ArkTS)
介绍
本篇 Codelab 介绍了如何实现一个基于ArkTS 的简易 图书/电影 内容评价 Demo 应用。其主要功能包括:
- 图书/电影双分区浏览
- 搜索与分类筛选(按关键词匹配、按分类标签筛选)
- 榜单(按评分排序,支持分类 Top)
- 图书/电影详情页:收藏、评分、评论
- 广场:发帖、点赞、评论
- 收藏:聚合展示已收藏的图书/电影
搭建 OpenHarmony 环境
软件要求
- DevEco Studio:DevEco Studio 6.0.0 Release。
- OpenHarmony SDK 版本:API version 20。
硬件要求
- 开发板类型:润和 DAYU200 开发板
- OpenHarmony系统:6.0 Release
环境搭建
完成本篇 Codelab 先要完成开发环境的搭建,本示例以 DAYU200 开发板为例,参照以下步骤进行:
-
获取OpenHarmony系统版本:标准系统解决方案(二进制)。以 6.0 版本为例:

-
搭建烧录环境
-
搭建开发环境
代码结构解读
本篇 Codelab 只对核心代码进行讲解,完整代码可以直接从 gitcode 获取。
├──entry/src/main/ets
│ ├──entryability
│ │ └──EntryAbility.ets // 程序入口类
│ ├──entrybackupability
│ │ └──EntryBackupAbility.ets // 数据备份与恢复类
│ ├──model
│ │ ├──BookData.ets // 图书数据
│ │ └──MovieData.ets // 电影数据
│ ├──pages
│ │ ├──CommunityComp.ets // 广场组件
│ │ ├──Index.ets // 首页
│ │ ├──Login.ets // 简易登录页
│ │ ├──MediaDetail.ets // 详情页
│ │ ├──MediaSearch.ets // 搜索页:关键词/分类筛选
│ │ ├──MyFavorites.ets // 收藏:展示收藏列表
│ │ ├──MyPage.ets // 用户主页
│ │ ├──PostDetail.ets // 帖子详情
│ │ ├──RankPage.ets // 榜单页
│ │ ├──RatingDialog.ets // 评分弹窗
│ └──utils
│ └──RdbUtil.ets // RDB 封装:用户/收藏/帖子/评分/评论
├──entry/src/main/resources // 应用静态资源目录
└──entry/src/main/module.json5 // 模块配置
首页(Index)
首页既是“书/影发现页”,也是应用的主容器:上层用 section 切换图书/电影内容区,中部提供“搜索/榜单”两个入口,下层用 Tabs 承载“首页/广场/个人中心”。
核心代码如下:
// 1) 书/影分区与推荐游标
@State private section: 'book' | 'movie' = 'book'
@State private recommendIndex: number = 0
@State private recommendMovieIndex: number = 0
private readonly featuredBooks: Book[] = initialBooks
private readonly featuredMovies: Movie[] = initialMovies
// 2) 分区切换(点击 “图书/电影” tab)
ForEach((['book', 'movie'] as const), (it) => {
Text(it === 'book' ? '图书' : '电影')
.onClick(() => { this.section = it })
})
// 3) 两个主要入口:搜索/榜单(电影模式通过 params.type 传参)
if (this.section === 'book') {
router.pushUrl({ url: 'pages/MediaSearch' })
router.pushUrl({ url: 'pages/RankPage' })
} else {
router.pushUrl({ url: 'pages/MediaSearch', params: { type: 'movie' } })
router.pushUrl({ url: 'pages/RankPage', params: { type: 'movie' } })
}
// 4) 推荐列表:每次展示 4 个,支持“ 换一批” 循环
ForEach(this.featuredBooks.slice(this.recommendIndex, this.recommendIndex + 4), (b: Book) => {
router.pushUrl({ url: 'pages/MediaDetail', params: { book: b } })
})
this.recommendIndex = (this.recommendIndex + 4) % this.featuredBooks.length
ForEach(this.featuredMovies.slice(this.recommendMovieIndex, this.recommendMovieIndex + 4), (m: Movie) => {
router.pushUrl({ url: 'pages/MediaDetail', params: { book: m, type: 'movie' } })
})
this.recommendMovieIndex = (this.recommendMovieIndex + 4) % this.featuredMovies.length
// 5) 底部 Tabs:承载首页/广场/个人中心;广场未登录时统一跳转登录页
Tabs({ barPosition: BarPosition.End, index: this.currentTab }) {
TabContent() { this.homeContent() }
TabContent() {
CommunityComp({
updateTrigger: this.userUpdateTrigger,
onNeedLogin: (tip?: string) => {
router.pushUrl({
url: 'pages/MyPage',
params: { tip: tip ?? '请登录后发布动态', backOnSuccess: true, isPageMode: true }
})
}
})
}
TabContent() { MyPageComp({ tip: this.loginTip, updateTrigger: this.userUpdateTrigger }) }
}
-
分区:
section控制图书/电影两套入口与推荐列表。 -
推荐:通过
slice(index, index + 4)做分页窗口,“换一批”把游标加 4 并对长度取模。 -
路由:进入搜索/榜单/详情页统一用
router.pushUrl();电影场景通过params.type = 'movie'透传到目标页。 -
Tabs:广场组件把“未登录提示”回调给首页统一处理,跳转到
pages/MyPage完成登录后再返回。搜索页(MediaSearch)
搜索页做两件事:
- 根据路由参数切换“图书/电影”数据源与分类。
- 根据输入关键词 + 分类过滤列表;有关键词时按评分排序(评分相同按评分人数),无关键词时保持原始列表顺序。
核心代码如下:
1) 类型切换(路由参数驱动)
aboutToAppear() {
const params = router.getParams() as Record<string, Object> | undefined
if (params && params['type'] === 'movie') {
this.searchType = 'movie'
this.categories = ['全部', '科幻', '动作', '喜剧']
this.items = initialMovies
} else {
this.searchType = 'book'
this.categories = ['全部', '计算机', '科学技术', '数学']
this.items = initialBooks
}
}
type === 'movie'时进入电影模式,否则默认图书模式。
2) 筛选与排序(关键词 AND + 分类)
private filteredItems(): (Book | Movie)[] {
const q = this.query.trim().toLowerCase()
const keywords = q.split(/\s+/).filter(k => k.length > 0)
const cat = this.selectedCategory
const base = this.items.filter((b: Book | Movie) => {
let hitQuery = true
if (keywords.length > 0) {
let fullText = ''
if (this.searchType === 'book') {
const book = b as Book
fullText = (book.title + ' ' + book.author + ' ' + book.publisher + ' ' + book.year).toLowerCase()
} else {
const movie = b as Movie
const castNames = movie.cast.map(c => c.name).join(' ')
fullText = (movie.title + ' ' + movie.director + ' ' + castNames + ' ' + movie.year).toLowerCase()
}
for (const k of keywords) {
if (!fullText.includes(k)) {
hitQuery = false
break
}
}
}
const hitCat = cat === '全部' || b.tags.includes(cat)
return hitQuery && hitCat
})
const sorted = base.slice()
if (keywords.length > 0) {
sorted.sort((a: Book | Movie, b: Book | Movie) => {
if (b.rating !== a.rating) {
return b.rating - a.rating
}
return b.ratingCount - a.ratingCount
})
}
return sorted
}
-
关键词用空格分词,并用
every()实现“所有关键词都命中”(AND)。 -
分类通过
item.tags.includes(cat)过滤;有关键词时按rating降序输出(评分相同按ratingCount),无关键词时保持原顺序。榜单页(RankPage)
榜单页的核心就是:选数据源(图书/电影)→ 统一排序(评分优先,评分人数兜底)→ 按榜单类型截取 Top。
核心逻辑在 getRankData():
private getRankData(): (Book | Movie)[] {
let list: (Book | Movie)[] = this.rankType === 'movie' ? [...initialMovies] : [...initialBooks]
const sortFn = (a: Book | Movie, b: Book | Movie) => {
if (b.rating !== a.rating) return b.rating - a.rating
return b.ratingCount - a.ratingCount
}
if (this.currentRank === '口碑榜Top10') {
list.sort(sortFn)
return list.slice(0, 10)
}
if (this.currentRank.endsWith('Top5')) {
const tag = this.currentRank.replace('Top5', '')
list = list.filter(item => item.tags.includes(tag))
list.sort(sortFn)
return list.slice(0, 5)
}
return list.slice(0, 10)
}
- 口碑榜:全量排序后取前 10。
- 分类 Top5:先按
tags过滤,再排序后取前 5。 - 数量不足时:
slice(0, 10)/slice(0, 5)会返回实际可用数量(可能少于 10/5),不会越界或报错。
详情页(MediaDetail)
详情页负责把一个条目的“互动能力”串联起来:初始化参数 → 加载登录态 → 收藏/评分/评论 → 刷新评分统计。
1) 参数初始化与页面加载
详情页通过路由参数拿到 book(图书或电影对象)和可选的 type(默认图书)。初始化后,会加载当前用户、检查收藏状态,并拉取评论与评分统计。
async aboutToAppear(): Promise<void> {
const params = router.getParams() as Record<string, Object> | undefined
if (!params || !params['book']) {
this.hasBook = false
return
}
this.book = params['book'] as MediaItem
this.type = (params['type'] as 'book' | 'movie') || 'book'
this.hasBook = true
this.status = this.type === 'movie' ? '想看' : '想读'
this.bookRating = this.book.rating
this.bookRatingCount = this.book.ratingCount
await this.loadUser()
if (this.userName.length > 0) {
await this.checkFavorite()
}
await this.loadReviews()
}
2) 收藏(依赖登录态)
收藏按钮会根据 isFavorited 调用 RDB 的增删接口;未登录时统一引导到 pages/MyPage 登录。
private async toggleFavorite(): Promise<void> {
if (this.userName.length <= 0) {
this.goLogin('请登录后收藏')
return
}
const ctx = getContext(this) as Context
if (this.isFavorited) {
const ok = await RdbUtil.removeFavorite(ctx, this.userName, this.book.id)
if (ok) this.isFavorited = false
} else {
const ok = await RdbUtil.addFavorite(ctx, this.userName, this.book.id, this.type)
if (ok) this.isFavorited = true
}
}
3) 评分/评价(弹窗 + 写入 RDB)
评分入口会打开 RatingDialog。提交后把评分/文本评价以 DbReview 写入本地数据库,并重新加载列表与统计。
private openRatingDialog() {
if (this.userName.length <= 0) {
this.goLogin('请登录后评分')
return
}
this.dialogController = new CustomDialogController({
builder: RatingDialog({
rating: this.userRating,
content: this.userReviewContent,
isExist: this.userRating > 0,
action: (rating: number, content: string): void => { this.saveReview(rating, content) },
deleteAction: () => { this.deleteRating() }
}),
alignment: DialogAlignment.Center,
autoCancel: true,
customStyle: true
})
this.dialogController.open()
}
private async saveReview(rating: number, content: string) {
const ctx = getContext(this) as Context
const now = new Date()
const dateStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
const review = new DbReview(
`review_${this.book.id}_${this.userName}`,
this.userName,
this.userAvatarKey,
rating,
content,
dateStr,
0
)
await RdbUtil.upsertReview(ctx, this.book.id, review)
await this.loadReviews()
}
4) 评论列表与评分统计
- 评论列表只展示“有文本内容”的评价(纯评分不进入列表),并在数据库无数据时使用内置 mock 评价作为 fallback。
- 评分统计(均分/人数/星级分布)在
loadReviews()末尾通过refreshStats()从本地 RDB 汇总后刷新 UI(包括顶部评分、人数与分布条形图)。
广场(CommunityComp / PostDetail)
广场是一个轻量动态流:列表展示帖子 → 登录后可发帖/点赞 → 点击进入详情评论。
1) 列表加载(用户态 + 帖子列表)
CommunityComp 在生命周期里加载当前用户与帖子列表;当 Tab 切换触发 updateTrigger 时也会刷新。
private async loadUser() {
const ctx = getContext(this) as Context
const u = await RdbUtil.getCurrentUser(ctx)
if (u) {
this.userName = u.name
this.userAvatarKey = u.avatarKey
} else {
this.userName = ''
this.userAvatarKey = 'user'
}
}
private async loadPosts() {
const ctx = getContext(this) as Context
this.posts = await RdbUtil.getPosts(ctx, this.userName)
}
2) 发帖(写入 posts 表)
发帖要求登录且内容非空,成功后关闭弹层并重新拉取列表。
private async submitPost() {
if (this.userName === '') {
promptAction.showToast({ message: '请先登录' })
return
}
if (this.newPostContent.trim() === '') {
promptAction.showToast({ message: '请输入内容' })
return
}
const ctx = getContext(this) as Context
const now = new Date()
const p = new DbPost(
`post_${now.getTime()}`,
this.userName,
this.userAvatarKey,
this.newPostContent,
this.newPostTags,
now.toISOString().split('T')[0],
0,
0
)
const ok = await RdbUtil.createPost(ctx, p)
if (ok) {
this.newPostContent = ''
this.newPostTags = ''
this.showPostModal = false
await this.loadPosts()
promptAction.showToast({ message: '发布成功' })
}
}
3) 点赞与进入帖子详情
点赞同样要求登录;未登录时通过 onNeedLogin 统一跳转到 pages/MyPage。
点击帖子卡片会进入 PostDetail,在详情页里评论/删除自己的评论。
router.pushUrl({
url: 'pages/PostDetail',
params: { post: post }
})
await RdbUtil.togglePostLike(ctx, this.userName, post.id)
await this.loadPosts()
PostDetail 中发表评论(写入 comments 表)与删除自己的评论:
const comment = new DbComment(
`c_${now.getTime()}`,
this.post.id,
this.userName,
this.userAvatarKey,
this.inputContent,
now.toISOString().split('T')[0]
)
await RdbUtil.addComment(ctx, comment)
await RdbUtil.deleteComment(ctx, comment)
本地数据存储(RdbUtil)
项目通过 @ohos.data.relationalStore 做本地持久化,统一封装在 [RdbUtil.ets](file:///d:/Code/codelabs_7053/ETSUI/MediaReview/entry/src/main/ets/utils/RdbUtil.ets)。核心点只有两类:
- 数据库只打开一次并在启动时建表;2) 页面只调用方法,不直接写 SQL。
private static async openStore(context: Context): Promise<relationalStore.RdbStore | null> {
const config: relationalStore.StoreConfig = {
name: 'media_review.db',
securityLevel: relationalStore.SecurityLevel.S1
}
const store: relationalStore.RdbStore = await relationalStore.getRdbStore(context, config)
await store.executeSql('CREATE TABLE IF NOT EXISTS reviews (...)')
await store.executeSql('CREATE TABLE IF NOT EXISTS user_profile (...)')
await store.executeSql('CREATE TABLE IF NOT EXISTS users (...)')
await store.executeSql('CREATE TABLE IF NOT EXISTS user_favorites (...)')
await store.executeSql('CREATE TABLE IF NOT EXISTS posts (...)')
await store.executeSql('CREATE TABLE IF NOT EXISTS comments (...)')
return store
}
private static async getStore(context: Context): Promise<relationalStore.RdbStore | null> {
if (RdbUtil.store) return RdbUtil.store
if (RdbUtil.opening) return await RdbUtil.opening
RdbUtil.opening = RdbUtil.openStore(context)
const s = await RdbUtil.opening
RdbUtil.opening = null
RdbUtil.store = s
return s
}
static async upsertReview(context: Context, bookId: string, review: DbReview): Promise<boolean> {
const store = await RdbUtil.getStore(context)
if (!store) return false
await store.executeSql(
'INSERT OR REPLACE INTO reviews (id, bookId, userName, avatarKey, rating, content, createdAt, likeCount) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[review.id, bookId, review.userName, review.avatarKey, review.rating, review.content, review.createdAt, review.likeCount]
)
return true
}
static async addFavorite(context: Context, userName: string, mediaId: string, type: string): Promise<boolean> {
const store = await RdbUtil.getStore(context)
if (!store) return true
await store.executeSql(
'INSERT OR REPLACE INTO user_favorites (id, userName, mediaId, type, createdAt) VALUES (?, ?, ?, ?, ?)',
[`${userName}_${mediaId}`, userName, mediaId, type, new Date().toISOString()]
)
return true
}
getStore()用静态缓存 +openingPromise 避免重复打开数据库。- 评分/收藏/帖子/评论/登录态等数据,都通过
executeSql()/querySql()封装为独立方法供页面调用。
页面路由
应用页面入口在 main_pages.json(entry/src/main/resources/base/profile/main_pages.json)中配置:
pages/Indexpages/MediaSearchpages/MediaDetailpages/Loginpages/MyPagepages/RankPagepages/MyFavoritespages/PostDetail