学习型应用里,"收藏"和"笔记"看起来只是两个列表,真正难点却是让它们记住用户在哪个知识点、哪个实验场景下做了什么。如果收藏只保存实验 ID,笔记只保存标题和正文,那么数据虽然落到了本地,学习上下文却断了:用户从"圆轨道"知识页写下一条笔记,回到"我的笔记"后无法重新打开原知识点;收藏页能进入实验,却不知道这次收藏源自哪段学习内容。
"天体运行模拟"的真实源码已经完成两个可运行的基础闭环:FavoritesPage.ets 读取实验 ID,映射到实验目录并跳回模拟页;NotesPage.ets 通过 NoteEditorDialog 新建笔记,使用 DataStore 写入 Preferences。本文不把尚未实现的能力说成现状,而是先复核这两条真实链路,再给出一套面向 HarmonyOS 5.0 及以上版本的演进方案:用稳定资源引用连接知识页、公式页、实验页与个人记录,同时处理页面恢复、并发写入、坏数据、空状态和多设备布局。

本文会解决四个具体问题:
- 收藏 ID 如何映射为可展示、可跳转的实验对象;
- 为什么页面同时在
aboutToAppear与onPageShow刷新; - 笔记模型怎样携带知识点和实验上下文,而不复制整份正文;
- 如何把 Preferences 的"能存下来"升级为可校验、可迁移、可测试的学习记录仓库。
版本基线:应用版本
1.0.0,targetSdkVersion 6.0.2(22),compatibleSdkVersion 6.0.1(21);设备范围包含 phone、tablet 与 2in1。文中的工程方法面向 HarmonyOS 5.0 及以上版本,API 细节以项目实际 SDK 为准。
一、先看真实边界:收藏实验,笔记独立创建
当前收藏页的状态不是 Experiment[] 的持久化副本,而是一组实验 ID。页面恢复时先读取 ID,再通过 getAllExperiments() 找回完整模型:
ts
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 会被忽略,不会让整个列表崩溃。它也揭示了当前边界:收藏对象是"实验",并不是"知识点"或"公式"。
笔记链路则相对独立。页面保存的 NoteItem 只有五个字段:
ts
interface NoteItem {
id: string
title: string
content: string
timestamp: string
category: string
}
它能表达一条普通笔记,却没有 knowledgeId、experimentId 或来源页面。因此,"同步知识页和个人学习记录"是本文要完成的工程演进目标,而不是对现有源码的夸大描述。
二、两条真实运行链路
收藏从实验页或实验列表发起。LabPage 和 ExperimentSimPage 都调用同一组 DataStore.loadFavorites() / saveFavorites();"我的收藏"页再把 ID 还原为实验,并携带参数打开模拟页。
ts
router.pushUrl({
url: 'views/experiment/ExperimentSimPage',
params: { expId: exp.id, expName: exp.name }
})
笔记从 NotesPage 的自定义弹窗发起。标题或正文为空时,弹窗直接保留,不调用保存回调;输入合法时,页面生成 ID 和日期,把新记录插到数组头部,再整体写回:
ts
private async addNote(
title: string,
content: string,
category: string
): Promise<void> {
const now = new Date()
const item: NoteItem = {
id: 'n_' + now.getTime(),
title,
content,
timestamp: this.formatDate(now),
category
}
this.notes = [item, ...this.notes]
await DataStore.saveNotes<NoteItem>(this.notes)
}
这里的"整体写回"对少量本地笔记很直接,但会引出并发覆盖和模型迁移问题,后文会分别处理。

