【细胞工坊|12】HarmonyOS ArkTS 离线实验数据实战:把资源、模型和页面状态保持一致

发布时间:2026/10/31 1:43:30
【细胞工坊|12】HarmonyOS ArkTS 离线实验数据实战:把资源、模型和页面状态保持一致
  • entry/src/main/ets/utils/DataStore.ets
  • entry/src/main/ets/model/Experiment.ets
  • entry/src/main/ets/model/ExperimentRecord.ets
  • entry/src/main/ets/entryability/EntryAbility.ets
  • entry/src/main/ets/pages/LabPage.ets
  • entry/src/main/ets/views/mine/ExperimentRecordsPage.ets
  • entry/src/main/ets/views/mine/SettingsPage.ets

这篇文章只讨论源码已经实现的离线数据链路:静态实验资源、Preferences 本地持久化、AppStorage 统计快照、页面状态回读、记录删除和缓存清理。当前源码没有云同步、账号体系、RDB 关系数据库、后台上传、远端实验资源下发,也没有把数据迁移到服务端的逻辑;这些能力不会在本文中被当作已实现功能描述。

1. 离线实验数据真正要解决的问题

很多教育类 HarmonyOS 应用初期会先把资源写在本地:实验名称、图标、分类、难度、参数范围都随包发布。这样做的优点很直接,启动快、无需登录、没有网络依赖;问题也同样直接,一旦用户收藏、完成实验、写笔记,静态资源和用户状态就会混在一起。

细胞工坊的源码把这两类数据分开处理:

数据类型源码位置生命周期读写方式
实验定义Experiment.ets随包发布getAllExperiments()返回数组
热门实验Experiment.ets随包发布getHotExperiments()通过 ID 选择
收藏 IDDataStore.ets本机持久化Preferences 字符串 JSON
实验记录DataStore.ets+ExperimentRecord.ets本机持久化Preferences 数组 JSON
笔记DataStore.ets本机持久化Preferences 数组 JSON
统计快照DataStore.ets运行期状态AppStorage 键值

这个边界是本文的主线:实验资源是稳定的,用户数据是变化的,页面展示需要把两者合并,但不能让静态模型直接承担持久化职责。

2. 启动阶段先把 Preferences 接到应用上下文

DataStore没有在页面里临时创建 Preferences,而是在EntryAbility.onCreate()中初始化。这样页面调用DataStore.loadFavorites()、DataStore.loadRecords()时,只需要面对业务方法,不需要到处传UIAbilityContext。

import { DataStore } from '../utils/DataStore'; export default class EntryAbility extends UIAbility { onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { 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); }); } }

这段代码的职责很明确:

  • EntryAbility负责拿到真实的UIAbilityContext。
  • DataStore.init()负责创建 Preferences 实例。
  • 页面不直接依赖preferences.getPreferences()。
  • 初始化失败时只记录日志,业务读取会回退默认值。

这里没有使用全局对象保存页面实例,也没有让 UI 组件持有上下文。对于离线小型应用,这种方式足够直接:启动时接好存储,页面只调用业务化接口。

3. DataStore 的最小持久化边界

DataStore.ets使用@kit.ArkData的 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,不是 RDB。它适合保存收藏 ID、少量记录、笔记、统计计数这类轻量数据。第二,init()成功后立即调用refreshStatsSnapshot(),说明统计展示并不是每个页面自己重新计算,而是由 DataStore 维护一份 AppStorage 快照。

如果项目后续要扩展到大量实验过程采样、曲线点、结构化查询,再考虑 RDB 会更合理。但以当前源码看,Preferences 是真实实现,不能把它描述成数据库方案。

4. 实验资源模型不保存用户状态

Experiment.ets定义了实验模型和参数模型:

export interface ExperimentParam { name: string unit: string min: number max: number defaultValue: number step: number } export interface Experiment { id: string name: string description: string category: string level: string icon: Resource isFavorite: boolean params: ExperimentParam[] }

模型里虽然有isFavorite字段,但源码真正使用收藏状态时,并没有直接修改Experiment.isFavorite并持久化整个对象,而是单独保存收藏 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 [] } }

这是一种更稳的离线设计:资源模型负责描述实验,用户状态用独立键保存。这样实验名称、图标、分类变更时,不会因为旧收藏对象里存了完整实验数据而变得难以对齐。

