基于鸿蒙OS开发静脉输液智能监控系统(5)-数据持久化服务设计

1. HarmonyOS 数据存储方案对比

HarmonyOS 为应用开发者提供了多种数据持久化方案,每种方案都有其特定的适用场景和性能特征。在 IVGuard 项目的架构设计阶段,我们需要对这些方案进行深入对比分析,以选择最适合 MVP 阶段的数据持久化技术路线。

1.1 Preferences(轻量键值对存储)

Preferences 是 HarmonyOS 提供的轻量级键值对(Key-Value)存储方案,其底层实现基于 XML 文件格式。它适用于存储少量、轻量的结构化数据,如应用配置信息、用户偏好设置、简单的状态标记等。

核心特征:

  • 存储格式:XML 文件,以键值对形式组织数据
  • 数据量限制:建议存储数据不超过 1KB ~ 10KB 级别,键值对条目建议不超过数百条
  • 读写模式:提供同步读写 API(putSyncgetSync)和异步持久化 API(flush
  • ValueType 支持:仅支持 stringnumberbooleanArray<string>Array<number>Array<boolean> 等基础类型,不支持直接存储对象(Object)
  • 使用场景:应用配置、开关状态、简单标记、小型数据集合
  • 线程模型:单进程内共享实例,不支持多进程并发写入

优势:

  • API 简洁直观,学习成本极低
  • 同步读写保证了代码的线性可读性
  • 异步 flush() 机制允许开发者精确控制磁盘写入时机
  • 初始化开销小,无需创建数据库表结构
  • 适合移动端应用的小数据量快速存取场景

劣势:

  • 不支持复杂查询(无 SQL 能力)
  • 不适合存储大量结构化数据
  • 不支持跨设备同步
  • 值类型有限,存储复杂对象需手动序列化/反序列化

1.2 关系型数据库(RDB)

关系型数据库基于 SQLite 引擎,提供了完整的 SQL 查询能力,适用于需要复杂查询、事务支持、多表关联的结构化数据持久化场景。

核心特征:

  • 存储格式:SQLite 数据库文件
  • 数据量限制:可支持 GB 级别的数据存储
  • 查询能力:完整的 SQL-92 标准支持,包括 JOIN、子查询、聚合函数等
  • 事务支持:ACID 事务保证,支持 BEGIN/COMMIT/ROLLBACK
  • ValueType 支持:NULL、INTEGER、REAL、TEXT、BLOB 五种基本存储类
  • 使用场景:结构化业务数据、多表关联查询、数据分析报表

优势:

  • 强大的查询能力,可应对复杂业务场景
  • 事务保证数据一致性和完整性
  • 数据量扩展性好,性能随数据量增长的衰减可控
  • 支持索引优化查询性能
  • 成熟的 SQLite 生态,社区资源丰富

劣势:

  • 需要预先定义表结构(Schema),增加了开发复杂度
  • 数据库版本迁移需要额外的升级脚本管理
  • 初始化开销较大,需要创建数据库和表结构
  • SQL 语句编写和调试成本高于简单的 KV 操作
  • 对于极少量数据,性能优势无法体现

1.3 分布式数据服务(DistributedData)

分布式数据服务是 HarmonyOS 面向 IoT 和多设备协同场景提供的数据同步方案,支持跨设备的数据自动同步和冲突解决。

核心特征:

  • 存储格式:基于分布式键值对存储
  • 同步能力:自动跨设备数据同步,支持同账号下的多设备数据一致性
  • 冲突解决:内置 Last-Write-Win 等冲突解决策略
  • 使用场景:多设备协同、IoT 设备数据同步、跨端状态共享

优势:

  • 开箱即用的跨设备数据同步
  • 自动冲突解决,降低开发复杂度
  • 适合分布式场景

劣势:

  • 依赖 HarmonyOS 分布式能力,设备需在同一网络环境下
  • 数据同步延迟不可控,不适合实时性要求高的场景
  • 调试和测试复杂度高,需要多设备环境
  • MVP 阶段不必要的基础设施复杂度

1.4 文件存储

文件存储是最基础的持久化方案,适用于大文件、媒体资源的直接存储,也支持自定义格式的小数据量存储。

核心特征:

  • 存储格式:任意格式,由开发者自定义
  • 数据量限制:仅受设备存储空间限制
  • 访问模式:基于文件路径的读写操作
  • 使用场景:图片、视频、文档、日志文件、数据导出

优势:

  • 完全灵活,不受数据格式限制
  • 适合存储大型二进制数据
  • 可与外部系统(PC、云端)通过文件交换数据

劣势:

  • 无查询能力,需要全量加载后手动筛选
  • 并发控制需要自行实现
  • 数据一致性需要自行保证
  • 不适合频繁更新的结构化数据

1.5 IVGuard MVP 阶段的选择分析

在 IVGuard 项目的 MVP 阶段,我们需要存储以下几类数据:

数据类型 预估数据量 读写频率 查询复杂度
Medicine(药物列表) < 20 条 低频写入,中频读取 按 ID 查找
MonitorSession(监控会话) < 10 条 中频写入,中频读取 按 ID 查找
MonitorRecord(完成记录) < 50 条 低频写入,中频读取 按时间排序
AlertEvent(预警事件) < 30 条 中频写入,高频读取 按 handled 过滤
CostItem(费用项) < 50 条 中频写入,中频读取 按分类聚合
InsurancePolicy(医保政策) 1 条 极低频写入,低频读取 单条读取
AppSettings(应用配置) 1 条 极低频写入,高频读取 单条读取
LevelRecord(液位时序) < 100 条 高频写入,中频读取 按 sessionId 过滤

数据量分析: 所有实体合计不超过 300 条记录,总数据量估算在 50KB ~ 200KB 范围内,远低于 Preferences 的推荐上限。

查询需求分析: MVP 阶段的查询均为简单的按 ID 查找、全量加载后内存筛选,不需要 SQL 级别的复杂查询能力。

基于以上分析,选择 Preferences 作为 IVGuard MVP 阶段的数据持久化方案:

  1. 数据量匹配:总数据量远低于 Preferences 推荐上限
  2. 查询需求匹配:无需复杂查询,全量加载后内存筛选即可满足
  3. 开发效率:API 简洁,无需定义表结构,可快速迭代
  4. 性能足够:同步读写保证了读取的即时性,异步 flush 保证了写入的可靠性
  5. 迁移路径清晰:DataStore 封装了存储细节,未来可无缝切换到 RDB

2. Preferences API 详解

2.1 模块导入与初始化

Preferences API 位于 @kit.ArkData 模块中,使用前需要进行模块导入:

import { preferences } from '@kit.ArkData'
import { common } from '@kit.AbilityKit'

其中 @kit.ArkData 提供数据持久化的核心 API,@kit.AbilityKit 提供 UIAbilityContext 上下文,这是初始化 Preferences 实例的必要参数。

初始化方法:

const store: preferences.Preferences = preferences.getPreferencesSync(
  context,
  { name: 'ivguard_data' }
)

getPreferencesSync 方法接收两个参数:

  • contextcommon.UIAbilityContext 类型,提供应用沙箱路径,Preferences 文件将存储在该路径下
  • options:包含 name 字段的配置对象,name 即为 Preferences 文件的名称,底层将创建名为 ivguard_data.xml 的文件

该方法为同步调用,立即返回 preferences.Preferences 实例。多次调用同一 name 将返回同一实例(单例语义),不会创建多个文件副本。

2.2 读写操作 API

Preferences 提供了同步读写和异步持久化两套 API,二者的分工如下:

写入操作 — putSync(key: string, value: ValueType)

store.putSync('medicines', JSON.stringify(medicines))

putSync 将键值对写入内存中的 Preferences 缓存。此操作是同步的,调用返回后即可通过 getSync 读取到最新值。但此时数据尚未持久化到磁盘文件,如果应用异常退出,这部分数据将丢失。

读取操作 — getSync(key: string, defaultValue: ValueType)

const raw = store.getSync('medicines', '[]') as string

getSync 从内存缓存中读取指定键的值。如果键不存在,则返回 defaultValue。此操作是同步的,保证了读取的即时性。

持久化操作 — flush()

await store.flush()

flush 将内存中的数据异步写入磁盘文件。这是一个异步操作,返回 Promise<void>。调用后数据将真正持久化到 ivguard_data.xml 文件中,即使应用异常退出,数据也不会丢失。

2.3 ValueType 类型窄化

Preferences 的 ValueType 定义如下:

type ValueType = string | number | boolean | Array<string> | Array<number> | Array<boolean>

这意味着 Preferences 不支持直接存储对象(Object)。对于复杂的数据结构,必须先进行 JSON 序列化,将其转换为 string 类型后再存储。

关键限制:

  • 不支持直接存储自定义类实例
  • 不支持嵌套对象
  • 不支持 Array<Object> 类型
  • 不支持 undefinednull
  • Array 仅支持基本类型数组,不支持对象数组

解决方案:

写入时使用 JSON.stringify() 将对象/数组转为字符串:

s.putSync('medicines', JSON.stringify(medicines))

读取时使用 JSON.parse() 将字符串转回原始结构,然后手动反序列化为类实例:

const raw = s.getSync('medicines', '[]') as string
const arr: Object[] = JSON.parse(raw) as Object[]
// 遍历 arr,手动创建类实例并逐字段赋值

2.4 为什么不能直接存储对象

在标准 TypeScript/JavaScript 环境中,JSON.stringifyJSON.parse 可以方便地处理对象的序列化与反序列化。但在 ArkTS 严格模式下,存在以下限制:

  1. JSON.parse 返回类型为 Object:ArkTS 不允许直接将 Object 类型赋值给自定义类实例,因为类型信息在序列化过程中已丢失
  2. 禁止 Object.assign:ArkTS 严格模式禁止使用 Object.assign 等反射式赋值操作,因为其类型不安全
  3. 禁止 as 直接强转 Object 到自定义类:虽然语法上可以通过 as 进行类型断言,但运行时对象仍然只是普通 Object,不包含类的方法和原型链信息
  4. 类实例需要通过 new 创建:ArkTS 要求通过构造函数 new ClassName() 创建类实例,然后逐字段赋值

因此,IVGuard 的 DataStore 采用了手动反序列化策略:JSON.parse 后得到 Object[],遍历每个元素转为 Record<string, Object>,然后通过逐字段类型断言和赋值,创建新的类实例。


3. DataStore 架构设计

3.1 单例模式

DataStore 采用静态类 + 静态属性的方式实现单例模式,确保全局只有一个 Preferences 实例:

export class DataStore {
  private static store: preferences.Preferences | undefined = undefined
  // ...
}

设计考量:

  • 全局唯一private static store 保证了整个应用生命周期内只有一个 Preferences 实例
  • 延迟初始化undefined 初始值允许在应用启动时按需初始化,而非在类加载时立即创建
  • 私有访问private 修饰符阻止外部直接操作 Preferences 实例,所有操作必须通过 DataStore 的公共方法
  • 未初始化保护| undefined 联合类型使得编译器和运行时都能检测到未初始化的状态

3.2 异步初始化流程

DataStore 的初始化通过 init 静态方法完成:

static async init(context: common.UIAbilityContext): Promise<void> {
  if (DataStore.store !== undefined) {
    return
  }
  DataStore.store = preferences.getPreferencesSync(context, { name: 'ivguard_data' })
}

流程解析:

  1. 幂等性保护if (DataStore.store !== undefined) return 确保多次调用 init 不会重复初始化。这在多页面场景下至关重要,因为每个页面的 aboutToAppear 都会调用 DataStore.init()
  2. 同步创建实例getPreferencesSync 是同步调用,立即返回 Preferences 实例
  3. 异步方法签名:尽管内部是同步操作,init 方法的返回类型是 Promise<void>,这为未来可能的异步初始化需求(如数据迁移、版本检查)预留了扩展空间
  4. Context 依赖:需要 UIAbilityContext 参数来获取应用沙箱路径,这是创建 Preferences 文件的前提条件

为什么用 async 包裹同步操作?

这是有意的架构设计。虽然 getPreferencesSync 本身是同步的,但将 init 设计为 async 方法有以下好处:

  • 调用方使用 .then() 语法,确保初始化完成后再执行数据加载
  • 未来如果初始化逻辑变为异步(如从旧版本迁移数据),调用方无需修改
  • 统一的异步接口风格,与 save* 方法保持一致

3.3 getStore() 安全检查

private static getStore(): preferences.Preferences {
  if (DataStore.store === undefined) {
    throw new Error('DataStore not initialized')
  }
  return DataStore.store
}

getStore() 是所有公共方法的内部辅助方法,它执行以下安全检查:

  1. 未初始化检测:如果 store 仍为 undefined,说明 init() 尚未被调用或尚未完成
  2. 快速失败:抛出 Error 而非静默返回 undefined,避免后续操作产生难以追踪的空引用异常
  3. 类型收窄:通过类型守卫(undefined 检查),TypeScript 编译器能够将 store 的类型从 preferences.Preferences | undefined 收窄为 preferences.Preferences,消除了后续操作的可空性警告

每个 save/load 方法的第一行都是 const s = DataStore.getStore(),这种模式确保了:

  • 统一的初始化检查入口
  • 减少重复的 undefined 判断代码
  • 方法体内部可以安全地使用 s 而无需担心空引用

3.4 实体类型的 save/load 方法对

DataStore 为每种持久化实体提供了一对 save/load 方法:

方法对 实体类型 存储键 值类型
saveMedicines / loadMedicines Medicine[] 'medicines' JSON 字符串(数组)
saveSessions / loadSessions MonitorSession[] 'sessions' JSON 字符串(数组)
saveRecords / loadRecords MonitorRecord[] 'records' JSON 字符串(数组)
saveAlerts / loadAlerts AlertEvent[] 'alerts' JSON 字符串(数组)
saveCostItems / loadCostItems CostItem[] 'costItems' JSON 字符串(数组)
saveInsurancePolicy / loadInsurancePolicy InsurancePolicy 'insurancePolicy' JSON 字符串(单条)
saveSettings / loadSettings AppSettings 'settings' JSON 字符串(单条)
saveLevelRecords / loadLevelRecords LevelRecord[] 'levelRecords' JSON 字符串(数组)

命名约定:

  • save 方法:async save{EntityName}(data: Type): Promise<void>
  • load 方法:load{EntityName}(): Type(同步返回)

save 方法的统一模式:

static async saveXxx(data: Type): Promise<void> {
  const s = DataStore.getStore()           // 1. 安全获取 store 实例
  s.putSync('key', JSON.stringify(data))    // 2. 同步写入内存缓存
  await s.flush()                           // 3. 异步持久化到磁盘
}

load 方法的统一模式(数组类型):

static loadXxx(): Type[] {
  const s = DataStore.getStore()            // 1. 安全获取 store 实例
  const raw = s.getSync('key', '[]') as string  // 2. 读取 JSON 字符串
  const arr: Object[] = JSON.parse(raw) as Object[]  // 3. 解析为 Object 数组
  const result: Type[] = []
  for (let i = 0; i < arr.length; i++) {
    const item = arr[i] as Record<string, Object>    // 4. 转为 Record
    const obj = new Type()                            // 5. 创建类实例
    obj.field = (item['field'] as FieldType) ?? default  // 6. 逐字段赋值
    result.push(obj)
  }
  return result
}

load 方法的统一模式(单条记录类型):

static loadXxx(): Type {
  const s = DataStore.getStore()            // 1. 安全获取 store 实例
  const raw = s.getSync('key', '') as string     // 2. 读取 JSON 字符串
  if (raw.length === 0) {                         // 3. 空值判断
    return new Type()                             // 4. 返回默认实例
  }
  const item = JSON.parse(raw) as Record<string, Object>  // 5. 解析为 Record
  const obj = new Type()                                    // 6. 创建类实例
  obj.field = (item['field'] as FieldType) ?? default       // 7. 逐字段赋值
  return obj
}

4. JSON 序列化策略详解

4.1 完整序列化/反序列化流程

IVGuard 的 DataStore 采用了一套统一的 JSON 序列化策略,将类实例持久化到 Preferences 中。完整的流程如下:

写入流程(类实例 → Preferences):

类实例数组 → JSON.stringify() → JSON 字符串 → putSync(key, string) → 内存缓存
                                                                      ↓
                                                              flush() → 磁盘文件

读取流程(Preferences → 类实例):

磁盘文件 → 内存缓存 → getSync(key, default) → JSON 字符串 → JSON.parse() → Object[]
                                                                     ↓
                                                    Record<string, Object>
                                                                     ↓
                                              逐字段类型断言 + 赋值 → 类实例数组

4.2 写入操作详解

写入操作的核心代码模式:

static async saveMedicines(medicines: Medicine[]): Promise<void> {
  const s = DataStore.getStore()
  s.putSync('medicines', JSON.stringify(medicines))
  await s.flush()
}

步骤解析:

  1. DataStore.getStore():获取已初始化的 Preferences 实例,未初始化则抛出错误
  2. JSON.stringify(medicines):将 Medicine[] 数组序列化为 JSON 字符串。由于 Medicine 类的所有字段都是基础类型(stringnumber),JSON.stringify 能够完整地保留所有字段的值
  3. s.putSync('medicines', ...):将 JSON 字符串以 'medicines' 为键写入 Preferences 的内存缓存
  4. await s.flush():异步将内存缓存的数据持久化到磁盘文件 ivguard_data.xml

JSON.stringify 的行为分析:

当对一个 Medicine 实例数组执行 JSON.stringify 时,输出类似:

[
  {
    "id": "1698765432100abc123",
    "name": "头孢曲松钠",
    "barcode": "6901234567890",
    "dosage": "1g/支",
    "volume": 500,
    "stickerId": "",
    "nfcTagId": "",
    "drugDbId": "drug_001",
    "addedAt": 1698765432100
  }
]

注意,JSON.stringify 只序列化实例的可枚举自有属性,不会序列化类的原型方法(如 Medicine.create)。这意味着反序列化后得到的是纯数据对象,不包含类的行为方法,需要手动重建类实例。

4.3 读取操作详解(数组类型)

数组类型的读取是最复杂的部分,以 loadMedicines 为例:

步骤 1:读取原始 JSON 字符串

const raw = s.getSync('medicines', '[]') as string
  • 使用 getSync 从内存缓存中读取键为 'medicines' 的值
  • 默认值为 '[]'(空数组的 JSON 表示),如果键不存在则返回此默认值
  • as string 类型断言:因为我们总是存储字符串类型,所以可以安全地断言为 string

步骤 2:解析 JSON 为 Object 数组

const arr: Object[] = JSON.parse(raw) as Object[]
  • JSON.parse(raw) 将 JSON 字符串解析为 JavaScript 对象
  • 在 ArkTS 中,JSON.parse 的返回类型是 Object,需要显式断言为 Object[]
  • 此时 arr 中的每个元素都是 Object 类型,无法直接访问其属性

步骤 3:遍历并逐元素反序列化

const result: Medicine[] = []
for (let i = 0; i < arr.length; i++) {
  const item = arr[i] as Record<string, Object>
  const m = new Medicine()
  m.id = (item['id'] as string) ?? ''
  m.name = (item['name'] as string) ?? ''
  // ... 其他字段
  result.push(m)
}
return result

每个元素的反序列化包含以下关键步骤:

  1. 转为 Record<string, Object>arr[i] as Record<string, Object>Object 类型的元素转为可索引的 Record 类型,使得可以通过 item['fieldName'] 访问属性
  2. 创建类实例new Medicine() 通过构造函数创建一个所有字段为默认值的类实例
  3. 逐字段赋值:通过 item['field'] as Type 访问并断言字段类型,然后赋值给类实例
  4. 空值保护?? defaultValue 使用空值合并运算符,当字段不存在或为 undefined/null 时提供合理的默认值

4.4 读取操作详解(单条记录类型)

单条记录类型(如 InsurancePolicyAppSettings)的读取模式略有不同:

static loadInsurancePolicy(): InsurancePolicy {
  const s = DataStore.getStore()
  const raw = s.getSync('insurancePolicy', '') as string
  if (raw.length === 0) {
    return new InsurancePolicy()
  }
  const item = JSON.parse(raw) as Record<string, Object>
  const p = new InsurancePolicy()
  p.insuranceType = (item['insuranceType'] as string) ?? ''
  // ... 其他字段
  return p
}

关键差异:

  1. 默认值为空字符串 '':而非 '[]',因为单条记录不是数组
  2. 空值判断if (raw.length === 0) 检查字符串长度是否为零,如果为空则返回一个所有字段为默认值的新实例
  3. 直接解析为 Record<string, Object>:无需数组遍历,因为只有一条记录
  4. 无需数组操作:没有 Object[] 中间步骤和循环遍历

4.5 为什么不用 Object.assign

在标准 TypeScript 中,可以使用 Object.assign 将反序列化后的对象属性批量复制到类实例:

// 标准TypeScript中的写法(ArkTS中禁止)
const m = new Medicine()
Object.assign(m, arr[i])

但在 ArkTS 严格模式下,Object.assign 被禁止使用,原因如下:

  1. 类型不安全Object.assign 是运行时反射操作,编译器无法在编译期检查属性名和类型的正确性。如果 JSON 中的属性名拼写错误或类型不匹配,编译器不会报错,但运行时会产生难以调试的问题
  2. ArkTS 类型系统限制:ArkTS 要求所有属性访问在编译时可确定,Object.assign 的动态特性违反了这一原则
  3. 原型链污染风险Object.assign 可能意外地复制原型链上的属性,导致不可预期的行为
  4. 性能不可控:反射式属性复制的性能取决于属性数量和类型,无法在编译期优化

IVGuard 的替代方案:手动逐字段赋值,虽然代码冗长,但类型安全、行为确定、性能可控。

4.6 ?? 默认值处理策略

在反序列化过程中,每个字段都使用 ?? 运算符提供默认值:

m.id = (item['id'] as string) ?? ''
m.volume = (item['volume'] as number) ?? 0
m.isActive = (item['isActive'] as boolean) ?? false

?? vs || 的选择:

  • ??(空值合并运算符):仅在值为 nullundefined 时使用默认值
  • ||(逻辑或运算符):在值为 falsy(0''falsenullundefined)时使用默认值

IVGuard 选择 ?? 而非 || 的原因:

  • 数字字段0 是合法的 volume 值,使用 || 会错误地将 0 替换为默认值
  • 布尔字段false 是合法的 isActive 值,使用 || 会错误地将 false 替换为 true
  • 字符串字段:虽然 '' 使用 || 也会触发默认值,但 ?? 的语义更清晰,统一使用 ?? 可以保持代码一致性

默认值的设计原则:

  • string 类型:默认值为 ''(空字符串),而非 nullundefined
  • number 类型:默认值为 0,个别字段如 currentLevel 默认为 100(表示满液位)
  • boolean 类型:根据业务语义选择,如 isActive 默认 falsevoiceAlertEnabled 默认 true

5. 各实体持久化实现详解

5.1 Medicine 数组序列化

数据模型定义(DataModels.ets:1-21):

export class Medicine {
  id: string = ''
  name: string = ''
  barcode: string = ''
  dosage: string = ''
  volume: number = 0
  stickerId: string = ''
  nfcTagId: string = ''
  drugDbId: string = ''
  addedAt: number = 0
}

Medicine 类包含 9 个字段,全部为基础类型(7 个 string + 2 个 number),无嵌套对象,适合直接 JSON 序列化。

saveMedicines 实现(DataStore.ets:22-26):

static async saveMedicines(medicines: Medicine[]): Promise<void> {
  const s = DataStore.getStore()
  s.putSync('medicines', JSON.stringify(medicines))
  await s.flush()
}

写入逻辑简洁明了:将整个 Medicine[] 数组序列化为 JSON 字符串,以 'medicines' 为键存储。

loadMedicines 实现(DataStore.ets:28-48):

static loadMedicines(): Medicine[] {
  const s = DataStore.getStore()
  const raw = s.getSync(
Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