基于鸿蒙OS开发静脉输液智能监控系统(18)-公共组件库设计与封装
基于鸿蒙OS开发静脉输液智能监控系统(18)-公共组件库设计与封装
概述
在 IVGuard 智能输液监护系统中,公共组件库是连接数据模型与用户界面的核心桥梁。一个设计良好的组件库不仅能够提升开发效率、保证 UI 一致性,还能显著降低维护成本和 Bug 率。本文档详细阐述 IVGuard 项目中所有自定义公共组件的设计理念、实现细节、踩坑经验以及最佳实践。
ArkUI 的声明式开发范式为组件化提供了天然的支持——@Component装饰器与struct的组合使得每个 UI 单元都可以被独立定义、独立测试、独立复用。然而,ArkTS 作为 TypeScript 的严格子集,在语法层面施加了大量限制(如build()方法中不允许const声明、组件属性命名不能与通用属性冲突等),这些限制直接影响组件的设计方式。本文档将结合实际代码,逐一剖析每个组件从设计到实现的全过程,重点记录那些"看似合理却编译报错"的典型案例,帮助开发者少走弯路。
本文涵盖的组件包括:
| 组件名 | 代码行数 | 用途 | 核心技术 |
|---|---|---|---|
| LevelProgress | ~40 | 液位进度指示 | Progress + Stack |
| MedicineCard | ~50 | 药物信息展示 | 布局 + 回调 |
| AlertCard | ~60 | 预警信息展示 | 条件样式 + 状态切换 |
| MonitorOverlay | ~48 | 监控叠加层 | Stack绝对定位 |
| HospitalMapView | ~97 | 医院平面图 | Canvas 2D |
| FlowChart | ~65 | 流速趋势图 | Canvas折线图 |
| CostPieChart | ~65 | 费用饼图 | Canvas饼图 |
| StepIndicator | ~45 | 步骤指示器 | ForEach + 条件样式 |
1. ArkUI 组件化开发原则
1.1 @Component + struct 规范
在 ArkUI 中,每个自定义组件必须遵循@Component + struct的组合模式。这是框架的硬性要求,不是可选约定。struct是 ArkTS 中定义组件的唯一合法方式——不能使用class,不能使用普通函数,不能使用箭头函数返回 UI 描述。
`` ypescript
@Component
export struct LevelProgress {
@Prop level: number = 0
build() {
// UI 描述
}
}
``
关键规则:
- 每个
struct必须有且仅有一个build()方法 build()方法的返回值必须是组件或容器,不能返回空struct不能继承,不能实现接口(不同于 TypeScript 的 class)- 一个文件一个组件是最佳实践,文件名与组件名保持一致(如
LevelProgress.ets导出LevelProgress组件)
为什么不用 class? ArkTS 的设计哲学是"值类型优先"。struct 是值类型,在传递和赋值时进行深拷贝,避免了引用共享带来的状态管理混乱。这与 ArkUI 的单向数据流理念高度契合——父组件传入@Prop时,子组件获得的是一份独立副本,修改不会回传。
1.2 @Prop 单向数据流
@Prop是 ArkUI 中最核心的状态装饰器之一,它实现了严格的单向数据流:数据从父组件流向子组件,不可逆。
`` ypescript
@Component
export struct MedicineCard {
@Prop medicineName: string = ‘’
@Prop dosage: string = ‘’
@Prop isBound: boolean = false
build() {
Row() {
Text(this.medicineName)
Text(this.dosage)
}
}
}
``
单向数据流的核心价值:
- 可预测性:子组件的 UI 完全由父组件传入的
@Prop决定,不存在隐式依赖 - 可调试性:当 UI 出错时,只需追踪父组件的状态变更链,无需在子组件中寻找"偷偷修改"的代码
- 可复用性:子组件不持有业务状态,可以在不同场景下复用
@Prop 的生命周期: 当父组件的状态变量(@State)发生变化时,框架会自动将新值同步到子组件的@Prop,并触发子组件的build()重新执行。这个过程是自动的、声明式的——开发者无需手动调用 setState 或 render。
@Prop vs @State vs @Link 的选择:
| 装饰器 | 数据流向 | 适用场景 |
|---|---|---|
| @State | 组件内部 | 组件私有的可变状态 |
| @Prop | 父→子(单向) | 子组件只需读取、不需修改 |
| @Link | 父↔子(双向) | 子组件需要修改父组件状态 |
在 IVGuard 的组件库中,我们优先使用 @Prop。只有当子组件确实需要修改父组件状态时(如 AlertCard 的"已处理"状态),才考虑使用回调函数模式或 @Link。
1.3 回调函数模式:子→父通信
当子组件需要向父组件传递信息(如用户点击、状态变更)时,ArkUI 推荐使用回调函数模式:
`` ypescript
@Component
export struct AlertCard {
@Prop alertTitle: string = ‘’
@Prop severity: string = ‘info’
public onHandle: () => void = () => {}
build() {
Row() {
Text(this.alertTitle)
Button(‘处理’)
.onClick(() => {
this.onHandle()
})
}
}
}
``
使用方式(父组件):
ypescript AlertCard({ alertTitle: '输液流速异常', severity: 'danger', onHandle: () => { this.handleAlert() } })
这种模式的优势在于显式性——父组件明确知道自己将什么行为传递给了子组件,数据流向清晰可见。相比之下,@Link虽然能实现双向同步,但隐式地修改了父组件状态,在高复杂度项目中容易导致状态变更难以追踪。
1.4 组件命名规范
| 规范 | 说明 | 示例 |
|---|---|---|
| 组件名 | PascalCase,名词性 | LevelProgress、MedicineCard |
| 文件名 | 与组件名一致 | LevelProgress.ets |
| @Prop 属性 | camelCase | level、medicineName |
| 回调属性 | on + 动词 | onCardClick、onHandle |
| 私有方法 | camelCase | getLevelColor()、formatTime() |
命名的一致性不仅影响代码可读性,还直接影响团队协作效率。在一个多人协作的项目中,当开发者看到onCardClick时,应该立即理解这是一个点击回调;看到getLevelColor()时,应该知道这是一个计算颜色值的私有方法。
2. LevelProgress 液位进度指示器
LevelProgress 是 IVGuard 中最基础的组件之一,它以环形进度条的形式展示当前输液瓶的液位百分比。这个组件虽然功能简单,却涵盖了 ArkUI 组件化的多个核心概念:@Prop数据绑定、内置组件组合、条件颜色计算、Stack 布局叠加。
2.1 环形 Progress 组件
ArkUI 提供了内置的Progress组件,支持线性(Linear)、环形(Ring)、比例尺(ScaleRing)等多种类型。对于液位指示场景,环形进度条是最直觉的选择——它模仿了仪表盘的视觉隐喻,用户一眼就能看出当前液位的大致范围。
ypescript Progress({ value: this.level, total: 100, type: ProgressType.Ring }) .width(100).height(100) .color(this.getLevelColor()) .style({ strokeWidth: 8 })
代码解析:
value: this.level:当前液位值,由父组件通过@Prop传入total: 100:总量设为 100,使value直接对应百分比type: ProgressType.Ring:环形类型,绘制一个圆弧strokeWidth: 8:圆弧宽度为 8vp,既不会太细看不清,也不会太粗显得笨重.color(this.getLevelColor()):根据液位值动态计算颜色
ProgressType 的选择考量:
| 类型 | 外观 | 适用场景 |
|---|---|---|
| Linear | 水平/垂直条 | 下载进度、表单完成度 |
| Ring | 圆环 | 仪表盘、容量指示 |
| ScaleRing | 带刻度圆环 | 精确读数场景 |
| Eclipse | 月牙形 | 轻量级指示 |
在 IVGuard 的输液监控场景中,Ring 类型是最佳选择。原因有三:第一,环形与"容量"的视觉隐喻最为契合(想想汽车油表);第二,环形占用的视觉面积小,适合在列表项中嵌入;第三,环形天然支持中心文字叠加(通过 Stack 布局)。
2.2 颜色渐变算法
液位的颜色编码遵循交通灯语义:绿色代表安全,橙色代表注意,红色代表危险。这是人类最直觉的颜色语义,无需学习成本。
ypescript private getLevelColor(): string { if (this.level > 50) { return '#4CAF50' } else if (this.level >= 20) { return '#FF9800' } else { return '#F44336' } }
阈值设计依据:
- > 50% → 绿色 #4CAF50:输液瓶仍有超过一半的液体,处于安全范围。此时护理人员无需特别关注。
- 20% ~ 50% → 橙色 #FF9800:液体已消耗过半,需要在近期关注。橙色是"预警"的颜色,提醒但不紧急。
- < 20% → 红色 #F44336:液体即将耗尽,需要立即处理。红色是"危险"的颜色,要求立即行动。
为什么不用连续渐变色? 连续渐变色(如从绿到黄到红的平滑过渡)在理论上更精确,但在实际使用中有两个问题:第一,在移动设备的小屏幕上,颜色差异难以辨识,"黄绿色到底是绿还是黄"容易引起歧义;第二,三段式颜色与护理流程的三级响应机制(正常/关注/紧急)一一对应,便于操作标准化。
Material Design 颜色值选择: #4CAF50、#FF9800、#F44336 均来自 Material Design 色板。选择 Material 色板而非自定义颜色的原因有二:第一,Material 色板经过无障碍测试,色盲用户也能通过明度差异区分;第二,使用标准色板可以确保与系统其他 UI 元素(如系统弹窗的颜色)视觉一致。
2.3 中心文字叠加
环形进度条的视觉焦点在圆心,自然需要在圆心叠加百分比文字。这需要使用 Stack 布局——Stack 允许子组件按层叠方式排列,后声明的组件覆盖在前面的组件之上。
`` ypescript
@Component
export struct LevelProgress {
@Prop level: number = 0
build() {
Stack() {
Progress({ value: this.level, total: 100, type: ProgressType.Ring })
.width(100)
.height(100)
.color(this.getLevelColor())
.style({ strokeWidth: 8 })
Text(this.level + '%')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(this.getLevelColor())
}
.width(100)
.height(100)
}
private getLevelColor(): string {
if (this.level > 50) {
return ‘#4CAF50’
} else if (this.level >= 20) {
return ‘#FF9800’
} else {
return ‘#F44336’
}
}
}
``
Stack 布局的关键细节:
- 声明顺序决定层叠顺序:先声明 Progress(底层),再声明 Text(上层)。Text 自然覆盖在 Progress 的圆心位置。
- Stack 的尺寸约束:必须显式设置 Stack 的 width/height,否则子组件的定位会失准。这里设为 100x100,与 Progress 一致。
- 文字颜色与进度条颜色同步:
fontColor(this.getLevelColor())确保文字颜色随液位变化,形成视觉整体感。
为什么不用 Flex 或 Column? Flex 和 Column 虽然也能实现类似效果,但需要额外的对齐参数设置。Stack 的默认行为就是居中层叠,代码更简洁。在"底层图形 + 顶层文字"的场景中,Stack 是最自然的选择。
fontSize 的选择: 18fp 在 100x100 的环形中是经过反复调试的最佳值。太小(如 12fp)看不清,太大(如 24fp)会超出圆心区域,与环形弧线视觉冲突。
3. MedicineCard 药物卡片
MedicineCard 是 IVGuard 中展示药物信息的核心组件。每张卡片展示一种药物的名称、剂量、绑定状态,以及药物相互作用的警告信息。这个组件的设计重点在于信息层次的清晰排列和交互回调的正确实现。
3.1 信息布局
药物信息有三个层次:基础信息(名称、剂量)、状态信息(绑定与否)、警告信息(相互作用)。这三个层次在视觉上必须清晰区分,但又要在一张紧凑的卡片中和谐共存。
`` ypescript
@Component
export struct MedicineCard {
@Prop medicineName: string = ‘’
@Prop dosage: string = ‘’
@Prop isBound: boolean = false
@Prop hasInteraction: boolean = false
@Prop interactionDetail: string = ‘’
public onCardClick: () => void = () => {}
build() {
Row() {
Column() {
Text(this.medicineName)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(‘#333333’)
Text(this.dosage)
.fontSize(13)
.fontColor('#666666')
.margin({ top: 4 })
if (this.isBound) {
Text('已绑定')
.fontSize(12)
.fontColor('#FFFFFF')
.backgroundColor('#4CAF50')
.borderRadius(4)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.margin({ top: 6 })
} else {
Text('未绑定')
.fontSize(12)
.fontColor('#999999')
.backgroundColor('#F5F5F5')
.borderRadius(4)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.margin({ top: 6 })
}
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
if (this.hasInteraction) {
Badge({ count: 1, position: BadgePosition.RightTop, maxCount: 1 }) {
Image(('app.media.ic_warning'))
.width(24)
.height(24)
.fillColor('#F44336')
}
.width(36)
.height(36)
}
}
.width('100%')
.padding(12)
.backgroundColor('#FFFFFF')
.borderRadius(8)
.shadow({ radius: 2, color: '#1A000000', offsetY: 1 })
.onClick(() => {
this.onCardClick()
})
}
}
``
布局分析:
外层Row实现水平排列:左侧是药物信息(Column),右侧是警告图标(Badge)。layoutWeight(1)使信息区占据剩余空间,警告图标保持固定宽度。
信息层次的处理:
- 第一层——名称:16fp 粗体,#333333 深色,最醒目
- 第二层——剂量:13fp 常规,#666666 中灰,次醒目
- 第三层——绑定状态:12fp 标签样式,绿色/灰色背景区分
- 第四层——相互作用警告:Badge 徽章 + 红色图标,视觉最突出的警告信号
3.2 相互作用警告徽章
药物相互作用是 IVGuard 的核心安全特性之一。当系统检测到当前药物与患者已绑定的其他药物存在潜在相互作用时,MedicineCard 会显示一个红色警告徽章。
Badge 组件的使用:
ypescript Badge({ count: 1, position: BadgePosition.RightTop, maxCount: 1 }) { Image(('app.media.ic_warning')) .width(24) .height(24) .fillColor('#F44336') } .width(36) .height(36)
count: 1:显示数字 1,表示有 1 条相互作用警告position: BadgePosition.RightTop:徽章位于右上角maxCount: 1:最大显示数字为 1(超过则显示 1+)- 内部是警告图标,填充红色
为什么用 Badge 而不是简单的图标? Badge 提供了数量指示的能力。当一种药物与多种其他药物存在相互作用时,Badge 的 count 值可以递增,提供更精确的警告信息。这在复杂用药场景(如 ICU 患者同时使用 5-10 种药物)中尤为重要。
3.3 点击回调设计
MedicineCard 的点击回调onCardClick是组件交互的核心入口。它的设计看似简单,但在 ArkTS 中有一个重要的陷阱需要注意。
ypescript public onCardClick: () => void = () => {}
为什么必须是 public 而不能是 private?
在 TypeScript 中,将回调属性声明为private是完全合法的,也是常见的封装实践。但在 ArkTS 中,这样做会导致编译错误。原因如下:
- ArkUI 的组件构造器(即
MedicineCard({ ... }))在编译期会生成属性赋值代码 - 如果属性是
private,外部无法赋值,构造器中的赋值操作就会违反访问控制 - 因此,ArkTS 要求所有需要在构造器中初始化的属性必须是
public
错误示例(编译报错):
`` ypescript
@Component
export struct MedicineCard {
private onCardClick: () => void = () => {} // 编译错误
build() {
// …
}
}
// 使用时
MedicineCard({
onCardClick: () => { this.navigateToDetail() } // 无法赋值给 private 属性
})
``
正确做法:
`` ypescript
@Component
export struct MedicineCard {
public onCardClick: () => void = () => {} // public 允许构造器赋值
build() {
// …
}
}
``
设计权衡: 将回调属性暴露为 public 意味着任何持有组件引用的代码都可以重新赋值该属性,这确实削弱了封装性。但在 ArkTS 的组件模型中,组件的属性本就是通过构造器从外部传入的——private 的概念在"值类型 + 构造器初始化"的模型中意义有限。因此,public 回调属性是 ArkUI 组件的惯用模式。
回调的默认值: () => {}(空函数)作为默认值确保了组件在没有传入回调时不会崩溃。这是防御性编程的基本实践——调用this.onCardClick()时,即使父组件没有传入回调,也不会抛出 undefined is not a function 错误。
4. AlertCard 预警卡片
AlertCard 是 IVGuard 预警系统的 UI 呈现载体。每一条预警信息(输液流速异常、液位过低、药物相互作用等)都以 AlertCard 的形式展示。该组件的设计重点在于严重程度的视觉区分、时间信息的格式化,以及预警处理的状态管理。
4.1 严重程度标签系统
IVGuard 的预警分为三个严重程度:danger(危险)、warning(警告)、info(提示)。每个级别对应不同的视觉样式,确保护理人员能够在第一时间判断预警的紧急程度。
`` ypescript
@Component
export struct AlertCard {
@Prop alertTitle: string = ‘’
@Prop alertMessage: string = ‘’
@Prop severity: string = ‘info’
@Prop alertTime: number = 0
@Prop isHandled: boolean = false
public onHandle: () => void = () => {}
build() {
Row() {
Column() {
Row() {
Text(this.getSeverityLabel())
.fontSize(11)
.fontColor(‘#FFFFFF’)
.backgroundColor(this.getSeverityColor())
.borderRadius(4)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
Text(this.alertTitle)
.fontSize(15)
.fontWeight(FontWeight.Medium)
.fontColor('#333333')
.margin({ left: 8 })
.layoutWeight(1)
Text(this.formatTime(this.alertTime))
.fontSize(12)
.fontColor('#999999')
}
.width('100%')
Text(this.alertMessage)
.fontSize(13)
.fontColor('#666666')
.margin({ top: 8 })
.width('100%')
}
.layoutWeight(1)
if (this.isHandled) {
Text('已处理')
.fontSize(12)
.fontColor('#4CAF50')
} else {
Button('处理')
.fontSize(12)
.height(28)
.backgroundColor(this.getSeverityColor())
.fontColor('#FFFFFF')
.onClick(() => {
this.onHandle()
})
}
}
.width('100%')
.padding(12)
.backgroundColor('#FFFFFF')
.borderRadius(8)
.borderWidth(1)
.borderColor(this.getSeverityBorderColor())
}
private getSeverityColor(): string {
switch (this.severity) {
case ‘danger’:
return ‘#F44336’
case ‘warning’:
return ‘#FF9800’
case ‘info’:
default:
return ‘#2196F3’
}
}
private getSeverityLabel(): string {
switch (this.severity) {
case ‘danger’:
return ‘危险’
case ‘warning’:
return ‘警告’
case ‘info’:
default:
return ‘提示’
}
}
private getSeverityBorderColor(): string {
switch (this.severity) {
case ‘danger’:
return ‘#33F44336’
case ‘warning’:
return ‘#33FF9800’
case ‘info’:
default:
return ‘#332196F3’
}
}
private formatTime(timestamp: number): string {
const date = new Date(timestamp)
const hours = date.getHours().toString().padStart(2, ‘0’)
const minutes = date.getMinutes().toString().padStart(2, ‘0’)
return hours + ‘:’ + minutes
}
}
``
严重程度的视觉层次:
| 级别 | 标签文字 | 标签背景色 | 边框色 | 按钮色 | 语义 |
|---|---|---|---|---|---|
| danger | 危险 | #F44336 | #33F44336 | #F44336 | 需立即处理 |
| warning | 警告 | #FF9800 | #33FF9800 | #FF9800 | 需尽快关注 |
| info | 提示 | #2196F3 | #332196F3 | #2196F3 | 一般性通知 |
边框色的设计细节: 边框色使用了带透明度的颜色值(如#33F44336是 #F44336 加 20% 透明度)。这样做是为了让边框与标签颜色保持色系一致,但不至于过于刺眼。实色边框在多条预警并列时会造成视觉噪声,半透明边框则更为温和。
4.2 时间格式化
预警的时效性至关重要。formatTime方法将时间戳转换为 HH:mm 格式,方便护理人员快速判断预警的新旧程度。
ypescript private formatTime(timestamp: number): string { const date = new Date(timestamp) const hours = date.getHours().toString().padStart(2, '0') const minutes = date.getMinutes().toString().padStart(2, '0') return hours + ':' + minutes }
设计决策:
- 为什么只显示时:分而非完整日期? 在临床场景中,护理人员关心的不是"哪一天"(他们知道是今天),而是"多久之前"。HH:mm 格式足够表达这一信息。
- padStart(2, ‘0’) 的必要性:不补零的话,9:5 看起来不如 09:05 正规,也占用不稳定的宽度。
- 为什么不用相对时间(如"5分钟前")? 相对时间需要持续更新(定时器刷新),增加了组件的复杂度。在 IVGuard 的使用场景中,预警列表的刷新频率足够高,绝对时间已经能满足需求。
ArkTS 限制下的日期处理: 注意 formatTime 是一个私有方法而非计算属性。在 ArkTS 中,get 访问器(getter)虽然语法上合法,但在@Component struct中使用时可能触发框架的优化限制。使用方法调用是更安全的选择。
4.3 处理按钮交互
AlertCard 的核心交互是"处理"按钮。点击后,预警状态从"未处理"切换为"已处理",按钮消失,显示"已处理"文字。
ypescript if (this.isHandled) { Text('已处理') .fontSize(12) .fontColor('#4CAF50') } else { Button('处理') .fontSize(12) .height(28) .backgroundColor(this.getSeverityColor()) .fontColor('#FFFFFF') .onClick(() => { this.onHandle() }) }
状态切换的流程:
- 用户点击"处理"按钮
- 触发
onHandle回调,通知父组件 - 父组件更新预警数据(isHandled = true)
- 框架自动将新值同步到
@Prop isHandled build()重新执行,条件分支切换到"已处理"
为什么不在子组件内部直接修改状态? 这是单向数据流的体现。AlertCard 只负责展示和触发回调,不负责修改数据。状态修改发生在父组件中,数据流向可追踪。如果子组件直接修改@Prop,虽然编译不报错,但修改不会同步回父组件,造成数据不一致。
按钮颜色与严重程度同步: "处理"按钮的背景色与严重程度标签一致——danger 用红色按钮,warning 用橙色按钮,info 用蓝色按钮。这强化了严重程度的视觉记忆,让护理人员在点击前就能感知预警级别。
5. MonitorOverlay 监控叠加层
MonitorOverlay 是 IVGuard 中技术含量最高的组件之一。它以绝对定位的方式叠加在摄像头预览画面之上,渲染识别标记框、液位线和百分比标注。这个组件虽然只有约 48 行代码,却涉及 Stack 绝对定位、条件渲染、坐标计算等多个技术要点,而且包含一个从const到@Prop的重要教训。
5.1 Stack 绝对定位布局
MonitorOverlay 的核心布局策略是 Stack + position() 绝对定位。Stack 允许子组件通过position()属性精确定位到任意坐标,这是叠加层的天然选择。
ypescript Stack() { ForEach(this.results, (result: VisionResult) => { if (result.markerId === this.activeMarkerId) { Row().width(200).height(350) .borderWidth(2).borderColor('#4CAF50').borderStyle(BorderStyle.Dashed) .position({ x: 100, y: 50 }) } else { Row().width(200).height(350) .borderWidth(1).borderColor('#999999').borderStyle(BorderStyle.Dashed) .position({ x: 320, y: 50 }) } }, (result: VisionResult) => result.markerId) }
布局策略解析:
- Stack 作为容器:Stack 的默认行为是子组件居中堆叠,但配合
position()后,每个子组件可以独立定位。这比 Flex 的绝对定位(需要额外的 alignItems 设置)更直观。 - ForEach 渲染标记框:每个识别到的输液瓶对应一个标记框。
ForEach的第三个参数是键值生成器,确保列表更新的高效性。 - Row 作为标记框载体:标记框本质上是一个空 Row,通过边框样式呈现。为什么不直接用 Shape 或 Canvas?因为 Row 的边框样式支持
BorderStyle.Dashed(虚线),虚线框是视觉识别领域的标准标记方式。
为什么用虚线而非实线? 实线边框会遮挡摄像头预览画面中的细节(如输液瓶上的刻度线),虚线则在提供视觉标记的同时保留了底层画面的可见性。这是计算机视觉应用中叠加层设计的通用惯例。
5.2 多标记框渲染
当摄像头画面中同时识别到多个输液瓶时,MonitorOverlay 需要渲染多个标记框。不同标记框有不同的视觉权重——激活标记(用户当前选中的)用绿色粗虚线突出,非激活标记用灰色细虚线淡化。
激活标记 vs 非激活标记:
| 属性 | 激活标记 | 非激活标记 |
|---|---|---|
| borderWidth | 2 | 1 |
| borderColor | #4CAF50 (绿) | #999999 (灰) |
| borderStyle | Dashed | Dashed |
| 语义 | 当前选中的输液瓶 | 其他识别到的输液瓶 |
条件渲染的实现:
ypescript if (result.markerId === this.activeMarkerId) { // 激活标记 - 绿色粗虚线 Row() .width(200) .height(350) .borderWidth(2) .borderColor('#4CAF50') .borderStyle(BorderStyle.Dashed) .position({ x: 100, y: 50 }) } else { // 非激活标记 - 灰色细虚线 Row() .width(200) .height(350) .borderWidth(1) .borderColor('#999999') .borderStyle(BorderStyle.Dashed) .position({ x: 320, y: 50 }) }
position() 的坐标计算: 在实际产品中,标记框的位置应该基于视觉识别算法返回的坐标来动态计算,而非硬编码。上述代码中的 x:100, y:50 和 x:320, y:50 是简化示例。生产级代码应该类似:
ypescript .position({ x: result.bbox.x, y: result.bbox.y }) .width(result.bbox.width) .height(result.bbox.height)
其中 result.bbox 是视觉识别算法返回的边界框(Bounding Box),包含 x、y、width、height 四个值。
5.3 液位线与百分比标注
液位线是 MonitorOverlay 最精妙的部分。它是一条蓝色水平线,叠加在标记框内部,位置对应输液瓶中的实际液面高度。线的旁边标注百分比数字,提供精确读数。
ypescript if (this.results.length > 0 && this.activeLevel > 0) { Row().width(200).height(2).backgroundColor('#2196F3') .position({ x: 100, y: 50 + (100 - this.activeLevel) * 3.5 }) Text(this.activeLevel + '%').fontSize(14).fontColor('#2196F3') .position({ x: 305, y: 50 + (100 - this.activeLevel) * 3.5 - 10 }) }
坐标计算详解:
液位线的 Y 坐标公式为:
y = 50 + (100 - level) * 3.5
这个公式的推导过程如下:
- 标记框的顶部 Y 坐标:50(标记框的 position.y)
- 标记框的高度:350px
- 液位映射:100% 液位对应标记框顶部(y=50),0% 液位对应标记框底部(y=400)
- 像素换算:350px / 100% = 3.5px/%,即每 1% 液位对应 3.5px
- Y 坐标:当液位为 level% 时,液位线距标记框顶部 (100 - level) * 3.5 像素
示例计算:
| 液位 | Y 坐标计算 | Y 值 | 视觉位置 |
|---|---|---|---|
| 100% | 50 + (100-100)*3.5 = 50 | 50 | 标记框顶部(满瓶) |
| 75% | 50 + (100-75)*3.5 = 137.5 | 137.5 | 标记框上部1/4 |
| 50% | 50 + (100-50)*3.5 = 225 | 225 | 标记框中部 |
| 25% | 50 + (100-25)*3.5 = 312.5 | 312.5 | 标记框下部3/4 |
| 0% | 50 + (100-0)*3.5 = 400 | 400 | 标记框底部(空瓶) |
百分比标注的偏移: x:305 使文字位于标记框右侧(标记框右边界为 100+200=300),y: … - 10 使文字基线上移 10px,与液位线视觉居中对齐(文字高度约 14fp,基线上移半个字高约 7-10px)。
颜色选择: #2196F3(蓝色)与标记框的绿色(#4CAF50)形成色相对比,避免视觉混淆。液位线是蓝色、标记框是绿色、百分比文字是蓝色——颜色语义一致(液位信息用蓝色系),功能区分清晰(标记框用绿色系)。
5.4 从 const 到 @Prop 的教训
MonitorOverlay 的开发过程中遇到了一个典型的 ArkTS 限制问题:在build()方法中使用const声明变量会触发编译错误。
原始设计(编译报错):
`` ypescript
@Component
export struct MonitorOverlay {
@Prop results: VisionResult[] = []
@Prop activeMarkerId: string = ‘’
build() {
const level = this.results[0].level // 编译错误:build() 中不允许 const 声明
Stack() {
// 使用 level 渲染液位线...
}
}
}
``
修复方案:
`` ypescript
@Component
export struct MonitorOverlay {
@Prop results: VisionResult[] = []
@Prop activeMarkerId: string = ‘’
@Prop activeLevel: number = 0 // 从父组件传入
build() {
Stack() {
// 使用 this.activeLevel 渲染液位线…
}
}
}
``
原因分析:
ArkTS 对build()方法施加了严格的语法限制:不允许使用 const、let、var 等变量声明语句。这个限制的出发点是:
- 声明式 UI 的纯粹性:build() 方法应该只包含 UI 声明,不应该包含命令式逻辑
- 性能优化:禁止变量声明使框架可以更激进地优化 build() 的执行和缓存
- 可预测性:变量声明可能引入闭包、作用域等复杂语义,增加框架追踪状态依赖的难度
替代方案对比:
| 方案 | 代码 | 可行性 | 推荐度 |
|---|---|---|---|
| @Prop | @Prop activeLevel: number = 0 | 完全可行 | 最高 |
| 内联表达式 | this.results.length > 0 ? this.results[0].level : 0 | 可行但冗长 | 中等 |
| 私有方法 | private getActiveLevel(): number | 可行 | 较高 |
| const(在 build 中) | const level = … | 编译错误 | 不可行 |
最终选择 @Prop 的理由: activeLevel 是由父组件的计算结果(从视觉识别数据中提取的当前激活标记的液位值),由父组件传入是最自然的做法。同时,@Prop 的值变化会自动触发 build() 重新执行,确保液位线始终与最新数据同步。
这个教训的普遍意义: 在 ArkUI 开发中,任何需要在 build() 方法中使用的计算值,都应该考虑通过 @Prop(来自父组件)或私有方法(组件内部计算)来提供,而不是试图在 build() 中声明局部变量。这不仅是一个编码规范问题,更是 ArkTS 编译器的硬性限制。
6. HospitalMapView 医院平面图
HospitalMapView 是 IVGuard 中代码量最大的组件(约 97 行),也是最复杂的 Canvas 2D 渲染组件。它负责绘制医院楼层平面图,标注各类 POI(兴趣点),显示用户当前位置,并绘制导航路径。
6.1 Canvas 2D 渲染引擎
ArkUI 的 Canvas 组件提供了 2D 图形绘制能力,API 与 HTML5 Canvas 基本一致。HospitalMapView 使用 Canvas 绘制所有图形元素,而非组合 ArkUI 内置组件——这是因为平面图的绘制涉及大量自由定位的图形元素(线条、矩形、圆弧、文字),用 Canvas 更高效也更灵活。
ypescript Canvas(this.context).width('100%').height(350) .onReady(() => { this.drawMap() })
完整组件结构:
`` ypescript
@Component
export struct HospitalMapView {
@Prop pois: POI[] = []
@Prop currentFloor: number = 1
@Prop userX: number = 150
@Prop userY: number = 200
@Prop targetPoiId: string = ‘’
private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(new Settings())
build() {
Canvas(this.context)
.width(‘100%’)
.height(350)
.onReady(() => {
this.drawMap()
})
}
private drawMap() {
const ctx = this.context
ctx.clearRect(0, 0, ctx.width, ctx.height)
this.drawFloor(ctx)
this.drawPOIs(ctx)
this.drawUserPosition(ctx)
this.drawNavigationPath(ctx)
}
}
``
Canvas 渲染架构:
- onReady 回调:Canvas 组件初始化完成后触发 onReady,此时可以安全地获取绘图上下文并开始绘制。不能在 build() 中直接绘制——Canvas 可能尚未就绪。
- drawMap 主函数:组织所有绘制步骤,按"底图→POI→用户位置→导航路径"的顺序绘制,确保层叠顺序正确。
- clearRect:每次绘制前清空画布,避免残影。
为什么不在 build() 中组合组件? 试想用 Row/Column/Shape 组合来绘制一个包含 20 个 POI、5 条走廊、1 个导航路径的平面图——代码量会暴增 3-5 倍,性能也会下降。Canvas 是"一次性绘制"模式,适合大量图形元素的渲染。
6.2 POI 颜色编码系统
每个 POI 类型对应一种颜色,形成视觉编码系统。护理人员通过颜色即可快速识别 POI 类型,无需阅读文字。
ypescript private getPoiColor(type: string): string { switch (type) { case 'nurse_station': return '#4CAF50' case 'infusion_room': return '#2196F3' case 'emergency': return '#F44336' case 'pharmacy': return '#FF9800' case 'ward': return '#9C27B0' default: return '#999999' } }
颜色编码详解:
| POI 类型 | 颜色 | 色值 | 语义联想 |
|---|---|---|---|
| nurse_station(护士站) | 绿色 | #4CAF50 | 安全、求助 |
| infusion_room(输液室) | 蓝色 | #2196F3 | 医疗、专业 |
| emergency(急诊) | 红色 | #F44336 | 紧急、危险 |
| pharmacy(药房) | 橙色 | #FF9800 | 药品、注意 |
| ward(病房) | 紫色 | #9C27B0 | 休息、隐私 |
颜色选择的考量:
- 可区分性:5 种颜色在色相环上分布均匀(绿-蓝-红-橙-紫),相互之间不易混淆
- 语义合理性:红色=急诊是最直觉的关联,绿色=护士站暗示"安全/可求助"
- 与系统其他颜色的一致性:#4CAF50(绿)、#F44336(红)、#FF9800(橙)与 LevelProgress 和 AlertCard 使用相同色值,保持系统级颜色语义一致
POI 绘制方法:
`` ypescript
private drawPOIs(ctx: CanvasRenderingContext2D) {
const filteredPois = this.pois.filter((poi: POI) => poi.floor === this.currentFloor)
for (const poi of filteredPois) {
const color = this.getPoiColor(poi.type)
ctx.fillStyle = color
ctx.fillRect(poi.x, poi.y, poi.width, poi.height)
ctx.strokeStyle = '#333333'
ctx.lineWidth = 1
ctx.strokeRect(poi.x, poi.y, poi.width, poi.height)
ctx.fillStyle = '#FFFFFF'
ctx.font = '12px sans-serif'
ctx.textAlign = 'center'
ctx.fillText(poi.name, poi.x + poi.width / 2, poi.y + poi.height / 2 + 4)
}
}
``
每个 POI 的绘制包含三个步骤:填充色块、描边轮廓、居中文字。三步缺一不可——色块提供颜色编码,描边区分相邻 POI,文字提供精确名称。
6.3 用户位置蓝点
在平面图上标注用户当前位置是导航功能的基础。蓝点是位置标注的标准视觉符号(源自 Google Maps 的设计惯例)。
`` ypescript
private drawUserPosition(ctx: CanvasRenderingContext2D) {
ctx.fillStyle = ‘#2196F3’
ctx.beginPath()
ctx.arc(userX, userY, 6) // 蓝色圆点
ctx.fill()
ctx.fillStyle = ‘#2196F3’
ctx.font = ‘11px sans-serif’
ctx.textAlign = ‘center’
ctx.fillText(‘您在这里’, this.userX, this.userY - 12)
}
``
绘制细节:
- arc(x, y, 6):半径 6px 的蓝色圆点。6px 足够醒目又不遮挡平面图细节。
- "您在这里"文字:位于蓝点正上方 12px 处。使用中文而非"Here"或"You"是面向国内用户的本地化选择。
- 2196F3 蓝色:与输液室 POI 同色系但用途不同——蓝点是位置标记而非 POI 类型标识。在视觉上,蓝点(圆形+文字标注)与 POI(矩形+名称文字)的形态差异足以区分。
定位精度说明: userX 和 userY 是父组件传入的坐标值。在实际应用中,这些值来自室内定位系统(如 BLE 信标、WiFi 指纹定位)。定位精度通常在 3-5 米范围内,对应的像素偏移量在平面图的比例尺下约为 10-20px,可以接受。
6.4 导航路径虚线
当用户选择了目标 POI 后,HospitalMapView 会绘制一条从用户当前位置到目标 POI 的导航路径。路径使用虚线样式,与实线区分。
`` ypescript
private drawNavigationPath(ctx: CanvasRenderingContext2D) {
if (this.targetPoiId === ‘’) {
return
}
const targetPoi = this.pois.find((poi: POI) =>
poi.id === this.targetPoiId && poi.floor === this.currentFloor
)
if (!targetPoi) {
return
}
ctx.strokeStyle = ‘#4CAF50’
ctx.lineWidth = 2
ctx.setLineDash([5, 3])
ctx.beginPath()
ctx.moveTo(this.userX, this.userY)
ctx.lineTo(targetPoi.x + targetPoi.width / 2, targetPoi.y + targetPoi.height / 2)
ctx.stroke()
ctx.setLineDash([])
}
``
虚线参数: setLineDash([5, 3]) 表示 5px 实线 + 3px 间隔的虚线模式。5:3 的比例在导航路径中视觉效果清晰,不会像 1:1 那样显得过于密集,也不会像 10:5 那样看起来像断线。
路径的简化处理: 上述代码使用 lineTo 绘制直线段,这是最简化的路径。在实际应用中,导航路径需要沿着走廊行进,不能穿墙。实现方式是将路径拆分为多段 lineTo,每段对应一条走廊。走廊数据通常来自建筑的 BIM(建筑信息模型)系统。
setLineDash([]) 的重置: 绘制完虚线后,必须调用 setLineDash([]) 重置为实线模式。否则后续的绘制操作(如 POI 描边、蓝点等)也会变成虚线。这是一个常见的 Canvas 编程陷阱——setLineDash 的效果是持久的,不会自动重置。
6.5 楼层数据切换
医院通常有多个楼层,每个楼层的平面图和 POI 数据不同。HospitalMapView 通过 currentFloor 参数过滤当前楼层的 POI 数据。
ypescript private drawMap() { const ctx = this.context ctx.clearRect(0, 0, ctx.width, ctx.height) this.drawFloor(ctx) this.drawPOIs(ctx) this.drawUserPosition(ctx) this.drawNavigationPath(ctx) }
楼层切换的流程:
- 用户在父组件中选择楼层(如"3F")
- 父组件更新 currentFloor 状态
- @Prop currentFloor 同步新值到 HospitalMapView
- 调用 updateView() 手动触发重绘
为什么需要手动 updateView? Canvas 的绘制是命令式的——drawMap() 执行后,图形就固定在画布上。@Prop 的变化虽然会触发 build() 重新执行,但 build() 中只声明了 Canvas(this.context).onReady(…) ——onReady 只在 Canvas 首次就绪时触发,不会因为 @Prop 变化而重新触发。因此需要手动调用绘制方法。
updateView 的实现:
ypescript public updateView() { this.drawMap() }
这是一个 public 方法,由父组件在楼层切换时调用:
`` ypescript
// 父组件
HospitalMapView({
pois: this.pois,
currentFloor: this.currentFloor,
userX: this.userX,
userY: this.userY,
targetPoiId: this.targetPoiId
})
// 楼层切换时
this.currentFloor = newFloor
this.hospitalMapRef.updateView() // 手动触发重绘
``
Canvas 刷新机制的权衡: 与声明式 UI(自动响应状态变化)不同,Canvas 是命令式 UI(需要手动触发重绘)。这两种模式各有利弊:
- 声明式:开发简单,状态驱动,但灵活性有限
- 命令式:灵活度高,性能可控,但需要手动管理刷新
在平面图这种场景中,命令式绘制是更好的选择——平面图的元素多、定位精确、更新频率低(楼层切换时才刷新),命令式模式可以精确控制何时重绘,避免不必要的性能开销。
7. FlowChart 流速趋势图
FlowChart 是 IVGuard 中展示输液流速历史趋势的组件。它使用 Canvas 绘制折线图,直观呈现流速随时间的变化趋势,帮助护理人员判断流速是否稳定、是否需要调整。整个组件约 65 行代码,是 Canvas 折线图绘制的典型范例。
7.1 Canvas 折线图绘制
折线图的绘制分为三个步骤:坐标轴(Y 轴标签 + 网格线)、数据折线、数据点。每个步骤都需要精确的坐标计算。
`` ypescript
@Component
export struct FlowChart {
@Prop records: FlowRecord[] = []
private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(new Settings())
build() {
Canvas(this.context)
.width(‘100%’)
.height(200)
.onReady(() => {
this.drawChart()
})
}
private drawChart() {
const ctx = this.context
const width = ctx.width
const height = ctx.height
ctx.clearRect(0, 0, width, height)
this.drawYAxis(ctx, height)
this.drawGridLines(ctx, width, height)
if (this.records.length === 0) {
this.drawEmptyState(ctx, width, height)
return
}
this.drawDataLine(ctx, width, height)
}
}
``
Y 轴标签渲染:
`` ypescript
private drawYAxis(ctx: CanvasRenderingContext2D, height: number) {
const labels = [‘100%’, ‘75%’, ‘50%’, ‘25%’, ‘0%’]
const yPositions = [20, 55, 90, 125, 160]
ctx.fillStyle = ‘#999999’
ctx.font = ‘10px sans-serif’
ctx.textAlign = ‘right’
for (let i = 0; i < labels.length; i++) {
ctx.fillText(labels[i], 45, yPositions[i] + 3)
}
}
``
Y 坐标计算公式:
流速的 Y 坐标由液位百分比换算:
y = 160 - (level / 100) * 140
推导过程:
- 图表绘图区域:Y 从 20(100% 位置)到 160(0% 位置),总高度 140px
- 液位 level% 的 Y 坐标:距 0% 位置 (level/100) * 140 像素
- 由于 Y 轴向下递增,需要取反:160 - (level/100) * 140
网格线渲染:
`` ypescript
private drawGridLines(ctx: CanvasRenderingContext2D, width: number, height: number) {
ctx.strokeStyle = ‘#EEEEEE’
ctx.lineWidth = 1
const yPositions = [20, 55, 90, 125, 160]
for (const y of yPositions) {
ctx.beginPath()
ctx.moveTo(50, y)
ctx.lineTo(width - 10, y)
ctx.stroke()
}
}
``
4 条水平网格线对应 25%/50%/75%/100% 四个刻度,帮助用户快速判断流速值的相对位置。使用浅灰色 #EEEEEE 确保网格线不干扰数据折线的视觉。
7.2 最近 30 条数据截断
输液流速数据可能持续数小时甚至数天,但折线图的可视空间有限。显示最近 30 条记录是视觉清晰度和信息完整性的平衡点。
`` ypescript
private drawDataLine(ctx: CanvasRenderingContext2D, width: number, height: number) {
const displayRecords = this.records.slice(-30)
const xStep = (width - 60) / Math.max(displayRecords.length - 1, 1)
ctx.strokeStyle = ‘#2196F3’
ctx.lineWidth = 2
ctx.beginPath()
for (let i = 0; i < displayRecords.length; i++) {
const record = displayRecords[i]
const x = 50 + i * xStep
const y = 160 - (record.level / 100) * 140
if (i === 0) {
ctx.moveTo(x, y)
} else {
ctx.lineTo(x, y)
}
}
ctx.stroke()
this.drawDataPoints(ctx, displayRecords, xStep)
}
``
X 步长计算: (width - 60) / (length - 1)。width - 60 扣除左侧 Y 轴标签区域(约 50px)和右侧边距(约 10px)。除以 length - 1 是因为 N 个数据点之间有 N-1 个间隔。
Math.max 的防御性编程: Math.max(displayRecords.length - 1, 1) 防止除零错误。当只有 1 条记录时,length - 1 = 0,除零会产生 Infinity,导致绘制异常。
数据点绘制:
`` ypescript
private drawDataPoints(ctx: CanvasRenderingContext2D, records: FlowRecord[], xStep: number) {
ctx.fillStyle = ‘#2196F3’
for (let i = 0; i < records.length; i++) {
const x = 50 + i * xStep
const y = 160 - (records[i].level / 100) * 140
ctx.beginPath()
ctx.arc(x, y, 3, 0, 2 * Math.PI)
ctx.fill()
}
}
``
每个数据点绘制一个半径 3px 的蓝色圆点。3px 是折线图中数据点的标准大小——大到可见,小到不重叠(在 30 个数据点的密度下)。
7.3 空状态处理
当没有流速记录时,FlowChart 不应显示空白——空白会让用户困惑(“是加载失败还是真的没数据?”)。显示"暂无数据"文字是标准的空状态处理方式。
ypescript private drawEmptyState(ctx: CanvasRenderingContext2D, width: number, height: number) { ctx.fillStyle = '#CCCCCC' ctx.font = '14px sans-serif' ctx.textAlign = 'center' ctx.fillText('暂无数据', width / 2, height / 2) }
空状态设计原则:
- 居中显示:width/2, height/2 使文字位于图表正中央
- 浅灰色:#CCCCCC 明确表示这不是正常数据,而是占位信息
- “暂无数据"而非"无数据”:"暂"暗示未来会有数据,减少用户焦虑
为什么在 drawChart 中提前返回? 空状态下不需要绘制数据折线和数据点,提前 return 避免了不必要的计算和潜在的数组越界错误。
8. CostPieChart 费用饼图
CostPieChart 是 IVGuard 中展示输液费用构成的组件。它使用 Canvas 绘制饼图,直观呈现药品费用与其他费用(如耗材费、护理费、床位费等)的占比关系。整个组件约 65 行代码,核心在于饼图的角度计算算法和图例渲染。
8.1 Canvas 饼图绘制算法
饼图的绘制核心是角度计算——每个扇区的角度与其数值占比成正比。
`` ypescript
@Component
export struct CostPieChart {
@Prop drugCost: number = 0
@Prop otherCost: number = 0
private chartSize: number = 140
private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(new Settings())
build() {
Canvas(this.context)
.width(this.chartSize)
.height(this.chartSize)
.onReady(() => {
this.drawPie()
})
}
private drawPie() {
const ctx = this.context
const total = this.drugCost + this.otherCost
if (total === 0) {
this.drawEmptyPie(ctx)
return
}
const cx = this.chartSize / 2
const cy = this.chartSize / 2
const radius = this.chartSize / 2 - 10
const drugAngle = (this.drugCost / total) * 2 * Math.PI
const startAngle = -Math.PI / 2
ctx.clearRect(0, 0, this.chartSize, this.chartSize)
// 药品扇区(蓝色)
ctx.fillStyle = '#2196F3'
ctx.beginPath()
ctx.moveTo(cx, cy)
ctx.arc(cx, cy, radius, startAngle, startAngle + drugAngle)
ctx.closePath()
ctx.fill()
// 其他扇区(橙色)
ctx.fillStyle = '#FF9800'
ctx.beginPath()
ctx.moveTo(cx, cy)
ctx.arc(cx, cy, radius, startAngle + drugAngle, startAngle + 2 * Math.PI)
ctx.closePath()
ctx.fill()
this.drawLegend(ctx)
}
}
``
角度计算详解:
- 总角度:2*Pi(360度),表示 100% 的费用
- 药品角度:drugAngle = (drugCost / total) * 2*Pi,药品费用占总费用的比例乘以总角度
- 起始角度:-Pi/2(即 -90度),对应 12 点钟方向。Canvas 的默认起始角度是 3 点钟方向(0度),但饼图从 12 点钟方向开始是更符合直觉的惯例
- 药品扇区:从 -Pi/2 到 -Pi/2 + drugAngle
- 其他扇区:从 -Pi/2 + drugAngle 到 -Pi/2 + 2*Pi(即回到起点)
绘制步骤解析:
每个扇区的绘制遵循 beginPath -> moveTo(圆心) -> arc(弧线) -> closePath -> fill 的模式:
- moveTo(cx, cy):从圆心开始画
- arc(cx, cy, radius, start, end):画弧线
- closePath():闭合路径(从弧线终点回到圆心)
- fill():填充
这个过程会绘制一个"扇形"——圆心 + 弧线 + 两条半径线围成的区域。
总费用为 0 的防御: if (total === 0) 检查防止除零错误。当两个费用都为 0 时,显示空饼图(灰色圆环)。
8.2 图例渲染
饼图需要图例来解释每种颜色代表的含义。图例使用 fillRect 绘制色块标记,搭配文字说明。
`` ypescript
private drawLegend(ctx: CanvasRenderingContext2D) {
const legendX = 10
const legendY = this.chartSize - 30
// 药品图例
ctx.fillStyle = ‘#2196F3’
ctx.fillRect(legendX, legendY, 10, 10)
ctx.fillStyle = ‘#333333’
ctx.font = ‘11px sans-serif’
ctx.textAlign = ‘left’
ctx.fillText(‘药品’, legendX + 14, legendY + 9)
// 其他图例
ctx.fillStyle = ‘#FF9800’
ctx.fillRect(legendX + 60, legendY, 10, 10)
ctx.fillStyle = ‘#333333’
ctx.fillText(‘其他’, legendX + 74, legendY + 9)
}
``
图例布局: 图例位于饼图下方,水平排列。色块(10x10 方块)+ 间隔 4px + 文字,两组图例间距 60px。
色块 vs 圆点的选择: 图例标记可以是色块(fillRect)或圆点(arc)。色块是饼图图例的传统形式——方块与扇区的"面积"视觉属性更匹配,而圆点与"数量"的视觉属性更匹配。
8.3 size 属性冲突的教训
CostPieChart 开发过程中遇到了一个难以理解的编译错误,原因是成员属性名与 CustomComponent 的通用属性名冲突。
原始设计(编译报错):
`` ypescript
@Component
export struct CostPieChart {
@Prop drugCost: number = 0
@Prop otherCost: number = 0
private size = 140 // 与 CustomComponent.size 属性冲突
build() {
Canvas(this.context)
.width(this.size)
.height(this.size)
.onReady(() => { this.drawPie() })
}
}
``
报错信息: 类似 “Cannot assign to ‘size’ because it is a read-only property” 或 “Duplicate identifier ‘size’”。
原因分析: 在 ArkUI 中,所有@Component struct都隐式继承自 CustomComponent,而 CustomComponent 定义了一系列通用属性方法(如 size()、width()、height()、padding() 等)。当我们声明一个名为 size 的成员属性时,就与 CustomComponent 的 size 方法产生了名称冲突。
CustomComponent 的常见通用属性名:
| 通用属性 | 类型 | 冲突风险 |
|---|---|---|
| size | method | 极高 |
| width | method | 极高 |
| height | method | 极高 |
| padding | method | 高 |
| margin | method | 高 |
| layoutWeight | method | 中等 |
| backgroundColor | method | 低 |
| borderRadius | method | 低 |
| enabled | property | 中等 |
| visibility | property | 中等 |
修复方案: 将 size 改名为 chartSize,避免冲突。
`` ypescript
@Component
export struct CostPieChart {
@Prop drugCost: number = 0
@Prop otherCost: number = 0
private chartSize: number = 140 // 改名避免冲突
build() {
Canvas(this.context)
.width(this.chartSize)
.height(this.chartSize)
.onReady(() => { this.drawPie() })
}
}
``
命名避让策略:
- 加前缀:size -> chartSize、width -> cardWidth、height -> itemHeight
- 换用更具体的名称:padding -> innerPadding、margin -> outerMargin
- 避免使用通用 UI 术语:不要用 color、font、border 等作为成员名
这个教训不仅适用于 CostPieChart,也适用于所有自定义组件。在为组件添加成员属性时,务必检查名称是否与 CustomComponent 的通用属性冲突。
9. StepIndicator 步骤指示器
StepIndicator 是 IVGuard 中展示输液流程进度(如"配药→核对→输液→监护→完成")的组件。它以圆形编号 + 连接线的形式呈现步骤序列,用颜色区分已完成/当前/待完成三种状态。
9.1 圆形编号 + 连接线
每个步骤由三部分组成:圆形编号(1, 2, 3…)、步骤标签文字、连接下一段的横线。
`` ypescript
@Component
export struct StepIndicator {
@Prop currentStep: number = 0
@Prop stepLabels: string[] = [‘配药’, ‘核对’, ‘输液’, ‘监护’, ‘完成’]
build() {
Row() {
ForEach(this.stepLabels, (label: string, index: number) => {
Column() {
Row() {
Text((index + 1) + ‘’)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(this.getStepTextColor(index))
.width(28)
.height(28)
.borderRadius(14)
.textAlign(TextAlign.Center)
.backgroundColor(this.getStepBgColor(index))
.borderWidth(2)
.borderColor(this.getStepBorderColor(index))
}
Text(label)
.fontSize(11)
.fontColor(this.getStepLabelColor(index))
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Center)
if (index < this.stepLabels.length - 1) {
Row()
.width(20)
.height(2)
.backgroundColor(this.getLineColor(index))
.margin({ bottom: 16 })
}
}, (label: string, index: number) => index + '')
}
.width('100%')
.justifyContent(FlexAlign.Center)
}
}
``
颜色状态系统:
`` ypescript
private getStepBgColor(index: number): string {
if (index < this.currentStep) {
return ‘#4CAF50’
} else if (index === this.currentStep) {
return ‘#2196F3’
} else {
return ‘#FFFFFF’
}
}
private getStepBorderColor(index: number): string {
if (index < this.currentStep) {
return ‘#4CAF50’
} else if (index === this.currentStep) {
return ‘#2196F3’
} else {
return ‘#CCCCCC’
}
}
private getStepTextColor(index: number): string {
if (index <= this.currentStep) {
return ‘#FFFFFF’
} else {
return ‘#CCCCCC’
}
}
private getStepLabelColor(index: number): string {
if (index < this.currentStep) {
return ‘#4CAF50’
} else if (index === this.currentStep) {
return ‘#2196F3’
} else {
return ‘#999999’
}
}
private getLineColor(index: number): string {
if (index < this.currentStep) {
return ‘#4CAF50’
} else {
return ‘#CCCCCC’
}
}
``
三态颜色体系:
| 状态 | 圆形背景 | 圆形边框 | 编号文字 | 标签文字 | 连接线 |
|---|---|---|---|---|---|
| 已完成(index < currentStep) | #4CAF50 | #4CAF50 | #FFFFFF | #4CAF50 | #4CAF50 |
| 当前(index = currentStep) | #2196F3 | #2196F3 | #FFFFFF | #2196F3 | #CCCCCC |
| 待完成(index > currentStep) | #FFFFFF | #CCCCCC | #CCCCCC | #999999 | #CCCCCC |
设计细节的深层考量:
- 已完成步骤的文字颜色为白色:绿色背景上的白色文字,对比度最高,视觉最清晰。
- 当前步骤的连接线是灰色:当前步骤正在进行中,连接到下一步的线应该是灰色(尚未完成),而不是蓝色。
- 待完成步骤的背景为白色:白色背景 + 灰色边框,视觉上"退让",不抢已完成和当前步骤的注意力。
- 标签文字的颜色区分:已完成的标签用绿色(呼应圆形背景),当前的用蓝色(呼应圆形背景),待完成的用中灰(降低视觉权重)。
9.2 标签文字
步骤标签是输液流程的标准术语:
- 配药:药师根据处方配置药物
- 核对:护士核对药物与患者信息
- 输液:执行输液操作
- 监护:输液过程中持续监测
- 完成:输液结束,拔针处理
标签的 stepLabels 参数化: @Prop stepLabels: string[] = […] 使得步骤标签可配置。不同的输液流程可能有不同的步骤序列(如化疗输液增加了"预处理"步骤),参数化设计提高了组件的复用性。
连接线的条件渲染: if (index < this.stepLabels.length - 1) 确保最后一个步骤后面不画连接线。这是一个容易被忽略的细节——如果最后也画一条线延伸到空白区域,视觉上会显得不完整。
连接线的 margin 调整: margin({ bottom: 16 }) 使连接线与圆形编号垂直居中对齐。因为圆形编号下方还有标签文字(margin top 4px + 11fp 文字高度约 16px),连接线需要下移 16px 才能与圆心齐平。这个偏移量是视觉调试的结果,需要根据实际的字号和间距来调整。
ForEach 的键值生成: (label: string, index: number) => index + ‘’ 使用 index 的字符串形式作为键值。在 stepLabels 顺序不变的列表中,index 是足够稳定的键值。如果步骤可能被重新排序,应该使用步骤的唯一标识符(如 stepId)作为键值。
10. 组件设计模式总结
经过上述八个组件的设计与实现,我们提炼出以下五大核心设计模式。这些模式不仅适用于 IVGuard 项目,也是 ArkUI 组件化开发的通用最佳实践。
10.1 避免 const in build()
这是 IVGuard 开发中最频繁遇到的 ArkTS 限制。build() 方法中不允许使用 const、let、var 声明局部变量。
替代方案优先级:
- @Prop:当计算值来自父组件时,使用 @Prop 从父组件传入。这是最推荐的方式,因为 @Prop 变化会自动触发 UI 刷新。
- 私有方法:当计算值可以在组件内部推导时,使用私有方法(如 getLevelColor())。方法调用在 build() 中是允许的。
- 内联表达式:对于简单的三元表达式(如 this.level > 50 ? ‘green’ : ‘red’),可以直接内联。但过长的内联表达式会降低可读性。
绝对禁止: 在 build() 中使用 const、let、var、for(传统 for 循环)、while、do-while 等命令式语法。ArkTS 编译器会直接报错。
深层原因: ArkTS 的 build() 方法被设计为纯声明式函数——它只描述"UI 应该长什么样",不描述"如何计算 UI"。这种限制虽然增加了开发者的心智负担(需要提前计算好所有值),但也带来了性能上的好处——框架可以更高效地 diff 和更新 UI 树,因为 build() 的执行结果完全由 @Prop/@State 决定,不存在中间变量引入的不确定性。
10.2 @Prop vs 计算属性
| 场景 | 推荐方式 | 示例 |
|---|---|---|
| 值来自父组件 | @Prop | @Prop level: number = 0 |
| 值可从 @Prop 简单计算 | 私有方法 | private getLevelColor(): string |
| 值需要复杂计算 | 私有方法 + 缓存 | private getActiveLevel(): number |
| 值需要跨多个 @Prop 组合 | @Prop + 私有方法 | 父组件计算好传入 @Prop |
关键原则: 简单值用 @Prop,复杂计算用方法。不要为了"减少 @Prop 数量"而把复杂计算塞进私有方法——如果计算逻辑依赖外部数据,应该由父组件计算后通过 @Prop 传入。
为什么不用 getter? 在标准 TypeScript 中,getter(计算属性)是替代方法的优雅选择:
ypescript // TypeScript 中的优雅写法 get levelColor(): string { if (this.level > 50) return '#4CAF50' if (this.level >= 20) return '#FF9800' return '#F44336' }
但在 ArkTS 中,@Component struct 中的 getter 存在两个问题:
- 框架追踪困难:getter 的值可能在 build() 执行期间多次读取,框架难以确定 getter 依赖了哪些状态变量,可能导致 UI 不及时更新
- 性能不可控:getter 每次访问都会重新计算,在 ForEach 等高频调用场景下可能造成性能问题
因此,IVGuard 统一使用私有方法而非 getter。
10.3 成员命名避让通用属性
CustomComponent 的通用属性名(size、width、height、padding、margin 等)不能用作组件的成员属性名。冲突会导致编译错误或运行时异常。
避让策略:
- 加功能前缀:size -> chartSize、width -> cardWidth、height -> itemHeight
- 加语义限定:color -> themeColor、font -> titleFont、border -> dividerBorder
- 使用更具体的名称:padding -> innerSpacing、margin -> outerGutter
检查方法: 在添加成员属性前,先查看 ArkUI API 文档中 CustomComponent 的属性列表,确认名称不冲突。
为什么 ArkUI 不给出编译警告? 这是一个框架设计的权衡。ArkUI 选择让 @Component struct 隐式继承 CustomComponent 的所有属性和方法,而不是通过显式继承。这种设计的优势是代码简洁(不需要写 extends),但劣势是名称冲突只在编译期报错,没有代码提示阶段的预警。
实战建议: 在项目初期建立一份"禁止使用的成员属性名"清单,作为团队编码规范的一部分。清单至少应包含:size、width、height、padding、margin、layoutWeight、backgroundColor、borderRadius、enabled、visibility、opacity、zIndex、position、offset、alignSelf、flexGrow、flexShrink。
10.4 回调属性必须 public
在 ArkUI 的组件模型中,所有通过构造器传入的属性都必须是 public 的。这包括回调函数属性(如 onCardClick、onHandle)。
`` ypescript
// 正确
public onCardClick: () => void = () => {}
// 错误(编译报错)
private onCardClick: () => void = () => {}
``
原因: ArkUI 的组件构造器在编译期会生成属性赋值代码。private 属性无法从外部赋值,导致构造器初始化失败。
默认值的重要性: 所有回调属性都应该提供默认值(通常是空函数 () => {}),确保组件在没有传入回调时不会崩溃。
回调模式 vs @Link 模式的选择:
| 特性 | 回调模式 | @Link 模式 |
|---|---|---|
| 数据流向 | 显式子→父 | 隐式双向 |
| 调试难度 | 低(回调链可追踪) | 高(双向绑定隐式修改) |
| 适用场景 | 事件通知(点击、确认) | 状态同步(开关、输入) |
| 推荐度 | 高(IVGuard 默认选择) | 中(仅用于表单类组件) |
10.5 Canvas 组件:onReady 回调中绘制,手动 updateView 刷新
Canvas 组件遵循"声明 + 命令"的混合模式:build() 中声明 Canvas 组件,onReady 回调中执行绘制命令。
核心流程:
- 在 build() 中声明 Canvas(this.context).onReady(() => { this.drawXxx() })
- 在 onReady 回调中调用绘制方法
- 当数据更新时,手动调用 updateView() 触发重绘
- 每次绘制前调用 clearRect 清空画布
常见陷阱:
- 忘记 onReady:在 build() 中直接调用绘制方法会导致"Canvas 未就绪"错误
- 忘记 clearRect:不清空画布会导致图形叠加、残影等问题
- 忘记 setLineDash([]) 重置:虚线效果是持久的,必须手动重置
- 忘记 onReady 只触发一次:数据更新后需要手动调用绘制方法
Canvas vs 声明式组件的选择:
| 场景 | 推荐方式 | 原因 |
|---|---|---|
| 固定布局的卡片/列表 | 声明式组件 | 状态驱动,自动刷新 |
| 自由定位的图形叠加 | Canvas | 坐标计算灵活,性能好 |
| 折线图/饼图等图表 | Canvas | 图形元素多,声明式代码冗长 |
| 需要高频刷新的动画 | Canvas | 可控制刷新频率 |
性能优化建议: 对于频繁更新的 Canvas(如实时流速图),可以使用 requestAnimationFrame 控制绘制频率,避免过度渲染。但对于 IVGuard 中的组件(更新频率为秒级),直接调用 updateView 即可满足性能需求。
10.6 状态管理最佳实践
综合 IVGuard 的组件开发经验,状态管理的最佳实践如下:
- @State 只用于组件私有状态:如果一个状态只在组件内部使用,用 @State。如果需要传递给子组件,用 @Prop。
- @Prop 是默认选择:子组件的属性优先使用 @Prop,只有当确实需要双向绑定时才考虑 @Link。
- 回调优于 @Link:子→父通信优先使用回调函数模式,而非 @Link。回调模式的数据流向更清晰,更易于调试。
- 避免在子组件中修改 @Prop:虽然语法上允许修改 @Prop,但修改不会同步回父组件,会导致数据不一致。
- 状态提升:当多个子组件需要共享状态时,将状态提升到最近的公共父组件中管理。
10.7 组件复用性设计
IVGuard 的组件库设计遵循以下复用性原则:
- 参数化一切可变的值:颜色、文本、尺寸、数据源都通过 @Prop 传入,不在组件内部硬编码。
- 提供合理的默认值:每个 @Prop 都有默认值,确保组件可以在不传参数的情况下正常渲染(虽然可能没有意义)。
- 单一职责:每个组件只做一件事。LevelProgress 只显示液位,MedicineCard 只显示药物信息,不搞"万能组件"。
- 回调函数而非内部导航:组件不直接调用 router.pushUrl,而是通过回调通知父组件。这使得组件可以在不同导航框架下复用。
- 样式一致性:所有组件使用统一的颜色体系(Material Design 色板)、统一的字号层级(11/12/13/15/16/18fp)、统一的间距体系(4/8/12px 基数)。
附录A:颜色体系总览
IVGuard 使用的完整颜色体系如下:
功能色
| 用途 | 色值 | 使用场景 |
|---|---|---|
| 成功/安全 | #4CAF50 | 已完成步骤、正常液位、已处理状态、护士站POI |
| 信息/专业 | #2196F3 | 当前步骤、液位线、输液室POI、用户位置蓝点、流速折线 |
| 警告/注意 | #FF9800 | 低液位预警、药房POI、费用饼图-其他 |
| 危险/紧急 | #F44336 | 极低液位、急诊POI、danger预警、交互警告 |
| 隐私/休息 | #9C27B0 | 病房POI |
文字色
| 层级 | 色值 | 使用场景 |
|---|---|---|
| 标题 | #333333 | 药物名称、预警标题 |
| 正文 | #666666 | 剂量、预警详情 |
| 辅助 | #999999 | 时间、待完成标签 |
| 禁用 | #CCCCCC | 空状态文字、待完成编号 |
背景色
| 用途 | 色值 | 使用场景 |
|---|---|---|
| 卡片背景 | #FFFFFF | 所有卡片组件 |
| 页面背景 | #F5F5F5 | 页面底色 |
| 边框 | #EEEEEE | 网格线、分隔线 |
附录B:字号体系总览
| 字号 | 用途 | 使用组件 |
|---|---|---|
| 18fp | 环形进度中心文字 | LevelProgress |
| 16fp | 卡片标题 | MedicineCard |
| 15fp | 预警标题 | AlertCard |
| 14fp | 步骤编号、百分比标注 | StepIndicator、MonitorOverlay |
| 13fp | 卡片副标题、预警详情 | MedicineCard、AlertCard |
| 12fp | 标签文字、按钮文字、时间 | MedicineCard、AlertCard |
| 11fp | 严重程度标签、步骤标签、图例 | AlertCard、StepIndicator、CostPieChart |
| 10fp | Y轴标签 | FlowChart |
附录C:组件依赖关系图
Page(页面) ├── LevelProgress ← @Prop level ├── MedicineCard ← @Prop medicineName, dosage, isBound, hasInteraction │ ← public onCardClick ├── AlertCard ← @Prop alertTitle, alertMessage, severity, alertTime, isHandled │ ← public onHandle ├── MonitorOverlay ← @Prop results, activeMarkerId, activeLevel ├── HospitalMapView ← @Prop pois, currentFloor, userX, userY, targetPoiId │ ← public updateView() ├── FlowChart ← @Prop records │ ← public updateView() ├── CostPieChart ← @Prop drugCost, otherCost │ ← public updateView() └── StepIndicator ← @Prop currentStep, stepLabels
附录D:踩坑清单速查
| 坑 | 表现 | 修复 | 涉及组件 |
|---|---|---|---|
| build() 中用 const | 编译错误 | 改用 @Prop 或私有方法 | MonitorOverlay |
| 成员名与通用属性冲突 | 编译错误或运行时异常 | 加前缀改名 | CostPieChart |
| 回调属性用 private | 构造器赋值失败 | 改为 public | MedicineCard、AlertCard |
| Canvas 忘记 onReady | 绘制无效 | 在 onReady 中绘制 | HospitalMapView、FlowChart、CostPieChart |
| Canvas 忘记 clearRect | 图形叠加残影 | 每次绘制前清空 | 所有 Canvas 组件 |
| setLineDash 忘记重置 | 后续绘制变虚线 | 绘制后 setLineDash([]) | HospitalMapView |
| Canvas 数据更新不刷新 | onReady 只触发一次 | 手动 updateView() | HospitalMapView、FlowChart、CostPieChart |
| 除零错误 | 绘制异常 | Math.max(…, 1) 防御 | FlowChart、CostPieChart |
更多推荐




所有评论(0)