部分内容由AI辅助生成。本文面向 HarmonyOS 5.0 及以上版本,基于 细胞工坊 项目真实源码展开,源码根目录为 D:\huawei\one14-9。本文重点复核 entry/src/main/ets/model/ExperimentRecord.etsentry/src/main/ets/utils/DataStore.etsentry/src/main/ets/views/experiment/ExperimentSimPage.etsentry/src/main/ets/views/mine/ExperimentRecordsPage.ets。文章只讨论源码已经实现的记录生成、Preferences 持久化、记录读取、分类筛选、空态展示和删除覆盖,不虚构云同步、详情复盘页、导出报表或服务端分析能力。

教学实验应用如果没有记录页,用户做完一次实验后只能看到当次结果,下一次回到应用就缺少连续学习线索。记录页的价值不是把所有数据都存下来,而是把“这次做了什么实验、用的什么参数、什么时候完成”压缩成一个可复盘的摘要。这个摘要足够轻,适合放进 Preferences;又足够具体,能让用户回到“我的实验记录”里快速找回学习轨迹。

细胞工坊的实验记录链路比较典型:实验模拟页在进度完成时调用 persistRecord(),用 buildRecord() 生成只包含字符串字段的 StoredExperimentRecord,再通过 DataStore.appendRecord() 写入 experiment_records。记录页进入前台时调用 loadRecords(),把持久化结构转换成带图标资源的视图结构,并提供分类筛选和编辑删除。当前源码还没有给列表右侧的 绑定点击事件,所以文章不会声称它已经支持详情复盘跳转。

实验记录封面

一、记录链路的关键边界:存储结构和视图结构分开

ExperimentRecord.ets 里有两个接口:一个用于持久化,一个用于页面展示。这个拆分很重要,因为 HarmonyOS 的 Resource 不能直接作为普通 JSON 稳定写入 Preferences。

export interface StoredExperimentRecord {
  id: string
  experimentId: string
  experimentName: string
  category: string
  sceneName: string
  paramSummary: string
  timestamp: string
}

export interface ExperimentRecord {
  id: string
  experimentId: string
  experimentName: string
  category: string
  icon: Resource
  sceneName: string
  paramSummary: string
  timestamp: string
}

这段代码把职责分得很清楚:

类型 用途 是否包含 Resource 是否适合 JSON
StoredExperimentRecord 写入 Preferences
ExperimentRecord 页面展示

实际项目里最容易犯的错误,是把页面需要的所有字段直接塞进本地存储。短期看省事,后期会遇到序列化失败、资源引用丢失、版本迁移困难等问题。当前源码采用“持久化只存可 JSON 序列化字段,页面加载时再恢复图标”的方式,更适合轻量本地记录。

实验记录流程

二、实验完成时生成记录,而不是用户手动保存

记录生成不在记录页发生,而在实验模拟页的完成分支里触发。ExperimentSimPage 的计时器推进进度,当 progress >= 1 时,会停止运行、统计学习时长、增加实验次数并持久化记录:

if (this.progress >= 1) {
  this.isRunning = false
  this.isFinished = true
  this.stopTimer()
  this.persistLearningTime()
  DataStore.incrementExperimentCount()
  this.persistRecord()
}

这个触发点选择得比较稳。记录代表一次已完成实验,而不是一次进入页面或一次点击开始。如果在进入页面时保存,记录会出现大量未完成项;如果放在结果页按钮里保存,用户不进结果页就可能漏记。当前实现把记录绑定到“实验完成”这个明确状态,语义更一致。

persistRecord() 负责把当前实验参数压缩成摘要:

private persistRecord(): void {
  const exp = this.currentExperiment()
  const parts: string[] = []
  for (let i = 0; i < this.paramDefs.length; i++) {
    const def = this.paramDefs[i]
    const val = this.paramValues[i] ?? def.defaultValue
    const unit = def.unit ? ' ' + def.unit : ''
    parts.push(`${def.name}:${val}${unit}`)
  }
  const summary = parts.length > 0 ? parts.join(';') : '默认实验条件'
  const rec: StoredExperimentRecord = buildRecord(this.expId, this.title, exp.category, summary)
  DataStore.appendRecord<StoredExperimentRecord>(rec)
}

这段代码的重点不是存了多少字段,而是把过程参数归并成一条可读摘要。记录页不需要还原整个实验运行过程,只需要展示足够用户识别的关键条件。

三、buildRecord() 生成稳定 ID 和时间戳

buildRecord() 是记录生成的模型函数,输入来自实验页,输出是可持久化结构:

