【知律|09】HarmonyOS ArkTS 收藏法条实战:保持详情页与收藏列表同步
收藏功能最容易出现一种“看起来已经成功”的错误用户在题目页点亮收藏图标切到收藏列表也能看到记录但应用重启后收藏消失或者两个题库恰好使用同一个题目 ID收藏其中一题却把另一题判定为已收藏。界面、内存和磁盘只要有一处采用了不同的身份规则所谓同步就只是暂时一致。本文基于知律项目D:\huawei\one19-11、包名com.jiaweikang.one19的真实源码沿着 brief 指向的FavoritePage.ets继续追踪PracticePage.ets、UserDataManager.ets、EntryAbility.ets与Index.ets。当前版本没有独立“法条详情页”收藏入口位于练习页收藏列表点击后进入同题库的随机练习也不会精准跳回被收藏题目。文章标题里的“详情页”因此按收藏发起页来理解并明确区分现状与改造方案。一、先画出当前真实同步链路应用启动时EntryAbility.onCreate()调用UserDataManager.init(this.context)管理器从 Preferences 读取 JSON 字符串再把收藏数组放进AppStorageconst favStr UserDataManager.prefs.getSync( UserDataManager.K_FAV, [] ) as string AppStorage.setOrCreateFavoriteRecord[]( favoriteRecords, JSON.parse(favStr) as FavoriteRecord[] )练习页和收藏页都通过相同键建立双向链接StorageLink(favoriteRecords) favRecords: FavoriteRecord[] []所以当前会话里的链路是练习页产生新数组AppStorage保存新引用收藏页订阅同一键并重新渲染进程重启后的链路则由 Preferences 恢复。两条链路都成立收藏才算真正同步。二、FavoriteRecord 的三个字段分别承担什么真实模型很小export interface FavoriteRecord { questionId: string bankId: string createdAt: string }questionId指向题目bankId指向题库createdAt用于列表展示收藏时间。这里已经保存了题库与题目两个维度说明数据模型认识到“题目属于某个题库”。但模型拥有字段不代表所有算法都使用了这些字段。后续判重、删除和ForEach键只看questionId身份契约在不同层发生了分裂。三、练习页如何切换收藏PracticePage底部工具栏读取当前题.onClick(() { const q this.currentQ() if (q) { this.favRecords UserDataManager.toggleFavorite( this.favRecords, q.id, q.bankId ) } })这里有一个正确细节没有原地push或splice页面持有的数组而是接收管理器返回的新数组并重新赋值。对于 ArkUI 状态更新新引用比“原数组内部悄悄变化”更容易触发依赖组件刷新。收藏按钮的颜色也从同一份状态推导private isCurFav(): boolean { const q this.currentQ() if (!q) return false return UserDataManager.isFavorite(this.favRecords, q.id) }因此按钮高亮和收藏列表数量在正常单题库 ID 唯一的情况下会保持一致没有另外维护一个容易漂移的isFavorite布尔值。四、toggleFavorite 做了哪些事现有实现先查找记录再生成新数组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 }新增记录放在头部列表天然按最近收藏排序取消收藏复制后删除避免直接修改输入数组最后同步写入 Preferences。这个实现短小但它把领域规则、持久化和结果返回放在一个同步方法中错误无法传回 UI。五、当前最大的隐患是身份只看 questionIdFavoriteRecord同时保存bankId和questionId可isFavorite()与toggleFavorite()都只比较questionIdrecords.some(r r.questionId questionId) records.findIndex(r r.questionId questionId)如果题库 A 和题库 B 都有q_001用户收藏 A 的q_001后B 的按钮也会显示已收藏再点击 B删除的可能是 A 的记录。这不是渲染延迟而是主键定义错误。更稳的身份应是function favoriteKey(bankId: string, questionId: string): string { return ${bankId}:${questionId} }所有查询、切换、列表键、路由参数和测试都必须使用同一复合身份不能一层看两个字段、另一层只看一个字段。六、ForEach 的 key 也存在相同风险收藏列表当前写法是ForEach(this.favRecords, (record: FavoriteRecord) { ListItem() { this.QuestionCard( record.questionId, record.bankId, record.createdAt, ) } }, (record: FavoriteRecord) record.questionId)ArkUI 用 key 识别重复项。如果两个题库出现相同questionId即使数据层允许两条记录列表层仍会收到重复 key。复用节点时可能出现文本、按压状态或顺序不符合预期。修正应与领域主键完全一致}, (record: FavoriteRecord) ${record.bankId}:${record.questionId})主键不是数据库层的私事。它会一路影响状态查询、组件复用、删除目标和页面导航。七、FavoritePage 如何把记录还原成可读卡片收藏记录没有冗余保存题干而是保存引用再从题库源查询private findQuestion( questionId: string, bankId: string ): Question | undefined { return getQuestions(bankId) .find(q q.id questionId) }题干展示使用private getStem(questionId: string, bankId: string): string { const q this.findQuestion(questionId, bankId) return q ? q.stem : 题目 ${questionId} }优点是题干修订后收藏列表能展示最新内容收藏记录也很轻。代价是题库删除、题目改 ID 或数据迁移失败后只剩兜底文字。产品需要决定收藏保存“实时引用”还是同时保存一份只读快照用于历史可读性。八、列表点击不是精准回到收藏题当前QuestionCard的点击行为是router.pushUrl({ url: pages/PracticePage, params: { bankId: bankId, mode: random } })参数没有questionId。练习页收到random后加载整个题库并不会定位收藏题。因此用户点的是某条收藏记录打开的却可能是该题库另一道题。所以当前源码只能证明“收藏列表可以进入所属题库练习”不能宣称已经实现详情页与列表精准跳转。要兑现这个体验需要给路由增加稳定身份或者提供独立题目详情页。九、为精准回跳定义显式路由契约可以扩展练习参数interface PracticeParams { bankId: string mode: string focusQuestionId?: string }收藏列表传递两个身份字段router.pushUrl({ url: pages/PracticePage, params: { bankId: record.bankId, mode: favorite, focusQuestionId: record.questionId } })练习页加载题库后再定位private locateFocusedQuestion( questions: Question[], questionId?: string ): number { if (!questionId) return 0 const index questions.findIndex(q q.id questionId) return index 0 ? index : 0 }重点不是“多传一个参数”而是接收方要验证该题确实属于传入题库。找不到时回到首题并给出提示不能让索引变成-1后继续访问数组。十、AppStorage 解决的是进程内共享PracticePage、FavoritePage、HomePage、MinePage和统计页都链接favoriteRecords。用户在练习页赋值后其他页面读取的是同一个应用级状态键因此数量、高亮和列表能在会话内联动。这层适合保存当前 UI 所需的共享状态但不应承担数据校验和磁盘错误处理。可以把它理解成“发布给界面的最新快照”不是最终数据源。十一、Preferences 才负责重启后的恢复管理器使用preferences.getPreferencesSync(context, { name: dialect_quiz })收藏数组序列化到favoriteRecords键。每次切换后执行prefs.putSync(key, JSON.stringify(value)) prefs.flushSync()这保证代码按顺序发起写入和刷盘但它发生在点击路径上。收藏数据通常很小短期可用如果记录量增长、字段扩展或一次清空多类数据最好评估同步 I/O 对交互线程的影响并把写入结果设计成可感知状态。十二、当前 persist 会吞掉所有失败真实代码是private static persist( key: string, value: Object | string | number | boolean ): void { if (UserDataManager.prefs null) return try { UserDataManager.prefs.putSync(key, JSON.stringify(value)) UserDataManager.prefs.flushSync() } catch (_) {} }如果prefs尚未初始化方法直接返回如果序列化或写入失败异常也被忽略。toggleFavorite()仍返回新数组页面于是显示收藏成功但磁盘可能没有变化。下一次启动恢复旧数据就形成“重启后丢收藏”。错误可以被转成业务结果interface SaveResultT { ok: boolean value: T message?: string }UI 只有在ok为真时发布新状态失败时保留旧数组并显示可重试提示。十三、提交顺序决定用户看到的真假当前顺序实际上是计算result尝试持久化无论成功失败都返回result页面更新AppStorage。推荐改为“持久化成功后发布”async toggleFavorite( records: FavoriteRecord[], target: FavoriteIdentity ): PromiseSaveResultFavoriteRecord[] { const next this.reducer.toggle(records, target) const saved await this.repository.save(next) return saved ? { ok: true, value: next } : { ok: false, value: records, message: 收藏保存失败 } }这样内存状态不会跑在磁盘前面。若产品更强调即时反馈也可以先乐观更新但必须保留旧快照写入失败时回滚并提示。两种策略都可以不能既乐观更新又隐藏失败。十四、用纯函数固定收藏切换规则先把不涉及平台 API 的部分抽成纯函数interface FavoriteIdentity { bankId: string questionId: string } function sameFavorite( record: FavoriteRecord, target: FavoriteIdentity ): boolean { return record.bankId target.bankId record.questionId target.questionId } function reduceFavorite( records: FavoriteRecord[], target: FavoriteIdentity, createdAt: string ): FavoriteRecord[] { const exists records.some(r sameFavorite(r, target)) if (exists) { return records.filter(r !sameFavorite(r, target)) } return [{ ...target, createdAt }, ...records] }纯函数不依赖Context、Preferences 或页面可以覆盖重复点击、跨题库同 ID、取消收藏和排序等测试。平台层只负责保存结果。十五、Repository 只负责存取与迁移更清晰的职责是interface FavoriteRepository { load(): FavoriteRecord[] save(records: FavoriteRecord[]): boolean }Preferences 实现应集中处理键名、JSON 解析、版本迁移和写入错误。页面不应知道dialect_quiz或favoriteRecords这些存储细节领域服务也不应直接操作 ArkUI 状态。这一步不是为了增加文件数量而是为了让三类错误有各自位置身份错误在领域层修磁盘错误在仓储层处理交互反馈在页面层展示。十六、启动解析失败不能只回退空数组UserDataManager.init()把多类数据放在同一个try中。任何一个 JSON 解析失败都会进入catch随后把收藏、笔记、错题、进度、历史全部设置为空数组。这会产生两个问题一类数据损坏可能让其他正常数据在本次会话不可见损坏字符串没有被修复下一次启动可能再次失败。更稳的做法是逐键解析function parseRecordsT(raw: string, fallback: T): T { try { return JSON.parse(raw) as T } catch (_) { return fallback } }每个键单独恢复发现损坏时写回合法默认值并记录不含用户内容的诊断信息。这样收藏损坏不会连带清空考试历史。十七、旧数据迁移要先补齐复合身份当前记录已经有bankId迁移成本较低。若历史版本存在缺失bankId的数据不能凭questionId随意猜题库。迁移策略可以分为输入情况处理方式bankId、questionId均合法保留并去重缺少bankId题目只在一个题库出现可安全补齐缺少bankId多个题库同 ID标记不可解析并跳过题库或题目已删除保留快照或显示失效记录迁移后按${bankId}:${questionId}去重避免旧重复记录在新列表产生重复 key。十八、收藏列表需要单条取消能力当前FavoritePage的收藏卡没有取消按钮。用户只能回到练习页找到目标题再切换或者在设置页清空所有收藏。这对精准管理不够友好。列表可以提供明确操作private removeFavorite(record: FavoriteRecord): void { const next this.favRecords.filter(item !(item.bankId record.bankId item.questionId record.questionId) ) // 交给 Service 持久化成功后再发布 }单条删除的触摸区域、无障碍文本和失败反馈要完整“清空全部”属于破坏性操作应增加确认而不是与普通跳转并列为一次点击。十九、不要把收藏数量手工同步到多个字段FavoritePage的数量直接来自private countFor(index: number): number { if (index 0) return this.favRecords.length if (index 1) return this.noteRecords.length return this.wrongRecords.length }这是正确方向数量是数组的派生值不需要额外维护favoriteCount。只要多个页面都从favoriteRecords.length计算便不会出现列表有 5 条、首页写 4 条的人工同步问题。设置页为了展示和交互持有displayFavCount并通过syncDataCounts()手动刷新。此类显示缓存需要明确刷新时机或者继续改为从共享数组派生减少状态副本。二十、连续点击需要串行化写入当前同步方法执行很快但未来若切换为异步存储用户连续点击可能产生第一次请求计算“新增”第二次请求仍基于旧数组计算“新增”两次写入乱序完成最终状态与最后一次点击不一致。可以在服务层维护单一提交队列或者在保存期间禁用当前收藏按钮State favoriteSaving: boolean false private async onFavoriteClick(): Promisevoid { if (this.favoriteSaving) return this.favoriteSaving true try { await this.toggleCurrentFavorite() } finally { this.favoriteSaving false } }禁用态要有视觉反馈但不能改变按钮尺寸避免工具栏跳动。二十一、多页面同步要验证四个观察点测试时不要只看收藏列表。至少观察观察点预期练习页按钮收藏后立即高亮取消后恢复收藏 Tab 数量与数组长度一致收藏卡片题库、题干、时间对应正确应用重启状态与退出前一致再加入跨题库同questionId的构造数据。如果 A、B 两个题库都含q_001收藏 A 不应点亮 B两条记录同时存在时列表 key 也必须不同。二十二、精准回跳的验收不能只看页面打开打开PracticePage不代表跳转正确。应验证路由同时携带bankId和questionId目标题库加载完成后再定位当前题 ID 与收藏记录一致找不到目标题时给出兜底提示返回收藏页后原 Tab 与滚动上下文合理在手机、平板和 2in1 布局中目标操作可达。当前源码只携带bankId random所以这组精准回跳验收在改造前应判为未实现。二十三、清空收藏必须保持内存与磁盘一致设置页调用this.favRecords UserDataManager.clearFavorites()管理器把空数组写入 Preferences 后返回。它与切换收藏存在同一个问题写入失败被吞掉UI 仍会显示清空成功。清空还应经过二次确认并在失败时保持旧列表。若一次操作清空收藏、笔记、错题、进度和考试历史最好定义批处理结果避免前几项写成功、后几项失败后却统一显示成功。二十四、时间字段适合展示不适合作为身份nowStr()只精确到分钟return ${year}-${month}-${day} ${hour}:${minute}同一分钟收藏多条记录会拥有相同时间。这不会影响当前排序因为新增记录直接放到数组头部但createdAt不能承担唯一键也无法表达秒级操作先后。如果后续需要稳定排序或跨设备合并可保存毫秒时间戳并单独格式化展示。身份仍然是bankId questionId不要用时间戳替代业务主键。二十五、推荐的落地顺序第一步保持当前 UI 不变把所有收藏判重和列表 key 改为复合主键。第二步把reduceFavorite()抽成纯函数并覆盖跨题库同 ID、重复点击、取消和排序测试。第三步让持久化返回明确结果只有保存成功才发布到AppStorage失败显示提示。第四步给收藏卡增加精准路由参数练习页按目标题定位找不到时提供可解释兜底。第五步拆分启动解析与迁移单个键损坏不影响其他学习数据。这个顺序先修数据正确性再补交互不需要一次重写所有页面。二十六、常见故障排查表现象优先检查真实源码中的关联点收藏 A 却点亮 B是否只比较questionIdisFavorite()取消 B 删除了 A删除条件是否缺少bankIdtoggleFavorite()列表卡片复用错乱ForEachkey 是否重复FavoritePage.FavList()点击收藏卡不是原题路由是否传questionIdQuestionCard()重启后收藏消失Preferences 写入是否失败persist()吞异常一条数据损坏后多类数据为空是否用一个总try解析全部键init()清空后重启又回来清空写入是否真正成功clearFavorites()题干显示“题目 xxx”题库或题目引用是否失效getStem()排查顺序应是身份契约、状态发布、持久化结果、路由定位不能先靠延时刷新掩盖问题。二十七、本地验证清单准备两个题库并人为加入相同questionId然后执行收藏 A 题A 高亮、B 不高亮收藏 B 题列表出现两条且 key 不重复取消 AB 保留从列表点击 B进入 B 的目标题返回后收藏 Tab 仍展示 B杀进程并重启B 仍存在模拟存储失败界面不假报成功写入损坏的笔记 JSON收藏仍能独立恢复清空收藏时显示确认取消确认不改数据手机、小窗、平板和 2in1 下卡片、按钮与底部安全区正常。这套验证同时覆盖 ArkUI 响应式状态、业务主键、Preferences 持久化和路由契约。二十八、结语知律当前已经具备一条可复核的收藏基础链路EntryAbility从 Preferences 恢复数据练习页与收藏页通过同一个AppStorage键共享数组toggleFavorite()返回不可变新数组收藏列表再按题库与题目引用还原卡片。对于题目 ID 全局唯一且存储正常的场景会话内联动与重启恢复都具备实现基础。真正需要补强的是一致性边界判重、删除和列表 key 只使用questionId持久化失败不会反馈启动时一处解析异常可能让多类数据统一回退收藏卡点击只进入随机练习不能精准定位原题。把复合主键、可验证提交、逐键恢复和显式路由契约统一起来收藏就不再是一个局部按钮状态而是一条可以测试、迁移和解释的数据链路。---本文部分内容由 AI 辅助整理。所有现状判断均基于D:\huawei\one19-11中com.jiaweikang.one19的本地源码复核示例改造代码用于说明工程方案不代表当前版本已经实现独立法条详情页、复合主键迁移、持久化失败回滚或收藏题精准定位。