5. 通过 ID 把资源和状态重新合并

实验资源来自getAllExperiments(),收藏来自DataStore.loadFavorites()。LabPage的做法是页面出现时重新读取收藏 ID,再在 UI 中根据 ID 判断状态。

@State experiments: Experiment[] = getAllExperiments() @State favoriteIds: string[] = [] aboutToAppear(): void { this.reloadFavorites() } onPageShow(): void { this.reloadFavorites() } private async reloadFavorites(): Promise<void> { this.favoriteIds = await DataStore.loadFavorites() }

这段逻辑解决的是页面返回后的状态一致性。用户可能从实验详情页收藏,也可能从收藏页删除。如果实验列表页只在创建时读一次,就容易出现返回后图标状态滞后。源码在aboutToAppear()和onPageShow()都调用reloadFavorites(),让列表页重新拉取本地收藏。

收藏写入也只操作 ID 数组:

private async toggleFavorite(id: string): Promise<void> { const idx = this.favoriteIds.indexOf(id) const next = [...this.favoriteIds] if (idx >= 0) { next.splice(idx, 1) } else { next.push(id) } this.favoriteIds = next await DataStore.saveFavorites(next) }

这里有一个细节值得保留:先复制数组再赋给@State。ArkUI 页面状态依赖状态变量变化触发刷新,直接在原数组上改动往往不如创建新数组清晰。

6. 记录持久化只保存可序列化字段

实验记录页面需要展示图标,但Resource不能直接作为本地 JSON 长期保存。源码通过StoredExperimentRecord和ExperimentRecord拆成两层:

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 }

持久化结构只保存字符串字段,视图结构再补上icon。转换逻辑从experimentId找回资源定义:

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

这段代码避免了一个常见问题:把 UI 资源句柄、组件状态、复杂对象直接 JSON 化。真正应该进 Preferences 的,是可恢复的业务字段;图标、颜色、分类展示等信息可以从当前资源模型重新计算。

7. appendRecord 用头插法保留最近记录

DataStore.appendRecord()的逻辑很短,但它体现了记录列表的产品意图:新完成的实验排在最前面。

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

这段源码没有做分页、上限裁剪、导出,也没有做记录去重。文章读者迁移时要看清边界:如果只是几十条本地学习记录,这样够用;如果要记录每一帧实验数据或长期曲线,就应该增加容量策略,而不是继续把无限数组塞进 Preferences。

实验完成时,ExperimentSimPage会构造记录并调用appendRecord:

private persistRecord(): void { const exp = this.currentExperiment() const rec = buildRecord( exp.id, exp.name, exp.category, this.paramSummary() ) DataStore.appendRecord<StoredExperimentRecord>(rec) }

这里的记录不是用户手动保存,而是实验运行完成后写入本地。记录页面只负责读取和展示,不负责推断实验参数。

8. 统计快照把 Preferences 变化同步到 AppStorage

细胞工坊还有一层运行期统计:收藏数量、实验次数、学习秒数。源码没有让每个页面重复解析 Preferences,而是在写入时调用notifyStatsChanged():

const STATS_VERSION_KEY = 'stats_version' const FAVORITE_COUNT_KEY = 'favorite_count' const EXPERIMENT_COUNT_KEY = 'experiment_count_total' const LEARNING_SECONDS_KEY = 'learning_seconds_total' 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) } } else if (key === 'experiment_count') { AppStorage.setOrCreate<number>(EXPERIMENT_COUNT_KEY, value as number) } else if (key === 'learning_seconds') { AppStorage.setOrCreate<number>(LEARNING_SECONDS_KEY, value as number) } DataStore.statsVersion++ AppStorage.setOrCreate<number>(STATS_VERSION_KEY, DataStore.statsVersion) }

这层快照有两个作用。

第一,把本地持久化结果转成页面更容易订阅的运行期键值。第二,用stats_version提供一个变化标记,页面可以通过版本变化触发刷新,而不是逐个监听每个业务键。

源码也兼容旧字段learning_minutes:

} else if (key === 'learning_minutes') { AppStorage.setOrCreate<number>(LEARNING_SECONDS_KEY, (value as number) * 60) }

这说明项目里存在从分钟到秒的统计口径兼容。写文章时只能说“兼容旧字段”,不能把它描述成完整迁移框架,因为源码没有版本化迁移表和批量迁移任务。

