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      // 是否收藏
}

在这里插入图片描述

八个字段的职责

字段类型写入时机读取方
idnumbercreateCapsule所有页面
contentstringcreateCapsule详情页、列表
moodstringMoodPicker详情页、列表
unlockAtnumbercreateCapsule倒计时、状态判断
isOpenedboolean详情页开启状态判断
createdAtnumbercreateCapsule排序、月历
tagsstring[]标签输入搜索、筛选
favoritedboolean详情页收藏收藏筛选

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
contextUIAbility 上下文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 + JSONSQLite
复杂度低(一个文件)高(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、测试、元服务和应用上架分发等。

更多推荐