欢迎光临
我们一直在努力

【句匠|11】HarmonyOS ArkTS 收藏同步实战:让详情页与收藏页即时一致

学习类应用里的收藏功能看起来很小,但它特别容易暴露状态设计问题。用户在答题页点了收藏,切到收藏页却看不到;收藏页数量变了,底部 Tab 徽标没有跟着变;重新打开应用后收藏丢失;在列表中点击收藏题又跳不到可练习的上下文。这些问题不是图标样式问题,而是“收藏记录由谁保存、谁订阅、谁渲染”的链路没有收口。

句匠项目的收藏能力没有做云端同步,也没有做账号体系。它走的是 HarmonyOS 5.0 以上 ArkTS 本地状态链路:EntryAbility 启动时由 UserDataManager.init() 从 Preferences 读取 favoriteRecords 并写入 AppStorage;PracticePage 在底部工具栏点击收藏按钮后调用 UserDataManager.toggleFavorite();FavoritePage 通过 @StorageLink('favoriteRecords') 订阅同一个数组,自动渲染收藏列表与计数。

本文唯一标记:com.jiaweikang.one18。以下内容只基于真实源码:entry/src/main/ets/pages/PracticePage.ets、entry/src/main/ets/views/FavoritePage.ets、librarya/src/main/ets/utils/UserDataManager.ets、entry/src/main/ets/pages/Index.ets,并参考 entry/src/main/ets/pages/BankDetailPage.ets 中基于 bankId 的题库上下文处理。需要说明真实边界:当前 BankDetailPage.ets 本身没有收藏按钮;收藏切换发生在 PracticePage 的题目练习上下文中。标题里的“详情页”指用户查看题目内容并操作收藏的练习详情体验,而不是题库详情页已经实现收藏功能。

收藏同步封面

一、收藏同步先明确一个唯一数据源

收藏同步最怕多个页面各存一份状态。练习页维护一份 isFavorite,收藏页维护一份 favoriteList,设置页再维护一个 favoriteCount,最后一定会不同步。句匠没有这样拆散,而是让所有页面都围绕 favoriteRecords 这一个 AppStorage 键工作。

收藏同步流程图

核心链路如下。

阶段源码位置作用
初始化 UserDataManager.init() 从 Preferences 读取收藏数组并写入 AppStorage
切换收藏 PracticePage 调用 toggleFavorite 并把返回数组赋给 favRecords
持久化 UserDataManager.persist() 把新数组写回 Preferences
列表显示 FavoritePage 通过 @StorageLink 读取同一组收藏记录
Tab 反馈 FavoritePage.Tabs() 用数组长度显示收藏计数

这条链路没有网络、没有服务器、没有跨设备同步。它解决的是同一设备、同一应用进程和持久化存储之间的一致性。

二、FavoriteRecord 模型保持足够小

收藏记录的数据模型在 UserDataManager.ets 中定义。

export interface FavoriteRecord {
questionId: string
bankId: string
createdAt: string
}

字段很少,但够用。

字段用途
questionId 判断某道题是否已收藏,并作为列表 key
bankId 回查题目、显示题库名称、跳回练习
createdAt 收藏列表显示时间

这里没有把题干、答案、解析完整复制进收藏记录。这样做的好处是收藏数据不会随着题目内容重复膨胀,也能保证题库内容修改后,收藏页展示的是最新题目内容。代价是收藏页渲染时需要通过 questionId + bankId 回查题目。

private findQuestion(questionId: string, bankId: string): Question | undefined {
return getQuestions(bankId).find(q => q.id === questionId)
}

private bankName(bankId: string): string {
const b = getBankById(bankId)
return b ? b.name : bankId
}

这两个方法都在 FavoritePage.ets 中。它们说明收藏页不是保存题目副本,而是根据记录中的 ID 去本地题库目录取展示数据。

三、启动时把 Preferences 载入 AppStorage

收藏数据的持久化文件名和键名由 UserDataManager 管理。

private static prefs: preferences.Preferences | null = null
private static readonly STORE_NAME: string = 'dialect_quiz'
private static readonly K_FAV: string = 'favoriteRecords'

应用启动后,init() 读取 Preferences 中的收藏记录,并创建 AppStorage 状态。

static init(context: common.UIAbilityContext | common.Context): void {
try {
UserDataManager.prefs = preferences.getPreferencesSync(context, { name: UserDataManager.STORE_NAME })
const favStr = UserDataManager.prefs.getSync(UserDataManager.K_FAV, '[]') as string

AppStorage.setOrCreate<FavoriteRecord[]>(
'favoriteRecords',
JSON.parse(favStr) as FavoriteRecord[]
)
} catch (_) {
AppStorage.setOrCreate<FavoriteRecord[]>('favoriteRecords', [])
}
}

