【天体运行模拟|11】HarmonyOS ArkTS 收藏与笔记实战:同步知识页和个人学习记录

学习型应用里,"收藏"和"笔记"看起来只是两个列表,真正难点却是让它们记住用户在哪个知识点、哪个实验场景下做了什么。如果收藏只保存实验 ID,笔记只保存标题和正文,那么数据虽然落到了本地,学习上下文却断了:用户从"圆轨道"知识页写下一条笔记,回到"我的笔记"后无法重新打开原知识点;收藏页能进入实验,却不知道这次收藏源自哪段学习内容。

"天体运行模拟"的真实源码已经完成两个可运行的基础闭环:FavoritesPage.ets 读取实验 ID,映射到实验目录并跳回模拟页;NotesPage.ets 通过 NoteEditorDialog 新建笔记,使用 DataStore 写入 Preferences。本文不把尚未实现的能力说成现状,而是先复核这两条真实链路,再给出一套面向 HarmonyOS 5.0 及以上版本的演进方案:用稳定资源引用连接知识页、公式页、实验页与个人记录,同时处理页面恢复、并发写入、坏数据、空状态和多设备布局。

本文会解决四个具体问题:

  • 收藏 ID 如何映射为可展示、可跳转的实验对象;
  • 为什么页面同时在 aboutToAppearonPageShow 刷新;
  • 笔记模型怎样携带知识点和实验上下文,而不复制整份正文;
  • 如何把 Preferences 的"能存下来"升级为可校验、可迁移、可测试的学习记录仓库。

版本基线:应用版本 1.0.0targetSdkVersion 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
}

它能表达一条普通笔记,却没有 knowledgeIdexperimentId 或来源页面。因此,"同步知识页和个人学习记录"是本文要完成的工程演进目标,而不是对现有源码的夸大描述。

二、两条真实运行链路

收藏从实验页或实验列表发起。LabPageExperimentSimPage 都调用同一组 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

FavoritesPageNotesPage 都在两个回调中刷新:

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}`
  }
})

页面接收参数后必须验证 sourceTypesourceId,不能直接相信路由输入。建议标题只是编辑体验,不应成为身份字段。真正的关联始终由 { 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",不会校验磁盘数据。旧版笔记没有 schemaVersioncreatedAtsource,需要显式迁移:

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 = ''

读取成功后根据数组长度进入 emptycontent;解析失败、初始化失败进入 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 中,没有看到上传、账号或第三方同步逻辑。文章中的"同步"指应用内部页面与个人记录的一致关联,不代表云同步或跨设备上传。

发布材料应如实说明:

  • 笔记与收藏保存在本地;
  • 不收集账号、通讯录或位置;
  • 卸载应用后,本地数据按系统机制清理;
  • 若未来增加云同步,必须重新评估权限、隐私政策、服务端位置和删除机制。

不要用"多端同步"描述尚未存在的能力。内部状态同步与网络数据同步是两个完全不同的承诺。

二十二、验证收藏链路

至少执行以下用例:

  1. 在实验地图收藏 stable_orbit,进入"我的收藏",能看到"稳定双体系统";
  2. 点击收藏项,模拟页收到正确 expIdexpName
  3. 在模拟页取消收藏,返回收藏页后条目消失;
  4. 手工放入一个目录不存在的旧 ID,页面忽略坏项且不崩溃;
  5. 连续快速点击星标,不出现重复 ID;
  6. 杀进程重启后收藏仍存在。

可把纯映射逻辑提取后做单元测试:

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 和重复值的业务规则。

二十三、验证笔记与来源关联

笔记链路需要覆盖:

  1. 空标题或空正文不允许保存;
  2. 保存中按钮禁用,避免重复提交;
  3. 从知识点进入时,笔记带正确 { type, id }
  4. 独立新建的笔记允许没有来源;
  5. 点击"查看来源"能回到对应知识页、公式页或实验页;
  6. 来源已删除时显示不可用状态,不跳默认页面;
  7. 旧版笔记能迁移,坏数据被隔离;
  8. 删除失败时 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 辅助整理,源码事实、工程边界与验证结论均依据文中所列项目文件复核。

相关推荐
2501_919749031 小时前
华为鸿蒙录音可视化APP—小羊声觉
华为·harmonyos·鸿蒙
大雷神1 小时前
HarmonyOS AR Engine深度估计实战——把毫米距离画成实时热力图
华为·ar·harmonyos
GKxx2 小时前
在 HarmonyOS 上给 QEMU 搭一个最小 aarch64 Linux guest(内核 + busybox initramfs)
linux·华为·qemu·harmonyos·鸿蒙·鸿蒙pc
黑臂麒麟2 小时前
屏幕信息全解析:HarmonyOS的分辨率、刷新率、折叠状态一个API搞定
华为·arkts·鸿蒙
黑臂麒麟2 小时前
HarmonyOS 网络连接诊断实战:检测网络状态、WiFi 切换、弱网监测一网打尽
网络·华为·arkts·鸿蒙
贾伟康2 小时前
【天体运行模拟|08】HarmonyOS ArkTS 单位换算实战:处理天文尺度、科学计数与精度
harmonyos·arkts·arkui·数值精度·单位换算
lilian2332 小时前
HarmonyOS 7 新特性(十三)|3DGS 模型加载、交互与性能回退
3d·交互·harmonyos
2501_919749033 小时前
华为鸿蒙免费反诈APP—小羊反诈
华为·harmonyos·鸿蒙
2501_919749034 小时前
华为鸿蒙免费视频播放器—小羊免费播放器
华为·harmonyos·鸿蒙