三、Preferences 为什么适合当前规模
项目的 DataStore 使用 @kit.ArkData 中的 Preferences,并把数组序列化为 JSON 字符串:
ts
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 []
}
}
收藏 ID、少量笔记、开关和统计快照都属于轻量键值数据,Preferences 足够简单,也符合当前离线应用的体量。若未来需要全文检索、按资源 ID 联表查询、数万条记录、复杂排序或迁移,才应评估关系型数据库,而不是因为"笔记"两个字就提前引入重型方案。
| 数据特征 | 当前选择 | 何时需要升级 |
|---|---|---|
| 少量实验 ID | Preferences | 通常无需升级 |
| 数十到数百条短笔记 | Preferences | 需要全文检索或复杂索引时 |
| 结构化学习轨迹 | 可先用版本化 JSON | 需要多条件统计和增量迁移时 |
| 图片、附件 | 文件目录 + 元数据 | 不应把二进制塞进 Preferences |
四、页面恢复为何调用两次 reload
FavoritesPage 和 NotesPage 都在两个回调中刷新:
ts
aboutToAppear(): void {
this.reload()
}
onPageShow(): void {
this.reload()
}
首次创建页面时需要加载数据;从模拟页或编辑流程返回时,又要拿到最新收藏和笔记。这个策略能覆盖常见返回路径,但两个异步读取可能在页面首次显示时重叠。数据量小的时候通常看不出问题,工程化后最好增加请求序号,避免慢请求覆盖新结果:
ts
@State private loading: boolean = false
private reloadVersion: number = 0
private async reload(): Promise<void> {
const version = ++this.reloadVersion
this.loading = true
const result = await this.repository.listNotes()
if (version !== this.reloadVersion) {
return
}
this.notes = result
this.loading = false
}
请求序号不负责取消 I/O,只负责保证最后一次刷新拥有状态写入权。这对路由快速往返、窗口切换和多次生命周期触发都更稳。
五、收藏应保存 ID,不应复制实验对象
直接持久化完整 Experiment 看似省去映射,实际会复制名称、描述、分类、图标引用和参数定义。应用升级后,旧副本可能与新目录不一致;资源对象也不适合直接 JSON 化。
更稳的持久化模型仍然是:
ts
export interface FavoriteRecord {
resourceType: 'experiment'
resourceId: string
createdAt: number
}
相比现有 string[],它只增加资源类型和时间戳,却保留了"目录为权威数据源"的原则。时间戳可以支持最近收藏排序,resourceType 为未来知识点收藏留出边界。
六、先为学习资源建立统一引用
收藏、笔记和最近学习记录不应该各自发明跳转参数。可以先定义一个只描述身份的联合类型:
ts
export type LearningResourceType =
| 'knowledge'
| 'formula'
| 'experiment'
export interface LearningResourceRef {
type: LearningResourceType
id: string
}
LearningResourceRef 不保存页面 URL,也不复制页面标题。它回答"这条记录属于谁";标题、摘要、图标、路由目标则由统一目录解析。这样知识内容改名时,用户笔记仍能指向同一个稳定 ID。
七、让笔记携带上下文,而不是复制知识正文
建议把笔记模型升级为显式版本,并把来源资源设为可选:
ts
export interface LearningNote {
schemaVersion: 2
id: string
title: string
content: string
category: string
createdAt: number
updatedAt: number
source?: LearningResourceRef
}
独立笔记可以没有 source;从知识页、公式页或模拟页创建的笔记则带上稳定引用。不要把知识点全文复制进笔记,否则原文更新后会产生两套互相冲突的内容。
这个模型还修复了当前 timestamp: string 的一个限制:展示日期适合 UI,但排序和跨时区处理更适合数值时间戳。日期格式应在视图层生成。
八、从知识页创建笔记的路由契约
知识页只需要传资源身份和一个可选的建议标题:
ts
interface NoteEditorParams {
sourceType?: LearningResourceType
sourceId?: string
suggestedTitle?: string
}
router.pushUrl({
url: 'views/mine/NotesPage',
params: {
sourceType: 'knowledge',
sourceId: knowledge.id,
suggestedTitle: `学习:${knowledge.title}`
}
})
页面接收参数后必须验证 sourceType 和 sourceId,不能直接相信路由输入。建议标题只是编辑体验,不应成为身份字段。真正的关联始终由 { type, id } 决定。
九、资源目录集中负责解析和跳转
页面不应散落 if (type === ...)。一个窄职责目录可以同时完成资源解析和路由构造:
ts
export interface LearningResourceSummary {
ref: LearningResourceRef
title: string
category: string
route: string
params: Record<string, string>
}
export class LearningCatalog {
resolve(ref: LearningResourceRef):
LearningResourceSummary | undefined {
if (ref.type === 'experiment') {
const exp = getAllExperiments()
.find(item => item.id === ref.id)
if (!exp) return undefined
return {
ref,
title: exp.name,
category: exp.category,
route: 'views/experiment/ExperimentSimPage',
params: { expId: exp.id, expName: exp.name }
}
}
return this.resolveLearningContent(ref)
}
}
目录负责把稳定 ID 解析为当前版本的标题和页面契约。笔记页只消费结果:能解析就显示"查看来源",不能解析就显示"来源内容已不可用",而不是崩溃或跳到错误页面。

