本文基于“细胞工坊”真实 HarmonyOS ArkTS 源码复盘,源码范围包括
entry/src/main/ets/utils/DataStore.ets、pages/LabPage.ets、views/experiment/ExperimentSimPage.ets、views/mine/ExperimentRecordsPage.ets、views/mine/NotesPage.ets、views/mine/FavoritesPage.ets和views/mine/SettingsPage.ets。文章面向 HarmonyOS 5.0 及以上版本,讨论的是本地状态持久化和页面返回后的状态一致性。当前源码使用 Preferences 与 AppStorage,不包含 RDB、云同步、账号体系、后台上传或跨设备同步能力。
1. 为什么这篇只谈本地状态,而不扩展成云同步
“细胞工坊”是一个离线生物实验学习类 HarmonyOS 应用,核心体验不是把数据上传到服务端,而是在本机内让实验记录、收藏、笔记、学习时长和统计数字保持可用。这个边界很重要,因为技术文章如果把源码没有实现的能力写成已经实现,就会误导读者,也会让 AppGallery 审核材料和实际行为不一致。
从源码看,当前应用的数据持久化入口集中在DataStore.ets。它引入的是@kit.ArkData中的preferences,以及@kit.AbilityKit的common.UIAbilityContext。也就是说,它选择的是轻量本地键值存储,而不是关系型数据库。收藏列表、实验记录、用户笔记这类集合数据以 JSON 字符串写入 Preferences;实验次数、学习秒数这类数字用 number 写入;统计入口所需的快照再同步到AppStorage。
这个实现适合“体量不大、数据结构简单、需要离线立即可读”的场景。比如收藏实验只是一组实验 id,笔记数量有限,实验记录也只是学习过程中的本地流水。相比直接上 RDB,这套实现的代码更短,调用成本更低,页面可以用统一工具类读写,不需要在每个页面理解数据库连接、表结构、游标和迁移。代价也很明确:它不适合大量记录、复杂查询、事务一致性、账号隔离或多端同步。
所以本文的目标不是泛泛讲“本地存储最佳实践”,而是复盘一个可由源码核验的工程问题:当用户在实验页保存记录、在列表页删除记录、从详情页返回实验室、在设置页清空缓存时,应用怎样尽量让 UI 状态和 Preferences 中的数据保持一致。
2. DataStore 的职责:把 Preferences 包成应用级本地数据服务
DataStore.ets的第一层设计是把 Preferences 实例藏在工具类内部,只暴露init、putString、getString、putNumber、getNumber、saveFavorites、loadFavorites、appendRecord、loadNotes、saveNotes等方法。页面并不直接调用preferences.getPreferences,也不直接维护 Preferences 文件名。
关键常量如下:
const PREF_NAME = 'bio_lab_app_data' 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' const STAT_KEYS: string[] = [ 'favorite_experiments', 'experiment_records', 'experiment_count', 'learning_seconds', 'learning_minutes' ] const LOCAL_DATA_KEYS: string[] = [ 'favorite_experiments', 'experiment_records', 'user_notes', 'experiment_count', 'learning_seconds', 'learning_minutes' ]这里有两个边界值得注意。
第一,PREF_NAME把这个应用的本地数据放在一个明确命名的 Preferences 文件中。虽然源码没有做多文件拆分,但至少避免了页面各自临时创建不同的存储文件。
第二,STAT_KEYS和LOCAL_DATA_KEYS分别服务于两个不同目标。STAT_KEYS表示哪些 key 的变化会影响首页或“我的”页统计快照;LOCAL_DATA_KEYS表示清理本地数据、估算缓存大小时需要处理哪些 key。二者有重叠,但不是同一个概念。这个拆分比简单地“所有 key 都刷新统计、所有 key 都清理”更可控。
初始化代码也比较直接:
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 需要 Ability 上下文,初始化完成后再刷新统计快照。如果初始化失败,后续读写方法会因为prefInstance为空而返回默认值或直接退出。它不是强错误模型,也没有把错误抛给页面;这对离线学习工具来说可以避免启动中断,但也意味着真正的写入失败不会被用户感知。这个边界后面会继续讨论。
3. 字符串、数字和布尔值:先统一写入,再派发统计变化
DataStore的底层读写方法保留了三个类型入口:字符串、数字和布尔值。收藏、记录、笔记用字符串保存;实验次数和学习秒数用数字保存;布尔入口当前是通用能力,本文涉及的页面没有把它作为主线。
以字符串写入为例:
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 (_) { } }数字写入也采用同样顺序:put、flush、notifyStatsChanged。这件事很关键,因为 UI 统计不是在页面里手动同步,而是在持久化服务里统一派发。换句话说,页面只负责表达“我要保存收藏列表”“我要追加实验记录”“我要增加学习秒数”,统计数字由DataStore根据 key 自动更新。
读方法则统一提供默认值:
static async getString(key: string, defaultValue: string = ''): Promise<string> { if (!DataStore.prefInstance) return defaultValue try { const value = await DataStore.prefInstance.get(key, defaultValue) return value as string } catch (_) { return defaultValue } }这个默认值策略让页面能简单处理空数据。例如收藏列表解析失败就返回空数组,实验记录解析失败也返回空数组。对学习应用来说,这比展示错误堆栈更稳。但它同时有一个明显缺点:如果 JSON 损坏,用户看到的是“暂无数据”,而不是“数据损坏”。当前源码没有恢复提示、备份或错误上报,因此文章不能把它夸成完整的数据容灾方案。
4. 收藏持久化:页面状态先变,再写入 Preferences
收藏能力在LabPage.ets和ExperimentSimPage.ets中都有调用。LabPage是实验列表页,它持有favoriteIds状态,并在aboutToAppear和onPageShow中重新读取收藏:
aboutToAppear(): void { this.reloadFavorites() } onPageShow(): void { this.reloadFavorites() } private async reloadFavorites(): Promise<void> { this.favoriteIds = await DataStore.loadFavorites() }这个设计解决的是“从实验详情页收藏或取消收藏后,返回列表页仍显示旧状态”的问题。页面初次出现时读一次,页面重新显示时再读一次。HarmonyOS 页面之间跳转后,返回页未必重新构造,因此仅依赖初始化读取不够,onPageShow这种回读点就成了状态一致性的关键。
收藏切换逻辑也比较清楚:
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) }这里先更新页面状态,再保存 Preferences。这样用户点击后视觉反馈会更快,但写入失败时 UI 会暂时呈现“已保存”的状态。由于底层saveFavorites没有返回失败原因,页面也没有回滚逻辑,所以这不是强一致性模型,而是面向本地轻量数据的乐观更新。
DataStore.saveFavorites实际只是把 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 [] } }当favorite_experiments写入成功后,notifyStatsChanged会解析数组长度,并写入AppStorage的favorite_count。这样收藏页、我的页或其他统计区域可以通过 AppStorage 读到最新数量,而不必每次都解析 JSON。
5. 实验记录:完成实验后追加到列表头部
实验记录的入口在ExperimentSimPage.ets。当实验进度达到 1 时,页面停止计时器,持久化学习时长,增加实验次数,并追加实验记录:
if (this.progress >= 1) { this.isRunning = false this.isFinished = true this.stopTimer() this.persistLearningTime() DataStore.incrementExperimentCount() this.persistRecord() }persistRecord会根据当前实验、分类和参数摘要构建StoredExperimentRecord,然后调用DataStore.appendRecord:
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) }真正的记录追加发生在DataStore.appendRecord:
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 (_) { } }这里的唯一标记是list.unshift(record)。它说明新记录被插入数组头部,而不是追加到末尾。因此“我的实验记录”页加载后,最新完成的实验会排在前面。这个设计符合学习工具的常见习惯:用户刚刚完成的实验最可能被立即查看、删除或复盘。
实验记录页的读取也遵循“出现时重新加载”的思路:
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)) }存储模型和展示模型通过storedToRecord转换,页面不直接展示原始 JSON。这一点虽然简单,但有实际意义:Preferences 里可以保存更稳定的结构,UI 列表可以转换出图标、时间展示、分类筛选所需字段。
6. 删除记录:先改持久化,再替换页面列表
记录删除逻辑在ExperimentRecordsPage.ets。它没有只从页面数组里删掉一项,而是先重新读取 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 } }这个顺序比“只改 UI 状态”更可靠。因为页面当前的records可能经过分类筛选或展示转换,如果直接对展示列表做删除,很容易只删除当前筛选视图中的对象,而没有同步完整持久化列表。源码里的做法是以 Preferences 中的完整StoredExperimentRecord数组为准,过滤后重新写入experiment_records,最后再把写回后的列表转换为页面状态。
由于putString('experiment_records', ...)命中STAT_KEYS,它还会触发notifyStatsChanged。不过notifyStatsChanged对experiment_records本身并没有直接设置experiment_count_total,它只是让stats_version增加。真正把记录数纳入实验次数快照的是refreshStatsSnapshot中的这行逻辑:
AppStorage.setOrCreate<number>(EXPERIMENT_COUNT_KEY, Math.max(experimentCount, recordCount))这个实现带来一个微妙但可解释的效果:实验次数既可以来自显式计数experiment_count,也可以至少不低于记录数组长度。假如历史版本只保存了记录而没有保存计数,快照仍能从记录数恢复一个不小于记录数的统计值。当前源码没有把这套逻辑写成迁移脚本,但它确实提供了轻量兼容能力。
7. 笔记持久化:页面拥有编辑态,DataStore 负责数组落盘
NotesPage.ets的结构和实验记录页类似,但它体现了另一种常见页面状态:编辑弹窗和列表更新。页面通过NoteEditorDialog接收标题、内容和分类,生成NoteItem后插入列表头部,再保存完整数组。
private async addNote(title: string, content: string, category: string): Promise<void> { 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()) const item: NoteItem = { id: 'n_' + now.getTime(), title, content, timestamp: ts, category } this.notes = [item, ...this.notes] await DataStore.saveNotes<NoteItem>(this.notes) }删除笔记也是先改页面数组,再保存完整数组:
private async removeNote(id: string): Promise<void> { this.notes = this.notes.filter(n => n.id !== id) await DataStore.saveNotes<NoteItem>(this.notes) }而DataStore端只是负责user_notes的 JSON 编解码:
static async loadNotes<T>(): Promise<T[]> { const json = await DataStore.getString('user_notes', '[]') try { return JSON.parse(json) as T[] } catch { return [] } } static async saveNotes<T>(notes: T[]): Promise<void> { try { await DataStore.putString('user_notes', JSON.stringify(notes)) } catch (_) { } }这里有一个值得保留的工程边界:笔记没有进入STAT_KEYS。也就是说,新增或删除笔记不会推动stats_version,也不会刷新收藏数、实验次数或学习时长。这符合当前页面需求,因为源码没有看到“笔记总数”作为全局统计指标。如果未来“我的”页要展示笔记数量,就不能只改 UI,还需要把user_notes加入统计快照或新增专门的 AppStorage key。
8. 学习时长:生命周期里分段累计,而不是只在结束时记录
实验页不仅保存完成记录,还保存学习时长。ExperimentSimPage中有一个learningStartedAt字段。开始实验时,如果它为 0,就记录当前时间:
private startExperiment(): void { if (this.isFinished) { this.resetExperiment() } this.isRunning = true if (this.learningStartedAt === 0) { this.learningStartedAt = Date.now() } this.startTimer() }暂停、离开页面或实验结束时都会调用persistLearningTime。这能覆盖几个用户路径:用户开始实验后暂停,用户直接返回上一页,用户完成实验后进入反馈页。源码没有等到应用退出才保存,而是在页面生命周期和状态变化点分段累计。
private persistLearningTime(stopTracking: boolean = true): void { if (this.learningStartedAt === 0) return const now = Date.now() const seconds = Math.floor((now - this.learningStartedAt) / 1000) if (seconds <= 0) { if (stopTracking) { this.learningStartedAt = 0 } return } this.learningStartedAt = stopTracking ? 0 : now DataStore.addLearningSeconds(seconds) }DataStore.addLearningSeconds会忽略非正数,并把累计秒数写到learning_seconds:
static async addLearningSeconds(seconds: number): Promise<number> { if (seconds <= 0) { return DataStore.getNumber('learning_seconds', 0) } try { const total = await DataStore.getNumber('learning_seconds', 0) const next = total + seconds await DataStore.putNumber('learning_seconds', next) return next } catch (_) { return 0 } }这套实现不是高精度计时器,也没有后台持续计时能力。它只是把前台实验学习过程中的有效停留时间以秒为单位累计。对于离线教育应用,这个粒度通常已经足够;如果未来要做排行榜、成就系统或防作弊统计,就需要重新定义计时可信度和数据校验策略。
9. AppStorage 快照:让统计展示不必反复解析 JSON
Preferences 是持久化层,AppStorage是应用级响应式状态入口。DataStore通过notifyStatsChanged把关键数据变化映射到 AppStorage:
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) } else if (key === 'learning_minutes') { AppStorage.setOrCreate<number>(LEARNING_SECONDS_KEY, (value as number) * 60) } DataStore.statsVersion++ AppStorage.setOrCreate<number>(STATS_VERSION_KEY, DataStore.statsVersion) }这个方法承担了两类工作:一类是把收藏数组长度、实验次数、学习秒数转换为直接可读的统计值;另一类是递增stats_version。stats_version是一个变化信号,适合让页面知道“底层统计源变了,可以重新取值或重新构建展示”。相比每个页面都监听不同 key,一个版本号更容易扩展。
初始化时的refreshStatsSnapshot则解决冷启动问题:
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 (_) { } }这里还有一个版本兼容细节:如果learning_seconds不存在,会回退到旧的learning_minutes * 60。这说明源码曾经或预留过分钟级字段,后来切到秒级累计。它不是完整迁移,但能让旧字段在统计快照中继续生效。
10. 设置页清理:清空本地 key,并同步归零统计
本地持久化不能只考虑保存,也要考虑清除。SettingsPage.ets提供了两个相关动作:清除实验数据和清理缓存。
“清除实验数据”在确认弹窗后逐项写入空数组或 0:
await DataStore.putString('favorite_experiments', '[]') await DataStore.putString('experiment_records', '[]') await DataStore.putString('user_notes', '[]') await DataStore.putNumber('experiment_count', 0) await DataStore.putNumber('learning_minutes', 0) await DataStore.putNumber('learning_seconds', 0) await this.refreshCacheSize()这个路径会触发部分统计更新,因为收藏、记录、实验次数、学习时长都在STAT_KEYS中。笔记虽然被清空,但它不是统计项。清理后再调用refreshCacheSize,让设置页展示的缓存估算随之刷新。
另一个能力是DataStore.clearCache()。它遍历LOCAL_DATA_KEYS,逐个删除 Preferences 中的本地 key,最后 flush,并把三个 AppStorage 统计值归零:
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) }这里和逐项写空值的差异在于:clearCache是删除 key,下一次读取会走默认值;逐项清除是把 key 留在 Preferences 中但值为空数组或 0。两种方式都能让页面读到空状态,但存储文件内容和缓存估算可能不同。
缓存估算方法也很朴素:
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 }它按字符串长度乘以 2 做估算,适合显示“本地数据大约占用多少”,不适合当作真实磁盘占用。文章需要把它称为估算,而不是精确存储统计。
11. 页面返回后一致性的关键点
把这些源码串起来,可以看到“保存、删除和页面返回后的数据即时一致”并不是靠某一个神奇 API 完成,而是靠几个固定约束组合出来的。
第一,写入口统一。收藏、记录、笔记、计数、学习时长都经过DataStore,页面不散落 Preferences 文件名和 key 名。这样未来要改存储实现,至少有一个集中替换点。
第二,页面在可见生命周期重新读取。LabPage、ExperimentRecordsPage、NotesPage都在aboutToAppear和onPageShow调用 reload/load 方法。返回页面时,不假设内存状态一定最新,而是从本地持久化层重新拉取。
第三,列表删除以持久化数据为准。实验记录删除先加载完整存储数组,再过滤并写回,然后用写回后的数组替换页面状态。这样能避免筛选列表、展示模型和存储模型之间发生偏差。
第四,统计快照由 DataStore 派发。页面不用到处手动维护收藏数、实验次数和学习秒数。只要写入命中STAT_KEYS,就能更新 AppStorage 或至少推动stats_version。
第五,清理路径包含本地 key 和统计快照。清空 Preferences 后,如果 AppStorage 不归零,用户会看到列表为空但统计仍显示旧值。源码在clearCache中显式归零了收藏数、实验次数和学习秒数,避免了这种 UI 断裂。
这些约束没有把系统变成强事务模型,但足够支撑当前应用的本地离线学习场景。
12. 当前实现的风险和可改进点
从工程审阅角度看,这套实现也有几个需要明确的风险。
第一,写入失败被吞掉。putString、putNumber、appendRecord、saveNotes等方法都捕获异常但不返回失败原因。用户点击收藏后,如果 Preferences 写入失败,页面可能已经乐观更新,但本地文件没有成功落盘。更严谨的方案是让写方法返回boolean或抛出业务错误,由页面决定是否回滚状态和提示用户。
第二,JSON 缺少结构校验。loadFavorites、loadRecords、loadNotes只做JSON.parse,没有验证数组元素结构。如果未来数据版本升级,旧字段缺失或类型变化,页面转换时可能出现展示异常。当前源码有默认空数组兜底,但没有更细的迁移策略。
第三,Preferences 不适合无限增长。实验记录和笔记是数组整体 JSON,每次追加或删除都要读写整个数组。当前离线学习应用数据量小,这样可以接受;如果记录增长到几千条,就应该考虑 RDB、分页、索引和迁移。
第四,统计更新不是完整事件系统。experiment_records写入后只推动版本号,没有直接设置实验总数。初始化快照会用Math.max(experimentCount, recordCount)修正,但实时删除后统计是否立即按记录数变化,取决于页面如何使用stats_version和是否重新刷新快照。当前文章不能声称它实现了全局实时强同步。
第五,清除实验数据和清理缓存有两套路径。一个是逐项写空值,一个是删除 key。它们都能达到“用户数据清空”的效果,但语义不同。未来如果新增 key,必须同时维护LOCAL_DATA_KEYS、设置页清除逻辑和统计快照逻辑,否则容易漏清。
这些问题不影响当前源码作为轻量本地持久化案例的价值。相反,它们说明工程实现必须根据真实规模选择方案,而不是一上来就堆数据库、同步服务和复杂状态机。
13. 可复核的源码结论
结合上述文件,可以给这篇文章的结论做一个清晰限定:
- “细胞工坊”的本地持久化由
DataStore.ets统一封装,底层使用 HarmonyOS Preferences。 - 收藏保存为
favorite_experimentsJSON 数组,并通过favorite_count同步到 AppStorage。 - 实验记录保存为
experiment_recordsJSON 数组,新记录通过list.unshift(record)插到列表头部。 - 笔记保存为
user_notesJSON 数组,当前不参与全局统计快照。 - 实验次数保存为
experiment_count,学习时长优先保存为learning_seconds。 refreshStatsSnapshot会在初始化时把 Preferences 中的数据恢复成 AppStorage 快照。- 页面通过
aboutToAppear和onPageShow重新读取本地数据,减少返回页面后的旧状态。 - 设置页可以逐项清空实验数据,也可以通过
clearCache删除本地 key 并把统计归零。 - 当前源码没有 RDB、云同步、账号隔离、后台上传、服务端备份或跨设备同步。
如果把这个实现抽象成一句工程经验,就是:轻量离线应用不一定需要复杂存储系统,但必须有统一读写入口、明确 key 边界、页面可见时回读、删除后写回、统计快照刷新和清理路径。否则数据量再小,也会出现“点了收藏但返回列表没变化”“删除记录后统计还在”“清空缓存后页面还显示旧数据”这类低级但高频的问题。
14. 发布前自检清单
围绕这类 HarmonyOS 本地持久化实现,发布前我会按以下清单复核:
- Preferences 初始化是否只依赖官方
UIAbilityContext,没有把 Context 当作全局业务状态乱传。 - 页面是否通过服务层读写本地数据,而不是散落 Preferences API。
- 收藏、记录、笔记、计数、学习时长的 key 是否集中定义或至少集中维护。
- 页面返回后是否有
aboutToAppear、onPageShow或等效机制重新读取关键状态。 - 删除动作是否同时更新持久化数据和页面状态。
- 清理动作是否覆盖所有本地 key,并同步清理 AppStorage 快照。
- JSON 解析失败时是否有默认状态,且默认状态不会造成崩溃。
- 文案、隐私说明和实际行为是否一致:当前是本地离线保存,不应写成云同步或跨设备备份。
- 数据规模是否仍适合 Preferences,如果记录量增长,应评估 RDB。
- AppGallery 材料中是否避免声称账号同步、云备份、社区互动、排行榜等源码没有实现的能力。
这些检查不复杂,但足以让一个本地学习类应用在 HarmonyOS 5.0 及以上版本中保持基础稳定性。对“细胞工坊”这样的离线实验应用来说,可靠保存用户刚刚产生的学习痕迹,比引入更炫的存储名词更重要。
---