9. 启动快照用于修复运行期状态缺口

如果应用被杀后重新启动,AppStorage 中的运行期值不一定还在。refreshStatsSnapshot()会从 Preferences 重新读取收藏、记录和学习时长:

static async refreshStatsSnapshot(): Promise<void> { if (!DataStore.prefInstance) return try { const favoritesJson = await DataStore.prefInstance.get('favorite_experiments', '[]') as string const recordsJson = await DataStore.prefInstance.get('experiment_records', '[]') as string const experimentCount = await DataStore.prefInstance.get('experiment_count', 0) as number const learningSeconds = await DataStore.prefInstance.get('learning_seconds', -1) as number const learningMinutes = await DataStore.prefInstance.get('learning_minutes', 0) as number let favoriteCount = 0 let recordCount = 0 try { favoriteCount = (JSON.parse(favoritesJson) as string[]).length } catch (_) { favoriteCount = 0 } try { recordCount = (JSON.parse(recordsJson) as object[]).length } catch (_) { recordCount = 0 } AppStorage.setOrCreate<number>(FAVORITE_COUNT_KEY, favoriteCount) AppStorage.setOrCreate<number>(EXPERIMENT_COUNT_KEY, Math.max(experimentCount, recordCount)) AppStorage.setOrCreate<number>(LEARNING_SECONDS_KEY, learningSeconds >= 0 ? learningSeconds : learningMinutes * 60) } catch (_) { } }

Math.max(experimentCount, recordCount)是一个实用处理:如果计数值和记录数组不一致,至少让展示值不小于已有记录数。它不是强一致事务,但在轻量离线应用里可以减少“记录里有 8 条,统计只显示 6 次”的体验问题。

这里也体现了一个工程原则:Preferences 是事实来源,AppStorage 是运行期快照。快照可以重建,不应该反过来成为唯一持久化数据。

10. 设置页把本地数据大小和清理动作收口

离线应用必须给用户一个清理入口。SettingsPage中有两个相关能力:估算本地数据大小、清除实验数据或缓存。

DataStore.estimateLocalDataBytes()遍历本地键,按字符串长度估算占用:

static async estimateLocalDataBytes(): Promise<number> { if (!DataStore.prefInstance) return 0 let total = 0 for (let i = 0; i < LOCAL_DATA_KEYS.length; i++) { const key = LOCAL_DATA_KEYS[i] try { const value = await DataStore.prefInstance.get(key, '') const text = `${key}:${JSON.stringify(value)}` total += text.length * 2 } catch (_) { } } return total }

这不是系统级真实磁盘占用统计,而是项目根据本地键内容做的近似值。它适合给设置页显示“无可清理缓存”“12 KB”这类提示,不适合拿来做精确容量账单。

清理缓存时,DataStore.clearCache()只删除白名单键:

const LOCAL_DATA_KEYS: string[] = [ 'favorite_experiments', 'experiment_records', 'user_notes', 'experiment_count', 'learning_seconds', 'learning_minutes' ] 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) DataStore.statsVersion++ AppStorage.setOrCreate<number>(STATS_VERSION_KEY, DataStore.statsVersion) }

白名单很重要。它避免误删 Preferences 文件里未来可能加入的设置项,也让清理动作和隐私说明更容易对应。

11. 删除记录时同步更新页面数组

记录页删除单条记录的代码也体现了“先改本地,再回写页面状态”的顺序:

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

如果只从页面数组里删除,不写 Preferences,下次进入页面会重新出现。反过来,如果只写 Preferences 不更新this.records,用户会看到删除按钮点了但列表没变化。源码两步都做了,并在空列表时退出编辑态。

这类局部状态处理很适合写在页面里:删除哪个记录、是否退出编辑态,是当前页面交互;记录数组如何持久化,则交给DataStore。

12. 这套离线方案适合哪些 HarmonyOS 场景

根据当前源码,这套方案适合以下场景:

场景是否适合原因
少量收藏 ID适合字符串数组简单稳定
学习笔记适合数量有限,可 JSON 化
实验完成记录适合当前记录字段较少
统计次数和学习秒数适合数字键值可直接保存
大量曲线采样点不适合Preferences 数组会膨胀
多条件查询排行不适合当前没有索引和查询层
多设备同步不适合当前没有云端或跨端同步实现
账号隔离不适合当前没有用户体系