export function buildRecord(
  experimentId: string,
  experimentName: string,
  category: string,
  paramSummary: string
): StoredExperimentRecord {
  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()) +
    ' ' + pad(now.getHours()) + ':' + pad(now.getMinutes())
  return {
    id: 'r_' + now.getTime(),
    experimentId,
    experimentName,
    category,
    sceneName: '',
    paramSummary,
    timestamp: ts
  }
}

这里有几个真实边界:

字段 当前实现 说明
id r_ + 毫秒时间戳 用于删除定位
timestamp 年月日小时分钟 页面可直接展示
sceneName 空字符串 当前没有从场景页传入
paramSummary 参数摘要字符串 便于列表一行显示

sceneName 目前是空字符串,这一点不能在文章里写成“记录已保存实验台名称”。如果后续把场景选择页和实验模拟页打通,可以在 buildRecord() 入参里补 sceneName,或者创建更明确的记录 DTO。

四、Preferences 存数组,appendRecord() 新记录前置

DataStore 使用 @kit.ArkData 的 Preferences 作为轻量本地存储。实验记录存储键是 experiment_records

static async loadRecords<T>(): Promise<T[]> {
  const json = await DataStore.getString('experiment_records', '[]')
  try {
    return JSON.parse(json) as T[]
  } catch {
    return []
  }
}

static async appendRecord<T>(record: T): Promise<void> {
  try {
    const list = await DataStore.loadRecords<T>()
    list.unshift(record)
    await DataStore.putString('experiment_records', JSON.stringify(list))
  } catch (_) {
  }
}

unshift(record) 让最新记录排在最前面,这符合记录页的阅读习惯。用户打开“我的实验记录”时,最关心通常是刚刚完成的实验,而不是最早的一条。

Preferences 适合这种轻量数据:字段少、记录量不大、无需复杂查询。如果后续要支持大量实验历史、按时间范围统计、全文搜索、导出或复杂筛选,就应该评估 RDB。当前源码没有这些需求,所以用 Preferences 是合理边界。

五、DataStore 初始化在 Ability 入口完成

本地存储服务能正常工作,前提是 DataStore.init() 已经拿到 UIAbilityContext。源码里由 EntryAbility 初始化:

DataStore.init(this.context).then(() => {
  hilog.info(DOMAIN, 'One9App', 'DataStore initialized')
}).catch((err: Error) => {
  hilog.error(DOMAIN, 'One9App', 'DataStore init failed: %{public}s', err.message)
})

这条链路说明 DataStore 不是纯静态内存缓存,它背后依赖 Preferences 实例:

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

如果 init() 失败,putString()getString() 会走空实例兜底:

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 (_) {
  }
}

这种兜底能避免页面崩溃,但也意味着持久化失败时用户不会看到错误提示。对教学类轻量记录来说可以接受;如果未来记录是核心资产,就需要把错误状态传回页面。

六、记录页在生命周期里重新加载,避免返回后数据旧

ExperimentRecordsPage 在两个生命周期入口加载记录:

aboutToAppear(): void {
  this.loadRecords()
}

onPageShow(): void {
  this.loadRecords()
}

这能覆盖两种场景:第一次进入页面和从其他页面返回记录页。记录页不是只在构建时读一次数据,而是在页面显示时刷新,避免用户完成实验后回到记录页仍看到旧列表。

加载逻辑如下:

private async loadRecords(): Promise<void> {
  const stored = await DataStore.loadRecords<StoredExperimentRecord>()
  this.records = stored.map(s => storedToRecord(s))
}

这里再次体现持久化结构和视图结构分离。Preferences 里读出来的是 StoredExperimentRecord[],页面展示需要 ExperimentRecord[],中间通过 storedToRecord() 转换。

七、storedToRecord() 根据实验 ID 恢复图标

记录页列表要展示实验图标,但图标不在持久化结构里。源码通过实验 ID 查找实验定义,再恢复图标:

export function storedToRecord(s: StoredExperimentRecord): ExperimentRecord {
  const exp = getAllExperiments().find(e => e.id === s.experimentId)
  return {
    id: s.id,
    experimentId: s.experimentId,
    experimentName: s.experimentName,
    category: s.category,
    icon: exp ? exp.icon : fallbackIcon(),
    sceneName: s.sceneName,
    paramSummary: s.paramSummary,
    timestamp: s.timestamp
  }
}

如果实验 ID 找不到,会使用默认显微镜图标:

function fallbackIcon(): Resource {
  return $r('app.media.ic_bio_microscope')
}

