【句匠|11】HarmonyOS ArkTS 收藏同步实战:让详情页与收藏页即时一致
学习类应用里的收藏功能看起来很小,但它特别容易暴露状态设计问题。用户在答题页点了收藏,切到收藏页却看不到;收藏页数量变了,底部 Tab 徽标没有跟着变;重新打开应用后收藏丢失;在列表中点击收藏题又跳不到可练习的上下文。这些问题不是图标样式问题,而是“收藏记录由谁保存、谁订阅、谁渲染”的链路没有收口。
句匠项目的收藏能力没有做云端同步,也没有做账号体系。它走的是 HarmonyOS 5.0 以上 ArkTS 本地状态链路:EntryAbility启动时由UserDataManager.init()从 Preferences 读取favoriteRecords并写入 AppStorage;PracticePage在底部工具栏点击收藏按钮后调用UserDataManager.toggleFavorite();FavoritePage通过@StorageLink('favoriteRecords')订阅同一个数组,自动渲染收藏列表与计数。
本文唯一标记:当前 Bundle Name(已脱敏)。以下内容只基于真实源码: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的题目练习上下文中。标题里的“详情页”指用户查看题目内容并操作收藏的练习详情体验,而不是题库详情页已经实现收藏功能。
证据边界:本文事实来自对 UserDataManager、PracticePage、FavoritePage 与 EntryAbility 当前源码的静态核对。当前同步范围是本地 Preferences 与 AppStorage,不是账号或云端同步;本轮没有执行构建、重启回读、写盘失败注入或并发操作测试。
一、收藏同步先明确一个唯一数据源
收藏同步最怕多个页面各存一份状态。练习页维护一份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的底部工具栏。
ForEachkey 使用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 链路描述成云同步能力。
十七、即时显示与持久化成功要分别验收
toggleFavorite()先生成新数组,再调用持久化方法,最后把数组返回给页面。PracticePage 将返回值重新赋给@StorageLink,因此收藏页可以立即观察到新引用并重绘。但当前持久化辅助方法会吞掉异常,页面即时变化只能证明内存层更新,不能证明 Preferences 已成功写入。
验收要拆成两层:点击后立即检查答题页图标和收藏页列表,再结束进程并重新初始化,确认同一条记录仍存在。还要注入写入失败,观察界面是否错误地提示成功。若产品需要强一致,应让服务返回明确的持久化结果,并由页面决定回滚或提示重试。这是建议实现,不是当前源码已有接口。
十八、收藏记录的复合身份不能丢
FavoriteRecord 同时保存 questionId 与 bankId,收藏页用二者定位题目与题库名称。只保存 questionId 会假设所有题库的题号全局唯一,一旦不同题库出现同号记录,就可能打开错误内容。去重、取消收藏和跳转都应使用同一套身份规则。
测试数据应包含不同题库的同名或同号题目、已下线题目和找不到题库的记录。当前收藏页在本地题库中解析展示,文章不能扩展成服务端容错能力。缺失内容应显示可理解的占位或允许清理,不能让整页构建失败。
十九、同步链路的最小回归集
第一组覆盖新增与取消:同一道题连续点击后不得产生重复记录,图标和列表数量同步变化。第二组覆盖跨页面:从答题页收藏,切到收藏页立即可见;从收藏页删除,再回到答题页图标应取消。第三组覆盖恢复:重启后记录、时间和题库关联保持一致。
第四组覆盖异常:Preferences 未初始化、写盘失败、JSON 损坏、题目不存在和快速连点都要得到确定结果。只有即时状态和重启回读都通过,才能宣称本地收藏同步完整。
二十、删除动作也必须回到同一条链路
收藏页如果提供删除或清空能力,不能只从当前 List 的临时数据中移除卡片。它仍要调用 UserDataManager 生成新的收藏数组、持久化并重新赋给同一个 StorageLink。否则收藏页暂时消失,返回答题页后图标仍然高亮,或者重启后被删除的记录重新出现。
删除验收应与新增对称:删除单条后检查两个页面,清空后检查数量和空状态,再重启确认 Preferences 回读为空。快速连续删除时还要避免基于旧数组计算后覆盖新结果。当前代码采用同步方法和新数组返回,但没有并发队列或事务保证,因此不能扩展宣称复杂并发写入已经安全。
二十一、初始化失败时要区分空数据和损坏数据
UserDataManager.init 在解析失败时为收藏写入空数组,这保证页面仍可构建,却也把“用户从未收藏”和“原有 JSON 已损坏”呈现为相同界面。对本地学习工具来说,这是可用性优先的降级,但诊断层最好记录不含私人内容的错误类型,并避免在未确认时用空数组覆盖仍可修复的原始值。
测试应准备空字符串、非法 JSON、字段缺失、重复记录和未知题库等样本。页面要能够展示空态或跳过坏记录,服务层则应明确迁移和清理策略。文章当前只证明 catch 分支提供默认数组,未证明数据迁移、备份恢复或损坏修复已经实现。
二十二、不要额外维护收藏计数
收藏数量可以直接由favRecords.length派生。如果再维护一个 count 状态,每次新增、取消、清空、初始化和恢复都要手动同步,很容易出现列表三条但数字仍为二的漂移。派生值应保持只读,真正的状态真源仍是收藏记录数组。
同样,按钮选中态应通过当前 questionId 与收藏数组查询得到,而不是独立保存布尔值。当前题切换后重新计算,能避免上一题的选中态泄漏到下一题。大数据量下如需优化,可以建立服务层索引,但仍要保证它由同一记录集重建并接受一致性测试。
二十三、页面跳转要携带可恢复上下文
收藏记录保存 bankId 的价值不仅是显示题库名,还用于重新定位题目上下文。收藏页点击卡片时要明确是打开对应题、进入该题库随机练习,还是只展示题目详情。不同产品语义需要不同路由参数,不能因为已有 questionId 就假设目标页一定会精确定位。
当前源码能够证明收藏页可取得 questionId 和 bankId,并通过本地 MockBanks 查找内容;具体跳转是否精确落到该题,应按实际 pushUrl 参数和目标页解析逻辑验收。测试需覆盖题库存在但题目缺失、题目顺序变化和参数为空,失败时应保留返回路径与可理解提示。
二十四、收藏同步的验收证据表
一次完整验收至少保留四类证据。状态证据记录点击前后 favoriteRecords 的内容与长度;界面证据记录答题页图标和收藏页列表;持久化证据记录重启初始化后的回读结果;异常证据记录写盘失败、非法 JSON 与缺失题目时的可见行为。四类证据不能互相替代。
还要明确没有覆盖的范围:当前实现没有账号、多设备、网络队列、冲突版本或服务端时间戳。因此测试通过后只能得出“同设备本地同步满足已测场景”,不能升级成“收藏已跨端一致”。准确描述能力边界,本身也是技术交付的一部分。
二十五、结语:新数组只是链路中的一环
句匠当前实现用 UserDataManager 集中收藏规则,以 Preferences 保存记录,以 AppStorage 连接答题页与收藏页,并通过重新赋值新数组触发界面同步。这条链路结构清晰,也明确限定在本地设备内。
真正可靠的收藏体验还需要写盘结果、异常恢复、复合身份和跨页面删除回归。把每一层单独留证,才能避免“页面看起来成功、重启后数据消失”的假同步。
二十六、上线前检查清单
上线前先确认初始化顺序:EntryAbility 在首屏装载前完成 UserDataManager.init,favoriteRecords 无论正常回读还是异常降级都具有确定数组值。再确认变更入口:所有收藏按钮都调用同一服务并重新赋值,收藏页的删除与清空也走相同路径,不存在绕开服务的原地 push、splice 或独立计数。
然后检查显示与导航:答题页选中态来自当前题目和收藏数组,收藏页列表、数量与空状态来自同一数组,卡片使用 questionId 和 bankId 恢复上下文。最后检查持久化:新增、取消、删除、清空后都执行重启回读,并在写入失败时观察是否出现内存与磁盘不一致。
每次验证应记录文章对应的源码版本、测试设备、操作步骤和实际结果。若只完成静态核对,就标注“源码已确认、运行未验证”;若完成即时页面测试但没有重启,就标注“内存同步通过、持久化未确认”。这种分级结论能避免把局部成功误写成完整能力。
CSDN-SERIES:ALL-163175795