基于鸿蒙OS开发静脉输液智能监控系统(4)-数据模型层设计与实现
基于鸿蒙OS开发静脉输液智能监控系统(4)-数据模型层设计与实现
目录
1. 数据模型设计原则
IVGuard 项目的数据模型层承载了整个应用的核心业务逻辑数据结构,从药物信息到监控会话,从预警事件到费用计算,所有业务实体的定义均集中于此。本节详细阐述模型层的设计原则,这些原则不仅指导了当前的实现,也为后续模型扩展提供了规范依据。
1.1 所有字段必须初始化默认值
ArkTS 作为 HarmonyOS 的开发语言,继承了 TypeScript 的静态类型系统,同时引入了更严格的编译时检查。在 ArkTS 的严格模式(strict mode)下,类的实例属性必须在声明时提供初始值,或者在构造函数中完成赋值。IVGuard 选择在声明时直接初始化默认值,这一选择基于以下考量:
编译时安全保障:如果不提供默认值,ArkTS 编译器会报出 arkts-no-uninitialized-declarations 错误,拒绝编译通过。这避免了运行时访问 undefined 字段导致的潜在崩溃,尤其在医疗场景下,任何因数据未定义而导致的异常都可能影响患者的安全监控。
反序列化兼容:当从 Preferences 持久化存储中加载数据时,如果某个字段在旧版本中不存在,使用 ?? 空值合并运算符可以优雅地回退到默认值,而默认值与类声明中的初始值保持一致,确保了行为的一致性。
代码自文档化:每个字段的默认值本身就是一种文档。例如 currentLevel: number = 100 清晰地表明液位的初始值是 100%,warningThreshold: number = 20 表明默认预警阈值是 20%,status: string = 'monitoring' 表明会话的初始状态是监控中。
typescript export class Medicine { id: string = '' name: string = '' barcode: string = '' dosage: string = '' volume: number = 0 stickerId: string = '' nfcTagId: string = '' drugDbId: string = '' addedAt: number = 0 }
在上述 Medicine 类中,所有字符串字段默认为空字符串 '',数值字段默认为 0。这种约定贯穿整个数据模型层,形成了一致的默认值策略:
| 字段类型 | 默认值 | 语义含义 |
|---|---|---|
string |
'' |
未设置/空值 |
number |
0 |
零值/未计算 |
boolean |
false |
未激活/未处理 |
| 嵌套对象 | new ClassName() |
全默认值的实例 |
特别注意 boolean 类型的默认值选择。在 MonitorSession 中,isActive: boolean = false 表示新创建的会话默认不活跃,只有通过 static create() 工厂方法创建时才会显式设为 true。在 AlertEvent 中,handled: boolean = false 表示预警事件默认未处理,需要护士手动确认后才变为 true。这些默认值的选择都体现了"最安全初始状态"的原则。
1.2 static工厂方法模式:替代构造器重载
在 TypeScript 中,开发者习惯使用构造器重载(constructor overloading)或可选参数来提供多种对象创建方式。然而,ArkTS 对构造器有严格限制:不支持构造器重载,构造函数的参数列表必须唯一。为了解决这一限制,IVGuard 采用了 static 工厂方法模式(Static Factory Method Pattern)。
核心模式:
typescript static create(name: string, dosage: string, volume: number): Medicine { const m = new Medicine() m.id = Date.now().toString() + Math.random().toString(36).substring(2, 8) m.name = name m.dosage = dosage m.volume = volume m.addedAt = Date.now() return m }
这一模式的工作流程如下:
-
调用无参构造器:
const m = new Medicine()创建一个所有字段为默认值的实例。由于所有字段都有默认值,无参构造器始终可用且安全。 -
生成唯一ID:
m.id = Date.now().toString() + Math.random().toString(36).substring(2, 8)通过时间戳加随机字符串生成全局唯一标识符。这一策略在后文 1.4 节 详细分析。 -
赋值业务字段:仅对调用方必须提供的核心字段进行赋值,其余字段保持默认值,由业务逻辑在适当时机填充。
-
返回完整实例:返回一个已初始化必要字段的类实例。
为什么不使用带参构造器:
ArkTS 虽然允许定义带参数的构造函数,但存在以下问题:
- 不支持构造器重载,只能定义一个构造函数签名
- 带参构造器与默认值策略冲突——如果构造器参数不覆盖所有字段,部分字段仍需默认值
- 反序列化场景需要先创建空实例再逐字段赋值,无参构造器更方便
- 工厂方法命名更具语义——
Medicine.create(name, dosage, volume)比new Medicine(name, dosage, volume)更清晰地表达了创建意图
工厂方法的命名约定:
所有工厂方法统一命名为 create,参数列表仅包含创建该实体时必须由调用方提供的业务数据。自动生成的字段(如 id、addedAt、date、timestamp)和有合理默认值的字段(如 status、isActive、handled)不在参数列表中。
各类的 create 方法参数对比:
| 类名 | create参数 | 自动生成字段 |
|---|---|---|
Medicine |
name, dosage, volume |
id, addedAt |
MonitorSession |
medicineId, patientName |
id, startAt, isActive |
LevelRecord |
sessionId, timestamp, level, flowRate |
无(全部由调用方提供) |
MonitorRecord |
sessionId, medicineName, patientName |
id, startAt |
AlertEvent |
sessionId, patientName, type, level, message |
id, timestamp |
CostItem |
category, name, quantity, unitPrice, isDrug |
id, totalPrice, date |
InsurancePolicy |
全部6个字段 | 无 |
CostSummary |
无参数 | 无(全零值) |
注意 LevelRecord 和 InsurancePolicy 的工厂方法接收所有字段值,这是因为它们的业务场景决定了创建时必须提供完整数据——LevelRecord 是时序数据点,每次记录都需要精确的时间戳和液位值;InsurancePolicy 是医保政策参数,缺失任何字段都意味着计算结果不可靠。而 CostSummary.create() 无参数,因为它是一个纯计算结果容器,初始状态就是全零值,由 CostService.calculateSummary() 填充。
1.3 单一声明原则(arkts-no-decl-merging)
ArkTS 严格执行 arkts-no-decl-merging 规则,禁止同名 interface 和 class 的声明合并(Declaration Merging)。在标准 TypeScript 中,以下代码是合法的:
``typescript
// TypeScript 允许,ArkTS 禁止
interface Medicine {
id: string
name: string
}
class Medicine {
constructor(public id: string, public name: string) {}
}
``
TypeScript 编译器会将 interface 和 class 合并为一个类型,class 的实例同时满足 interface 的契约。但在 ArkTS 中,这种模式被明确禁止,原因在于:
- 声明合并会引入运行时类型不确定性
- 在静态分析和 AOT 编译中增加复杂度
- 违背 ArkTS 追求的类型安全和可预测性
IVGuard 的应对策略非常简洁:只使用 class,不使用同名 interface。所有业务模型都定义为 class,包含字段声明和工厂方法。在需要纯数据契约的场景(如路由参数),使用独立命名的 interface(详见 第5节)。
这一策略的影响是深远的:
-
模型即类型:每个 class 既是运行时构造(可以通过
new创建实例),也是编译时类型(可以作为类型注解使用)。不需要额外定义 interface 来描述数据结构。 -
序列化类型安全:反序列化时,通过
new ClassName()创建实例再逐字段赋值,确保结果始终是类的实例而非普通对象。这避免了as类型断言带来的类型安全隐患。 -
IDE支持完整:class 的字段、方法、static 工厂方法都能获得完整的代码补全和类型检查支持,不需要借助 interface 来提供类型信息。
1.4 ID生成策略:时间戳+随机字符串
IVGuard 的所有需要持久化的实体都使用字符串类型的 ID,生成策略为:
typescript id = Date.now().toString() + Math.random().toString(36).substring(2, 8)
组成分析:
Date.now():返回自 1970-01-01 00:00:00 UTC 以来的毫秒数,如1722659200000.toString():将数值转为字符串,如"1722659200000"Math.random():生成 [0, 1) 区间的伪随机浮点数,如0.3a5b7c9d.toString(36):以36进制(0-9, a-z)编码,如"0.3a5b7c9d"→"0.dl5j3q".substring(2, 8):截取小数点后的6位字符,如"dl5j3q"
最终生成的 ID 形如 "1722659200000dl5j3q",总长度约 19(时间戳部分)+ 6(随机部分)= 25 个字符。
唯一性分析:
- 时间戳部分保证同一毫秒内生成的 ID 前缀相同,不同毫秒的 ID 前缀不同
- 随机部分提供 36^6 = 2,176,782,336 种可能组合
- 在同一毫秒内生成两个相同 ID 的概率为 1/2,176,782,336,约 4.6 × 10⁻¹⁰
- 对于医疗监控应用的并发量(最多几十个同时进行的监控会话),碰撞概率可以忽略不计
为什么不用 UUID:
HarmonyOS 的 @kit.BasicServicesKit 中虽然提供了 UUID 生成能力,但 IVGuard 选择自行实现 ID 生成,原因如下:
- 零依赖:不引入额外的 Kit 依赖,减少模块耦合
- 可读性:时间戳前缀使得 ID 具备时间序特性,便于调试时按时间排序
- 轻量级:仅使用
Date和Math两个内置对象,无需异步调用 - 足够唯一:对于单设备单应用场景,碰撞概率远低于可接受阈值
ID 不作为构造参数的设计理由:
ID 在 create() 工厂方法中自动生成,而不作为参数传入。这一设计确保了:
- 调用方无法传入重复或格式错误的 ID
- ID 生成逻辑集中管理,便于未来替换为其他策略(如服务器分配的 ID)
- 减少调用方的认知负担——创建实体时不需要关心 ID 怎么生成
1.5 字符串常量替代枚举
ArkTS 对 enum 的支持有限制,特别是在 @Sendable 装饰器和跨线程传递场景下,枚举类型可能引发序列化问题。IVGuard 选择使用字符串常量来定义所有分类值,这一决策体现在多个模型中:
typescript export class UserRole { static PATIENT: string = 'patient' static NURSE: string = 'nurse' static FAMILY: string = 'family' }
字符串常量方案的优势:
- 序列化友好:字符串直接 JSON 序列化/反序列化,无需额外转换
- 跨线程安全:字符串是
Sendable兼容类型,可以在 TaskPool 中传递 - 可扩展:新增分类值只需添加一个 static 字段,不影响已有代码
- 调试直观:日志和调试器中直接显示
'patient'而非UserRole.PATIENT (0)
各类别常量汇总:
| 模型 | 字段 | 常量值 |
|---|---|---|
UserRole |
role | patient / nurse / family |
MonitorSession |
status | monitoring / paused / completed |
AlertEvent |
type | level_alert / flow_anomaly / complete |
AlertEvent |
level | info / warning / danger |
DrugInteraction |
severity | danger / warning / info |
IVDrugInfo |
insuranceCategory | class_a / class_b / self_pay |
InsurancePolicy |
insuranceType | urban_worker / urban_resident / rural |
InsurancePolicy |
hospitalLevel | level3 / level2 / level1 |
CostItem |
category | drug / other |
AppSettings |
sensitivity | low / medium / high |
HospitalPOI |
type | nurse_station / infusion_room / emergency / pharmacy / ward |
这种"字符串常量分类"模式在 IVGuard 中被一致应用,任何需要分类标记的场景都使用小写字母加下划线的命名风格(snake_case),确保了全局一致性。
2. 核心业务模型详解
DataModels.ets 是 IVGuard 项目中最核心的模型文件,共定义了 15 个类(含 NavPathData),覆盖了从药物信息到医院导航的全部业务实体。本节逐一分析每个业务模型的设计动机、字段语义、生命周期和典型使用场景。
2.1 Medicine — 药物信息
Medicine 是 IVGuard 中最基础的业务实体,代表一次输液治疗中所使用的药物。它不仅存储药物的通用信息,更重要的是通过多标识绑定机制,将物理世界的药物与数字化监控系统关联起来。
字段详解:
typescript export class Medicine { id: string = '' // 内部唯一标识 name: string = '' // 药物通用名称,如"头孢呋辛钠" barcode: string = '' // 商品条码/药品追溯码 dosage: string = '' // 用法用量,如"2-3支/日" volume: number = 0 // 容量(毫升),如250ml stickerId: string = '' // 贴纸标识(视觉识别码) nfcTagId: string = '' // NFC标签标识 drugDbId: string = '' // 药物数据库ID(关联IVDrugInfo) addedAt: number = 0 // 添加时间戳 }
多标识绑定设计:
Medicine 最独特的设计是四个标识字段的并存:barcode、stickerId、nfcTagId、drugDbId。这四个标识分别服务于不同的识别场景:
-
barcode(条码):用于扫码添加药物。护士使用设备摄像头扫描药品包装上的条码或二维码,系统通过 barcode 匹配已录入的药物记录。这是最传统的药品识别方式,兼容现有医院信息系统。
-
stickerId(贴纸标识):用于视觉识别。IVGuard 的 VisionService 通过摄像头识别贴在输液袋上的专用贴纸,每个贴纸有唯一的视觉图案标识。当摄像头捕捉到贴纸图像时,系统通过 stickerId 关联到对应的 Medicine 实例,实现非接触式的药物识别。
-
nfcTagId(NFC标签标识):用于近场通信识别。护士可以将 NFC 标签贴在输液架上,设备靠近时自动读取 nfcTagId 并关联药物。这种方式适合需要快速、可靠识别的场景,NFC 通信不受光照条件影响。
-
drugDbId(药物数据库ID):用于关联
IVDrugDatabase中的药物信息。drugDbId 指向IVDrugInfo的 id 字段,通过这一关联,Medicine 实例可以获取药物的参考价格、医保分类、规格等详细信息,从而支持费用计算和医保报销。
这种多标识绑定的设计使得 IVGuard 能够适应不同医院、不同场景下的药物识别需求。一个 Medicine 实例可能只绑定了部分标识(例如只有 barcode 没有 nfcTagId),系统根据可用的标识进行匹配。
工厂方法:
typescript static create(name: string, dosage: string, volume: number): Medicine { const m = new Medicine() m.id = Date.now().toString() + Math.random().toString(36).substring(2, 8) m.name = name m.dosage = dosage m.volume = volume m.addedAt = Date.now() return m }
create() 方法要求提供药物名称、用法用量和容量三个核心参数,自动生成 id 和 addedAt。四个标识字段(barcode、stickerId、nfcTagId、drugDbId)不在参数列表中,因为它们不是创建药物的必要条件,而是在后续的扫码、贴纸识别、NFC读取等流程中逐步绑定的。
典型使用流程:
- 护士在药物管理页面点击"添加药物"
- 调用
Medicine.create('头孢呋辛钠', '2-3支/日', 250)创建实例 - 护士扫描药品条码,设置
m.barcode = '6901234567890' - 护士将贴纸贴在输液袋上,视觉识别后设置
m.stickerId = 'STK-001' - 护士将NFC标签贴近输液架,设置
m.nfcTagId = 'NFC-A3F2' - 从药物数据库搜索匹配,设置
m.drugDbId = '1' - 调用
DataStore.saveMedicines()持久化
2.2 MonitorSession — 监控会话
MonitorSession 是 IVGuard 最核心的运行时实体,代表一次输液监控的完整会话。它关联了药物、患者和监控参数,是所有监控功能的中心节点。
字段详解:
typescript export class MonitorSession { id: string = '' // 会话唯一标识 medicineId: string = '' // 关联的Medicine.id patientName: string = '' // 患者姓名 bedNumber: string = '' // 床位号,如"12-03" startAt: number = 0 // 会话开始时间戳 currentLevel: number = 100 // 当前液位百分比(0-100) status: string = 'monitoring' // 会话状态 warningThreshold: number = 20 // 预警阈值百分比 isActive: boolean = false // 是否正在监控 }
生命周期状态机:
MonitorSession 的 status 字段描述了会话的生命周期状态,状态转换如下:
创建 ──→ monitoring ──→ paused ──→ monitoring(恢复) │ │ └────→ completed ←──────┘
- monitoring:监控中,VisionService 定时检测液位,AlertService 监控预警条件
- paused:暂停监控,护士可手动暂停(如更换输液袋时),计时器停止
- completed:监控完成,液位降至0或护士手动结束
isActive 是 status 的运行时辅助字段。当 status === 'monitoring' 时,isActive 为 true;其他状态时为 false。虽然存在信息冗余,但 isActive 作为布尔值在 UI 绑定中更方便——直接用于条件渲染和按钮状态切换,无需字符串比较。
预警阈值机制:
warningThreshold 默认值为 20,表示当液位降至 20% 以下时触发预警。这个阈值可以在 AppSettings 中全局设置,也可以在创建会话时指定。预警逻辑在 MonitorPage.checkAlert() 中实现:
typescript private checkAlert(): void { const settings = DataStore.loadSettings() if (this.currentLevel <= settings.warningThreshold && !this.alertTriggered) { this.alertTriggered = true NotificationService.sendLevelAlert(this.patientName, this.currentLevel) if (settings.voiceAlertEnabled) { SpeechService.playLowLevelAlert() } } if (this.currentLevel <= 0) { this.stopMonitoring() NotificationService.sendAlert('输液完成', ${this.patientName}的输液已完成,请通知护士) } }
注意预警只触发一次(alertTriggered 标志位防止重复预警),而输液完成通知在液位降至0时触发。
工厂方法:
typescript static create(medicineId: string, patientName: string): MonitorSession { const s = new MonitorSession() s.id = Date.now().toString() + Math.random().toString(36).substring(2, 8) s.medicineId = medicineId s.patientName = patientName s.startAt = Date.now() s.isActive = true return s }
创建会话时只需提供药物ID和患者姓名。bedNumber 可以在创建后补充设置,currentLevel 默认为 100(满液位),status 默认为 monitoring,warningThreshold 默认为 20。
与 MonitorPage 的交互:
MonitorPage 是 MonitorSession 的主要消费者。在页面 aboutToAppear() 生命周期中,从 DataStore 加载会话列表,找到活跃会话并展示其关联的药物名称和患者信息。3秒定时器周期性更新液位和流速,根据阈值触发预警。
2.3 LevelRecord — 液位时序数据
LevelRecord 是 IVGuard 中最精简的业务模型,仅包含 4 个字段,但承载了输液监控的核心时序数据。
字段详解:
typescript export class LevelRecord { sessionId: string = '' // 关联的MonitorSession.id timestamp: number = 0 // 记录时间戳 level: number = 0 // 液位百分比(0-100) flowRate: number = 0 // 流速(ml/min) }
时序数据的业务价值:
LevelRecord 的核心价值在于提供液位变化的时序数据,支撑以下业务场景:

-
流速趋势分析:通过相邻 LevelRecord 的 level 差值和时间差,可以计算出实时流速的变化趋势。如果流速突然增大或减小,可能意味着输液管路异常(如堵塞、泄漏),系统应及时预警。
-
异常检测:正常输液过程中,流速应在一定范围内波动。如果某段时序数据显示流速异常(如突然归零可能表示管路堵塞,突然激增可能表示管路脱落),系统应生成
AlertEvent。 -
输液完成预估:基于历史 LevelRecord 数据,可以建立液位下降的数学模型,预估输液完成时间。这比简单的线性外推更准确,特别是在流速随时间变化的情况下。
-
历史回放:监控完成后,LevelRecord 数据可以用于历史回放,让护士或医生回顾整个输液过程的液位变化和流速波动。
为什么没有 id 字段:
LevelRecord 是唯一没有 id 字段的业务模型。这是因为:
- LevelRecord 的唯一性由
(sessionId, timestamp)组合确定——同一个会话在同一时间点只有一条记录 - LevelRecord 不需要独立持久化和检索,总是作为某个会话的附属数据批量存取
- 省略 id 减少了存储空间和序列化开销,在频繁写入的时序数据场景下尤为重要
工厂方法:
typescript static create(sessionId: string, timestamp: number, level: number, flowRate: number): LevelRecord { const r = new LevelRecord() r.sessionId = sessionId r.timestamp = timestamp r.level = level r.flowRate = flowRate return r }
与其他模型不同,LevelRecord 的工厂方法没有生成 ID,所有四个字段都由调用方提供。这反映了时序数据的特性——每条记录都是被动的观测值,不需要唯一标识。
2.4 MonitorRecord — 监控完成记录
MonitorRecord 是监控会话完成后的归档记录,它浓缩了整个监控过程的关键统计信息,用于历史查询和数据分析。
字段详解:
typescript export class MonitorRecord { id: string = '' // 记录唯一标识 sessionId: string = '' // 关联的MonitorSession.id medicineName: string = '' // 药物名称(冗余存储) patientName: string = '' // 患者姓名(冗余存储) bedNumber: string = '' // 床位号(冗余存储) startAt: number = 0 // 监控开始时间 endAt: number = 0 // 监控结束时间 finalLevel: number = 0 // 最终液位 avgFlowRate: number = 0 // 平均流速(ml/min) anomalies: number = 0 // 异常事件次数 status: string = 'completed' // 完成状态 }
冗余字段的设计考量:
MonitorRecord 中 medicineName、patientName、bedNumber 是冗余字段——它们可以通过 sessionId 关联到 MonitorSession 再关联到 Medicine 获取。但 IVGuard 选择冗余存储,原因如下:
-
查询性能:历史记录页面(HistoryPage)需要展示药物名称和患者信息,如果每次都通过关联查询获取,在数据量增大时性能会下降。冗余存储使得单表查询即可获取所有展示信息。
-
数据快照:患者可能转床,药物信息可能修改。MonitorRecord 记录的是监控时的状态,如果通过关联查询,可能获取到修改后的信息,而非监控时的实际情况。
-
离线可用:即使关联的 Medicine 或 MonitorSession 被删除,MonitorRecord 仍能独立展示完整的监控摘要。
统计信息字段:
-
avgFlowRate:平均流速,通过对 LevelRecord 序列计算得出。计算方法为所有 flowRate 值的算术平均值。这一指标反映了输液的整体速度,可用于与标准流速范围进行对比。 -
anomalies:异常事件次数,统计该会话期间触发的AlertEvent中type === 'flow_anomaly'的数量。异常次数越多,说明输液过程越不稳定,需要重点关注。 -
finalLevel:最终液位,正常完成时应为 0(输液完全结束),非零值可能表示提前终止。
与 MonitorSession 的关系:
MonitorRecord 和 MonitorSession 是一对一关系,但生命周期不同:
- MonitorSession 在监控期间存在,是运行时活跃对象
- MonitorRecord 在监控完成后创建,是历史归档对象
当护士点击"结束监控"时,系统执行以下流程:
- 从 LevelRecord 序列计算 avgFlowRate
- 统计 anomalies 数量
- 设置 endAt 和 finalLevel
- 创建 MonitorRecord 实例并持久化
- 将 MonitorSession 的 status 设为
completed
2.5 DrugInteraction — 药物相互作用
DrugInteraction 表示两种药物之间的相互作用关系,是 IVGuard 用药安全功能的核心数据模型。
字段详解:
typescript export class DrugInteraction { drugA: string = '' // 药物A名称 drugB: string = '' // 药物B名称 severity: string = 'warning' // 严重程度 description: string = '' // 相互作用描述 }
严重程度三级分类:
| 级别 | 语义 | UI呈现 | 典型示例 |
|---|---|---|---|
danger |
危险,禁止合用 | 红色警告 | 头孢类与氨基糖苷类合用增加肾毒性 |
warning |
警告,需谨慎 | 橙色提示 | 青霉素与氨基糖苷类需分开给药 |
info |
信息,需注意 | 蓝色通知 | 氨溴索与抗生素有协同作用 |
检测算法:
DrugInteraction 的检测逻辑在 DrugDatabase 类中实现:
typescript static checkInteractions(drugNames: string[]): DrugInteraction[] { DrugDatabase.init() const results: DrugInteraction[] = [] for (let i = 0; i < drugNames.length; i++) { for (let j = i + 1; j < drugNames.length; j++) { const nameA = drugNames[i] const nameB = drugNames[j] for (let k = 0; k < DrugDatabase.interactions.length; k++) { const inter = DrugDatabase.interactions[k] if ((inter.drugA === nameA && inter.drugB === nameB) || (inter.drugA === nameB && inter.drugB === nameA)) { results.push(inter) } } } } return results }
该算法的时间复杂度为 O(n² × m),其中 n 为输入药物数量,m 为相互作用数据库大小。对于典型的输液场景(2-5种同时使用的药物),性能完全满足要求。
算法的关键细节:内外循环使用 j = i + 1 起始,避免了重复比较(drugA+drugB 与 drugB+drugA 是同一对),同时内层条件 (inter.drugA === nameA && inter.drugB === nameB) || (inter.drugA === nameB && inter.drugB === nameA) 确保了数据库中无论 A-B 还是 B-A 的记录都能被匹配到。
为什么 DrugInteraction 没有 id 字段:
DrugInteraction 是只读的参考数据,不涉及持久化存储和独立检索。它总是作为 DrugDatabase 的内部数据批量加载和查询,不需要唯一标识。其唯一性由 (drugA, drugB) 组合隐式确定。
数据库内容:
DrugDatabase 内置了 12 条药物相互作用记录,覆盖了临床最常见的静脉注射药物配伍禁忌:
| 药物A | 药物B | 严重程度 | 描述 |
|---|---|---|---|
| 头孢呋辛钠 | 庆大霉素 | danger | 头孢类与氨基糖苷类合用增加肾毒性 |
| 头孢曲松钠 | 钙剂 | danger | 头孢曲松与含钙输液配伍可形成沉淀 |
| 青霉素钠 | 庆大霉素 | warning | 青霉素与氨基糖苷类有协同作用但需分开给药 |
| 左氧氟沙星 | NSAIDs | warning | 喹诺酮类与非甾体抗炎药合用增加癫痫风险 |
| 奥美拉唑 | 氯吡格雷 | warning | 质子泵抑制剂可能降低氯吡格雷抗血小板效果 |
| 氨溴索 | 抗生素 | info | 氨溴索与抗生素有协同作用,促进抗生素向肺组织渗透 |
| 地塞米松 | 胰岛素 | warning | 糖皮质激素可升高血糖,拮抗胰岛素作用 |
| 氯化钾 | 螺内酯 | danger | 保钾利尿剂与钾盐合用可致高钾血症 |
| 阿莫西林 | 甲氨蝶呤 | danger | 青霉素类可减少甲氨蝶呤清除,增加毒性 |
| 万古霉素 | 庆大霉素 | danger | 两者合用增加肾毒性和耳毒性 |
| 维生素C | 维生素B12 | info | 大剂量维生素C可破坏维生素B12 |
| 葡萄糖酸钙 | 地高辛 | danger | 钙剂可增强洋地黄类心脏毒性 |
这些数据来源于临床药学权威参考,覆盖了抗菌药物、心血管药物、电解质等常见静脉注射药物的配伍禁忌。
2.6 AlertEvent — 预警事件
AlertEvent 代表输液监控过程中产生的预警事件,是 IVGuard 主动安全机制的核心输出。
字段详解:
typescript export class AlertEvent { id: string = '' // 事件唯一标识 sessionId: string = '' // 关联的MonitorSession.id patientName: string = '' // 患者姓名(冗余存储) type: string = '' // 预警类型 level: string = 'info' // 预警级别 message: string = '' // 预警消息 timestamp: number = 0 // 事件时间戳 handled: boolean = false // 是否已处理 }
预警类型分类:
| 类型 | 语义 | 触发条件 | 典型消息 |
|---|---|---|---|
level_alert |
液位预警 | 液位 ≤ warningThreshold | “张三的输液液位已降至20%以下” |
flow_anomaly |
流速异常 | 流速突变超过阈值 | “检测到流速异常波动” |
complete |
输液完成 | 液位降至0 | “张三的输液已完成,请通知护士” |
预警级别与类型的区别:
type 和 level 是两个独立维度:
type描述预警的原因(液位低、流速异常、输液完成)level描述预警的紧急程度(info、warning、danger)
例如,液位预警(level_alert)的 level 可能是 warning(液位接近阈值)或 danger(液位极低);输液完成(complete)的 level 通常是 info(正常完成)或 warning(异常完成)。这种双维度分类使得 UI 可以按原因筛选预警,同时按紧急程度排序显示。
处理状态追踪:
handled 字段追踪预警的处理状态。初始值为 false,表示未处理。护士确认预警后,将其设为 true。这一机制确保:
- 未处理的预警在 UI 中持续高亮显示,不会被遗漏
- 护士可以筛选未处理的预警,优先关注
- 管理者可以统计预警响应时间(从 timestamp 到 handled 设为 true 的时间差)
通知渠道:
AlertEvent 生成后,通过多个渠道通知相关人员:
NotificationService:发送系统通知SpeechService:语音播报(可在 AppSettings 中关闭)- 振动提醒(可在 AppSettings 中关闭)
- 手表联动通知(如果 watchLinkEnabled 开启)
2.7 HospitalPOI — 医院兴趣点
HospitalPOI 代表医院内的一个兴趣点(Point of Interest),用于医院室内导航功能。
字段详解:
typescript export class HospitalPOI { id: string = '' // POI唯一标识,如"poi_nurse_1f" name: string = '' // POI名称,如"护士站1楼" type: string = '' // POI类型 floor: number = 1 // 所在楼层 x: number = 0 // X坐标(像素单位) y: number = 0 // Y坐标(像素单位) description: string = '' // 描述信息 }
POI类型分类:
| 类型 | 语义 | 导航场景 |
|---|---|---|
nurse_station |
护士站 | 输液异常时导航到护士站求助 |
infusion_room |
输液室 | 导航到输液室开始输液 |
emergency |
急诊室 | 紧急情况导航 |
pharmacy |
药房 | 取药导航 |
ward |
病房 | 住院患者导航回病房 |
坐标系设计:
HospitalPOI 使用 2D 平面直角坐标系,原点 (0, 0) 在楼层平面图的左上角,X 轴向右延伸,Y 轴向下延伸。坐标单位为像素,与 HospitalMapView 组件的画布尺寸对应。当前楼层平面图尺寸为 400×400 像素(由 HospitalData.getFloorWidth() 和 HospitalData.getFloorHeight() 定义)。
这种坐标系设计使得 POI 的位置可以直接映射到 UI 组件上,无需额外的坐标变换。例如,HospitalMapView 渲染楼层平面图时,直接使用 POI 的 (x, y) 坐标在画布上绘制标记点。
楼层与3D导航:
floor 字段支持多楼层导航。当前医院数据包含 3 层楼(由 HospitalData.getFloorCount() 返回),POI 按楼层分组展示。跨楼层导航时,系统先在同一楼层计算路径到电梯/楼梯,然后切换楼层继续导航。
导航路径计算:
NavService 使用 HospitalPOI 进行路径计算:
typescript static findNearestPOI(userX: number, userY: number, floor: number, type: string): HospitalPOI | undefined { const pois = HospitalData.getPOIs() let nearest: HospitalPOI | undefined = undefined let minDist = Number.MAX_VALUE for (let i = 0; i < pois.length; i++) { const poi = pois[i] if (poi.floor !== floor || poi.type !== type) { continue } const dx = userX - poi.x const dy = userY - poi.y const dist = Math.sqrt(dx * dx + dy * dy) if (dist < minDist) { minDist = dist nearest = poi } } return nearest }
findNearestPOI() 在指定楼层内查找指定类型的最近 POI,使用欧几里得距离度量。步行时间估算基于 80 像素/分钟的步行速度假设。
预置POI数据:
HospitalData 内置了 8 个 POI,覆盖了 3 层楼的主要功能区域:
| ID | 名称 | 类型 | 楼层 | 坐标 |
|---|---|---|---|---|
| poi_nurse_1f | 护士站1楼 | nurse_station | 1 | (200, 150) |
| poi_nurse_2f | 护士站2楼 | nurse_station | 2 | (200, 150) |
| poi_infusion_1f | 输液室A | infusion_room | 1 | (100, 300) |
| poi_infusion_1f_b | 输液室B | infusion_room | 1 | (300, 300) |
| poi_emergency | 急诊室 | emergency | 1 | (50, 50) |
| poi_pharmacy | 药房 | pharmacy | 1 | (350, 100) |
| poi_ward_2f | 住院部2楼 | ward | 2 | (100, 200) |
| poi_ward_3f | 住院部3楼 | ward | 3 | (100, 200) |
从坐标分布可以看出,1楼是医院的核心服务层,包含护士站、输液室、急诊室和药房;2楼和3楼以住院部为主,各有一个护士站提供就近服务。
2.8 NavPathData — 导航路径数据
NavPathData 是一个辅助模型,用于封装页面导航时的路由名称和参数数据。
``typescript
export class NavPathData {
name: string = ‘’
data: Object = new Object()
static create(name: string, data: Object): NavPathData {
const n = new NavPathData()
n.name = name
n.data = data
return n
}
}
``
name 字段存储目标页面的路由名称,data 字段存储传递给目标页面的参数对象。由于 ArkTS 的类型系统限制,data 的类型为 Object(基础类型),实际使用时需要调用方自行确保类型安全。
NavPathData 在 IVGuard 中主要用于封装 Navigation 组件的路径信息,使得页面跳转逻辑更加结构化和类型安全。
3. 医疗费用模型
IVGuard 的费用模型是整个应用中字段最多、逻辑最复杂的模型集群,涉及药物数据库、费用项、医保政策和费用汇总四个核心模型。它们协同工作,实现了从药物信息到医保报销计算的完整费用管理流程。
3.1 IVDrugInfo — 静脉注射药物数据库条目
IVDrugInfo 代表静脉注射药物数据库中的一个条目,提供了药物的通用名、商品名、规格、剂型、参考价格、医保分类和日用剂量等关键信息。
字段详解:
typescript export class IVDrugInfo { id: string = '' // 数据库条目ID genericName: string = '' // 通用名称,如"头孢呋辛钠" brandName: string = '' // 商品名称,如"西力欣" specification: string = '' // 规格,如"0.75g/支" dosageForm: string = '' // 剂型,如"粉针"、"水针"、"大输液" referencePrice: number = 0 // 参考价格(元) insuranceCategory: string = '' // 医保分类 dailyDosage: string = '' // 日用剂量,如"2-3支/日" }
通用名与商品名:
genericName 和 brandName 的区分在医药领域至关重要。通用名是药物的国际非专利名称(INN),唯一标识一种活性成分;商品名是制药企业为其产品注册的品牌名称,同一种通用名药物可能有多个商品名。例如:
- 通用名"头孢呋辛钠" → 商品名"西力欣"(GSK)
- 通用名"奥美拉唑" → 商品名"洛赛克"(AstraZeneca)
- 通用名"万古霉素" → 商品名"稳可信"(Lilly)
在 IVDrugDatabase 中,部分药物的 brandName 为空字符串,表示该药物没有特定商品名或使用了通用名销售。
剂型分类:
dosageForm 字段将静脉注射药物按剂型分为四类:
| 剂型 | 语义 | 使用方式 | 价格特征 |
|---|---|---|---|
| 粉针 | 冻干粉针剂 | 需用溶媒溶解后注射 | 通常较高 |
| 水针 | 水溶液注射剂 | 直接注射或加入输液 | 通常较低 |
| 大输液 | 大容量注射液 | 直接静脉滴注 | 通常较低 |
| 预充针 | 预充式注射器 | 直接注射,无需配药 | 通常较高 |
| 片剂 | 口服片剂 | 口服(IVGuard仅少量收录) | 通常最低 |
医保分类体系:
insuranceCategory 字段使用中国医保的三级分类:
| 分类 | 值 | 含义 | 报销比例 |
|---|---|---|---|
| 甲类 | class_a |
临床必需、使用广泛、疗效确切 | 全额纳入报销基数 |
| 乙类 | class_b |
可供临床治疗选择使用 | 部分纳入报销基数(需先自付一定比例) |
| 自费 | self_pay |
非医保目录内药品 | 全部自费 |
甲类药品的参考价格通常较低(如青霉素钠 2.8元/支、氯化钾 5.0元/支),乙类药品价格较高(如万古霉素 128元/支、人血白蛋白 380元/支)。
50种药物数据库:
IVDrugDatabase.ets 内置了 50 种常见的静脉注射药物数据,覆盖了以下临床类别:
- 抗菌药物(1-14号):头孢类、青霉素类、喹诺酮类、氨基糖苷类、糖肽类、碳青霉烯类
- 消化系统(6号):奥美拉唑
- 祛痰药物(7号):氨溴索
- 糖皮质激素(8号、42号):地塞米松、甲泼尼龙
- 电解质(9-10号):氯化钾、葡萄糖酸钙
- 维生素(11-12号):维生素C、维生素B6
- 降糖药物(16号):胰岛素
- 抗真菌(20-21号):氟康唑
- 抗病毒(22-24号):阿昔洛韦、更昔洛韦、利巴韦林
- 基础输液(25-29号):葡萄糖、氯化钠、乳酸林格液、右旋糖酐40
- 肠外营养(30-32号):脂肪乳、复方氨基酸、人血白蛋白
- 心血管药物(34-39号):多巴胺、去甲肾上腺素、地高辛、硝普钠、硝酸甘油、氨茶碱
- 呼吸系统(40-41号):布地奈德、异丙托溴铵
- 抗肿瘤(43-46号):环磷酰胺、顺铂、紫杉醇、奥沙利铂
- 抗凝药物(47-48号):低分子肝素钠、那屈肝素钙
- 中成药(49-50号):七叶皂苷钠、丹参酮IIA磺酸钠、参麦注射液
价格区间分析:
50种药物的参考价格分布如下:
| 价格区间 | 药物数量 | 典型药物 |
|---|---|---|
| 0-5元 | 12种 | 青霉素钠(2.8)、庆大霉素(1.8)、维生素B6(2.5)、呋塞米(2.0) |
| 5-30元 | 16种 | 氯化钾(5.0)、甲硝唑(8.0)、氨茶碱(4.2)、环磷酰胺(35.0) |
| 30-100元 | 13种 | 头孢呋辛钠(28.5)、奥美拉唑(68.0)、低分子肝素钠(46.0) |
| 100-500元 | 8种 | 万古霉素(128.0)、人血白蛋白(380.0)、紫杉醇(298.0) |
| 500元以上 | 1种 | 奥沙利铂(520.0) |
搜索功能:
IVDrugDatabase 提供了按通用名和商品名的模糊搜索功能:
typescript static search(keyword: string): IVDrugInfo[] { IVDrugDatabase.init() if (keyword.length === 0) { return IVDrugDatabase.drugs } const lower = keyword.toLowerCase() const results: IVDrugInfo[] = [] for (let i = 0; i < IVDrugDatabase.drugs.length; i++) { const d = IVDrugDatabase.drugs[i] if (d.genericName.toLowerCase().indexOf(lower) >= 0 || d.brandName.toLowerCase().indexOf(lower) >= 0) { results.push(d) } } return results }
搜索使用大小写不敏感的子串匹配(indexOf),同时搜索通用名和商品名。空关键词返回全部药物列表。这一搜索逻辑简单高效,适合药物数量较少(50种)的场景。如果未来药物数据库规模增大,可以考虑引入索引或Trie树优化。
3.2 CostItem — 费用项
CostItem 代表一次输液治疗中的一个费用项,可以是药品费用或非药品费用(如输液器、输液费等)。
字段详解:
typescript export class CostItem { id: string = '' // 费用项唯一标识 category: string = '' // 分类:drug/other name: string = '' // 费用名称 quantity: number = 1 // 数量 unitPrice: number = 0 // 单价(元) totalPrice: number = 0 // 总价(元),自动计算 isDrug: boolean = false // 是否为药品 drugId: string = '' // 关联的IVDrugInfo.id date: string = '' // 日期,格式"YYYY-MM-DD" }
分类体系:
category 字段将费用项分为两类:
drug:药品费用,与IVDrugInfo关联,参与医保报销计算other:非药品费用(如输液器费、输液操作费、床位费等),按不同比例报销
isDrug 和 category 存在逻辑关联:当 category === 'drug' 时,isDrug 应为 true。之所以同时存在两个字段,是因为 isDrug 在 CostService.calculateSummary() 中作为快速判断条件使用,避免了字符串比较的性能开销。
自动计算机制:
totalPrice 在 create() 工厂方法中自动计算:
typescript c.totalPrice = quantity * unitPrice
这确保了总价与数量和单价的一致性。调用方无需手动计算总价,也不可能出现数量×单价≠总价的错误。注意,如果后续修改了 quantity 或 unitPrice,需要同步更新 totalPrice,这是当前设计的一个局限——未来可以考虑使用 getter 方法替代存储字段。
日期自动生成:
typescript const now = new Date() c.date = ${now.getFullYear()}--
日期在创建时自动生成为 YYYY-MM-DD 格式的字符串。注意 getMonth() 返回 0-11,需要 +1;使用 padStart(2, '0') 确保月份和日期始终为两位数。这一设计确保了费用项的日期始终是创建当天的日期,避免了人工输入错误。
与 IVDrugInfo 的关联:
当 isDrug === true 时,drugId 指向 IVDrugInfo 的 id 字段。通过这一关联,CostService 可以查询药物的医保分类(class_a/class_b/self_pay),从而计算可报销金额。
3.3 InsurancePolicy — 医保政策参数
InsurancePolicy 封装了中国医保报销的关键参数,是费用计算的核心输入。
字段详解:
typescript export class InsurancePolicy { insuranceType: string = '' // 医保类型 hospitalLevel: string = '' // 医院等级 deductible: number = 0 // 起付线(元) classARate: number = 0 // 甲类报销比例(%) classBRate: number = 0 // 乙类报销比例(%) ceiling: number = 0 // 封顶线(元) }
医保类型分类:
| 类型 | 值 | 参保人群 | 报销特点 |
|---|---|---|---|
| 城镇职工 | urban_worker |
城镇在职/退休职工 | 报销比例高、封顶线高 |
| 城镇居民 | urban_resident |
城镇非从业居民 | 报销比例中等、封顶线中等 |
| 新农合 | rural |
农村居民 | 报销比例较低、封顶线较低 |
医院等级分类:
| 等级 | 值 | 报销影响 |
|---|---|---|
| 三级 | level3 |
起付线最高、报销比例最低 |
| 二级 | level2 |
起付线中等、报销比例中等 |
| 一级 | level1 |
起付线最低、报销比例最高 |
这一分类体现了中国医保"分级诊疗"的政策导向——在低等级医院就诊报销比例更高,鼓励患者优先在基层医疗机构就医。
参数来源:
InsurancePolicy 的参数由 InsuranceReference 根据城市和医保类型生成:
typescript static buildPolicy(ref: InsuranceReferenceData, insuranceType: string, hospitalLevel: string): InsurancePolicy { const policy = new InsurancePolicy() policy.insuranceType = insuranceType policy.hospitalLevel = hospitalLevel if (insuranceType === 'urban_worker') { policy.deductible = hospitalLevel === 'level3' ? ref.workerDeductible3 : ref.workerDeductible2 policy.classARate = ref.workerClassARate3 policy.classBRate = ref.workerClassBRate3 policy.ceiling = ref.workerCeiling } else { policy.deductible = hospitalLevel === 'level3' ? ref.residentDeductible3 : ref.residentDeductible2 policy.classARate = ref.residentClassARate3 policy.classBRate = ref.residentClassBRate3 policy.ceiling = ref.residentCeiling } return policy }
目前 InsuranceReference 内置了 6 个城市的数据:北京、上海、广州、杭州、成都、武汉。每个城市提供了城镇职工和城镇居民两类医保的三级和二级医院参数。
典型参数示例(北京城镇职工,三级医院):
| 参数 | 值 | 说明 |
|---|---|---|
| deductible | 1300元 | 门诊/住院起付线 |
| classARate | 85% | 甲类药品报销比例 |
| classBRate | 80% | 乙类药品报销比例 |
| ceiling | 500,000元 | 年度报销封顶线 |
六城市参数对比:
| 城市 | 职工起付线(三级) | 职工甲类比例 | 职工封顶线 | 居民起付线(三级) | 居民甲类比例 | 居民封顶线 |
|---|---|---|---|---|---|---|
| 北京 | 1300 | 85% | 50万 | 1300 | 75% | 25万 |
| 上海 | 1500 | 85% | 55万 | 1500 | 70% | 25万 |
| 广州 | 1600 | 80% | 60万 | 1600 | 70% | 25万 |
| 杭州 | 1500 | 82% | 50万 | 1500 | 70% | 25万 |
| 成都 | 1200 | 85% | 40万 | 1200 | 65% | 20万 |
| 武汉 | 1200 | 85% | 40万 | 1200 | 65% | 20万 |
3.4 CostSummary — 费用汇总计算结果
CostSummary 是费用计算的输出模型,包含所有费用维度的汇总值。它是一个纯计算结果容器,不持久化存储。
字段详解:
typescript export class CostSummary { totalCost: number = 0 // 总费用 reimbursable: number = 0 // 可报销金额 selfPay: number = 0 // 自付金额 drugCost: number = 0 // 药品费用小计 otherCost: number = 0 // 非药品费用小计 classADrugCost: number = 0 // 甲类药品费用 classBDrugCost: number = 0 // 乙类药品费用 selfPayDrugCost: number = 0 // 自费药品费用 }
计算逻辑(在 CostService.calculateSummary 中实现):
- 费用分类汇总:遍历所有 CostItem,将药品费用和非药品费用分别累加到
drugCost和otherCost - 药品医保分类:通过 drugId 查询 IVDrugInfo 的 insuranceCategory,分别累加到
classADrugCost、classBDrugCost、selfPayDrugCost - 报销金额计算:
- 甲类药品可报销 = classADrugCost × classARate / 100
- 乙类药品可报销 = classBDrugCost × classBRate / 100
- 非药品可报销 = otherCost × otherRate / 100(otherRate 根据医保类型和医院等级确定)
- 起付线扣除:总可报销金额 - deductible,不低于0
- 封顶线限制:总可报销金额不超过 ceiling
- 自付金额计算:selfPay = totalCost - reimbursable
非药品报销比例:
otherRate 由 CostService.getOtherReimbursableRate() 根据医保类型和医院等级确定:
| 医保类型 | 三级医院 | 二级医院 | 一级医院 |
|---|---|---|---|
| 城镇职工 | 80% | 85% | 90% |
| 城镇居民/新农合 | 65% | 70% | 80% |
完整计算示例:
假设某患者(北京城镇职工,三级医院)有以下费用:
- 头孢呋辛钠 0.75g × 3支 = 28.5 × 3 = 85.5元(甲类)
- 奥美拉唑 40mg × 2支 = 68.0 × 2 = 136.0元(乙类)
- 人血白蛋白 10g × 1瓶 = 380.0元(乙类)
- 输液器 × 3 = 5.0 × 3 = 15.0元(非药品)
计算过程:
- totalCost = 85.5 + 136.0 + 380.0 + 15.0 = 616.5元
- drugCost = 85.5 + 136.0 + 380.0 = 601.5元
- otherCost = 15.0元
- classADrugCost = 85.5元
- classBDrugCost = 136.0 + 380.0 = 516.0元
- selfPayDrugCost = 0元
- 甲类可报销 = 85.5 × 85% = 72.675元
- 乙类可报销 = 516.0 × 80% = 412.8元
- 非药品可报销 = 15.0 × 80% = 12.0元
- 总可报销 = 72.675 + 412.8 + 12.0 = 497.475元
- 扣除起付线 = 497.475 - 1300 = -802.525 → 0元(低于起付线)
- reimbursable = 0元
- selfPay = 616.5 - 0 = 616.5元
在这个例子中,由于总可报销金额低于起付线(1300元),患者需要全额自付。这也是实际医保场景中常见的情况——单次输液费用通常不足以达到起付线。
为什么不持久化:
CostSummary 每次都从 CostItem 列表和 InsurancePolicy 重新计算,原因如下:
- 数据一致性:如果 CostItem 或 InsurancePolicy 发生变化,持久化的 CostSummary 会过时
- 计算简单:遍历几十个 CostItem 的计算量很小,无需缓存
- 避免存储冗余:CostSummary 的所有字段都可以从已有数据推导,持久化是冗余的
4. 应用配置模型
应用配置模型包含全局设置(AppSettings)和用户角色定义(UserRole),它们控制着 IVGuard 的行为参数和权限模式。
4.1 AppSettings — 全局设置
AppSettings 封装了应用的所有可配置参数,通过 DataStore 持久化存储。
字段详解:
typescript export class AppSettings { role: string = '' // 用户角色 warningThreshold: number = 20 // 预警阈值(%) voiceAlertEnabled: boolean = true // 语音预警开关 vibrationEnabled: boolean = true // 振动提醒开关 sensitivity: string = 'medium' // 检测灵敏度 hospitalId: string = '' // 医院标识 watchLinkEnabled: boolean = false // 手表联动开关 patientId: string = '' // 患者标识 insurancePolicy: InsurancePolicy = new InsurancePolicy() // 嵌套医保政策 }
嵌套组合模式:
AppSettings 最显著的设计特点是嵌套了 InsurancePolicy 对象。这一设计将医保政策参数作为设置的一部分,使得用户切换医保类型或医院等级后,费用计算自动使用最新的政策参数。
嵌套组合在 ArkTS 中的实现需要注意一点:insurancePolicy: InsurancePolicy = new InsurancePolicy()。默认值直接创建了 InsurancePolicy 实例,确保了 settings.insurancePolicy 始终是一个有效的对象引用,而非 undefined。这避免了空指针异常,也简化了使用方的代码——无需判空即可访问 settings.insurancePolicy.classARate。
各字段的业务影响:
| 字段 | 影响范围 | 说明 |
|---|---|---|
role |
全局 | 决定首页入口、功能权限、导航结构 |
warningThreshold |
MonitorPage | 液位低于此值触发预警 |
voiceAlertEnabled |
AlertService | 控制语音播报开关 |
vibrationEnabled |
AlertService | 控制振动提醒开关 |
sensitivity |
VisionService | 控制视觉检测的灵敏度等级 |
hospitalId |
NavService | 关联医院POI数据 |
watchLinkEnabled |
WatchService | 控制手表端通知推送 |
patientId |
FamilyHomePage | 家属角色关联的患者标识 |
insurancePolicy |
CostService | 费用计算的医保参数 |
检测灵敏度:
sensitivity 字段影响 VisionService 的检测行为:
low:低灵敏度,减少误报,可能遗漏真实的液位变化medium:中等灵敏度(默认),平衡误报率和检测率high:高灵敏度,及时捕捉液位变化,但可能增加误报
预警阈值的全局与局部:
warningThreshold 在 AppSettings 中定义了全局默认值(20%),当创建新的 MonitorSession 时,会话的 warningThreshold 默认使用 AppSettings 中的值。但护士可以为特定会话调整阈值(例如,某些药物需要更早预警时),实现全局默认+局部定制的灵活策略。
反序列化的特殊处理:
在 DataStore.loadSettings() 中,AppSettings 的反序列化没有包含 insurancePolicy 的嵌套反序列化:
typescript static loadSettings(): AppSettings { const s = DataStore.getStore() const raw = s.getSync('settings', '') as string if (raw.length === 0) { return new AppSettings() } const item = JSON.parse(raw) as Record<string, Object> const st = new AppSettings() st.role = (item['role'] as string) ?? '' st.warningThreshold = (item['warningThreshold'] as number) ?? 20 st.voiceAlertEnabled = (item['voiceAlertEnabled'] as boolean) ?? true st.vibrationEnabled = (item['vibrationEnabled'] as boolean) ?? true st.sensitivity = (item['sensitivity'] as string) ?? 'medium' st.hospitalId = (item['hospitalId'] as string) ?? '' st.watchLinkEnabled = (item['watchLinkEnabled'] as boolean) ?? false st.patientId = (item['patientId'] as string) ?? '' return st }
InsurancePolicy 需要通过 DataStore.loadInsurancePolicy() 单独加载和保存。这一设计使得医保政策的更新不会影响其他设置,也避免了嵌套序列化的复杂性。加载完整的 AppSettings 时,需要先加载基础设置,再加载 InsurancePolicy 并赋值。
4.2 UserRole — 三角色常量
UserRole 使用静态字符串常量定义了 IVGuard 的三种用户角色:
typescript export class UserRole { static PATIENT: string = 'patient' static NURSE: string = 'nurse' static FAMILY: string = 'family' }
为什么不使用枚举:
ArkTS 对 enum 的支持有以下限制:
const enum在某些编译配置下可能不可用- 枚举值在跨
@Sendable边界时需要特殊处理 - 枚举的运行时表示是数值(默认),不如字符串直观
- JSON 序列化枚举值得到的是数值索引,可读性差
字符串常量方案完美规避了以上问题,同时在语义表达上与枚举等价。使用方式对比:
``typescript
// 枚举方式(不采用)
if (settings.role === UserRole.PATIENT) { … }
// 字符串常量方式(采用)
if (settings.role === UserRole.PATIENT) { … } // 完全相同!
``
调用语法完全一致,但底层值是可读的字符串而非数字索引。当设置被序列化为 JSON 存储时,role 字段存储的是 "patient" 而非 0,日志和调试信息更加直观可读。
三种角色的权限差异:
| 功能 | 患者角色 | 护士角色 | 家属角色 |
|---|---|---|---|
| 首页 | HomePage | NurseHomePage | FamilyHomePage |
| 实时监控 | 仅查看自己 | 查看所有患者 | 查看关联患者 |
| 药物管理 | 查看详情 | 增删改查 | 查看详情 |
| 历史记录 | 查看自己 | 查看所有患者 | 查看关联患者 |
| 预警通知 | 接收自己的 | 接收所有患者的 | 接收关联患者的 |
| 费用查看 | 查看详情 | 查看详情 | 查看详情 |
| 医保设置 | 设置自己 | 设置自己 | 查看关联患者 |
| 医院导航 | 使用 | 使用 | 使用 |
| 分析报告 | 查看自己 | 查看所有/汇总 | 查看关联患者 |
| 患者列表 | 不可见 | 可见 | 可见(关联患者) |
角色判定的代码模式:
在整个应用中,角色判定使用统一的比较模式:
typescript const settings = DataStore.loadSettings() if (settings.role === UserRole.PATIENT) { // 患者专属逻辑 } else if (settings.role === UserRole.NURSE) { // 护士专属逻辑 } else if (settings.role === UserRole.FAMILY) { // 家属专属逻辑 }
这种模式简洁清晰,且每个分支都是类型安全的字符串比较。
5. 路由参数接口设计
RouteParams.ets 定义了页面导航时传递的参数接口,共 5 个接口。这是 IVGuard 中唯一使用 interface 而非 class 定义数据结构的文件,这一选择有着深思熟虑的设计理由。
5.1 接口定义
``typescript
export interface MonitorParams {
sessionId: string
}
export interface MedicineDetailParams {
medicineId: string
}
export interface CostDetailParams {
costItemId: string
}
export interface NursePatientParams {
patientName: string
bedNumber: string
}
export interface FamilyPatientParams {
patientId: string
patientName: string
}
``
5.2 各接口的页面映射
| 接口 | 目标页面 | 语义 |
|---|---|---|
MonitorParams |
MonitorPage | 传递要监控的会话ID |
MedicineDetailParams |
MedicineDetailPage | 传递要查看的药物ID |
CostDetailParams |
CostDetailPage | 传递要查看的费用项ID |
NursePatientParams |
NurseHomePage | 护士角色传递患者信息 |
FamilyPatientParams |
FamilyHomePage | 家属角色传递关联患者信息 |
5.3 为什么用接口而非类
路由参数使用 interface 而非 class,基于以下考量:
-
纯数据传递:路由参数只携带数据,不需要方法或工厂方法。interface 完美描述了这种"数据契约"。
-
结构化类型兼容:ArkTS 的路由系统接受
Object类型的参数,interface 定义的参数对象在传递时更自然地与路由 API 集成。 -
无实例化需求:路由参数在发送端构造为普通对象字面量
{ sessionId: 'xxx' },在接收端按接口类型读取。不需要new创建实例。 -
轻量级:interface 在编译后不产生运行时代码,减少了包体积。
-
不违反 arkts-no-decl-merging:这些 interface 的名称(MonitorParams、MedicineDetailParams等)与任何 class 名称都不冲突,完全符合 ArkTS 的单一声明原则。
5.4 参数传递的实际使用
在 ArkUI 的路由框架中,参数传递通常如下:
``typescript
// 发送端:跳转到监控页面
this.getUIContext().getRouter().pushUrl({
url: ‘pages/MonitorPage’,
params: { sessionId: ‘1722659200000dl5j3q’ } as MonitorParams
})
// 接收端:在目标页面的 aboutToAppear 中获取参数
aboutToAppear(): void {
const params = this.getUIContext().getRouter().getParams() as MonitorParams
if (params) {
this.sessionId = params.sessionId
}
}
``
每个路由参数接口的字段都经过精心设计,只包含目标页面真正需要的最小字段集合。例如 MonitorParams 只传递 sessionId,MonitorPage 内部通过 sessionId 自行加载 MonitorSession 和关联的 Medicine 数据,无需传递更多参数。这种"传ID、内部加载"的模式避免了参数膨胀,也确保了数据的实时性。
5.5 护士端与家属端参数差异
NursePatientParams 和 FamilyPatientParams 的差异体现了两种角色的数据获取方式不同:
-
护士端:通过
patientName和bedNumber标识患者。护士在 PatientListPage 选择患者后,传递姓名和床号到护士首页。护士需要床号来在物理空间中定位患者,这是护士日常工作中最常用的患者定位方式。 -
家属端:通过
patientId和patientName标识患者。家属不需要床号(家属通常知道患者位置),但需要patientId来关联患者的监控数据和预警事件。patientId 是系统内部标识,家属不需要记忆,由系统在登录/绑定流程中自动获取。
5.6 单ID参数与多字段参数的设计哲学
观察 5 个路由参数接口,可以发现两类设计:
单ID参数:MonitorParams、MedicineDetailParams、CostDetailParams 只包含一个 ID 字段。这种设计的哲学是"最小信息传递"——目标页面只需要知道"操作哪个实体",所有详细数据通过 DataStore 内部加载。好处是:
- 参数简洁,不易出错
- 数据始终是最新的(每次都从存储加载)
- 页面间耦合度低(不依赖对方的数据结构)
多字段参数:NursePatientParams、FamilyPatientParams 包含多个字段。这是因为:
- 患者信息需要即时展示,不适合异步加载
- 床号和姓名是护士/家属选择患者时已知的上下文信息
- 避免在目标页面再执行一次患者查询
6. 模型间的关联关系图
6.1 ER 关系图
以下 ASCII ER 图展示了 IVGuard 核心模型之间的关联关系:
``
┌─────────────┐ ┌──────────────────┐ ┌─────────────┐
│ Medicine │◄──────│ MonitorSession │──────►│ LevelRecord │
│ │medicineId│ │sessionId│ │
│ id ◄─────────│ │ id │ │ sessionId │
│ name │ │ medicineId ──────►│ │ timestamp │
│ barcode │ │ patientName │ │ level │
│ dosage │ │ bedNumber │ │ flowRate │
│ volume │ │ startAt │ └─────────────┘
│ stickerId │ │ currentLevel │
│ nfcTagId │ │ status │ ┌──────────────┐
│ drugDbId ────│──┐ │ warningThreshold │──────►│ MonitorRecord│
│ addedAt │ │ │ isActive │sessionId│ │
└─────────────┘ │ └──────────────────┘ │ id │
│ │ │ sessionId │
│ │sessionId │ medicineName │
│ ▼ │ patientName │
│ ┌──────────────┐ │ bedNumber │
│ │ AlertEvent │ │ startAt │
│ │ │ │ endAt │
│ │ id │ │ finalLevel │
│ │ sessionId │ │ avgFlowRate │
│ │ patientName │ │ anomalies │
│ │ type │ │ status │
│ │ level │ └──────────────┘
│ │ message │
│ │ timestamp │
│ │ handled │
│ └──────────────┘
│
▼
┌──────────────┐ ┌──────────────┐ ┌─────────────────┐
│ IVDrugInfo │◄─────│ CostItem │ │ InsurancePolicy│
│ │drugId│ │ │ │
│ id ◄─────────│ │ id │ │ insuranceType │
│ genericName │ │ category │ │ hospitalLevel │
│ brandName │ │ name │ │ deductible │
│ specification│ │ quantity │ │ classARate │
│ dosageForm │ │ unitPrice │ │ classBRate │
│ referencePrice│ │ totalPrice │ │ ceiling │
│ insuranceCategory│ │ isDrug │ └────────┬────────┘
│ dailyDosage │ │ drugId ─────►│ │
└──────────────┘ │ date │ │nested
└──────────────┘ │
▼
┌──────────────────────────────────────┐
│ AppSettings │
│ │
│ role │
│ warningThreshold │
│ voiceAlertEnabled │
│ vibrationEnabled │
│ sensitivity │
│ hospitalId │
│ watchLinkEnabled │
│ patientId │
│ insurancePolicy ─────────────────────►│
└──────────────────────────────────────┘
┌──────────────┐ ┌──────────────┐
│ HospitalPOI │◄──────│ NavService │
│ │query │ │
│ id │ │ findNearest │
│ name │ │ calculatePath│
│ type │ │ estimateTime │
│ floor │ └──────────────┘
│ x │
│ y │
│ description │
└──────────────┘
``
6.2 关联关系详解
| 源模型 | 目标模型 | 关联字段 | 关系类型 | 说明 |
|---|---|---|---|---|
| MonitorSession | Medicine | medicineId | 多对一 | 一个药物可被多个会话使用 |
| MonitorSession | LevelRecord | sessionId | 一对多 | 一个会话有多条液位记录 |
| MonitorSession | MonitorRecord | sessionId | 一对一 | 一个会话对应一条完成记录 |
| MonitorSession | AlertEvent | sessionId | 一对多 | 一个会话可触发多个预警 |
| Medicine | IVDrugInfo | drugDbId | 多对一 | 多个Medicine可指向同一药物条目 |
| CostItem | IVDrugInfo | drugId | 多对一 | 多个费用项可关联同一药物 |
| AppSettings | InsurancePolicy | insurancePolicy | 嵌套组合 | 设置中包含医保政策 |
| HospitalPOI | NavService | 查询参数 | 使用关系 | 导航服务查询POI数据 |
6.3 关联关系的实现方式
IVGuard 的模型关联采用"ID引用"模式,而非对象引用嵌套。具体来说:
- MonitorSession.medicineId 存储的是 Medicine.id 的值,而非 Medicine 实例引用
- CostItem.drugId 存储的是 IVDrugInfo.id 的值,而非 IVDrugInfo 实例引用
这种模式在 ArkTS 的 Preferences 持久化场景下最为合适:
- 序列化简单:每个模型可以独立序列化为 JSON 字符串,不涉及循环引用
- 存储分离:不同类型的实体存储在不同的 Preferences key 下,更新互不影响
- 按需加载:只有需要关联数据时才加载,避免一次性加载所有数据
- 内存友好:同一份数据不会被多次实例化
关联数据的加载在业务层而非模型层完成。例如,MonitorPage 加载会话后,再通过 medicineId 加载对应的 Medicine:
typescript const sessions = DataStore.loadSessions() const medicines = DataStore.loadMedicines() for (let i = 0; i < medicines.length; i++) { if (medicines[i].id === sessions[0].medicineId) { this.medicineName = medicines[i].name break } }
6.4 数据流向图
``typescript
// 监控流程的数据流向
ScanService/NfcService ──→ Medicine(扫码/贴纸/NFC识别添加药物)
│
▼ medicineId
MonitorSession(创建监控会话)
│
┌───────┼───────┐
▼ ▼ ▼
LevelRecord AlertEvent MonitorRecord
(液位时序) (预警事件) (完成归档)
// 费用流程的数据流向
IVDrugDatabase ──→ IVDrugInfo(药物数据库查询)
│
▼ drugId
CostItem(创建费用项)
│
▼
InsurancePolicy ──→ CostSummary(费用计算)
(医保政策参数) (汇总结果)
// 导航流程的数据流向
HospitalData ──→ HospitalPOI(加载POI数据)
│
▼
NavService(路径计算与导航)
``
7. JSON序列化/反序列化策略
IVGuard 使用 HarmonyOS 的 @kit.ArkData 中的 preferences 模块进行数据持久化。由于 preferences 只支持字符串值的存储,所有模型数据都需要经过 JSON 序列化/反序列化。
7.1 序列化流程(保存)
typescript // 保存 Medicines static async saveMedicines(medicines: Medicine[]): Promise<void> { const s = DataStore.getStore() s.putSync('medicines', JSON.stringify(medicines)) await s.flush() }
序列化流程非常直接:
- 调用
JSON.stringify(medicines)将 Medicine 数组转换为 JSON 字符串 - 使用
putSync()将字符串写入 Preferences - 调用
flush()异步写入磁盘
JSON.stringify() 对类实例的序列化行为:它会遍历实例的所有可枚举自有属性,生成对应的 JSON 对象。由于 IVGuard 的所有模型字段都是基本类型(string、number、boolean),序列化结果与普通对象完全一致。
序列化示例:
``typescript
const m = Medicine.create(‘头孢呋辛钠’, ‘2-3支/日’, 250)
m.barcode = ‘6901234567890’
m.drugDbId = ‘1’
// JSON.stringify(m) 的结果:
// {
// “id”: “1722659200000dl5j3q”,
// “name”: “头孢呋辛钠”,
// “barcode”: “6901234567890”,
// “dosage”: “2-3支/日”,
// “volume”: 250,
// “stickerId”: “”,
// “nfcTagId”: “”,
// “drugDbId”: “1”,
// “addedAt”: 1722659200000
// }
``
注意:序列化后丢失了类型信息。JSON.parse() 返回的是普通 Object,而非 Medicine 实例。这就是为什么反序列化需要手动处理。
7.2 反序列化流程(加载)
反序列化是 IVGuard 数据层最复杂的部分,需要将 JSON 字符串还原为类型安全的类实例。
完整流程(以 loadMedicines 为例):
``typescript
static loadMedicines(): Medicine[] {
const s = DataStore.getStore()
// Step 1: 从 Preferences 读取原始 JSON 字符串
const raw = s.getSync(‘medicines’, ‘[]’) as string
// Step 2: 解析 JSON 为 Object 数组
const arr: Object[] = JSON.parse(raw) as Object[]
// Step 3: 逐个转换为 Medicine 实例
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) ?? ‘’
m.barcode = (item[‘barcode’] as string) ?? ‘’
m.dosage = (item[‘dosage’] as string) ?? ‘’
m.volume = (item[‘volume’] as number) ?? 0
m.stickerId = (item[‘stickerId’] as string) ?? ‘’
m.nfcTagId = (item[‘nfcTagId’] as string) ?? ‘’
m.drugDbId = (item[‘drugDbId’] as string) ?? ‘’
m.addedAt = (item[‘addedAt’] as number) ?? 0
result.push(m)
}
return result
}
``
流程解析:
Step 1:s.getSync('medicines', '[]') 读取 Preferences 中 key 为 medicines 的值,如果不存在则返回默认值 '[]'(空数组的 JSON 字符串)。这确保了首次使用时返回空数组而非崩溃。
Step 2:JSON.parse(raw) as Object[] 将 JSON 字符串解析为 Object 数组。注意这里使用 Object[] 而非 Medicine[],因为 JSON.parse() 返回的是普通对象,不是类实例。as Object[] 是安全的类型断言,因为 JSON 解析的结果确实是对象数组。
Step 3:遍历 Object 数组,逐个转换:
arr[i] as Record<string, Object>:将 Object 断言为 Record 类型,以支持属性访问new Medicine():创建新的类实例(所有字段为默认值)(item['id'] as string) ?? '':读取 JSON 对象的属性,进行类型断言和空值合并result.push(m):将还原的实例加入结果数组
7.3 为什么不用 Object.assign
在标准 TypeScript/JavaScript 中,可以使用 Object.assign() 简化反序列化:
typescript // 标准 TypeScript 写法(ArkTS 禁止) const m = new Medicine() Object.assign(m, arr[i]) // 一次性赋值所有字段
IVGuard 不使用 Object.assign,原因如下:
-
ArkTS 类型安全要求:
Object.assign()的参数类型为any,在 ArkTS 严格模式下不受欢迎。它绕过了类型检查,可能导致运行时类型不匹配。 -
字段类型不可控:
Object.assign()会复制所有源对象的属性,包括 JSON 中可能存在但类定义中不存在的字段。这可能导致意外的属性出现在类实例上。 -
默认值不可控:如果 JSON 数据中缺少某个字段,
Object.assign()不会使用类的默认值,而是保持 undefined。而手动赋值配合??运算符可以确保回退到默认值。 -
版本兼容问题:如果新版本添加了新字段,旧版本的 JSON 数据中不包含这些字段。
Object.assign()不会为新字段提供默认值,而手动赋值可以。 -
ArkTS 编译限制:ArkTS 对
Object.assign等动态 API 的使用有额外限制,可能无法通过编译。
7.4 手动字段赋值的必要性
手动字段赋值虽然代码冗长,但带来了关键的类型安全保障:
typescript m.id = (item['id'] as string) ?? ''
这一行代码包含了三重保障:
- 类型断言
as string:明确告知编译器该值应为 string 类型 - 空值合并
?? '':如果值为 null/undefined,使用默认值 - 字段名明确
item['id']:只读取已知存在的字段,忽略多余字段
对比 Object.assign 的一次性赋值,手动赋值的每一行都是类型安全的、可审计的、可维护的。在医疗场景下,数据完整性至关重要,任何因反序列化错误导致的数据丢失都可能影响患者安全。
7.5 各模型的反序列化方法汇总
| 方法 | 数据来源 | 返回类型 | 特殊处理 |
|---|---|---|---|
loadMedicines() |
key: ‘medicines’ | Medicine[] |
无 |
loadSessions() |
key: ‘sessions’ | MonitorSession[] |
无 |
loadRecords() |
key: ‘records’ | MonitorRecord[] |
无 |
loadAlerts() |
key: ‘alerts’ | AlertEvent[] |
无 |
loadCostItems() |
key: ‘costItems’ | CostItem[] |
无 |
loadLevelRecords() |
key: ‘levelRecords’ | LevelRecord[] |
无 |
loadInsurancePolicy() |
key: ‘insurancePolicy’ | InsurancePolicy |
空字符串→新实例 |
loadSettings() |
key: ‘settings’ | AppSettings |
空字符串→新实例 |
注意 loadInsurancePolicy() 和 loadSettings() 的特殊处理:它们返回单个对象而非数组,当 Preferences 中没有对应数据时,返回新创建的默认实例。这确保了首次使用时不会返回 null。
7.6 嵌套对象的序列化问题
AppSettings 包含嵌套的 InsurancePolicy 对象,序列化时 JSON.stringify 会递归处理嵌套对象:
typescript const settings = new AppSettings() settings.insurancePolicy.classARate = 85 // JSON.stringify(settings) 的结果: // { // "role": "", // "warningThreshold": 20, // "insurancePolicy": { // "insuranceType": "", // "hospitalLevel": "", // "deductible": 0, // "classARate": 85, // "classBRate": 0, // "ceiling": 0 // } // }
序列化没有问题,但反序列化时嵌套对象变成了普通 Object,而非 InsurancePolicy 实例。当前 IVGuard 的解决方案是 InsurancePolicy 独立存储和加载,避免嵌套反序列化的复杂性。未来如果需要深度嵌套反序列化,可以在 loadSettings() 中增加对 insurancePolicy 字段的递归处理。
7.7 性能考量
Preferences + JSON 的方案在 IVGuard 的数据规模下(几十条药物记录、十几个监控会话)性能完全满足要求。但如果数据量增长到数千条记录,需要考虑以下优化:
- 增量保存:当前每次保存都写入整个数组。可以改为只保存变更的记录
- 分页加载:对于历史记录,可以实现分页查询,避免一次性加载所有数据
- 关系型数据库:HarmonyOS 提供了
@kit.ArkData中的关系型数据库 API,适合大规模结构化数据的存储和查询
当前的 Preferences + JSON 方案在简洁性和可维护性方面具有显著优势,适合 IVGuard 现阶段的数据规模和复杂度。
8. 模型演进与版本兼容
软件系统的数据模型不可避免地会随需求变化而演进。IVGuard 的模型层设计从一开始就考虑了版本兼容问题,确保应用升级后旧数据能被正确加载。
8.1 新增字段的默认值策略
这是最常见的模型演进场景。假设在 v2.0 版本中,需要为 Medicine 添加 manufacturer 字段:
``typescript
// v1.0
export class Medicine {
id: string = ‘’
name: string = ‘’
barcode: string = ‘’
dosage: string = ‘’
volume: number = 0
stickerId: string = ‘’
nfcTagId: string = ‘’
drugDbId: string = ‘’
addedAt: number = 0
}
// v2.0 — 新增 manufacturer 字段
export class Medicine {
id: string = ‘’
name: string = ‘’
barcode: string = ‘’
dosage: string = ‘’
volume: number = 0
stickerId: string = ‘’
nfcTagId: string = ‘’
drugDbId: string = ‘’
addedAt: number = 0
manufacturer: string = ‘’ // 新增字段
}
``
反序列化时的处理:
typescript // v2.0 的 loadMedicines() const m = new Medicine() m.id = (item['id'] as string) ?? '' m.name = (item['name'] as string) ?? '' // ... 其他字段 m.manufacturer = (item['manufacturer'] as string) ?? '' // 旧数据中没有此字段
当加载 v1.0 存储的数据时,item['manufacturer'] 为 undefined,?? '' 运算符将其回退为默认空字符串。整个过程无需任何迁移逻辑,旧数据自动兼容新模型。
这一优雅的兼容性得益于两个设计决策的协同作用:
- 所有字段有默认值:新字段声明时必须有初始值
- 反序列化使用
??运算符:缺少的字段自动获得默认值
8.2 删除字段的影响
假设在 v2.0 版本中,需要删除 Medicine 的 stickerId 字段:
typescript // v2.0 — 删除 stickerId export class Medicine { id: string = '' name: string = '' barcode: string = '' dosage: string = '' volume: number = 0 nfcTagId: string = '' drugDbId: string = '' addedAt: number = 0 }
反序列化时,旧数据中的 stickerId 字段会被忽略——因为我们只读取已知的字段名:
typescript const m = new Medicine() m.id = (item['id'] as string) ?? '' // ... 不读取 stickerId,它被自然忽略
JSON 对象中的多余字段不会造成任何问题。这与关系型数据库的 ALTER TABLE DROP COLUMN 类似,旧数据中的冗余字段被安全忽略。
8.3 重命名字段的处理
重命名字段是最复杂的演进场景,因为旧数据使用旧字段名,新代码期望新字段名。假设将 Medicine 的 barcode 重命名为 productCode:
typescript // v2.0 — barcode → productCode export class Medicine { id: string = '' name: string = '' productCode: string = '' // 原 barcode dosage: string = '' // ... }
反序列化需要迁移逻辑:
typescript const m = new Medicine() m.id = (item['id'] as string) ?? '' // 兼容旧数据:先尝试读取新字段名,再回退到旧字段名 m.productCode = (item['productCode'] as string) ?? (item['barcode'] as string) ?? ''
这种迁移逻辑简单但有效。不过,IVGuard 当前的模型定义中没有版本号字段,迁移逻辑只能硬编码在反序列化方法中。未来如果重命名频繁发生,建议引入 schema 版本号。
8.4 版本号预留
未来可以在 AppSettings 中添加 schemaVersion 字段,用于标识数据模型的版本:
typescript export class AppSettings { schemaVersion: number = 1 // 预留版本号 role: string = '' // ... 其他字段 }
版本号驱动的迁移策略:
``typescript
static loadMedicines(): Medicine[] {
const settings = DataStore.loadSettings()
const schemaVersion = settings.schemaVersion
// 加载数据
const result = this.doLoadMedicines()
// 根据版本号执行迁移
if (schemaVersion < 2) {
// v1→v2 迁移:例如 barcode → productCode
for (let i = 0; i < result.length; i++) {
if (result[i].productCode.length === 0 && result[i].barcode.length > 0) {
result[i].productCode = result[i].barcode
}
}
}
return result
}
``
当前 IVGuard 处于 v1.0 阶段,尚未引入版本号和迁移逻辑。但默认值+空值合并的设计已经为未来的版本兼容奠定了基础。
8.5 模型演进的最佳实践总结
| 变更类型 | 难度 | 是否需要迁移 | 建议 |
|---|---|---|---|
| 新增字段 | 低 | 否 | 默认值+??自动兼容 |
| 删除字段 | 低 | 否 | 多余字段被自然忽略 |
| 重命名字段 | 中 | 是 | 需要双字段名读取逻辑 |
| 修改字段类型 | 高 | 是 | 需要类型转换逻辑 |
| 修改关联关系 | 高 | 是 | 需要数据重构逻辑 |
核心原则:尽量使用新增字段而非修改/删除现有字段。新增字段的兼容成本最低,是模型演进的首选策略。
9. 设计模式总结
IVGuard 的数据模型层虽然代码量不大(DataModels.ets 仅 260 行),但蕴含了多个精心选择的设计模式。本节系统性地总结这些模式,分析它们的适用场景和权衡取舍。
9.1 Static Factory Method — 静态工厂方法
模式定义:通过类的静态方法创建实例,而非直接调用构造函数。
IVGuard 中的应用:
所有 15 个类中有 12 个定义了 static create() 方法。三个没有 create 方法的类是:
UserRole:纯常量容器,不需要实例化AppSettings:直接new AppSettings()即可,所有字段有合理默认值CostSummary:通过CostSummary.create()创建(实际就是new CostSummary())
优势:
- 命名语义:
Medicine.create(name, dosage, volume)比new Medicine(name, dosage, volume)更明确地表达了"创建"意图 - 参数精简:只暴露必要的参数,隐藏自动生成的字段(id、timestamp等)
- 逻辑封装:ID生成、日期格式化、总价计算等逻辑集中在工厂方法中
- 替代构造器重载:在 ArkTS 不支持构造器重载的限制下,提供灵活的对象创建方式
局限:
- 工厂方法返回的是已部分初始化的实例,调用方可能不清楚哪些字段已设置、哪些还需设置
- 工厂方法的参数列表需要精心设计,过多参数降低可读性
- 无法使用
instanceof区分工厂方法创建的实例和直接new创建的实例
9.2 Value Object — 值对象
模式定义:对象的相等性基于其字段值而非标识(ID)。
IVGuard 中的应用:
IVGuard 的大部分模型是值对象风格的——它们的字段都是公开的数据属性,没有封装业务逻辑。但严格来说,由于大多数模型有 id 字段,它们的相等性可以基于 ID 判断,这使得它们更接近实体(Entity)而非纯值对象。
最接近纯值对象的模型:
LevelRecord:没有 id 字段,相等性完全基于 (sessionId, timestamp, level, flowRate)InsurancePolicy:相等性基于参数值而非标识CostSummary:相等性基于计算结果而非标识
9.3 ID生成模式 — 时间戳+随机字符串
模式定义:使用时间戳和随机数的组合生成全局唯一标识符。
IVGuard 中的应用:
typescript id = Date.now().toString() + Math.random().toString(36).substring(2, 8)
特征:
- 时间有序:ID 前缀是毫秒时间戳,自然按时间排序
- 本地生成:不需要网络请求或外部服务
- 碰撞安全:36^6 = 21亿种随机组合,碰撞概率极低
- 可读性好:调试时可以直接从 ID 推断创建时间
与其他ID生成策略的对比:
| 策略 | 唯一性 | 有序性 | 长度 | 依赖 | 适用场景 |
|---|---|---|---|---|---|
| 时间戳+随机 | 高 | 有序 | ~25字符 | 无 | 单设备应用 |
| UUID v4 | 极高 | 无序 | 36字符 | 无 | 分布式系统 |
| 雪花算法 | 极高 | 有序 | 19位数字 | 配置 | 大规模分布式 |
| 数据库自增 | 保证 | 有序 | 短数字 | 数据库 | 有数据库场景 |
IVGuard 选择时间戳+随机字符串是基于以下考量:单设备应用、不需要分布式唯一性、优先考虑简洁性和零依赖。
9.4 嵌套组合 — Composite Pattern
模式定义:将一个对象组合到另一个对象中,形成部分-整体的层次结构。
IVGuard 中的应用:
AppSettings 嵌套了 InsurancePolicy:
typescript export class AppSettings { // ... insurancePolicy: InsurancePolicy = new InsurancePolicy() }
设计意义:
- 语义聚合:医保政策是应用设置的一部分,嵌套表达了这一语义关系
- 生命周期一致:InsurancePolicy 随 AppSettings 一起加载和保存
- 访问便捷:
settings.insurancePolicy.classARate比policy.classARate更清楚地表达了"设置的医保政策中的甲类报销比例"
当前局限:
嵌套组合在序列化/反序列化时带来了复杂性——InsurancePolicy 独立存储,不随 AppSettings 一起序列化嵌套。这是当前实现的一个折衷,未来可以考虑深度嵌套序列化。
9.5 分类常量模式 — String Constants as Enum
模式定义:使用类的静态字符串常量替代枚举类型定义分类值。
IVGuard 中的应用:
typescript export class UserRole { static PATIENT: string = 'patient' static NURSE: string = 'nurse' static FAMILY: string = 'family' }
与枚举的详细对比:
| 特性 | 枚举 (enum) | 字符串常量 |
|---|---|---|
| 类型安全 | 编译时检查 | 编译时检查(需手动比较) |
| 序列化 | 数值索引(默认) | 原始字符串 |
| 可读性 | 低(运行时为数字) | 高(始终为字符串) |
| 可扩展 | 需修改枚举定义 | 添加 static 字段即可 |
| Sendable兼容 | 可能有问题 | 完全兼容 |
| 反射支持 | 有 | 无 |
| switch支持 | 完整 | 需字符串比较 |
| 包体积 | 可能更大(生成JS代码) | 无额外代码 |
在 IVGuard 的场景下,字符串常量方案在所有关键维度上都不劣于枚举,且在序列化友好性、Sendable兼容性和调试可读性上具有显著优势。
9.6 懒初始化单例 — Lazy Singleton
模式定义:在首次使用时初始化数据,后续使用缓存的数据。
IVGuard 中的应用:
DrugDatabase、IVDrugDatabase、InsuranceReference 都采用了懒初始化模式:
``typescript
export class IVDrugDatabase {
private static drugs: IVDrugInfo[] = []
private static loaded: boolean = false
static init(): void {
if (IVDrugDatabase.loaded) {
return // 已加载,直接返回
}
IVDrugDatabase.loaded = true
// … 加载50种药物数据
IVDrugDatabase.drugs = list
}
}
``
优势:
- 延迟加载:只在首次使用时加载数据,避免启动时加载所有参考数据
- 单例缓存:数据只加载一次,后续调用直接返回缓存
- 线程安全:ArkTS 运行在单线程环境中,不存在并发初始化问题
9.7 Data Transfer Object — DTO模式
模式定义:仅包含数据字段、没有业务逻辑的对象,用于层间数据传递。
IVGuard 中的应用:
RouteParams.ets 中的 5 个接口是典型的 DTO:
typescript export interface MonitorParams { sessionId: string }
这些接口没有方法、没有默认值、没有逻辑,纯粹用于页面间的数据传递。使用 interface 而非 class 定义 DTO,进一步强调了它们的"纯数据"本质。
9.8 设计模式协同关系
IVGuard 数据模型层的设计模式并非孤立存在,而是相互协同形成完整的设计体系:
- Static Factory Method + 默认值初始化:工厂方法依赖无参构造器的默认值初始化,先创建全默认值实例,再设置必要字段
- ID生成 + Static Factory Method:ID 在工厂方法中生成,确保调用方无法传入无效ID
- 分类常量 + 默认值:分类字段的默认值就是最安全的分类值(如 severity 默认 ‘warning’,status 默认 ‘monitoring’)
- 懒初始化单例 + 工厂方法:DrugDatabase 的 init() 在 DrugInteraction.create() 的数据准备阶段被调用
- 嵌套组合 + 手动反序列化:嵌套对象的独立存储和加载,避免了深度反序列化的复杂性
- DTO + ID引用:路由参数只传ID,目标页面内部加载完整数据,实现了松耦合的页面间通信
9.9 设计权衡与取舍
IVGuard 的模型设计并非没有取舍,以下是最重要的权衡:
简洁性 vs 完备性:
- 选择:优先简洁性
- 表现:模型只有数据字段,没有验证逻辑、计算属性、状态转换方法
- 代价:业务逻辑分散在 Service 和 Page 层,模型本身不能保证数据一致性
- 理由:ArkTS 的 struct/@Component 架构天然将逻辑分散在多个层级,强求模型层完备会引入不必要的复杂度
ID引用 vs 对象引用:
- 选择:ID引用
- 表现:MonitorSession 通过 medicineId(字符串)关联 Medicine,而非持有 Medicine 实例
- 代价:加载关联数据需要额外的 DataStore 查询
- 理由:Preferences 持久化方案下,ID引用最自然;对象引用会导致序列化循环引用问题
字符串常量 vs 枚举:
- 选择:字符串常量
- 表现:所有分类值使用小写 snake_case 字符串
- 代价:无法在 switch 语句中获得穷举检查(编译器不会警告遗漏的分类值)
- 理由:序列化友好、Sendable兼容、调试可读的优势远大于类型安全的微小损失
手动反序列化 vs 通用序列化框架:
- 选择:手动反序列化
- 表现:每个 load 方法逐字段赋值
- 代价:代码冗长,新增字段时需要同步更新 load 方法
- 理由:类型安全、版本兼容、避免 Object.assign 的隐患
附录A:DataModels.ets 完整类清单
| 序号 | 类名 | 字段数 | 有id | 有create() | 持久化 | 分类 |
|---|---|---|---|---|---|---|
| 1 | Medicine | 9 | 是 | 是 | 是 | 核心业务 |
| 2 | MonitorSession | 8+1(isActive) | 是 | 是 | 是 | 核心业务 |
| 3 | LevelRecord | 4 | 否 | 是 | 是 | 核心业务 |
| 4 | MonitorRecord | 9+2(avgFlowRate,anomalies) | 是 | 是 | 是 | 核心业务 |
| 5 | DrugInteraction | 4 | 否 | 是 | 否(内嵌DrugDatabase) | 核心业务 |
| 6 | AlertEvent | 8 | 是 | 是 | 是 | 核心业务 |
| 7 | HospitalPOI | 7 | 是 | 是 | 否(内嵌HospitalData) | 核心业务 |
| 8 | IVDrugInfo | 8 | 是 | 是 | 否(内嵌IVDrugDatabase) | 医疗费用 |
| 9 | CostItem | 9 | 是 | 是 | 是 | 医疗费用 |
| 10 | InsurancePolicy | 6 | 否 | 是 | 是 | 医疗费用 |
| 11 | CostSummary | 8 | 否 | 是 | 否(纯计算) | 医疗费用 |
| 12 | AppSettings | 9(含嵌套) | 否 | 否 | 是 | 应用配置 |
| 13 | UserRole | 3(static) | 否 | 否 | 否(常量) | 应用配置 |
| 14 | NavPathData | 2 | 否 | 是 | 否(路由辅助) | 辅助 |
附录B:DataStore 键值映射表
| Preferences Key | 值类型 | 对应模型 | 默认值 |
|---|---|---|---|
medicines |
JSON字符串 | Medicine[] |
'[]' |
sessions |
JSON字符串 | MonitorSession[] |
'[]' |
records |
JSON字符串 | MonitorRecord[] |
'[]' |
alerts |
JSON字符串 | AlertEvent[] |
'[]' |
costItems |
JSON字符串 | CostItem[] |
'[]' |
levelRecords |
JSON字符串 | LevelRecord[] |
'[]' |
insurancePolicy |
JSON字符串 | InsurancePolicy |
'' |
settings |
JSON字符串 | AppSettings |
'' |
附录C:InsuranceReference 城市参数完整数据
| 参数 | 北京 | 上海 | 广州 | 杭州 | 成都 | 武汉 |
|---|---|---|---|---|---|---|
| 职工起付线(三级) | 1300 | 1500 | 1600 | 1500 | 1200 | 1200 |
| 职工起付线(二级) | 800 | 1000 | 800 | 800 | 600 | 600 |
| 职工甲类比例(%) | 85 | 85 | 80 | 82 | 85 | 85 |
| 职工乙类比例(%) | 80 | 80 | 75 | 78 | 80 | 80 |
| 职工封顶线(万) | 50 | 55 | 60 | 50 | 40 | 40 |
| 居民起付线(三级) | 1300 | 1500 | 1600 | 1500 | 1200 | 1200 |
| 居民起付线(二级) | 800 | 1000 | 800 | 800 | 600 | 600 |
| 居民甲类比例(%) | 75 | 70 | 70 | 70 | 65 | 65 |
| 居民乙类比例(%) | 70 | 65 | 65 | 65 | 60 | 60 |
| 居民封顶线(万) | 25 | 25 | 25 | 25 | 20 | 20 |
更多推荐




所有评论(0)