
部分内容由AI辅助生成。本文面向 HarmonyOS 5.0 及以上版本基于细胞工坊项目真实源码展开源码根目录为D:\huawei\one14-9。本文重点复核这些文件entry/src/main/ets/views/experiment/ExperimentSimPage.etsentry/src/main/ets/views/mine/FavoritesPage.etsentry/src/main/ets/views/mine/NotesPage.etsentry/src/main/ets/components/NoteEditorDialog.etsentry/src/main/ets/utils/DataStore.ets先把边界讲清楚当前源码实现的是实验收藏和手动学习笔记。收藏保存的是实验expId收藏列表再用getAllExperiments()把 ID 映射回实验卡片笔记通过NoteEditorDialog手动输入标题、内容、分类再保存到user_notes。源码没有实现知识详情收藏、笔记自动同步知识详情、云同步、账号体系、跨设备同步或富文本笔记。1. 收藏和笔记最容易出错的地方不是 UI而是边界在学习类 HarmonyOS 应用里“收藏”和“笔记”看起来只是两个入口但实际会牵涉多个页面实验模拟页要能切换收藏状态我的收藏页要能重新加载列表笔记页要能新增和删除数据层要能把这些记录稳定保存下来。如果边界没划清常见问题会很快出现问题表现源码里的处理方式收藏按钮状态和列表不一致实验页点了收藏返回列表看不到FavoritesPage在aboutToAppear/onPageShow调用reload()收藏保存整个对象实验改名后收藏里还是旧内容只保存实验 ID展示时映射最新实验定义笔记输入为空仍保存列表出现空标题或空内容NoteEditorDialog在保存前trim()并拦截空值删除只改 UI 不改本地重进页面后被删笔记又出现removeNote()更新状态后调用DataStore.saveNotes()本地统计不同步我的页面收藏数量不变DataStore.notifyStatsChanged()更新 AppStorage 快照这篇文章要解决的工程问题是在一个 ArkTS 应用里用轻量级 Preferences 实现本地收藏和笔记页面状态和持久化状态保持一致同时不夸大源码没有实现的同步能力。2. DataStore把 Preferences 包成统一入口DataStore.ets是本地数据入口。它使用 HarmonyOS 数据管理里的 Preferencesimport { preferences } from kit.ArkData import { common } from kit.AbilityKit const PREF_NAME bio_lab_app_data export class DataStore { private static prefInstance: preferences.Preferences | null null static async init(context: common.UIAbilityContext): Promisevoid { try { DataStore.prefInstance await preferences.getPreferences(context, PREF_NAME) await DataStore.refreshStatsSnapshot() } catch (_) { DataStore.prefInstance null } } }这里的关键是把preferences.Preferences实例藏在DataStore内部。页面不直接调用preferences.getPreferences()而是调用DataStore.loadFavorites()、DataStore.saveNotes()这类业务方法。这种做法有三个好处页面不用知道 Preferences 文件名JSON 解析和异常兜底集中处理后续如果从 Preferences 换成 RDB 或文件存储页面改动范围更小。当前数据量很小收藏只是字符串 ID 数组笔记也只是本地对象数组Preferences 是合适选择。若后续笔记支持全文搜索、标签过滤、图片附件或大量记录就应考虑关系型数据库或文件存储。3. 通用读写失败时返回默认值避免页面崩溃DataStore的基础读写方法都做了异常兜底static async putString(key: string, value: string): Promisevoid { if (!DataStore.prefInstance) return try { await DataStore.prefInstance.put(key, value) await DataStore.prefInstance.flush() DataStore.notifyStatsChanged(key, value) } catch (_) { } } static async getString(key: string, defaultValue: string ): Promisestring { if (!DataStore.prefInstance) return defaultValue try { const value await DataStore.prefInstance.get(key, defaultValue) return value as string } catch (_) { return defaultValue } }收藏和笔记都依赖字符串 JSON。写入时flush()保证数据落盘读取失败时返回默认值页面可以继续显示空态。这段代码的工程取舍也很明显它没有把错误抛到 UI 层也没有显示失败提示。对于当前轻量学习工具来说这能保证页面稳定如果是强一致的生产记录系统就应把保存失败反馈给用户并提供重试。4. 收藏数据结构只保存实验 ID不保存实验对象收藏方法非常明确static async saveFavorites(ids: string[]): Promisevoid { await DataStore.putString(favorite_experiments, JSON.stringify(ids)) } static async loadFavorites(): Promisestring[] { const json await DataStore.getString(favorite_experiments, []) try { return JSON.parse(json) as string[] } catch { return [] } }它只保存string[]。这比保存完整实验对象更稳。保存方式优点风险保存完整实验对象列表渲染不需要再查模型实验名称、图标、分类更新后本地旧对象会过期保存实验 ID本地数据小展示时使用最新模型如果模型里删除实验 ID需要过滤不存在项当前源码选择第二种方式。FavoritesPage.reload()会处理“ID 已不存在”的情况只把能找到的实验推入列表。5. ExperimentSimPage收藏入口在实验模拟页实验模拟页维护收藏状态State isFavorite: boolean false State expId: string microscope_observation页面出现时加载收藏状态aboutToAppear(): void { const params router.getParams() as SimRouterParams | undefined if (params?.expId) { this.expId params.expId } if (params?.expName) { this.title params.expName } this.initExperiment() this.resetExperiment() this.loadFavoriteState() } private async loadFavoriteState(): Promisevoid { const ids await DataStore.loadFavorites() this.isFavorite ids.includes(this.expId) }这里先读路由参数再初始化实验再加载收藏状态。顺序很关键如果先加载收藏再更新expId按钮状态就会根据默认实验计算导致进入其他实验时收藏图标不准确。收藏切换逻辑如下private async toggleFavorite(): Promisevoid { const ids await DataStore.loadFavorites() const idx ids.indexOf(this.expId) if (idx 0) { ids.splice(idx, 1) this.isFavorite false } else { ids.push(this.expId) this.isFavorite true } await DataStore.saveFavorites(ids) }这段代码先读取当前 ID 数组再根据expId是否存在决定添加或移除。页面状态isFavorite会立即更新最后保存到本地。它没有防重复添加因为idx 0已经覆盖了重复点击场景。6. 收藏按钮UI 状态来自 isFavorite而不是列表长度实验页右上角按钮根据isFavorite切换颜色Row() { Text(this.isFavorite ? ★ : ☆) .fontSize(20) .fontColor(this.isFavorite ? AppColors.ACCENT_GREEN : AppColors.TEXT_SECONDARY) } .width(40) .height(40) .borderRadius(20) .backgroundColor(#111827) .justifyContent(FlexAlign.Center) .onClick(() { this.toggleFavorite() })源码输出中图标字符可能因为编码显示为乱码但结构可以确认按钮显示由isFavorite控制点击调用toggleFavorite()。这里有一个值得保留的原则收藏按钮不应该每次渲染都重新读取 Preferences。读取本地数据是异步操作频繁放在 UI 构建路径里会让页面状态不可控。当前源码在生命周期中加载一次点击时更新一次是合理的。7. FavoritesPage收藏列表通过 ID 映射实验定义收藏页只保存一个状态State favorites: Experiment[] []加载逻辑是private async reload(): Promisevoid { const ids await DataStore.loadFavorites() const all getAllExperiments() const list: Experiment[] [] for (const id of ids) { const found all.find(e e.id id) if (found) { list.push(found) } } this.favorites list }这段代码把本地 ID 数组转换成当前实验定义数组。它有一个很实用的容错如果某个 ID 在getAllExperiments()中找不到就跳过不让列表出现空对象。这也解释了为什么收藏本地只保存 ID收藏页展示的名称、描述、图标、分类、难度都来自模型最新定义而不是历史缓存。8. 生命周期 reload解决返回页面后的列表刷新收藏页和笔记页都使用了两个生命周期入口aboutToAppear(): void { this.reload() } onPageShow(): void { this.reload() }aboutToAppear()负责页面首次进入时加载onPageShow()负责页面重新显示时刷新。对于收藏列表尤其重要用户可能从收藏页进入实验模拟页切换收藏状态后返回收藏页。如果只在首次进入加载列表就可能显示旧数据。这类本地数据页面一般要遵守一个简单规则页面刷新时机详情页按钮状态进入详情时读取一次点击时更新列表页首次进入和返回显示时重新读取统计页依赖 AppStorage 快照或进入时重新计算当前FavoritesPage和NotesPage都选择了进入/显示时重新加载适合本地轻量数据。9. 收藏空态没有数据时给用户下一步收藏页空态代码if (this.favorites.length 0) { Column() { Text(暂无收藏) .fontSize(16) .fontColor(AppColors.TEXT_HINT) Text(去实验室收藏你感兴趣的实验吧) .fontSize(13) .fontColor(AppColors.TEXT_HINT) .margin({ top: 8 }) } .width(100%) .layoutWeight(1) .justifyContent(FlexAlign.Center) .alignItems(HorizontalAlign.Center) }空态不是装饰它告诉用户下一步该去哪去实验室收藏实验。对学习工具来说这比只显示空白页面更清楚也能避免用户误以为数据加载失败。如果后续要增强可以在空态加入“去实验室”按钮直接路由到实验列表。但当前源码没有这个按钮文章只描述现有提示文案。10. 收藏列表卡片点击后回到实验模拟页收藏列表的卡片点击会进入实验模拟.onClick(() { router.pushUrl({ url: views/experiment/ExperimentSimPage, params: { expId: exp.id, expName: exp.name } }) })这里传入expId和expName。实验模拟页再通过router.getParams()读取这些参数初始化当前实验。这条链路说明收藏列表是实验入口不是知识点收藏入口。它没有传kpTitle、kpSummary也没有记录知识点 ID。知识详情页虽然存在getRelatedExperiment()这样的映射逻辑但当前收藏页的持久化对象仍是实验 ID。这就是本文收窄标题的原因源码支持“实验收藏与本地笔记”不支持“知识详情、收藏列表和本地记录三方同步”。11. NotesPage笔记是独立的本地数组笔记页定义本地接口interface NoteItem { id: string title: string content: string timestamp: string category: string }状态如下State notes: NoteItem[] [] State isEditing: boolean falsenotes是展示列表isEditing决定是否显示删除按钮。页面加载时private async reload(): Promisevoid { this.notes await DataStore.loadNotesNoteItem() }笔记没有和知识详情或实验结果自动绑定。它是用户手动输入的学习记录包含标题、内容、日期和分类。这个边界很重要因为“自动同步知识详情”会涉及路由参数、关联 ID、笔记来源、重复合并等逻辑当前源码没有实现。12. NoteEditorDialog保存前拦截空标题和空内容笔记弹窗通过CustomDialog实现CustomDialog export struct NoteEditorDialog { controller: CustomDialogController title: string content: string category: string 基础 onSave: (title: string, content: string, category: string) void () {} }保存按钮里做了输入清理.onClick(() { const t this.title.trim() const c this.content.trim() if (t.length 0 || c.length 0) { return } this.onSave(t, c, this.category) this.controller.close() })这段逻辑防止空标题和空内容进入本地数组。它没有显示错误提示只是静默返回。对于当前简洁工具页来说可以接受如果要增强可用性可以在弹窗内增加提示状态例如State errorText但当前源码没有做。13. 新建笔记先更新页面状态再保存本地笔记页通过CustomDialogController接收保存回调private editorController: CustomDialogController new CustomDialogController({ builder: NoteEditorDialog({ onSave: (title: string, content: string, category: string) { this.addNote(title, content, category) } }), autoCancel: true, customStyle: true })新增逻辑private async addNote(title: string, content: string, category: string): Promisevoid { const now new Date() const pad (n: number): string (n 10 ? 0 n : n) const ts now.getFullYear() - pad(now.getMonth() 1) - pad(now.getDate()) const item: NoteItem { id: n_ now.getTime(), title, content, timestamp: ts, category } this.notes [item, ...this.notes] await DataStore.saveNotesNoteItem(this.notes) }这里有几个细节id使用时间戳前缀适合本地轻量记录timestamp只保存日期不保存具体时分秒新笔记插入数组头部列表优先展示最近创建的记录保存的是整个notes数组不是 append 单条。由于当前记录量不会很大保存整个数组是简单有效的。记录量变大后就要考虑分页、增量写入和索引。14. 删除笔记编辑态控制删除入口删除逻辑也很直接private async removeNote(id: string): Promisevoid { this.notes this.notes.filter(n n.id ! id) await DataStore.saveNotesNoteItem(this.notes) }UI 上只有进入编辑态才显示删除按钮Text(this.isEditing ? 完成 : 编辑) .fontSize(14) .fontColor(AppColors.PRIMARY) .onClick(() { this.isEditing !this.isEditing })列表项里if (this.isEditing) { Text(✕) .fontSize(16) .fontColor(AppColors.ACCENT_RED) .onClick(() { this.removeNote(note.id) }) }这避免了普通浏览状态下误触删除。当前源码没有二次确认也没有撤销。对于学习笔记这种用户输入内容后续如果要提高安全性应加确认弹窗或撤销提示。当前文章只按实际源码描述“编辑态删除并持久化”。15. DataStore 的统计快照收藏数会同步到 AppStorageDataStore里还有一层统计通知private static notifyStatsChanged(key: string, value: string | number): void { if (STAT_KEYS.indexOf(key) 0) return if (key favorite_experiments) { try { const ids JSON.parse(value as string) as string[] AppStorage.setOrCreatenumber(FAVORITE_COUNT_KEY, ids.length) } catch (_) { AppStorage.setOrCreatenumber(FAVORITE_COUNT_KEY, 0) } } DataStore.statsVersion AppStorage.setOrCreatenumber(STATS_VERSION_KEY, DataStore.statsVersion) }这说明保存收藏后不只是 Preferences 变化AppStorage 中的收藏数量快照也会更新。这样“我的”页面或其他统计组件可以不重新解析 JSON也能读到收藏数量。需要注意user_notes不在STAT_KEYS中因此保存笔记不会触发统计快照。这也是源码边界之一。文章不能写成“笔记数量同步到全局统计”因为当前代码没有这样的键。16. 清理本地数据收藏、记录和笔记一起清LOCAL_DATA_KEYS包含const LOCAL_DATA_KEYS: string[] [ favorite_experiments, experiment_records, user_notes, experiment_count, learning_seconds, learning_minutes ]clearCache()会删除这些键并重置统计快照static async clearCache(): Promisevoid { if (!DataStore.prefInstance) return for (let i 0; i LOCAL_DATA_KEYS.length; i) { const key LOCAL_DATA_KEYS[i] try { await DataStore.prefInstance.delete(key) } catch (_) { } } try { await DataStore.prefInstance.flush() } catch (_) { } AppStorage.setOrCreatenumber(FAVORITE_COUNT_KEY, 0) AppStorage.setOrCreatenumber(EXPERIMENT_COUNT_KEY, 0) AppStorage.setOrCreatenumber(LEARNING_SECONDS_KEY, 0) }这意味着收藏和笔记都属于“本地缓存/本地学习数据”的一部分。清理动作会影响用户收藏和笔记产品文案必须明确。当前这篇文章只讨论数据层实现不假设已有完整的清理确认流程。17. 适合迁移的服务边界当前源码中DataStore已经承担了数据入口职责但收藏和笔记业务规则仍分散在页面里。如果后续功能变多可以继续抽出服务层export class FavoriteService { static async toggleExperiment(expId: string): Promiseboolean { const ids await DataStore.loadFavorites() const index ids.indexOf(expId) if (index 0) { ids.splice(index, 1) await DataStore.saveFavorites(ids) return false } ids.push(expId) await DataStore.saveFavorites(ids) return true } }页面可以改成private async toggleFavorite(): Promisevoid { this.isFavorite await FavoriteService.toggleExperiment(this.expId) }这样页面只关心按钮状态收藏数组的读写、去重、持久化都放到服务里。当前源码还没有这个服务层因此这是后续可迁移写法不是现有实现。18. 验证清单从实验页、收藏页、笔记页分别测验证收藏链路操作预期从实验列表进入某个实验模拟页右上角收藏按钮根据本地 ID 状态显示点击收藏按钮favorite_experiments增加当前expId返回我的收藏页列表出现该实验卡片再次进入实验页取消收藏本地 ID 被移除收藏页 reload 后不显示模型删除某实验 ID收藏页跳过找不到的 ID不渲染空卡验证笔记链路操作预期打开我的笔记列表为空显示“暂无笔记”空态点击新建笔记标题或内容为空保存不新增记录输入标题、内容、分类后保存新笔记插入列表顶部退出再进入笔记页DataStore.loadNotes()重新加载本地数组点击编辑再点删除该笔记从列表和本地数组移除验证数据层操作预期初始化 DataStore 失败页面读取默认空数组不崩溃收藏 JSON 损坏loadFavorites()返回空数组笔记 JSON 损坏loadNotes()返回空数组清理缓存收藏、实验记录、笔记和学习时长键被删除19. 常见问题与修复方向问题可能原因修复方向收藏按钮状态不对进入实验页前没有更新expId先读取路由参数再调用loadFavoriteState()收藏页返回后不刷新只在首次进入加载数据在onPageShow()中也调用reload()收藏列表出现空白卡本地 ID 找不到模型映射时过滤found为空的记录删除笔记后重进又出现只改了notes状态没有保存删除后调用DataStore.saveNotes()空笔记被保存弹窗保存前没有trim()校验保存前拦截空标题和空内容收藏数量统计不变保存后没有触发统计快照通过putString()调用notifyStatsChanged()清理缓存后 UI 还显示旧数量AppStorage 快照没归零clearCache()同步重置统计键这些问题都能从当前源码里找到对应的防线。写这类页面时不要只看按钮能不能点还要检查页面返回、重新进入、数据损坏和清理缓存后的状态。20. 小结本地学习记录要靠明确的数据归属05-10 收藏与笔记的源码实现并不复杂但边界很清楚。收藏属于实验维度ExperimentSimPage切换收藏状态DataStore保存favorite_experimentsFavoritesPage重新加载 ID 并映射到实验模型。笔记属于用户手动记录NoteEditorDialog收集标题、内容和分类NotesPage生成NoteItemDataStore保存user_notes数组。本地数据属于 PreferencesDataStore统一封装读写、JSON 解析、默认值兜底、统计快照和缓存清理。页面不直接操作 Preferences这让 UI 逻辑保持简单。当前源码支持实验收藏与手动学习笔记的本地持久化没有知识详情收藏、笔记自动同步、云同步或账号体系。把这个边界写清楚是技术文章可复核的前提也是后续继续扩展服务层、同步层或搜索能力时的工程起点。