【Flutter for OpenHarmony 跨平台征文】第三方库Flutter 血压趋势统计实战:从统计数据计算到简化柱状图的鸿蒙开发指南
Flutter血压趋势统计实战摘要 本文介绍了使用ArkTS实现血压趋势统计的简化方案,重点讲解了如何通过原生组件构建轻量级柱状图。主要内容包括: 需求分析:血压趋势统计需展示平均血压、最高/最低值及可视化图表 技术方案:对比了第三方图表库、简化柱状图和Canvas自绘三种方案,选择原生ArkTS实现 核心实现: 使用Divider组件构建双柱状图 动态计算柱状图高度反映血压值 配合Row/Col
【Flutter for OpenHarmony 跨平台征文】Flutter 血压趋势统计实战:从统计数据计算到简化柱状图的鸿蒙开发指南
🎯 写在前面
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
👋 自我介绍
嗨,大家好!,上海某高校大一计算机专业的学生 🚀。前面三篇文章我们讲了:
- 《血压数据模型 + WHO分类算法》
- 《血压录入表单 + 实时预览》
- 《历史记录列表 + List组件》
这是血压记录系列的最后一篇,来聊聊 趋势统计图表 的实现 📊。
说实话,可视化是我之前最怕的部分,总觉得图表特别复杂。但这次我用的是简化柱状图方案,不需要引入第三方图表库,纯原生 ArkTS 实现,效果居然还不错!今天就把我的实现过程分享出来,希望能帮到有同样需求的同学!
一、趋势统计需求分析
1.1 功能需求
趋势统计页面需要展示用户血压的长期变化趋势:
| 需求 | 说明 |
|---|---|
| 统计卡片 | 显示平均血压、最高、最低 |
| 趋势图表 | 可视化展示一周/一月的血压变化 |
| 数据列表 | 配合图表显示具体数值 |
1.2 UI 设计稿
┌─────────────────────────────────┐
│ 📊 本周平均血压 │
├─────────────────────────────────┤
│ 118 78 118/78 │
│ 平均收缩 平均舒张 综合评估 │
├─────────────────────────────────┤
│ 📈 本周血压趋势 │
├─────────────────────────────────┤
│ ┃ ┃ │
│ ┃┃ ┃┃ ┃┃ ┃┃ ┃┃ ┃┃ ┃┃ │
│ ┃┃ ┃┃ ┃┃ ┃┃ ┃┃ ┃┃ ┃┃ ┃┃ │
│ ┃┃ ┃┃ ┃┃ ┃┃ ┃┃ ┃┃ ┃┃ ┃┃ │
│ Mon Tue Wed Thu Fri Sat Sun │
│ 🔴 收缩压 🟠 舒张压 │
├─────────────────────────────────┤
│ 📋 本周数据 │
├─────────────────────────────────┤
│ 04-30 120/80 72bpm 正常 │
│ 04-29 118/78 70bpm 正常 │
└─────────────────────────────────┘
1.3 技术方案选择
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 第三方图表库 | 功能丰富 | 包体积大,适配复杂 | 复杂图表需求 |
| 简化柱状图 | 轻量,定制灵活 | 功能有限 | 基础趋势展示 |
| Canvas 自绘 | 完全可控 | 开发量大 | 特殊需求 |
我们选择简化柱状图方案,用原生 ArkTS 的 Divider 组件实现!
二、完整代码实现
2.1 趋势页面结构
// ============================================
// 趋势Tab页面
// ============================================
@Builder
TrendTab() {
Column() {
Scroll() {
Column() {
// 平均血压统计卡片
this.AverageStatsCard()
// 趋势图卡片
this.TrendChartCard()
// 周数据列表
this.WeekDataList()
}
.padding({ bottom: 100 })
}
.layoutWeight(1)
.scrollBar(BarState.Off)
}
.width('100%')
.height('100%')
}
2.2 平均血压统计卡片
统计卡片展示本周的平均血压数据:
// ============================================
// 平均血压统计卡片
// ============================================
@Builder
AverageStatsCard() {
// 获取本周平均血压
const avg = this.healthService.getAverageBloodPressure()
Column() {
// 标题
Row() {
Text('📊')
.fontSize(18)
.margin({ right: 8 })
Text('本周平均血压')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
}
.width('94%')
.margin({ bottom: 20 })
// 统计数据行
Row() {
// 平均收缩压
Column() {
Text(avg.systolic.toString())
.fontSize(36)
.fontWeight(FontWeight.Bold)
.fontColor('#F44336')
Text('平均收缩压')
.fontSize(12)
.fontColor('#999999')
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
// 平均舒张压
Column() {
Text(avg.diastolic.toString())
.fontSize(36)
.fontWeight(FontWeight.Bold)
.fontColor('#F44336')
Text('平均舒张压')
.fontSize(12)
.fontColor('#999999')
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
// 综合评估
Column() {
Text(`${avg.systolic}/${avg.diastolic}`)
.fontSize(24)
.fontWeight(FontWeight.Bold)
.fontColor('#666666')
Text('综合评估')
.fontSize(12)
.fontColor('#999999')
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}
.width('94%')
}
.width('94%')
.padding(16)
.backgroundColor('#FFFFFF')
.borderRadius(16)
.margin({ top: 15, left: '3%', right: '3%' })
}
2.3 趋势图卡片(核心!)
这是最关键的部分 - 用原生组件实现双柱状图!
// ============================================
// 趋势图卡片
// 展示最近7天的血压数据(简化双柱状图)
// ============================================
@Builder
TrendChartCard() {
Column() {
// 标题
Row() {
Text('📈')
.fontSize(18)
.margin({ right: 8 })
Text('本周血压趋势')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
}
.width('94%')
.margin({ bottom: 20 })
// ============================================
// 双柱状图
// 使用 Row + Column + Divider 实现
// ============================================
Row() {
ForEach(this.records.slice(0, 7), (record: BloodPressureRecord, index: number) => {
Column() {
// 双柱状图
Row() {
// 收缩压柱子
Column() {
Divider()
.vertical(true)
.height(this.getSysBarHeight(record.systolic)) // 动态高度
.color('#F44336') // 红色
.width(12)
.borderRadius(3)
}
.height(80)
.justifyContent(FlexAlign.End)
// 舒张压柱子
Column() {
Divider()
.vertical(true)
.height(this.getDiaBarHeight(record.diastolic)) // 动态高度
.color('#FF9800') // 橙色
.width(12)
.borderRadius(3)
}
.height(80)
.justifyContent(FlexAlign.End)
.margin({ left: 4 })
}
// 星期标签
Text(this.getWeekdayLabel(index))
.fontSize(11)
.fontColor('#999999')
.margin({ top: 6 })
}
.layoutWeight(1)
})
}
.width('94%')
.height(110)
.padding({ left: 10, right: 10 })
// ============================================
// 图例
// ============================================
Row() {
// 收缩压图例
Row() {
Circle()
.width(10)
.height(10)
.fill('#F44336')
Text('收缩压')
.fontSize(12)
.fontColor('#666666')
.margin({ left: 6 })
}
.margin({ right: 24 })
// 舒张压图例
Row() {
Circle()
.width(10)
.height(10)
.fill('#FF9800')
Text('舒张压')
.fontSize(12)
.fontColor('#666666')
.margin({ left: 6 })
}
}
.width('94%')
.margin({ top: 16 })
.justifyContent(FlexAlign.Center)
}
.width('94%')
.padding(16)
.backgroundColor('#FFFFFF')
.borderRadius(16)
.margin({ top: 15, left: '3%', right: '3%' })
}
2.4 图表计算方法
柱状图高度的计算是核心算法:
// ============================================
// 图表计算方法
// ============================================
// 计算收缩压柱状图高度
// 输入:收缩压值(通常 90-180)
// 输出:vp 单位的高度值(20-70)
getSysBarHeight(value: number): string {
const minHeight = 20 // 最小高度
const maxHeight = 70 // 最大高度
// 归一化计算
// 假设收缩压正常范围是 90-210
const normalized = (value - 90) / 120
// 映射到高度范围
const height = minHeight + normalized * (maxHeight - minHeight)
// 限制在合理范围内
const clampedHeight = Math.max(minHeight, Math.min(maxHeight, height))
return clampedHeight.toString() + 'vp'
}
// 计算舒张压柱状图高度
getDiaBarHeight(value: number): string {
const minHeight = 20
const maxHeight = 70
// 假设舒张压正常范围是 50-130
const normalized = (value - 50) / 80
const height = minHeight + normalized * (maxHeight - minHeight)
const clampedHeight = Math.max(minHeight, Math.min(maxHeight, height))
return clampedHeight.toString() + 'vp'
}
// 获取星期标签
getWeekdayLabel(index: number): string {
const labels = ['一', '二', '三', '四', '五', '六', '日']
return labels[index] || ''
}
2.5 周数据列表
配合图表展示具体数值:
// ============================================
// 周数据列表
// ============================================
@Builder
WeekDataList() {
Column() {
// 标题
Row() {
Text('📋')
.fontSize(18)
.margin({ right: 8 })
Text('本周数据')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
}
.width('94%')
.margin({ bottom: 12 })
// 数据列表
Column() {
ForEach(this.records.slice(0, 7), (record: BloodPressureRecord, index: number) => {
Column() {
Row() {
// 日期
Text(this.healthService.formatDate(record.date))
.fontSize(13)
.fontColor('#666666')
.width(60)
// 血压值
Text(`${record.systolic}/${record.diastolic}`)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(this.getCategoryColor(record))
.layoutWeight(1)
// 脉搏
if (record.pulse) {
Text(`${record.pulse} bpm`)
.fontSize(12)
.fontColor('#999999')
.margin({ right: 12 })
}
// 状态标签
Text(this.getCategoryText(record))
.fontSize(12)
.fontColor(this.getCategoryColor(record))
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.backgroundColor(this.getCategoryColor(record) + '20')
.borderRadius(10)
}
.width('100%')
.padding({ top: 12, bottom: 12 })
// 分隔线
if (index < Math.min(this.records.length, 7) - 1) {
Divider()
.color('#F0F0F0')
}
}
})
}
.width('94%')
.padding(12)
.backgroundColor('#FAFAFA')
.borderRadius(12)
.margin({ left: '3%', right: '3%' })
}
.width('94%')
.padding(16)
.backgroundColor('#FFFFFF')
.borderRadius(16)
.margin({ top: 15, left: '3%', right: '3%' })
}
三、统计数据计算逻辑
3.1 平均值计算
// 获取平均血压
getAverageBloodPressure(): { systolic: number; diastolic: number; pulse: number } {
// 没有记录时返回默认值
if (this.bloodPressureRecords.length === 0) {
return { systolic: 0, diastolic: 0, pulse: 0 }
}
// 取最近7条记录计算平均值
const weekRecords = this.bloodPressureRecords.slice(0, 7)
// 计算总和
const sumSystolic = weekRecords.reduce((sum, r) => sum + r.systolic, 0)
const sumDiastolic = weekRecords.reduce((sum, r) => sum + r.diastolic, 0)
const sumPulse = weekRecords.reduce((sum, r) => sum + r.pulse, 0)
// 计算平均值并四舍五入
return {
systolic: Math.round(sumSystolic / weekRecords.length),
diastolic: Math.round(sumDiastolic / weekRecords.length),
pulse: Math.round(sumPulse / weekRecords.length)
}
}
3.2 统计函数解析
// Array.reduce 用于求和
const sum = array.reduce((accumulator, current) => accumulator + current, 0)
// Math.round 用于四舍五入
Math.round(3.5) // 4
Math.round(3.4) // 3
// Array.slice 用于取子数组
const weekRecords = allRecords.slice(0, 7) // 取前7条
四、图表设计原理
4.1 归一化算法
图表的核心是归一化算法,把血压值映射到视觉高度:
原始血压值范围:90 - 180(收缩压)
目标高度范围:20 - 70(vp)
公式:
height = minHeight + (value - minValue) / (maxValue - minValue) * (maxHeight - minHeight)
4.2 算法图解
血压值 90 → 高度 20vp
血压值 105 → 高度 32.5vp
血压值 120 → 高度 45vp
血压值 135 → 高度 57.5vp
血压值 150 → 高度 70vp
血压值 165 → 高度 70vp(限制最大)
4.3 颜色编码
| 血压状态 | 颜色代码 | 说明 |
|---|---|---|
| 正常 | #4CAF50 | 绿色 |
| 正常高值 | #FFEB3B | 黄色 |
| 高血压1级 | #FF9800 | 橙色 |
| 高血压2级 | #F44336 | 红色 |
| 危象 | #B71C1C | 深红色 |
五、开发踩坑与解决方案
5.1 踩坑一:柱状图高度计算不对 😱
问题描述:
血压值 180 的柱子和 90 的柱子看起来差不多高,完全看不出差异!
崩溃现场:
收缩压 90:柱子高度 30vp
收缩压 180:柱子高度 65vp
期望:180 应该明显比 90 高很多
实际:差异不明显
排查过程:
检查了计算公式,发现归一化的范围设置不合理:
// ❌ 错误的归一化范围
const normalized = (value - 90) / 30 // 范围跨度只有30
// 90 → 0, 120 → 1, 180 → 3(超出范围!)
// ✅ 合理的归一化范围
const normalized = (value - 90) / 120 // 范围跨度120
// 90 → 0, 150 → 0.5, 210 → 1
解决方案:
getSysBarHeight(value: number): string {
const minHeight = 20
const maxHeight = 70
// 合理的归一化范围(90-210,覆盖常见血压值)
const normalized = (value - 90) / 120
const height = minHeight + normalized * (maxHeight - minHeight)
// 限制范围,防止极端值溢出
const clampedHeight = Math.max(minHeight, Math.min(maxHeight, height))
return clampedHeight.toString() + 'vp'
}
5.2 踩坑二:柱子从中间开始而不是底部 🤔
问题描述:
柱子是从中间(50vp)开始显示的,而不是从底部开始。
排查过程:
// ❌ 缺少对齐设置
Column() {
Divider()
.vertical(true)
.height(50)
}
// ✅ 需要设置底部对齐
Column() {
Divider()
.vertical(true)
.height(50)
}
.justifyContent(FlexAlign.End) // 底部对齐
解决方案:
Column() {
Divider()
.vertical(true)
.height(this.getSysBarHeight(record.systolic))
}
.height(80) // 固定容器高度
.justifyContent(FlexAlign.End) // 柱子从底部开始
5.3 踩坑三:数据为空时图表报错 😅
问题描述:
没有数据时,图表区域显示异常。
排查过程:
// ❌ 没有数据时会报错
ForEach(this.records.slice(0, 7), (record) => {
// 访问 record.systolic 时报错
})
// ✅ 需要处理空数据
ForEach(
this.records.length > 0 ? this.records.slice(0, 7) : [],
(record) => {
// ...
}
)
5.4 踩坑四:星期标签显示不正确 💡
问题描述:
星期标签没有按顺序显示。
解决方案:
// ✅ 正确的星期标签
getWeekdayLabel(index: number): string {
const labels = ['一', '二', '三', '四', '五', '六', '日']
return labels[index] || ''
}
5.5 踩坑五:颜色选择不当 🎨
问题描述:
红色和橙色的对比度不够,柱子看起来不明显。
解决方案:
选择对比度更高的颜色组合:
// ✅ 收缩压:深红色
color: '#F44336'
// ✅ 舒张压:橙色
color: '#FF9800'
六、图表优化技巧
6.1 添加参考线
可以添加一条"正常血压"参考线:
// 参考线位置:收缩压 120 的高度
const normalSysHeight = this.getSysBarHeight(120)
// 添加参考线
Divider()
.width('100%')
.height(1)
.color('#4CAF50')
.strokeWidth(1)
6.2 添加 Tooltip
点击柱子时显示详细信息:
// 在 Column 上添加点击事件
Column() {
// ...柱子...
}
.onClick(() => {
promptAction.showToast({
message: `${record.systolic}/${record.diastolic} mmHg`
})
})
6.3 动画效果
给柱子添加简单的动画:
// 伪代码:渐入动画
.animateTo({
duration: 500,
curve: Curve.EaseOut
}, () => {
// 高度变化
})
七、数据展示优化
7.1 筛选功能
可以添加日期筛选:
@State selectedRange: 'week' | 'month' = 'week'
// 根据筛选条件获取数据
getFilteredRecords() {
if (this.selectedRange === 'week') {
return this.records.slice(0, 7)
} else {
return this.records.slice(0, 30)
}
}
7.2 排序功能
// 按日期降序(最新的在前)
const sorted = this.records.sort((a, b) => b.date - a.date)
// 按日期升序(最旧的在前)
const sorted = this.records.sort((a, b) => a.date - b.date)
7.3 聚合统计
// 计算最高和最低值
function getMinMax(records: BloodPressureRecord[]) {
const sysValues = records.map(r => r.systolic)
const diaValues = records.map(r => r.diastolic)
return {
maxSys: Math.max(...sysValues),
minSys: Math.min(...sysValues),
maxDia: Math.max(...diaValues),
minDia: Math.min(...diaValues)
}
}
八、鸿蒙专属适配
8.1 屏幕适配
不同尺寸的设备需要适配:
// 获取屏幕宽度
@StorageProp('windowWidth') windowWidth: number = 0
// 根据屏幕宽度调整图表
.width(this.windowWidth > 600 ? '94%' : '100%')
8.2 性能优化
图表组件需要注意性能:
// ✅ 使用显式高度避免重新布局
.height(110)
// ✅ 限制数据量
ForEach(this.records.slice(0, 7), ...)
// ✅ 避免在 build 中计算
aboutToAppear() {
this.calculateStats()
}
8.3 深色模式适配
// 浅色模式颜色
@State chartColor: string = '#F44336'
// 深色模式切换
aboutToAppear() {
const colorMode = this.context.getColorMode()
this.chartColor = colorMode === ColorMode.Dark ? '#FF6B6B' : '#F44336'
}
九、最终实现效果

9.1 功能验证清单
| 验证项 | 期望效果 | 验证结果 |
|---|---|---|
| 统计卡片 | 显示平均收缩压、舒张压 | ✅ |
| 柱状图 | 双柱显示,收缩压红色、舒张压橙色 | ✅ |
| 柱高正确 | 数值大的柱子更高 | ✅ |
| 图例 | 显示颜色说明 | ✅ |
| 星期标签 | 显示周一到周日 | ✅ |
| 数据列表 | 配合图表显示数值 | ✅ |
| 空数据处理 | 无数据时显示友好提示 | ✅ |
9.2 视觉效果
| 元素 | 样式 |
|---|---|
| 统计卡片 | 白色背景,16px 圆角 |
| 收缩压颜色 | #F44336(红色) |
| 舒张压颜色 | #FF9800(橙色) |
| 柱子宽度 | 12px |
| 柱子间距 | 4px |
| 柱子圆角 | 3px |
| 容器高度 | 80px |
| 星期标签 | 11px,灰色 |
十、个人总结
10.1 学习心得
趋势统计图表是血压记录功能的最后一个模块,也是我收获最多的部分 💪。
最大的收获是对数据可视化有了初步的理解:
- 学会了归一化算法,把数值映射到视觉元素
- 理解了颜色编码的重要性
- 学会了用简单组件实现复杂效果
其实图表没有想象中那么难!用原生的 Divider + Column 组合就能做出不错的效果。
10.2 核心要点回顾
- 归一化是关键:把数据值映射到视觉高度是图表的核心
- 高度限制很重要:用
Math.max/min防止极端值溢出 - 底部对齐不可少:
.justifyContent(FlexAlign.End)让柱子从底部开始 - 颜色对比要清晰:红色和橙色对比明显,用户容易区分
10.3 后续计划
血压记录功能的基础部分已经全部完成了!🎉
后续可以考虑的进阶功能:
- 📅 月历打卡视图:用 table_calendar 实现
- 🔔 定时提醒:用 flutter_local_notifications
- 📄 报告导出:用 pdf 库生成报告
- 💾 数据持久化:用 hive 存储历史数据
感谢大家的陪伴,我们下个系列再见!
创作日期:2026 年 4 月
版权所有,转载须注明出处
更多推荐




所有评论(0)