这段代码的价值在于把“持久化读取”和“页面订阅”分开。页面不需要知道 Preferences 文件名,也不需要自己处理 JSON 解析失败。失败时回落到空数组,至少能保证页面可打开。

在 HarmonyOS ArkTS 应用中,这种写法比页面直接调用 preferences.getPreferencesSync() 更稳。页面只关心业务状态,平台存储能力由服务层包装。

四、PracticePage 通过 StorageLink 订阅收藏数组

练习页持有收藏数组。

@StorageLink('favoriteRecords') favRecords: FavoriteRecord[] = []

收藏按钮的高亮状态依赖当前题目是否存在于收藏数组中。源码里通过 isCurFav() 判断。

private isCurFav(): boolean {
const q = this.currentQ()
if (!q) return false
return UserDataManager.isFavorite(this.favRecords, q.id)
}

底部工具栏根据这个状态改变图标颜色、文本颜色和背景。

Column({ space: 2 }) {
Image($r('app.media.ic_practice_favorite'))
.width(26)
.height(26)
.objectFit(ImageFit.Contain)
.colorBlend(this.favoriteToolColor())
Text('收藏')
.fontSize(10)
.fontColor(this.favoriteToolColor())
.fontWeight(this.isCurFav() ? FontWeight.Medium : FontWeight.Regular)
}
.backgroundColor(this.favoriteToolBg())

这说明收藏状态不是独立的按钮局部状态,而是从 favoriteRecords 推导出来的。只要数组变化,按钮重新渲染时就能拿到最新状态。

五、toggleFavorite 同时处理新增和取消

收藏按钮点击后,练习页调用 UserDataManager.toggleFavorite()。

.onClick(() => {
const q = this.currentQ()
if (q) {
this.favRecords = UserDataManager.toggleFavorite(this.favRecords, q.id, q.bankId)
}
})

toggleFavorite 的实现是先找 questionId 是否已存在。

static toggleFavorite(records: FavoriteRecord[], questionId: string, bankId: string): FavoriteRecord[] {
const idx = records.findIndex(r => r.questionId === questionId)
let result: FavoriteRecord[]
if (idx >= 0) {
const next = […records]
next.splice(idx, 1)
result = next
} else {
result = [{ questionId, bankId, createdAt: nowStr() }, …records]
}
UserDataManager.persist(UserDataManager.K_FAV, result)
return result
}

这段代码有三个关键点。

点作用
findIndex(r => r.questionId === questionId) 避免同一道题重复收藏
const next = […records] 不直接修改传入数组
return result 调用方把新数组赋给 @StorageLink

最后一步很重要。只调用 persist() 不够,页面状态也要更新。this.favRecords = … 让 AppStorage 中的数组同步变化,收藏页订阅的同一个 key 才能立即刷新。

六、FavoritePage 订阅同一个 favoriteRecords

收藏页的第一行状态就是 @StorageLink('favoriteRecords')。

@StorageLink('favoriteRecords') favRecords: FavoriteRecord[] = []
@StorageLink('noteRecords') noteRecords: NoteRecord[] = []
@StorageLink('wrongRecords') wrongRecords: WrongRecord[] = []
@StorageLink('favoriteTabIndex') tab: number = 0

它没有自己从 Preferences 读取收藏,也没有维护一个额外的列表缓存。收藏 Tab 的计数直接来自数组长度。

private countFor(index: number): number {
if (index === 0) return this.favRecords.length
if (index === 1) return this.noteRecords.length
return this.wrongRecords.length
}

Tab 头部显示收藏、笔记、错题三个数量。

ForEach(['收藏', '笔记', '错题'], (label: string, index: number) => {
Row({ space: 5 }) {
Text(label)
Text(`${this.countFor(index)}`)
}
.onClick(() => { this.tab = index })
}, (label: string, index: number) => `ftab_${index}`)

这种写法的好处是清楚:收藏数量就是 favRecords.length,不会再出现一个手工维护的 favoriteCount。

收藏同步结构图

七、收藏列表为空和非空分支分开

FavList() 对空状态和列表状态做了明确分支。