这个 fallback 很实用。实验定义可能在版本升级时被改名、删除或合并,老记录仍然需要能显示出来。即使图标退回默认图,记录名称、分类、摘要和时间戳也不会丢。

实验记录结构

八、分类筛选只过滤内存数组,不反复读写存储

记录页提供分类筛选:

@State selectedCategory: number = 0
private filterCategories: string[] = ['全部', '基础', '培养', 'DNA', '病毒', '遗传', '细胞']

筛选函数只读页面内存状态:

private filteredRecords(): ExperimentRecord[] {
  const cat = this.filterCategories[this.selectedCategory]
  if (cat === '全部') {
    return this.records
  }
  return this.records.filter(r => r.category === cat)
}

这种写法适合记录量不大的 Preferences 列表。页面加载时一次性读取记录,后续切换分类只做内存过滤,不需要每次点击分类都访问 Preferences。对用户体验来说,分类切换会更直接,也减少持久化层压力。

分类标签的 UI 反馈来自 selectedCategory

Text(cat)
  .fontColor(this.selectedCategory === index ? AppColors.TEXT_WHITE : AppColors.TEXT_SECONDARY)
  .backgroundColor(this.selectedCategory === index ? AppColors.PRIMARY : '#111827')
  .onClick(() => {
    this.selectedCategory = index
  })

这里同样是一个单一状态真源:当前分类索引决定标签样式和列表内容。

九、空态文案说明记录从哪里来

记录为空时,页面不是直接留白,而是给出两行提示:

if (this.filteredRecords().length === 0) {
  Column() {
    Text('暂无实验记录')
      .fontSize(16)
      .fontColor(AppColors.TEXT_HINT)
    Text('在实验室运行任意实验后,记录会自动保存到这里')
      .fontSize(13)
      .fontColor(AppColors.TEXT_HINT)
      .margin({ top: 8 })
      .padding({ left: 32, right: 32 })
      .textAlign(TextAlign.Center)
  }
}

这个空态和真实持久化链路一致:记录来自实验运行完成后的自动保存,不来自用户在记录页手动创建。很多应用的空态只写“暂无数据”,用户不知道怎么产生数据。这里明确告诉用户“运行任意实验后自动保存”,能降低理解成本。

但也要注意:如果当前分类没有记录,而全部分类有记录,空态文案仍然是“暂无实验记录”。后续可以细化成“当前分类暂无记录”,避免用户误以为所有记录都丢了。当前源码尚未区分这两种空态。

十、编辑模式删除:先读全量,再过滤覆盖

记录页右上角在有记录时允许进入编辑模式:

Text(this.isEditing ? '完成' : '编辑')
  .fontSize(14)
  .fontColor(AppColors.PRIMARY)
  .onClick(() => {
    if (this.records.length === 0) {
      return
    }
    this.isEditing = !this.isEditing
  })

编辑模式下,列表右侧显示删除符号:

if (this.isEditing) {
  Text('✕')
    .fontSize(18)
    .fontColor(AppColors.ACCENT_RED)
    .padding(4)
    .onClick(() => { this.removeRecord(record.id) })
} else {
  Text('⋮')
    .fontSize(20)
    .fontColor(AppColors.TEXT_HINT)
}

删除逻辑不是直接改页面数组,而是重新读取持久化数组、过滤目标 ID、写回 Preferences,再刷新页面状态:

private async removeRecord(id: string): Promise<void> {
  const stored = await DataStore.loadRecords<StoredExperimentRecord>()
  const next = stored.filter(s => s.id !== id)
  await DataStore.putString('experiment_records', JSON.stringify(next))
  this.records = next.map(s => storedToRecord(s))
  if (this.records.length === 0) {
    this.isEditing = false
  }
}

这个流程保证删除结果和本地存储一致。删除后如果列表为空,页面自动退出编辑模式,避免用户停留在没有可编辑项的状态。

十一、 目前只是图标,不能写成已支持复盘

非编辑模式下,列表右侧显示

Text('⋮')
  .fontSize(20)
  .fontColor(AppColors.TEXT_HINT)

这段代码没有 .onClick(),也没有路由跳转。因此当前源码不能证明它支持详情页、复盘页或重新打开实验。brief 里提到“复盘”,但本页源码没有实现复盘入口,文章必须如实标注。

如果后续要补复盘,建议让 进入记录详情页或重新实验入口:

private openRecord(record: ExperimentRecord): void {
  router.pushUrl({
    url: 'pages/mine/ExperimentRecordDetailPage',
    params: {
      recordId: record.id,
      experimentId: record.experimentId
    }
  })
}

