
【口算王11】HarmonyOS ArkTS 收藏题型实战同步首页与收藏列表状态证据边界本文的“当前实现”来自本轮对本地口算王工程中PracticePage.ets、HomePage.ets、FavoritePage.ets、UserDataManager.ets与题库查询代码的静态复核。文中关于复合标识、事务式保存、失效引用清理和批量性能的段落属于建议实现。本轮未执行构建、安装、真机交互、数据迁移或性能测量。收藏功能最常见的故障不是按钮点不动而是不同页面各自维护了一份状态练习页的图标已经变红首页仍显示旧数量收藏列表出现了题目重新启动后却全部消失从收藏卡片进入练习页后收藏按钮又回到未选中。每个页面单独看都能运行放到完整链路里却互相矛盾。稳定的收藏链路需要一个事实源。练习页只负责发起“收藏或取消收藏”数据管理层负责去重与持久化AppStorage负责把新数组广播给首页、统计页、收藏列表和“我的”页面。任何页面都不应该再维护手工计数器。本文基于口算王项目本地口算王工程脱敏路径的真实源码复核PracticePage.ets、FavoritePage.ets、HomePage.ets、Index.ets、EntryAbility.ets、UserDataManager.ets与StatService.ets。包名com.jiaweikang.one16是本文草稿核验使用的唯一标记。当前实现支持本地收藏切换、Preferences 持久化、跨页面数量同步、收藏列表展示和从收藏项定位回原题收藏页同时承载笔记与错题三个页签底部收藏导航上的红色徽标显示的是错题数不是收藏数。本文将解决五个具体问题收藏记录应该保存什么为什么不能保存整道题toggleFavorite()如何保证同一题不会重复收藏StorageLink如何让练习页、首页和列表共享同一数组收藏项如何通过bankId questionId回到原题如何处理题库变更、重复 ID、空状态、清空操作和多设备布局。一、收藏记录只保存稳定引用当前收藏模型非常轻量export interface FavoriteRecord { questionId: string bankId: string createdAt: string }它没有复制题干、选项、答案和解析。收藏项只记录字段用途questionId标识具体题目bankId限定题目所在题库createdAt展示收藏时间收藏页需要显示内容时再从本地题库解析引用private findQuestion( questionId: string, bankId: string ): Question | undefined { return getQuestions(bankId).find( question question.id questionId ) }这种设计避免把题目对象重复写入 Preferences。题干修正、解析更新后收藏列表能直接读取新版题目不必迁移旧快照。代价也很明确questionId和bankId必须长期稳定。如果新版本删除题目或修改 ID收藏引用会失效。因此收藏功能不是单独的 UI 状态它依赖题库标识的版本兼容性。二、AppStorage 是页面间共享状态边界应用启动时EntryAbility.onCreate()调用UserDataManager.init(this.context)管理器从 Preferences 读取favoriteRecords反序列化后放入AppStorageconst favStr UserDataManager.prefs.getSync( UserDataManager.K_FAV, [] ) as string AppStorage.setOrCreateFavoriteRecord[]( favoriteRecords, JSON.parse(favStr) as FavoriteRecord[] )随后多个页面用同一个 key 建立链接StorageLink(favoriteRecords) favRecords: FavoriteRecord[] []这段声明真实出现在PracticePage判断当前题是否收藏并执行切换FavoritePage渲染收藏列表HomePage显示收藏总数MinePage显示个人数据中的收藏数LearningStatsPage显示收藏题目数量SettingsPage清空收藏记录。只要某个页面给favRecords赋一个新数组所有链接到同一AppStoragekey 的页面都能观察到变化。首页计数和收藏列表不需要手工发送事件也不需要各自重新读取 Preferences。三、练习页的收藏按钮直接派生视觉状态练习页没有额外声明State isFavorite。它通过当前题和共享数组实时判断private isCurFav(): boolean { const question this.currentQ() if (!question) return false return UserDataManager.isFavorite( this.favRecords, question.id ) }颜色和背景继续从这个判断派生private favoriteToolColor(): string { return this.isCurFav() ? Colors.ERROR : #B85B64 } private favoriteToolBg(): string { return this.isCurFav() ? #FCE9E8 : #FFF1F1 }点击时获取当前题调用数据管理器再把返回的新数组赋回链接状态.onClick(() { const question this.currentQ() if (question) { this.favRecords UserDataManager.toggleFavorite( this.favRecords, question.id, question.bankId ) } })这里最重要的不是图标颜色而是没有维护第二份布尔状态。如果既有favRecords又有isFavoriteState切题、从收藏页返回或应用恢复时就需要手工同步两者。直接从事实源计算状态数量更少也更难漂移。四、toggleFavorite 同时处理去重、顺序和持久化当前切换实现如下static toggleFavorite( records: FavoriteRecord[], questionId: string, bankId: string ): FavoriteRecord[] { const index records.findIndex( record record.questionId questionId ) let result: FavoriteRecord[] if (index 0) { const next [...records] next.splice(index, 1) result next } else { result [{ questionId, bankId, createdAt: nowStr() }, ...records] } UserDataManager.persist( UserDataManager.K_FAV, result ) return result }它完成四件事查找当前题是否已存在存在则复制数组并删除不存在则把新收藏放在数组头部把完整新数组写回 Preferences。新收藏前插因此收藏页默认按最近收藏优先展示。取消收藏不会修改其他记录的相对顺序。函数返回新数组而不是直接修改传入数组。ArkUI 状态赋值能更明确地感知变化this.favRecords nextRecords持久化和状态更新使用同一个result避免内存里是一份、磁盘上是另一份。五、当前去重只使用 questionIdtoggleFavorite()和isFavorite()都只比较questionIdstatic isFavorite( records: FavoriteRecord[], questionId: string ): boolean { return records.some( record record.questionId questionId ) }虽然记录里有bankId但去重没有使用它。如果不同题库可能出现相同questionId收藏其中一道会让另一道也显示为已收藏。更稳的业务主键应包含两个字段function sameFavorite( record: FavoriteRecord, bankId: string, questionId: string ): boolean { return record.bankId bankId record.questionId questionId }然后统一用于查询和切换const index records.findIndex( record sameFavorite( record, bankId, questionId ) )如果项目能保证题目 ID 在全应用范围永久唯一当前实现可以工作如果只保证题库内唯一就必须升级为复合键。这个约束应写进题库生成规则而不是靠开发者记忆。六、首页收藏数来自同一个数组首页调用StatService.summarize()private myStats() { return StatService.summarize( this.progressList, this.examHistory, this.favRecords, this.wrongRecords ) }统计服务没有另存收藏计数favoriteCount: favorites.length概览卡显示this.HeroStat( ${this.myStats().favoriteCount}, 收藏 )因此练习页新增收藏后favRecords变长首页下一次构建直接读取新长度。取消收藏时长度减少。这里不存在“收藏成功后再通知首页加一”的过程。手工计数器容易出现两个问题重复点击同一题时数量继续增加清空收藏后列表为空但首页计数没有归零。让数量从数组长度派生就能天然跟随去重、取消和清空操作。七、收藏页是三个数据集合的统一入口FavoritePage顶部有三个页签ForEach( [收藏, 笔记, 错题], (label: string, index: number) { // 页签按钮 } )计数规则分别读取三组数组private countFor(index: number): number { if (index 0) { return this.favRecords.length } if (index 1) { return this.noteRecords.length } return this.wrongRecords.length }页面标题是“我的学习夹”收藏只是其中一个页签。文章讨论收藏时不能把笔记和错题记录算进收藏数量。当前选中页签也使用共享状态StorageLink(favoriteTabIndex) tab: number 0首页的“错题复习”会先设置this.favoriteTabIndex 2 this.currentTabIndex 3这样进入学习夹时直接打开错题页签。favoriteTabIndex在启动时初始化为0没有写入 Preferences因此应用重启后默认回到收藏页签。它是导航状态不是持久化业务数据。八、收藏列表通过记录反查题目收藏列表遍历favRecordsForEach( this.favRecords, (record: FavoriteRecord) { ListItem() { this.QuestionCard( record.questionId, record.bankId, record.createdAt, ) } }, (record: FavoriteRecord) record.questionId )卡片显示题干题库名称收藏时间题库名称首字作为圆形标识。题干来自private getStem( questionId: string, bankId: string ): string { const question this.findQuestion(questionId, bankId) return question ? question.stem : 题目 ${questionId} }如果题目已经不存在页面不会崩溃而是显示 ID 兜底。不过这只是视觉兜底点击后仍可能进入空的单题模式。更完整的处理应该把失效记录标识为“题目已更新或移除”并提供删除该收藏的操作。九、从收藏项回到原题使用 single 模式点击卡片时收藏页传递两个关键参数router.pushUrl({ url: pages/PracticePage, params: { bankId, mode: single, startQuestionId: questionId } })练习页在single模式下只加载指定题目const all getQuestions(params.bankId) const target params.startQuestionId ? all.find(question question.id params.startQuestionId ) : undefined this.questions target ? [target] : []这条路由让用户从收藏列表直接回到对应题目不会随机打开同题库里的另一题。进入后isCurFav()又会读取共享数组所以收藏按钮保持已选中用户再次点击即可取消收藏。这里的完整契约是FavoriteRecord - bankId questionId - PracticePage single mode - currentQ() - isFavorite(favoriteRecords)只传questionId不足以定位题库只传bankId又无法定位具体题目。十、列表 key 也应考虑复合标识收藏列表当前使用(record: FavoriteRecord) record.questionId如果题目 ID 全局唯一这个 key 稳定有效。如果两个题库存在相同 IDForEach可能把两条记录识别成同一项导致复用错误或刷新异常。可以定义共享 keyfunction favoriteKey( record: FavoriteRecord ): string { return ${record.bankId}::${record.questionId} }并在三个地方复用相同语义toggleFavorite()去重isFavorite()查询ForEach渲染 key。业务身份和 UI 身份一致后数据层认为是两条记录时视图层也不会把它们合并。十一、收藏列表当前没有单项删除按钮收藏页卡片本身只提供“打开题目”。单项取消路径是点击收藏卡进入PracticePage在单题模式中再次点击收藏按钮返回收藏页后记录从共享数组中消失。设置页还提供清空全部收藏this.favRecords UserDataManager.clearFavorites()clearFavorites()将空数组写入 Preferences 并返回static clearFavorites(): FavoriteRecord[] { const result: FavoriteRecord[] [] UserDataManager.persist( UserDataManager.K_FAV, result ) return result }当前源码没有在收藏列表卡片上实现星标取消、侧滑删除或多选管理。发布介绍不应声称支持这些交互。如果增加单项删除仍应调用数据管理层而不是列表页面直接splice后忘记持久化static removeFavorite( records: FavoriteRecord[], bankId: string, questionId: string ): FavoriteRecord[] { const result records.filter( record !sameFavorite( record, bankId, questionId ) ) UserDataManager.persist( UserDataManager.K_FAV, result ) return result }十二、底部导航徽标显示的不是收藏数Index.ets的第四个 Tab 标题为“收藏”但它实际承载“收藏、笔记和错题”学习夹。导航图标上的红色徽标使用StorageLink(wrongRecords) wrongRecords: WrongRecord[] []只有错题数量大于零时显示if ( index 3 this.wrongRecords.length 0 ) { Text(${this.wrongRecords.length 99 ? 99 : this.wrongRecords.length}) }因此首页概览“收藏”数字是favoriteRecords.length学习夹“收藏”页签数字是favoriteRecords.length底部“收藏”导航徽标是wrongRecords.length。三处视觉上都与“收藏”有关但口径不同。测试与截图验收时必须分开不能看到导航徽标是 3就期待首页收藏数也等于 3。如果产品认为容易误解可以把 Tab 名称改成“学习夹”或让徽标使用更明确的错题图标和语义。十三、Preferences 写入和 AppStorage 更新的顺序toggleFavorite()内部先执行持久化再返回新数组调用方随后赋值到StorageLinkpersist(result) return result // 调用方 this.favRecords result当前persist()使用同步写入prefs.putSync(key, JSON.stringify(value)) prefs.flushSync()优点是链路直观函数返回时写入已尝试完成。收藏数据量通常较小同步开销有限。需要注意的是persist()捕获异常后不会向调用方返回失败。即使磁盘写入失败内存中的AppStorage仍会更新当前会话看起来收藏成功重启后却可能丢失。可以返回结果状态interface PersistResultT { value: T persisted: boolean }或者在开发日志中记录失败并在 UI 需要高可靠反馈时显示“保存失败请重试”。不要把异常完全吞掉后仍宣称收藏已经持久化。十四、题库升级时要处理失效引用轻量引用模型需要迁移策略。应用升级后可以执行一次收藏清理function validFavorites( records: FavoriteRecord[] ): FavoriteRecord[] { return records.filter( record getQuestions(record.bankId) .some(question question.id record.questionId ) ) }但直接删除所有失效记录会让用户困惑。更友好的方式是保留记录并显示“题目已更新”若存在旧 ID 到新 ID 的映射执行迁移给用户一个清理失效收藏的按钮在版本迁移日志中记录数量。题库 ID 一旦成为收藏、错题和笔记的外键就不应随意重建。生成脚本必须把 ID 稳定性当作数据兼容要求。十五、空状态和大列表都要可用没有收藏时页面显示还没有收藏题目 答题时点击收藏按钮即可添加并配合本地空状态图片。它准确告诉用户入口在练习页没有虚构推荐或云同步能力。有数据时使用List和ListItem关闭滚动条并启用弹性效果。列表底部当前只有16的 padding而整个页面作为主 Tab 位于Index内容区底部导航由父页面单独布局因此内容不会直接覆盖导航栏。大量收藏时还应验证1000 条记录的反查性能getQuestions(bankId)是否重复构建大数组长题干两行截断是否清晰收藏时间和题库标签是否挤压返回列表后滚动位置是否符合预期。如果每张卡都重复扫描题库可以提前构建Mapstring, Questionconst questionMap new Mapstring, Question() BANKS.forEach(bank { getQuestions(bank.id).forEach(question { questionMap.set( ${bank.id}::${question.id}, question ) }) })小数据量不必急于优化但应知道性能成本来自哪里。十六、跨页面同步测试矩阵收藏功能不能只在一个页面自测。建议按下面链路回归场景操作预期首次收藏练习页点击收藏按钮高亮数量加一重复点击同题再次点击取消收藏数量减一快速切题收藏后进入下一题新题按自身记录显示首页同步收藏后返回首页首页收藏数立即更新列表同步进入学习夹收藏页签新收藏出现在首位单题回跳点击收藏卡精确打开原题状态恢复原题页查看按钮保持已收藏重启恢复关闭并重新打开应用Preferences 恢复收藏清空全部设置页清空收藏首页与列表同时归零题目失效删除题库中的目标题显示可理解兜底同 ID 跨题库构造冲突记录复合键版本互不影响错题徽标增加错题但不收藏导航徽标变化收藏数不变还要检查手机、平板和 2in1手机底部导航切换正常平板侧边导航进入同一个学习夹页签长文本和三组计数不拥挤大字号下收藏卡仍能点击空状态图片与文字不遮挡深浅色与锁定色彩策略保持可读。十七、常见问题与排查顺序现象优先检查修复方向图标变红但首页未更新是否给StorageLink赋新数组不要只原地改布尔值重启后收藏丢失Preferences 是否写入成功记录持久化失败同一题重复出现去重键是否稳定统一复合主键两题互相影响收藏状态是否只比较questionId同时比较bankId点击收藏卡进入空页面题目引用是否失效迁移或提示失效记录列表顺序异常新记录是否前插明确最近优先规则首页数与导航徽标不同两者统计对象是否不同徽标当前显示错题数清空后列表不刷新是否返回并赋空数组favRecords clearFavorites()返回原题按钮不高亮单题页是否连接同一 key使用favoriteRecords大列表卡顿是否反复扫描题库构建复合键索引排查时优先打印记录的复合标识和数组长度不要只看图标颜色。图标是派生结果事实源仍是favoriteRecords。十八、发布前复核清单面向 HarmonyOS 5.0 及以上版本发布前应确认收藏事实源只有favoriteRecords练习页没有维护第二份收藏布尔状态收藏和取消都返回新数组新收藏放在列表头部Preferences 写入与 AppStorage 更新使用同一结果首页、学习统计和“我的”页面数量均来自数组长度收藏页准确区分收藏、笔记和错题favoriteTabIndex只是导航状态重启后默认回收藏页签收藏项通过bankId questionId打开单题题目失效时有可理解的兜底底部导航徽标明确表示错题数若题目 ID 不是全局唯一去重与列表 key 使用复合键设置页清空后所有页面同步归零不宣称收藏云同步、跨设备同步、侧滑删除或多选管理题库升级不会静默破坏收藏、笔记和错题引用。总结收藏同步的核心不是发通知而是减少事实源。口算王当前源码把收藏记录保存在 Preferences启动后恢复到AppStorage练习页、首页、统计页、收藏列表和设置页都通过StorageLink使用同一数组。收藏图标、首页数量和列表内容都是这个数组的派生结果。继续增强时最值得优先处理的是复合主键、持久化失败反馈和题库升级迁移。只要bankId questionId保持稳定、所有写操作都经过UserDataManager、所有页面都从共享记录派生 UI收藏状态就能在页面切换和应用重启后保持一致而不需要维护容易失真的手工计数器。十九、建议实现把一次收藏看成完整事务当前调用顺序是先由UserDataManager.toggleFavorite()生成新数组并写入 Preferences再由练习页把返回值赋给StorageLink。这条路径简洁但如果持久化失败被内部捕获页面仍可能显示“已收藏”重启后又恢复为旧状态。工程上应先定义清楚产品语义收藏按钮是以即时交互为准允许后台保存失败后提示重试还是以落盘成功为准失败时必须回滚视觉状态。两种策略都可以不能同时假设“界面一定成功”和“磁盘一定成功”。export interface FavoriteChangeResult { records: FavoriteRecord[] persisted: boolean action: added | removed } function applyFavoriteResult(result: FavoriteChangeResult): void { this.favRecords result.records if (!result.persisted) { this.showSaveRetry true } }建议结果对象同时返回新数组、动作类型和持久化状态。页面只消费明确结果不读取服务内部变量也不自己复制去重规则。这样首页统计、收藏列表和按钮状态仍然由同一份数组派生失败提示则由发起操作的页面负责不会让其他页面承担保存细节。上面的接口是改进方向不代表当前工程已经实现。二十、复合标识要贯穿记录、查询和列表 key收藏记录已经保存questionId与bankId但当前部分查找和去重只比较questionId。如果不同题库未来允许复用题号删除一个题库中的收藏就可能同时影响另一个题库。修复不能只改isFavorite()添加、取消、笔记查询、错题查询、收藏页反查题目以及ForEach的 key 都应使用同一复合标识。function favoriteKey(bankId: string, questionId: string): string { return bankId :: questionId } function sameFavorite(record: FavoriteRecord, bankId: string, questionId: string): boolean { return record.bankId bankId record.questionId questionId }迁移时要考虑已有 Preferences 数据。旧记录本身已经包含两个字段因此可以在读取后过滤字段缺失、题库不存在或题目不存在的条目再用复合 key 去重。清洗后的结果是否立即回写需要产品决定若直接回写应保留迁移前数量和清理数量的日志避免把题库加载失败误当成“所有收藏都已失效”。二十一、跨页面同步的验收应观察同一状态源验证收藏同步时不能只看星标变色。完整用例应从练习页添加收藏切到首页核对统计再进入收藏列表核对题目与题库名称随后返回练习页取消收藏重复观察两个消费页面。每一步还要覆盖连续点击、相同题号不同题库、应用重启、题库升级后引用失效和 Preferences 写入异常。只要某个页面通过生命周期手工刷新一份副本就可能在其中一个路径出现旧数据。排查顺序应从状态源向外扩散先确认favRecords是否产生新数组再确认StorageLink的 key 是否一致然后检查统计函数是否直接使用这份数组最后检查收藏列表是否能用bankId questionId找回题目。这样可以区分“状态没有变化”“页面没有订阅”和“记录存在但题目无法反查”三类问题避免在 UI 层堆叠无效的强制刷新。还要特别检查页面重建场景。旋转屏幕、窗口尺寸变化或应用从后台恢复后组件可能重新创建但应用级收藏数组不应被页面默认值覆盖。测试时可以先收藏两道题再触发页面重建核对按钮、首页统计与列表数量是否仍一致随后重启应用确认 Preferences 恢复的数据与重建前完全相同。如果重建后短暂归零应继续检查初始化时序而不是在每个页面分别补一次读取。AI 辅助声明本文在人工核对真实工程源码、状态绑定和持久化调用后使用 AI 辅助整理结构、润色表达并生成示意图当前事实、建议实现和未执行的验证均已分别说明。CSDN-SERIES:ALL-163188874