【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 核心要点回顾

  1. 归一化是关键:把数据值映射到视觉高度是图表的核心
  2. 高度限制很重要:用 Math.max/min 防止极端值溢出
  3. 底部对齐不可少.justifyContent(FlexAlign.End) 让柱子从底部开始
  4. 颜色对比要清晰:红色和橙色对比明显,用户容易区分

10.3 后续计划

血压记录功能的基础部分已经全部完成了!🎉

后续可以考虑的进阶功能:

  • 📅 月历打卡视图:用 table_calendar 实现
  • 🔔 定时提醒:用 flutter_local_notifications
  • 📄 报告导出:用 pdf 库生成报告
  • 💾 数据持久化:用 hive 存储历史数据

感谢大家的陪伴,我们下个系列再见!


创作日期:2026 年 4 月
版权所有,转载须注明出处

Logo

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

更多推荐