【细胞工坊|07】HarmonyOS ArkTS 实验记录实战:持久化过程摘要并支持删除
部分内容由AI辅助生成。本文面向 HarmonyOS 5.0 及以上版本,基于 细胞工坊 项目真实源码展开,源码根目录为 D:\huawei\one14-9。本文重点复核 entry/src/main/ets/model/ExperimentRecord.ets、entry/src/main/ets/utils/DataStore.ets、entry/src/main/ets/views/experiment/ExperimentSimPage.ets 与 entry/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 })
这是一个小但重要的布局保护。实验参数摘要由多个参数拼接而来,长度不可控。如果不做 maxLines 和 textOverflow,长摘要会把时间戳挤下去,甚至让列表项高度不稳定。
记录页的布局也使用了 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 教学实验应用来说,先把“完成实验 -> 生成摘要 -> 本地保存 -> 列表展示 -> 分类筛选 -> 删除写回”这条链路做稳定,再扩展详情复盘、重新实验或导出功能,才是更稳的工程顺序。
更多推荐




所有评论(0)