部分内容由AI辅助生成。
本文面向 HarmonyOS 5.0 及以上版本,基于 细胞工坊 项目真实源码展开,源码根目录为 D:\\huawei\\one14-9。本文重点复核这些文件:
- entry/src/main/ets/views/experiment/ExperimentSimPage.ets
- entry/src/main/ets/views/mine/FavoritesPage.ets
- entry/src/main/ets/views/mine/NotesPage.ets
- entry/src/main/ets/components/NoteEditorDialog.ets
- entry/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 = false
notes 是展示列表,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 逻辑保持简单。
当前源码支持实验收藏与手动学习笔记的本地持久化;没有知识详情收藏、笔记自动同步、云同步或账号体系。把这个边界写清楚,是技术文章可复核的前提,也是后续继续扩展服务层、同步层或搜索能力时的工程起点。
网硕互联帮助中心








评论前必须登录!
注册