然后在非编辑态绑定:

Text('⋮')
  .onClick(() => {
    this.openRecord(record)
  })

这只是可扩展方向,不是当前实现。当前真实能力是记录列表展示、分类筛选和删除。

十二、过程摘要一行展示,长文本用省略号保护布局

记录列表里展示三类文本:实验名、参数摘要、时间戳。参数摘要可能很长,所以源码做了一行截断:

Text(record.paramSummary)
  .fontSize(AppFonts.CAPTION_SIZE)
  .fontColor(AppColors.TEXT_SECONDARY)
  .margin({ top: 3 })
  .maxLines(1)
  .textOverflow({ overflow: TextOverflow.Ellipsis })

这是一个小但重要的布局保护。实验参数摘要由多个参数拼接而来,长度不可控。如果不做 maxLinestextOverflow,长摘要会把时间戳挤下去,甚至让列表项高度不稳定。

记录页的布局也使用了 layoutWeight(1)

Column() {
  Text(record.experimentName)
  Text(record.paramSummary)
  Text(record.timestamp)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.margin({ left: 12 })

图标、文字区和右侧操作形成稳定横向结构。对手机、小窗和平板来说,这比固定文字宽度更安全。

十三、底部和顶部安全区仍然在根容器处理

记录页和前面的选择页一样,读取系统栏高度:

@StorageProp('statusBarHeight') statusBarHeight: number = 36
@StorageProp('bottomBarHeight') bottomBarHeight: number = 0

根容器统一避让:

.width('100%')
.height('100%')
.backgroundColor(AppColors.PAGE_BG)
.padding({ top: this.statusBarHeight, bottom: this.bottomBarHeight })

记录页通常是长列表,底部安全区非常关键。如果最后一条记录贴近系统手势区域,用户编辑删除时容易误触系统手势。当前页面把避让放在根容器上,列表使用 layoutWeight(1),能保证内容区域不会直接冲到系统栏下面。

十四、验证清单:从完成实验到删除记录

复核实验记录链路时,可以按这个顺序检查:

步骤 操作 预期
完成实验 让模拟页进度达到 100% 调用 persistRecord()
生成摘要 检查参数定义和值 paramSummary 由参数名、值、单位拼接
写入记录 查看 experiment_records 新记录前置到数组
打开记录页 进入“我的实验记录” aboutToAppear() / onPageShow() 加载
分类筛选 点击 DNA、培养等标签 只显示对应分类记录
编辑删除 点击编辑,再点 删除目标记录并写回 Preferences
空列表 删除最后一条 退出编辑模式并显示空态

如果要进一步验证数据一致性,可以在删除后再次进入页面,确认记录不会恢复。因为删除逻辑已经写回 experiment_records,不是只改页面内存数组。

十五、常见问题和修复方向

问题 可能原因 修复方向
完成实验后记录页没有新数据 DataStore.init() 失败或实验未完成 检查 Ability 初始化日志和进度完成分支
参数摘要太长导致布局异常 摘要缺少截断 保留 maxLines(1)textOverflow
删除后重新进入又出现 只改了页面数组,没有写回 Preferences 使用 putString('experiment_records', JSON.stringify(next))
分类切换后显示为空 当前分类确实没有记录 增加“当前分类暂无记录”文案
图标恢复失败 实验 ID 找不到 使用 fallbackIcon(),并保留实验名展示
用户以为 能复盘 图标无事件但视觉像菜单 增加点击事件或改成不可交互样式

这些问题都能从当前源码直接推导出来。写文章时把它们列出来,可以帮助读者判断自己项目里要补哪一层,而不是盲目复制页面。

十六、小结:轻量记录页要先保证数据闭环

细胞工坊的实验记录实现并不依赖数据库或后端服务,它用 Preferences 存储可 JSON 序列化的记录数组,用 StoredExperimentRecord 保持持久化结构轻量,再在页面层恢复图标资源和列表展示。实验完成时自动保存,记录页按生命周期重新加载,分类筛选只处理内存数组,删除时重新写回 Preferences。

当前源码也有明确边界:sceneName 还没有实际写入,非编辑态的 没有复盘跳转,记录详情页也没有在本页体现。把这些边界讲清楚,文章才对读者有用。对 HarmonyOS 教学实验应用来说,先把“完成实验 -> 生成摘要 -> 本地保存 -> 列表展示 -> 分类筛选 -> 删除写回”这条链路做稳定,再扩展详情复盘、重新实验或导出功能,才是更稳的工程顺序。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