@Builder
FavList() {
if (this.favRecords.length === 0) {
this.EmptyHint('还没有收藏题目', '答题时点击收藏按钮即可添加')
} else {
List({ space: 10 }) {
ForEach(this.favRecords, (record: FavoriteRecord) => {
ListItem() {
this.QuestionCard(record.questionId, record.bankId, record.createdAt, '')
}
.padding({ left: Sizes.PADDING_LARGE, right: Sizes.PADDING_LARGE })
}, (record: FavoriteRecord) => record.questionId)
}
}
}

空状态不是简单显示“暂无数据”,而是告诉用户“答题时点击收藏按钮即可添加”。这和真实入口一致,因为源码中收藏按钮确实在 PracticePage 的底部工具栏。

ForEach key 使用 record.questionId。这要求同一道题在收藏列表里只出现一次,而这个约束由 toggleFavorite 保证。服务层和 UI 层的约束是一致的。

八、QuestionCard 让收藏记录回到题目上下文

收藏列表渲染的不是纯文本,而是 QuestionCard。

@Builder
QuestionCard(questionId: string, bankId: string, time: string, noteContent: string) {
Column({ space: 10 }) {
Row({ space: 10 }) {
Stack() {
Text(this.bankName(bankId).substring(0, 1))
}

Column({ space: 5 }) {
Text(this.getStem(questionId, bankId))
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Row({ space: 8 }) {
Text(this.bankName(bankId))
Text(time)
}
}
}
.onClick(() => {
router.pushUrl({ url: 'pages/PracticePage', params: { bankId: bankId, mode: 'random' } })
})
}
}

它做了三件事:

展示内容来源
题干 getQuestions(bankId).find(q => q.id === questionId)
题库名 getBankById(bankId)
时间 FavoriteRecord.createdAt

点击卡片会回到 PracticePage,参数是 { bankId, mode: 'random' }。这里也要如实说明边界:它不是精确跳到收藏题本身,而是进入同一个题库的随机练习。当前源码没有传 questionId,也没有实现“打开收藏题详情”的定位逻辑。

如果要精确打开收藏题,可以扩展参数:

router.pushUrl({
url: 'pages/PracticePage',
params: { bankId: bankId, mode: 'favorite', questionId: questionId }
})

这只是改造建议,不是当前已实现能力。实现时还需要 PracticePage 支持 favorite 模式并按 questionId 定位题目。

九、笔记和错题复用同一张 QuestionCard

收藏页不只是收藏列表,还包含笔记和错题。三种记录都复用 QuestionCard。

this.QuestionCard(record.questionId, record.bankId, record.createdAt, '')
this.QuestionCard(note.questionId, note.bankId, note.updatedAt, note.content)
this.QuestionCard(record.questionId, record.bankId, record.wrongAt, '')

复用的价值在于:题干展示、题库名展示、时间展示和点击回练习页的逻辑一致。差异只在第四个参数 noteContent。如果笔记内容存在,卡片下方显示笔记摘要和编辑按钮。

if (noteContent.length > 0) {
Row({ space: 8 }) {
Text(noteContent)
.fontSize(Sizes.CAPTION_FONT)
.fontColor(Colors.TEXT_SECONDARY)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text('编辑')
.onClick(() => { this.openEditNote(questionId, bankId, noteContent) })
}
}

这说明收藏页的定位是“学习夹”:收藏、笔记、错题都围绕题目 ID 和题库 ID 展示,而不是三个完全独立的页面。

十、Index 只显示错题徽标,不显示收藏徽标

Index.ets 的底部 Tab 对收藏页做了徽标处理,但源码显示的是错题数,而不是收藏数。

if (index === 3 && this.wrongRecords.length > 0) {
Text(`${this.wrongRecords.length > 99 ? '99+' : this.wrongRecords.length}`)
.fontSize(8)
.fontColor(Color.White)
.backgroundColor(Colors.ERROR)
.borderRadius(8)
}

这段逻辑说明:收藏页入口上的红点是错题提醒,不是收藏同步提醒。文章不能说底部 Tab 会展示收藏数量。收藏数量只在 FavoritePage.Tabs() 中显示。

如果后续产品希望收藏页 Tab 同时提示收藏数,需要明确设计优先级:底部 Tab 徽标更适合提醒待处理错题,收藏数则适合放在页面内部 Tab。

十一、BankDetailPage 在这条链路中的真实位置

队列提示里包含 BankDetailPage.ets,但源码显示它主要负责题库详情、章节练习、进度展示和底部练习入口。

interface BankDetailParams {
bankId: string
}

aboutToAppear(): void {
const params = router.getParams() as BankDetailParams | undefined
if (params && params.bankId) {
this.bank = getBankById(params.bankId)
}
}

它通过 bankId 找题库,通过 UserDataManager.getProgress() 展示学习进度。

