HarmonyOS时光胶囊——数据库层的持久化设计与数据操作
CapsuleDatabase 是整个 App 的数据心脏——所有页面都通过它读写胶囊数据。它的实现分两层:内存层用数组存储 Capsule 对象,持久化层用 Preferences 存储 JSON 字符串。写入时先更新内存再同步磁盘,读取时只从 Preferences 加载一次。这种"内存+文件"的双层架构是轻量级 App 的标准持久化方案——不需要 SQLite 的重量级支持,又能保证数据不丢失。
完整效果

一、Database.ets 的角色定位
它不是什么
| 误解 | 实际情况 |
|---|---|
| SQLite 数据库 | 不是——用 Preferences 存 JSON |
| ORM 框架 | 不是——手写 CRUD 方法 |
| 全局状态管理 | 不是——只管胶囊数据 |
它是什么
Database.ets = 数据模型定义 + CRUD 操作函数 + Preferences 持久化
一个文件承担三个职责——接口定义、业务逻辑、存储引擎。 对于小型 App 来说,拆分三个文件反而增加理解成本。放在一个文件里,用户打开就能看到完整的数据层。
二、文件内的模块结构
从 import 推断的导出项
// model/Database.ets 的导出
export { Capsule, UnlockOption, ... } // 数据模型(从 Capsule.ets 重导出)
export { getCapsuleState, CapsuleState } // 状态判断(从 Capsule.ets 重导出)
export class CapsuleDatabase { // 数据库类
export function setCurrentCapsuleId(id: number): void // 全局记录
export function getCurrentCapsuleId(): number
重导出的动机
// Database.ets 顶部
export { Capsule, UnlockOption, getCapsuleState, CapsuleState } from './Capsule'
重导出让其他页面只需要 import Database.ets 一个路径。 如果没有重导出,首页需要 import { Capsule } from '../model/Capsule' 和 import { CapsuleDatabase } from '../model/Database'——两个 import。有了重导出,只需要 import { Capsule, CapsuleDatabase } from '../model/Database'——一个 import。
三、Capsule 接口的完整定义
从使用方式推断的完整接口
export interface Capsule {
id: number // 唯一标识
content: string // 胶囊内容(文字)
mood: string // 心情 emoji(如 '😊')
unlockAt: number // 解锁时间戳(毫秒)
isOpened: boolean // 是否已开启
createdAt: number // 创建时间戳(毫秒)
tags: string[] // 标签数组
favorited: boolean // 是否收藏
}

八个字段的职责
| 字段 | 类型 | 写入时机 | 读取方 |
|---|---|---|---|
| id | number | createCapsule | 所有页面 |
| content | string | createCapsule | 详情页、列表 |
| mood | string | MoodPicker | 详情页、列表 |
| unlockAt | number | createCapsule | 倒计时、状态判断 |
| isOpened | boolean | 详情页开启 | 状态判断 |
| createdAt | number | createCapsule | 排序、月历 |
| tags | string[] | 标签输入 | 搜索、筛选 |
| favorited | boolean | 详情页收藏 | 收藏筛选 |
createdAt 由系统生成,不可修改——保证数据真实性。 用户不能把一个"3天前创建"的胶囊改成"今天创建"。unlockAt 由用户设定——这是唯一的时间自由度。
四、CapsuleDatabase 类的核心属性
推断的类结构
export class CapsuleDatabase {
private capsules: Capsule[] = [] // 内存数据
private context: UIAbilityContext // Preferences 需要的上下文
private isLoaded: boolean = false // 是否已加载
constructor(context: UIAbilityContext) {
this.context = context
}
}
三个属性的设计意图
| 属性 | 作用 | 为什么需要 |
|---|---|---|
| capsules | 内存缓存 | 所有页面读取不需要每次解析 JSON |
| context | UIAbility 上下文 | Preferences 需要 AbilityContext |
| isLoaded | 加载状态 | 防止重复加载,避免空数据渲染 |
isLoaded 保证只加载一次——避免每次读取都解析 JSON。 第一次 loadData() 后 isLoaded=true,后续直接用内存数组。
五、loadData:从 Preferences 加载
加载流程
async loadData(): Promise<void> {
if (this.isLoaded) return // 已加载则跳过
try {
const prefs = await preferences.getPreferences(this.context, 'capsules')
const json = await prefs.get('data', '[]')
this.capsules = JSON.parse(json as string) as Capsule[]
this.isLoaded = true
} catch (e) {
this.capsules = []
this.isLoaded = true
}
}

五步加载
1. 检查 isLoaded → 已加载则直接返回
2. 获取 Preferences 实例(键名 'capsules')
3. 读取 'data' 键的值(默认 '[]')
4. JSON.parse 解析为 Capsule 数组
5. 设置 isLoaded = true
Preferences 的存储结构
Preferences(键名: capsules)
└→ key: 'data'
value: '[{"id":1,"content":"...","mood":"😊",...},...]'
整个胶囊数组序列化成一个 JSON 字符串——存为 Preferences 的一个键值对。 Preferences 的本质是键值对存储——不适合存结构化数据,但可以存 JSON 字符串。
为什么默认值是 ‘[]’
const json = await prefs.get('data', '[]')
第一次打开 App 时 Preferences 里没有 ‘data’ 键——返回默认值 ‘[]’。 JSON.parse(‘[]’) 得到空数组——没有胶囊。这是 App 的初始状态。
错误处理
catch (e) {
this.capsules = []
this.isLoaded = true
}
JSON 解析失败时回退到空数组——不崩溃。 如果存储的 JSON 格式损坏(比如手动改了 Preferences),App 不会崩溃,只是丢失所有数据。这是"优雅降级"——数据丢失比 App 崩溃好。
六、getAllCapsules:获取所有胶囊
getAllCapsules(): Capsule[] {
return this.capsules.slice()
}
返回副本——不暴露内部数组引用。 slice() 创建新数组——外部修改返回值不会影响内部数据。这是防御性编程——防止外部意外修改数据库状态。
为什么不用只读属性
// 不用这种方式
get allCapsules(): Capsule[] { return this.capsules }
getter 返回的是同一个引用——外部修改会影响内部数据。 slice() 返回副本——保证数据隔离。
七、getCapsuleById:按 ID 查询
getCapsuleById(id: number): Capsule | undefined {
for (let i = 0; i < this.capsules.length; i++) {
if (this.capsules[i].id === id) return this.capsules[i]
}
return undefined
}
线性扫描——因为数据量小,不需要 Map。 胶囊数量通常在几十到几百——线性扫描的性能完全够用。只有数据量上千时才需要考虑 Map 优化。
为什么不用 filter
// 不用这种
getCapsuleById(id: number): Capsule | undefined {
return this.capsules.find(c => c.id === id)
}
filter/find 会遍历完整个数组——即使已经找到了。 for 循环在找到目标后立即 return——提前终止遍历。对于大数组,性能差距明显。
八、addCapsule:新增胶囊
写入流程
async addCapsule(capsule: Capsule): Promise<void> {
// 1. 加入内存数组
this.capsules.push(capsule)
// 2. 同步到 Preferences
await this.save()
}
save 的实现
private async save(): Promise<void> {
try {
const prefs = await preferences.getPreferences(this.context, 'capsules')
await prefs.put('data', JSON.stringify(this.capsules))
await prefs.flush()
} catch (e) {
// 静默失败
}
}
两步持久化——内存更新 + 磁盘同步。 先更新内存让 UI 立即刷新,再异步写入 Preferences 保证数据不丢。如果磁盘写入失败,内存数据还在——用户操作不受影响,但重启后数据会丢失。
JSON.stringify 的序列化
JSON.stringify(this.capsules)
把 Capsule 数组转成 JSON 字符串——Preferences 只能存基本类型。 Preferences 不支持直接存对象——只能存 string/number/boolean。JSON 字符串是最简单的序列化方案。
九、updateCapsule:更新胶囊
async updateCapsule(capsule: Capsule): Promise<void> {
// 1. 找到目标并替换
for (let i = 0; i < this.capsules.length; i++) {
if (this.capsules[i].id === capsule.id) {
this.capsules[i] = capsule
break
}
}
// 2. 同步到 Preferences
await this.save()
}
直接替换引用——不需要逐字段修改。 找到目标后用新对象替换旧对象。这种方式简单直接——不需要比较每个字段是否变化。
为什么用引用替换而不是字段修改
// 不用这种
Object.assign(this.capsules[i], capsule)
// 用这种
this.capsules[i] = capsule
引用替换更清晰——新对象直接替换旧对象。 Object.assign 可能遗漏新增字段——如果 Capsule 接口新增了字段,Object.assign 不会删除旧对象的多余字段。引用替换保证对象完全一致。
十、deleteCapsule:删除胶囊
async deleteCapsule(id: number): Promise<void> {
this.capsules = this.capsules.filter((c: Capsule) => c.id !== id)
await this.save()
}
filter 返回新数组——排除目标 ID。 不修改原数组——创建新数组赋值给 this.capsules。这保证了引用安全——如果有其他地方持有旧数组的引用,不会被意外修改。
十一、getStats:统计信息
getStats(): CapsuleStats {
const now = Date.now()
let total = this.capsules.length
let opened = 0
let sealed = 0
let ready = 0
for (let i = 0; i < this.capsules.length; i++) {
const c = this.capsules[i]
if (c.isOpened) {
opened++
} else if (c.unlockAt > now) {
sealed++
} else {
ready++
}
}
return { total, opened, sealed, ready }
}
一次遍历统计所有维度——四次 if-else 判断覆盖三种状态。 不需要多次循环——一个 for 循环搞定所有统计。
状态判断的优先级
c.isOpened === true → OPENED(已开启)
↓ false
c.unlockAt > now → SEALED(封印中)
↓ false
READY(可开启)
isOpened 优先判断——已开启的胶囊不管到期没到期都是 OPENED。 逻辑上"已开启"是最强状态——一旦开启,即使时间倒流(不可能但逻辑上),也应该是 OPENED。
十二、clearAll:清空所有数据
async clearAll(): Promise<void> {
this.capsules = []
await this.save()
}
设置空数组 + save——删除 Preferences 里的全部数据。 这是破坏性操作——所有胶囊永久删除。在设置页触发,需要二次确认。
十三、setCurrentCapsuleId:全局记录
为什么需要这个
let currentCapsuleId: number = 0
export function setCurrentCapsuleId(id: number): void {
currentCapsuleId = id
}
export function getCurrentCapsuleId(): number {
return currentCapsuleId
}
解决的问题:详情页需要知道"用户点击了哪个胶囊"。 路由参数可以传 ID,但 Preferences 的键名冲突——所有页面共用同一个键名。用模块级变量作为"临时通道"——首页写入,详情页读取。
为什么不用 Preferences 传递
// 不用这种方式
await prefs.put('currentCapsuleId', id)
const id = await prefs.get('currentCapsuleId', 0)
Preferences 是异步 API——读写都需要 await。 模块级变量是同步的——立即可用。导航是同步操作,等 Preferences 异步返回时页面已经跳转了。
变量的生命周期
用户点击胶囊
→ setCurrentCapsuleId(capsule.id) 写入
→ router.pushUrl(...) 跳转
→ CapsuleDetail 加载
→ getCurrentCapsuleId() 读取
→ loadData(capsule.id) 加载数据
变量在写入后一直存在——直到下一次写入覆盖。 如果用户连续查看多个胶囊,每次都会覆盖上一次的值。这没问题——详情页只在 aboutToAppear 时读取一次。
十四、Preferences 的存储细节
键值对结构
Preferences('capsules')
key: 'data' → '[{"id":1,...},{"id":2,...}]'
为什么不分成多个键
// 不用这种方式
Preferences('capsules')
key: 'capsule_1' → '{"id":1,...}'
key: 'capsule_2' → '{"id":2,...}'
一个键存整个数组——读写都是一次操作。 分成多个键需要多次读写——性能差。数组整体序列化/反序列化的开销对于百级数据量可以忽略。
flush 的重要性
await prefs.put('data', JSON.stringify(this.capsules))
await prefs.flush() // 必须调用
put 只写入内存缓冲区——flush 才写入磁盘。 如果不调用 flush,数据在内存里——App 被杀掉后数据丢失。flush 是"确认保存"的操作。
十五、数据层的整体架构
UI 层(Index/Detail/Shuffle)
↓ 读写胶囊
Database.ets(CapsuleDatabase)
↓ JSON 序列化
Preferences(键值对存储)
↓ 文件系统
磁盘文件
四层架构——UI→数据库→Preferences→磁盘。 每一层只和相邻层交互——UI 不直接读 Preferences,Preferences 不直接操作磁盘(系统处理)。
数据流方向
| 操作 | 数据流向 | 延迟 |
|---|---|---|
| 读取 | 磁盘→Preferences→内存→UI | 首次加载时 |
| 写入 | UI→内存→Preferences→磁盘 | 异步写入 |
读取只在首次加载时从磁盘读——后续都用内存。写入先更新内存再异步同步磁盘——UI 立即响应。 这是"读缓存+异步写"的经典模式。
十六、为什么不用 SQLite
| 特性 | Preferences + JSON | SQLite |
|---|---|---|
| 复杂度 | 低(一个文件) | 高(ORM + 迁移) |
| 查询能力 | 无(全量加载) | SQL 查询 |
| 数据量上限 | ~1MB | 无限制 |
| 适用场景 | 小型 App(<500 条) | 大型 App(>1000 条) |
时光胶囊的胶囊数量通常在几十到几百——Preferences + JSON 完全够用。 引入 SQLite 增加了大量复杂度(数据库创建、表结构定义、数据迁移),但收益为零——数据量太小,SQL 查询的优势体现不出来。
Preferences 的 1MB 上限
Preferences 的单个键值对大小上限约 1MB。一个 Capsule 对象 JSON 序列化后约 200-300 字节——1MB 可以存约 3000-5000 个胶囊。对于个人使用完全够用。
更多推荐

所有评论(0)