部分内容由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 数据管理里的 Preferences:
import { 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): Promise<void> { 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): Promise<void> { 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 = ''): Promise<string> { 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[]): Promise<void> { await DataStore.putString('favorite_experiments', JSON.stringify(ids)) } static async loadFavorites(): Promise<string[]> { 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(): Promise<void> { const ids = await DataStore.loadFavorites() this.isFavorite = ids.includes(this.expId) }这里先读路由参数,再初始化实验,再加载收藏状态。顺序很关键:如果先加载收藏,再更新expId,按钮状态就会根据默认实验计算,导致进入其他实验时收藏图标不准确。
收藏切换逻辑如下:
private async toggleFavorite(): Promise<void> { 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(): Promise<void> { 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(): Promise<void> { this.notes = await DataStore.loadNotes<NoteItem>() }笔记没有和知识详情或实验结果自动绑定。它是用户手动输入的学习记录,包含标题、内容、日期和分类。这个边界很重要,因为“自动同步知识详情”会涉及路由参数、关联 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): Promise<void> { 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.saveNotes<NoteItem>(this.notes) }这里有几个细节:
id使用时间戳前缀,适合本地轻量记录;timestamp只保存日期,不保存具体时分秒;- 新笔记插入数组头部,列表优先展示最近创建的记录;
- 保存的是整个
notes数组,不是 append 单条。
由于当前记录量不会很大,保存整个数组是简单有效的。记录量变大后,就要考虑分页、增量写入和索引。
14. 删除笔记:编辑态控制删除入口
删除逻辑也很直接:
private async removeNote(id: string): Promise<void> { this.notes = this.notes.filter(n => n.id !== id) await DataStore.saveNotes<NoteItem>(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 的统计快照:收藏数会同步到 AppStorage
DataStore里还有一层统计通知:
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.setOrCreate<number>(FAVORITE_COUNT_KEY, ids.length) } catch (_) { AppStorage.setOrCreate<number>(FAVORITE_COUNT_KEY, 0) } } DataStore.statsVersion++ AppStorage.setOrCreate<number>(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(): Promise<void> { 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.setOrCreate<number>(FAVORITE_COUNT_KEY, 0) AppStorage.setOrCreate<number>(EXPERIMENT_COUNT_KEY, 0) AppStorage.setOrCreate<number>(LEARNING_SECONDS_KEY, 0) }这意味着收藏和笔记都属于“本地缓存/本地学习数据”的一部分。清理动作会影响用户收藏和笔记,产品文案必须明确。当前这篇文章只讨论数据层实现,不假设已有完整的清理确认流程。
17. 适合迁移的服务边界
当前源码中DataStore已经承担了数据入口职责,但收藏和笔记业务规则仍分散在页面里。如果后续功能变多,可以继续抽出服务层:
export class FavoriteService { static async toggleExperiment(expId: string): Promise<boolean> { 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(): Promise<void> { 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_experiments,FavoritesPage重新加载 ID 并映射到实验模型。
笔记属于用户手动记录:NoteEditorDialog收集标题、内容和分类,NotesPage生成NoteItem,DataStore保存user_notes数组。
本地数据属于 Preferences:DataStore统一封装读写、JSON 解析、默认值兜底、统计快照和缓存清理。页面不直接操作 Preferences,这让 UI 逻辑保持简单。
当前源码支持实验收藏与手动学习笔记的本地持久化;没有知识详情收藏、笔记自动同步、云同步或账号体系。把这个边界写清楚,是技术文章可复核的前提,也是后续继续扩展服务层、同步层或搜索能力时的工程起点。