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 选择 |
| 收藏 ID | DataStore.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 或云端同步,都不会被早期混在一起的数据结构拖住。