
entry/src/main/ets/utils/DataStore.etsentry/src/main/ets/model/Experiment.etsentry/src/main/ets/model/ExperimentRecord.etsentry/src/main/ets/entryability/EntryAbility.etsentry/src/main/ets/pages/LabPage.etsentry/src/main/ets/views/mine/ExperimentRecordsPage.etsentry/src/main/ets/views/mine/SettingsPage.ets这篇文章只讨论源码已经实现的离线数据链路静态实验资源、Preferences 本地持久化、AppStorage 统计快照、页面状态回读、记录删除和缓存清理。当前源码没有云同步、账号体系、RDB 关系数据库、后台上传、远端实验资源下发也没有把数据迁移到服务端的逻辑这些能力不会在本文中被当作已实现功能描述。1. 离线实验数据真正要解决的问题很多教育类 HarmonyOS 应用初期会先把资源写在本地实验名称、图标、分类、难度、参数范围都随包发布。这样做的优点很直接启动快、无需登录、没有网络依赖问题也同样直接一旦用户收藏、完成实验、写笔记静态资源和用户状态就会混在一起。细胞工坊的源码把这两类数据分开处理数据类型源码位置生命周期读写方式实验定义Experiment.ets随包发布getAllExperiments()返回数组热门实验Experiment.ets随包发布getHotExperiments()通过 ID 选择收藏 IDDataStore.ets本机持久化Preferences 字符串 JSON实验记录DataStore.etsExperimentRecord.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): Promisevoid { 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[]): Promisevoid { await DataStore.putString(favorite_experiments, JSON.stringify(ids)) } static async loadFavorites(): Promisestring[] { 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(): Promisevoid { this.favoriteIds await DataStore.loadFavorites() }这段逻辑解决的是页面返回后的状态一致性。用户可能从实验详情页收藏也可能从收藏页删除。如果实验列表页只在创建时读一次就容易出现返回后图标状态滞后。源码在aboutToAppear()和onPageShow()都调用reloadFavorites()让列表页重新拉取本地收藏。收藏写入也只操作 ID 数组private async toggleFavorite(id: string): Promisevoid { 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 loadRecordsT(): PromiseT[] { const json await DataStore.getString(experiment_records, []) try { return JSON.parse(json) as T[] } catch { return [] } } static async appendRecordT(record: T): Promisevoid { try { const list await DataStore.loadRecordsT() list.unshift(record) await DataStore.putString(experiment_records, JSON.stringify(list)) } catch (_) { } }这段源码没有做分页、上限裁剪、导出也没有做记录去重。文章读者迁移时要看清边界如果只是几十条本地学习记录这样够用如果要记录每一帧实验数据或长期曲线就应该增加容量策略而不是继续把无限数组塞进 Preferences。实验完成时ExperimentSimPage会构造记录并调用appendRecordprivate persistRecord(): void { const exp this.currentExperiment() const rec buildRecord( exp.id, exp.name, exp.category, this.paramSummary() ) DataStore.appendRecordStoredExperimentRecord(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.setOrCreatenumber(FAVORITE_COUNT_KEY, ids.length) } catch (_) { AppStorage.setOrCreatenumber(FAVORITE_COUNT_KEY, 0) } } else if (key experiment_count) { AppStorage.setOrCreatenumber(EXPERIMENT_COUNT_KEY, value as number) } else if (key learning_seconds) { AppStorage.setOrCreatenumber(LEARNING_SECONDS_KEY, value as number) } DataStore.statsVersion AppStorage.setOrCreatenumber(STATS_VERSION_KEY, DataStore.statsVersion) }这层快照有两个作用。第一把本地持久化结果转成页面更容易订阅的运行期键值。第二用stats_version提供一个变化标记页面可以通过版本变化触发刷新而不是逐个监听每个业务键。源码也兼容旧字段learning_minutes} else if (key learning_minutes) { AppStorage.setOrCreatenumber(LEARNING_SECONDS_KEY, (value as number) * 60) }这说明项目里存在从分钟到秒的统计口径兼容。写文章时只能说“兼容旧字段”不能把它描述成完整迁移框架因为源码没有版本化迁移表和批量迁移任务。9. 启动快照用于修复运行期状态缺口如果应用被杀后重新启动AppStorage 中的运行期值不一定还在。refreshStatsSnapshot()会从 Preferences 重新读取收藏、记录和学习时长static async refreshStatsSnapshot(): Promisevoid { 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.setOrCreatenumber(FAVORITE_COUNT_KEY, favoriteCount) AppStorage.setOrCreatenumber(EXPERIMENT_COUNT_KEY, Math.max(experimentCount, recordCount)) AppStorage.setOrCreatenumber(LEARNING_SECONDS_KEY, learningSeconds 0 ? learningSeconds : learningMinutes * 60) } catch (_) { } }Math.max(experimentCount, recordCount)是一个实用处理如果计数值和记录数组不一致至少让展示值不小于已有记录数。它不是强一致事务但在轻量离线应用里可以减少“记录里有 8 条统计只显示 6 次”的体验问题。这里也体现了一个工程原则Preferences 是事实来源AppStorage 是运行期快照。快照可以重建不应该反过来成为唯一持久化数据。10. 设置页把本地数据大小和清理动作收口离线应用必须给用户一个清理入口。SettingsPage中有两个相关能力估算本地数据大小、清除实验数据或缓存。DataStore.estimateLocalDataBytes()遍历本地键按字符串长度估算占用static async estimateLocalDataBytes(): Promisenumber { 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(): Promisevoid { 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.setOrCreatenumber(FAVORITE_COUNT_KEY, 0) AppStorage.setOrCreatenumber(EXPERIMENT_COUNT_KEY, 0) AppStorage.setOrCreatenumber(LEARNING_SECONDS_KEY, 0) DataStore.statsVersion AppStorage.setOrCreatenumber(STATS_VERSION_KEY, DataStore.statsVersion) }白名单很重要。它避免误删 Preferences 文件里未来可能加入的设置项也让清理动作和隐私说明更容易对应。11. 删除记录时同步更新页面数组记录页删除单条记录的代码也体现了“先改本地再回写页面状态”的顺序private async removeRecord(id: string): Promisevoid { const stored await DataStore.loadRecordsStoredExperimentRecord() 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 putJsonT(key: string, value: T): Promisevoid { if (!LocalLabStore.pref) return await LocalLabStore.pref.put(key, JSON.stringify(value)) await LocalLabStore.pref.flush() } static async getJsonT(key: string, fallback: T): PromiseT { 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 } } }这段示例比源码更通用但工程意图一致页面不直接操作 PreferencesJSON 解析失败回退默认值写入后 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 或云端同步都不会被早期混在一起的数据结构拖住。