十、仓库层统一读、写、校验
当前页面直接调用 DataStore,小项目足够清晰。关联类型增加后,可以在页面与 Preferences 之间加一个 LearningRecordRepository,让校验和迁移只有一个入口:
ts
export class LearningRecordRepository {
async listNotes(): Promise<LearningNote[]> {
const raw = await DataStore.loadNotes<object>()
return raw
.map(item => this.migrateNote(item))
.filter((item): item is LearningNote => item !== undefined)
}
async saveNotes(notes: LearningNote[]): Promise<void> {
const normalized = this.deduplicateNotes(notes)
await DataStore.saveNotes<LearningNote>(normalized)
}
}
页面负责交互状态,仓库负责存储格式,目录负责学习资源。三个边界分开后,测试不需要启动完整 ArkUI 页面。
十一、旧笔记迁移不能只靠类型断言
JSON.parse(json) as T[] 只告诉编译器"把它当作 T",不会校验磁盘数据。旧版笔记没有 schemaVersion、createdAt 和 source,需要显式迁移:
ts
private migrateNote(raw: object): LearningNote | undefined {
const value = raw as Record<string, string | number>
if (typeof value.id !== 'string' ||
typeof value.title !== 'string' ||
typeof value.content !== 'string') {
return undefined
}
const fallbackTime = Date.now()
return {
schemaVersion: 2,
id: value.id,
title: value.title,
content: value.content,
category: typeof value.category === 'string'
? value.category : '未分类',
createdAt: typeof value.createdAt === 'number'
? value.createdAt : fallbackTime,
updatedAt: typeof value.updatedAt === 'number'
? value.updatedAt : fallbackTime
}
}
生产实现还可以解析旧 YYYY-MM-DD 字符串。重点不是补默认值,而是让错误记录被识别、被隔离,并为升级路径留下可复核的规则。
十二、避免"读---改---写"覆盖
现有收藏切换采用"读取数组、修改数组、整体保存"。如果两个页面几乎同时切换收藏,后写入者可能覆盖先写入者。可以在仓库内部串行化写操作:
ts
private writeChain: Promise<void> = Promise.resolve()
toggleFavorite(id: string): Promise<void> {
this.writeChain = this.writeChain.then(async () => {
const ids = await DataStore.loadFavorites()
const set = new Set(ids)
if (set.has(id)) {
set.delete(id)
} else {
set.add(id)
}
await DataStore.saveFavorites([...set])
})
return this.writeChain
}
串行队列适用于单进程内的轻量 Preferences 写入。若未来出现跨进程、多端同步或云端合并,就需要版本号、冲突策略或事务能力,不能继续依赖内存队列。
十三、按钮点击与父级点击要分清
LabPage 的实验卡片整体可点击,右侧星标也可点击。实际设备上要验证点击星标时是否同时触发卡片跳转。更稳的 UI 结构是把收藏按钮放在独立命中区域,并在交互测试中明确"收藏不跳转,卡片才跳转"。
ts
Row() {
ExperimentSummary(exp)
.layoutWeight(1)
.onClick(() => this.openExperiment(exp))
Button(this.isFavorite(exp.id) ? '★' : '☆')
.width(44)
.height(44)
.onClick(() => this.toggleFavorite(exp.id))
}
44vp 左右的触控区域比只给一个字符绑定点击更适合手机,也兼顾平板和 2in1 的指针操作。
十四、保存笔记要有进行中和失败状态
当前 NoteEditorDialog 调用 onSave 后立即关闭,而 onSave 的类型是同步 void。真实持久化是异步的,如果写入失败,用户会误以为已经保存。可以把回调改为返回结果:
ts
export interface SaveNoteResult {
ok: boolean
message?: string
}
onSave: (
title: string,
content: string,
category: string
) => Promise<SaveNoteResult>
弹窗增加 saving 状态:保存中禁用按钮,成功后关闭,失败时保留输入并显示错误。这样数据可靠性不是藏在日志里,而是成为用户能理解的页面状态。
十五、删除笔记不能先乐观消失再静默失败
现有代码先过滤 this.notes,再保存;而 DataStore.putString() 捕获异常后不向上传递。若落盘失败,列表当次看起来已删除,重新进入又会出现。
可采用"保存成功后提交 UI":
ts
private async removeNote(id: string): Promise<void> {
const next = this.notes.filter(note => note.id !== id)
const result = await this.repository.replaceNotes(next)
if (!result.ok) {
this.errorMessage = '删除失败,请重试'
return
}
this.notes = next
}
数据量很小时,这种保守策略足够直观。若选择乐观更新,也必须在失败时恢复旧数组并提示用户。
十六、空状态要把用户带回有效入口
当前两个空状态的文案清晰,但没有动作。收藏为空时可以提供"去实验地图",笔记为空时可以提供"浏览知识点"或"新建笔记"。目标必须与按钮文案一致:
ts
Button('浏览知识点')
.onClick(() => {
router.pushUrl({
url: 'views/learning/KnowledgeListPage'
})
})
空状态不是装饰页,而是恢复用户任务的最短路径。不要把"去学习"绑定到默认模拟场景,否则文案和行为仍然脱节。
十七、加载、空、错误、内容应当互斥
只用 notes.length === 0 无法区分"还没读完"和"确实为空"。建议显式建模:
ts
type PageState = 'loading' | 'empty' | 'content' | 'error'
@State pageState: PageState = 'loading'
@State errorMessage: string = ''
读取成功后根据数组长度进入 empty 或 content;解析失败、初始化失败进入 error;错误状态提供重试。这样首次加载不会短暂闪出"暂无笔记"。
十八、AppStorage 只放统计快照,不放完整记录
项目在收藏写入后更新 favorite_count,并通过 stats_version 通知统计页刷新。这种做法适合跨页面展示计数:
ts
AppStorage.setOrCreate<number>(
'favorite_count',
ids.length
)
不要把完整笔记数组或实验模型塞进 AppStorage。完整数据仍由仓库和 Preferences 管理,AppStorage 只承载需要被多个页面即时观察的轻量快照,避免形成两个权威数据源。
十九、多设备布局:列表能伸缩,操作必须可达
当前页面根容器使用 width('100%')、height('100%'),列表用 layoutWeight(1),并读取状态栏和底部导航栏高度。这为 phone、tablet、2in1 提供了基础。
进一步检查时要关注:
- 平板宽屏不要把单条笔记拉成过长行,可限制内容最大宽度;
- 2in1 窗口缩窄后,分类标签应换行或横向滚动;
- "新建笔记"按钮底部至少保留系统避让区域;
- 长标题使用
maxLines与省略号,正文允许两到三行; - 编辑删除按钮需要稳定宽度,不能挤压标题到不可读。
ts
Column() {
this.NoteList()
}
.width('100%')
.constraintSize({ maxWidth: 840 })
.alignSelf(ItemAlign.Center)
固定的是内容阅读宽度,不是窗口宽度。这样大屏上更易读,小窗中仍可自然收缩。
二十、深浅色和对比度不能绕过主题令牌
页面主体使用 AppColors 是正确方向,但 NoteEditorDialog 仍包含 #F5F7FA、#F0F0F0、#F2F2F2 等硬编码浅色。系统切换深色模式时,这些输入区和按钮可能与文本令牌冲突。
建议补齐语义颜色:
ts
export class AppColors {
static readonly INPUT_BG: Resource =
$r('app.color.input_background')
static readonly MUTED_ACTION_BG: Resource =
$r('app.color.muted_action_background')
}
浅色、深色资源使用同名 token,页面不需要判断颜色模式。正文文字与背景对比度应达到 4.5:1,关键图标和按钮至少达到 3:1。
二十一、隐私边界:离线笔记仍然是用户数据
当前 DataStore 把收藏和笔记保存在应用 Preferences 中,没有看到上传、账号或第三方同步逻辑。文章中的"同步"指应用内部页面与个人记录的一致关联,不代表云同步或跨设备上传。
发布材料应如实说明:
- 笔记与收藏保存在本地;
- 不收集账号、通讯录或位置;
- 卸载应用后,本地数据按系统机制清理;
- 若未来增加云同步,必须重新评估权限、隐私政策、服务端位置和删除机制。
不要用"多端同步"描述尚未存在的能力。内部状态同步与网络数据同步是两个完全不同的承诺。
二十二、验证收藏链路
至少执行以下用例:
- 在实验地图收藏
stable_orbit,进入"我的收藏",能看到"稳定双体系统"; - 点击收藏项,模拟页收到正确
expId和expName; - 在模拟页取消收藏,返回收藏页后条目消失;
- 手工放入一个目录不存在的旧 ID,页面忽略坏项且不崩溃;
- 连续快速点击星标,不出现重复 ID;
- 杀进程重启后收藏仍存在。
可把纯映射逻辑提取后做单元测试:
ts
export function resolveFavoriteExperiments(
ids: string[],
all: Experiment[]
): Experiment[] {
const byId = new Map(all.map(item => [item.id, item]))
return ids
.map(id => byId.get(id))
.filter((item): item is Experiment => item !== undefined)
}
测试重点不是 ArkUI 渲染,而是顺序、坏 ID 和重复值的业务规则。
二十三、验证笔记与来源关联
笔记链路需要覆盖:
- 空标题或空正文不允许保存;
- 保存中按钮禁用,避免重复提交;
- 从知识点进入时,笔记带正确
{ type, id }; - 独立新建的笔记允许没有来源;
- 点击"查看来源"能回到对应知识页、公式页或实验页;
- 来源已删除时显示不可用状态,不跳默认页面;
- 旧版笔记能迁移,坏数据被隔离;
- 删除失败时 UI 不伪装成功。
建议为路由解析写表驱动测试:
ts
const cases: LearningResourceRef[] = [
{ type: 'knowledge', id: 'orbit_1' },
{ type: 'formula', id: 'gravity_force' },
{ type: 'experiment', id: 'stable_orbit' }
]
每个引用都要验证标题、目标路由和参数,而不是只验证"没有抛异常"。
二十四、常见问题与定位顺序
| 现象 | 优先检查 | 修复方向 |
|---|---|---|
| 收藏后列表不更新 | 返回时是否执行 reload | 在页面显示阶段刷新,增加请求序号 |
| 收藏项点击进入错误场景 | expId 是否真实传递 |
统一由目录构造路由参数 |
| 笔记重启后丢失 | Preferences 是否已初始化、flush 是否成功 | 返回可观察的保存结果 |
| 删除后重新出现 | 是否先改 UI、落盘却失败 | 成功后提交 UI 或失败回滚 |
| 旧数据导致空白页 | JSON 只做了类型断言 | 增加字段校验和版本迁移 |
| 快速操作丢收藏 | 并发读改写覆盖 | 仓库内串行化写入 |
| 深色模式输入框刺眼 | 弹窗存在浅色硬编码 | 改为深浅色同名资源 |
| 笔记无法回到知识页 | 模型没有来源引用 | 保存稳定资源类型与 ID |
排查时先确认 DataStore.init() 是否完成,再看存储键值,最后检查页面生命周期和目录解析。不要一开始就怀疑 ArkUI 列表组件。
二十五、上线前的最小验收清单
- 收藏和笔记只保存必要本地数据;
- Preferences 初始化失败有错误状态;
- 收藏 ID 去重,坏 ID 不导致崩溃;
- 笔记有版本字段和迁移策略;
- 来源使用稳定资源 ID,不复制知识正文;
- 路由目标与按钮文案一致;
- 保存、删除具备失败反馈;
- 首次加载不会闪现错误空状态;
- phone、tablet、2in1 下操作均可达;
- 深浅色文字、输入框、按钮满足对比度;
- 隐私材料没有宣称不存在的云同步;
- 完成安装、启动、核心流程、退出和卸载冒烟验证。
总结
收藏与笔记真正共享的不是一个页面,而是一套稳定的学习资源身份。现有源码已经把实验收藏和本地笔记分别跑通:收藏以实验 ID 为核心,笔记以 JSON 数组写入 Preferences。下一步应保持这两个真实基础不变,把 { type, id } 引入个人记录,让知识点、公式和实验都由统一目录解析;再由仓库集中处理迁移、校验和串行写入。
这样做之后,"我的收藏"和"我的笔记"才不只是两个数据列表,而是可以回到原学习现场的入口。页面负责交互,目录负责资源,仓库负责可靠存储,三者各自守住边界,应用内部的学习记录才能稳定同步。
LEARNING-ONE13-FAVORITE-NOTE-CONTEXT-20260726:收藏保存稳定资源引用,笔记携带可选学习来源,目录解析跳转,仓库统一迁移与并发写入。
本文部分内容由 AI 辅助整理,源码事实、工程边界与验证结论均依据文中所列项目文件复核。