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 个胶囊。对于个人使用完全够用。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