private bankFinished(): number {
const p = UserDataManager.getProgress(this.progressList, this.bankId())
return p ? p.finished : 0
}

这和收藏同步的关系是“共享题库上下文”,不是“题库详情页直接管理收藏”。收藏记录也包含 bankId,收藏页能通过 bankId 找题库名,练习页能通过 bankId 回到对应题库。这个 ID 设计让题库详情、练习页、收藏页能围绕同一套题库数据协作。

十二、即时一致依赖重新赋值,而不是原地改数组

ArkUI 状态同步里,一个容易踩的坑是原地修改数组。例如直接 records.push(…),某些情况下 UI 刷新不够稳定。当前 toggleFavorite 返回一个新数组,然后调用方重新赋值。

const next = […records]
next.splice(idx, 1)
result = next

this.favRecords = UserDataManager.toggleFavorite(this.favRecords, q.id, q.bankId)

这套写法有两个收益。

第一,状态变更是显式的。this.favRecords = result 能让订阅同一个 AppStorage key 的页面拿到新值。

第二,持久化和 UI 状态在同一个操作里完成。toggleFavorite 先构造结果,再 persist,最后返回结果。调用方不用自己记得写 Preferences。

如果收藏按钮只调用 UserDataManager.persist() 而不更新 favRecords,收藏页可能要等下次应用启动才看到变化;如果只更新 favRecords 而不 persist(),重启后收藏会丢失。

十三、验证收藏同步链路

收藏同步验证要覆盖“即时显示”和“重启后仍存在”。

验证项操作期望结果
首次进入收藏页 清空收藏后打开收藏 Tab 显示空状态和添加提示
收藏一道题 在练习页点击收藏 图标高亮,收藏页数量 +1
取消收藏 对同一道题再次点击收藏 图标恢复,收藏页移除该题
重启应用 收藏后杀进程重启 收藏记录仍存在
收藏卡片点击 在收藏页点击题目卡片 进入对应题库的随机练习
笔记复用卡片 给题目写笔记 笔记 Tab 显示同一题干和笔记内容
错题徽标 答错题后看底部收藏 Tab 徽标显示错题数,不显示收藏数

排查时先看 favoriteRecords 是否变化,再看 FavoritePage 是否订阅了同一个 key,最后看持久化是否写入。不要先从列表 UI 样式查起。

十四、常见问题与修复方向

问题可能原因修复方向
收藏页不刷新 只持久化但没有给 favRecords 重新赋值 保持 this.favRecords = toggleFavorite(…)
同一道题重复收藏 新增时没有按 questionId 去重 使用 findIndex(r => r.questionId === questionId)
重启后收藏丢失 没有 persist(K_FAV, result) 或 flush 失败 检查 UserDataManager.persist
收藏卡片题干为空 bankId 错误或题库数据找不到题目 检查 getQuestions(bankId) 与记录 ID
收藏页数量不对 额外维护了本地计数 直接使用 favRecords.length
以为 Tab 红点是收藏数 Index 显示的是 wrongRecords.length 在页面内 Tab 显示收藏数量

收藏功能越小,越不应该分散状态。只要围绕一个 favoriteRecords 工作,大部分同步问题都会消失。

十五、总结:本地同步的关键是同一个 key、同一个服务、同一次赋值

句匠的收藏同步链路很适合做 HarmonyOS ArkTS 本地状态实践样例。它的核心不是复杂算法,而是边界清楚:

  • FavoriteRecord 只保存 questionId、bankId 和 createdAt。
  • UserDataManager.init() 从 Preferences 初始化 favoriteRecords。
  • PracticePage 用 @StorageLink 订阅收藏数组,并在按钮点击时调用 toggleFavorite。
  • toggleFavorite 负责新增、取消、去重、持久化,并返回新数组。
  • FavoritePage 订阅同一个 favoriteRecords,用数组长度渲染计数,用 QuestionCard 渲染记录。
  • Index 的收藏页徽标显示错题数量,不是收藏数量。
  • BankDetailPage 共享的是 bankId 题库上下文,不直接处理收藏按钮。

对本地学习应用来说,这种实现已经能覆盖同设备内的收藏即时一致和重启保留。后续如果要做云端同步、精确打开收藏题、收藏分组或多设备合并,需要新增账号、冲突合并和隐私说明,不能把当前本地 Preferences 链路描述成云同步能力。

赞(0)
未经允许不得转载:171主机测评 » 【句匠|11】HarmonyOS ArkTS 收藏同步实战:让详情页与收藏页即时一致
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址