这不是说 Preferences 不好,而是要把工具放在合适的位置。细胞工坊当前的离线数据是“资源包 + 少量用户行为”,Preferences 能覆盖;如果后续变成“实验过程数据平台”,就要重新设计存储边界。

13. 可迁移的实现清单

如果在自己的 HarmonyOS 应用里复用这种思路,可以按下面顺序拆:

// 1. 静态资源模型只描述资源,不保存运行期状态 export interface LabResource { id: string title: string category: string icon: Resource } // 2. 用户状态只保存可 JSON 化字段 export interface StoredUserAction { id: string resourceId: string summary: string createdAt: string } // 3. 页面展示结构运行时重新拼资源 export interface UserActionView { id: string title: string icon: Resource summary: string createdAt: string }

这三个接口对应本文源码中的Experiment、StoredExperimentRecord和ExperimentRecord。关键不是接口名字,而是职责边界:持久化只保存可以稳定恢复的业务字段,视图所需资源从当前资源表重新查。

Preferences 封装也可以沿用 DataStore 的方向:

class LocalLabStore { private static pref: preferences.Preferences | null = null static async putJson<T>(key: string, value: T): Promise<void> { if (!LocalLabStore.pref) return await LocalLabStore.pref.put(key, JSON.stringify(value)) await LocalLabStore.pref.flush() } static async getJson<T>(key: string, fallback: T): Promise<T> { if (!LocalLabStore.pref) return fallback const text = await LocalLabStore.pref.get(key, JSON.stringify(fallback)) as string try { return JSON.parse(text) as T } catch (_) { return fallback } } }

这段示例比源码更通用,但工程意图一致:页面不直接操作 Preferences,JSON 解析失败回退默认值,写入后 flush。

14. 验证这条离线链路时看什么

验证离线实验数据,不应该只看“页面能打开”。更实用的是按数据流逐段确认:

验证点操作预期结果
初始化冷启动应用DataStore.init()完成,页面读默认值不崩溃
实验资源进入实验列表getAllExperiments()返回分类、图标、参数
收藏状态收藏一个实验后返回列表favoriteIds重新读取,本地状态保持
实验记录完成一次实验experiment_records写入新记录,记录页排在顶部
统计快照完成实验或收藏变化AppStorage 中统计键更新
清理缓存设置页执行清理Preferences 白名单键删除,统计快照归零
异常 JSON手动构造坏数据解析失败后回退空数组,不影响页面进入

如果这里任一环节失败,优先查DataStore是否初始化、Preferences key 是否一致、JSON 结构是否和页面转换函数一致。

15. 常见问题和修复方向

问题常见原因修复方向
收藏后返回列表没有变化页面没有重新读取收藏 ID在aboutToAppear()或onPageShow()调用加载方法
记录页显示默认图标experimentId找不到资源定义保持资源 ID 稳定,删除旧资源前处理历史记录
统计次数和记录数量不一致计数键和记录数组各自写入启动时用记录数量修正展示快照
清理后我的页统计仍显示旧值只删 Preferences,没有重置 AppStorage清理后同步设置统计键为 0
数据大小显示不准确当前是字符串长度估算文案中使用“缓存大小估算”,不要当成系统磁盘统计
JSON 解析导致页面空白没有 catch 或 fallback所有 JSON 读取都回退空数组或默认值

这些问题都能从源码里找到对应处理点。细胞工坊的实现没有追求复杂架构,但把轻量离线应用最容易出错的边界收住了。

16. 小结:离线不是把所有东西都写进一个文件

细胞工坊这套离线实验数据实现可以概括为四句话。

第一,实验资源用Experiment.ets固定定义,页面通过 ID 进入实验。第二,用户状态通过DataStore写入 Preferences,收藏、记录、笔记和学习时长各自有明确 key。第三,记录持久化只保存可 JSON 化字段,展示时再用experimentId找回图标等资源。第四,统计信息通过 AppStorage 做运行期快照,启动和清理时都能重建或归零。

这套方案的价值不在于复杂,而在于边界清楚。对于 HarmonyOS 5.0 及以上的离线教育应用,先把资源模型、持久化字段、页面状态和统计快照分开,后续无论继续用 Preferences,还是迁移到 RDB 或云端同步,都不会被早期混在一起的数据结构拖住。