学习类应用里的收藏功能看起来很小,但它特别容易暴露状态设计问题。用户在答题页点了收藏,切到收藏页却看不到;收藏页数量变了,底部 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 链路描述成云同步能力。





