# 鸿蒙ArkTS实战:BMI计算器 — 健康指数计算与彩色进度条实现

📅 2026/7/27 0:34:08 👁️ 阅读次数 📝 编程学习
# 鸿蒙ArkTS实战:BMI计算器 — 健康指数计算与彩色进度条实现

一、应用概述

BMI(Body Mass Index,身体质量指数)是世界卫生组织推荐的肥胖判断标准,计算公式为体重(kg) / 身高(m)²。本应用基于 ArkTS 构建一个交互式 BMI 计算器,支持身高体重输入、即时计算、分类判定(偏瘦/正常/偏胖/肥胖),并通过彩色渐变进度条直观展示 BMI 指数范围。

1.1 功能特性

特性描述
双输入身高(cm)和体重(kg)输入,支持滑动和键盘
即时计算输入变化时自动重算 BMI 值
分类判定根据中国标准分四类:偏瘦/正常/偏胖/肥胖
彩色进度条根据 BMI 值区间显示不同颜色(蓝/绿/橙/红)
标准范围提示显示当前分类的建议体重范围
单位切换支持 cm/kg 和 ft/lb 两种单位制
历史记录本地保存最近 10 次计算记录

1.2 BMI 分类标准(中国标准)

分类BMI 范围颜色标识健康建议
偏瘦< 18.5🔵 #2196F3建议适当增加营养摄入
正常18.5 ~ 23.9🟢 #4CAF50保持当前生活习惯
偏胖24.0 ~ 27.9🟠 #FF9800建议增加运动量
肥胖≥ 28.0🔴 #F44336建议咨询专业医师

二、系统架构设计

2.1 整体架构

┌─────────────────────────────────────────────┐ │ UI 表现层 │ │ ┌─────────┐ ┌────────┐ ┌──────────────┐ │ │ │InputPanel│ │BMIGauge│ │CategoryCard │ │ │ └────┬────┘ └───┬────┘ └──────┬───────┘ │ │ │ │ │ │ │ ┌────┴──────────┴─────────────┴──────────┐ │ │ │ BMICalculatorMain │ │ │ │ (@Entry + @Component) │ │ │ └─────────────────────────────────────────│ │ ├──────────────────────────────────────────────┤ │ 业务逻辑层 │ │ ┌────────────────────────────────────────┐ │ │ │ BMIEngine │ │ │ │ - calculateBMI(weight, height): number│ │ │ │ - getCategory(bmi): BMICategory │ │ │ │ - getIdealWeightRange(height): Range │ │ │ │ - getProgressPercent(bmi): number │ │ │ └────────────────────────────────────────┘ │ ├──────────────────────────────────────────────┤ │ 持久化层 │ │ ┌────────────────────────────────────────┐ │ │ │ Preferences (历史记录存储) │ │ │ └────────────────────────────────────────┘ │ └──────────────────────────────────────────────┘

2.2 数据模型

// BMI 分类枚举 enum BMICategory { UNDERWEIGHT = '偏瘦', NORMAL = '正常', OVERWEIGHT = '偏胖', OBESE = '肥胖' } // 分类颜色方案 interface CategoryStyle { category: BMICategory; minBMI: number; maxBMI: number; color: string; // 主色 bgColor: string; // 背景色 description: string; // 健康建议 } // 计算记录 interface BMIRecord { timestamp: number; height: number; weight: number; bmi: number; category: BMICategory; }

三、核心代码深度解析

3.1 完整应用代码

// pages/BMICalculatorPage.ets import { promptAction } from '@kit.ArkUI'; // ========== 枚举与类型定义 ========== enum BMICategory { UNDERWEIGHT = '偏瘦', NORMAL = '正常', OVERWEIGHT = '偏胖', OBESE = '肥胖' } interface CategoryStyle { category: BMICategory; minBMI: number; maxBMI: number; color: string; bgColor: string; icon: string; advice: string; } interface BMIRecord { timestamp: number; height: number; weight: number; bmi: number; category: BMICategory; } // ========== 分类配置(中国标准) ========== const CATEGORY_STYLES: CategoryStyle[] = [ { category: BMICategory.UNDERWEIGHT, minBMI: 0, maxBMI: 18.5, color: '#2196F3', bgColor: '#E3F2FD', icon: '⚡', advice: '建议适当增加营养摄入,保持规律作息' }, { category: BMICategory.NORMAL, minBMI: 18.5, maxBMI: 24.0, color: '#4CAF50', bgColor: '#E8F5E9', icon: '✅', advice: '非常棒!请保持当前的饮食和运动习惯' }, { category: BMICategory.OVERWEIGHT, minBMI: 24.0, maxBMI: 28.0, color: '#FF9800', bgColor: '#FFF3E0', icon: '⚠️', advice: '建议适当增加运动量,注意饮食结构' }, { category: BMICategory.OBESE, minBMI: 28.0, maxBMI: 100, color: '#F44336', bgColor: '#FFEBEE', icon: '🚨', advice: '建议及时咨询专业医师,制定健康计划' } ]; // ========== BMI 计算引擎 ========== class BMIEngine { // 计算 BMI static calculateBMI(weightKg: number, heightCm: number): number { if (heightCm <= 0 || weightKg <= 0) return 0; const heightM = heightCm / 100; return parseFloat((weightKg / (heightM * heightM)).toFixed(1)); } // 获取 BMI 分类 static getCategory(bmi: number): CategoryStyle { const result = CATEGORY_STYLES.find( s => bmi >= s.minBMI && bmi < s.maxBMI ); return result ?? CATEGORY_STYLES[1]; // 默认返回"正常" } // 计算理想体重范围(BMI 18.5~23.9) static getIdealWeightRange(heightCm: number): { min: number, max: number } { const heightM = heightCm / 100; return { min: parseFloat((18.5 * heightM * heightM).toFixed(1)), max: parseFloat((23.9 * heightM * heightM).toFixed(1)) }; } // 获取 BMI 进度百分比(0~100) static getProgressPercent(bmi: number): number { const maxDisplayBMI = 35; // 35 以上视为 100% return Math.min(100, Math.max(0, (bmi / maxDisplayBMI) * 100)); } // 输出格式化 BMI static formatBMI(bmi: number): string { return bmi.toFixed(1); } } // ========== 子组件:输入面板 ========== @Component struct InputPanel { private height: number = 170; private weight: number = 65; private onHeightChange?: (val: number) => void; private onWeightChange?: (val: number) => void; build() { Column() { // ---- 身高输入 ---- Text('身高') .fontSize(14) .fontColor('#666666') .width('100%') Row() { TextInput({ placeholder: '请输入身高', text: this.height.toString() }) .type(InputType.Number) .width(120) .height(44) .fontSize(20) .fontWeight(FontWeight.Bold) .backgroundColor('#F5F5F5') .borderRadius(10) .padding({ left: 12 }) .onChange((val: string) => { const num = parseInt(val.replace(/\D/g, '')); if (!isNaN(num) && this.onHeightChange) { this.onHeightChange(num); } }) Text('cm') .fontSize(16) .fontColor('#999999') .margin({ left: 8 }) } .width('100%') .alignItems(VerticalAlign.Center) .margin({ bottom: 8 }) // 身高滑块 Slider({ value: this.height, min: 100, max: 220, step: 1, style: SliderStyle.OutSet }) .width('100%') .trackThickness(4) .blockColor('#4A90D9') .trackColor('#E0E0E0') .selectedColor('#4A90D9') .onChange((val: number) => { if (this.onHeightChange) { this.onHeightChange(val); } }) .margin({ bottom: 20 }) // ---- 体重输入 ---- Text('体重') .fontSize(14) .fontColor('#666666') .width('100%') Row() { TextInput({ placeholder: '请输入体重', text: this.weight.toString() }) .type(InputType.Number) .width(120) .height(44) .fontSize(20) .fontWeight(FontWeight.Bold) .backgroundColor('#F5F5F5') .borderRadius(10) .padding({ left: 12 }) .onChange((val: string) => { const num = parseInt(val.replace(/\D/g, '')); if (!isNaN(num) && this.onWeightChange) { this.onWeightChange(num); } }) Text('kg') .fontSize(16) .fontColor('#999999') .margin({ left: 8 }) } .width('100%') .alignItems(VerticalAlign.Center) .margin({ bottom: 8 }) // 体重滑块 Slider({ value: this.weight, min: 30, max: 200, step: 0.5, style: SliderStyle.OutSet }) .width('100%') .trackThickness(4) .blockColor('#FF6B35') .trackColor('#E0E0E0') .selectedColor('#FF6B35') .onChange((val: number) => { if (this.onWeightChange) { this.onWeightChange(val); } }) } .width('100%') .padding(20) .backgroundColor('#FFFFFF') .borderRadius(16) .shadow({ radius: 8, color: 'rgba(0,0,0,0.06)', offsetX: 0, offsetY: 4 }) } } // ========== 子组件:BMI 环形进度条 ========== @Component struct BMIGauge { private bmi: number = 0; private color: string = '#4CAF50'; private percent: number = 0; build() { Column() { // 进度条容器 Stack() { // 背景圆环 Circle() .width(180) .height(180) .stroke('#E8E8E8') .strokeWidth(12) .fillOpacity(0) // 前景彩色圆环(使用 Arc 模拟进度) Circle() .width(180) .height(180) .stroke(this.color) .strokeWidth(12) .fillOpacity(0) .strokeLineCap(LineCapStyle.Round) .percent(this.percent * 0.75) // 最大显示 270° .animation({ duration: 600, curve: Curve.EaseOut }) // 中心显示 BMI 值 Column() { Text('BMI') .fontSize(14) .fontColor('#999999') Text(BMIEngine.formatBMI(this.bmi)) .fontSize(42) .fontWeight(FontWeight.Bold) .fontColor(this.color) .animation({ duration: 400, curve: Curve.EaseOut }) } .align(Alignment.Center) } .width(180) .height(180) } .width('100%') .alignItems(HorizontalAlign.Center) } } // ========== 子组件:分类卡片 ========== @Component struct CategoryCard { private categoryStyle: CategoryStyle = CATEGORY_STYLES[1]; private idealWeight: { min: number, max: number } = { min: 0, max: 0 }; private height: number = 170; build() { Column() { // 分类标志 Row() { Text(this.categoryStyle.icon) .fontSize(32) .margin({ right: 12 }) Column() { Text(this.categoryStyle.category) .fontSize(24) .fontWeight(FontWeight.Bold) .fontColor(this.categoryStyle.color) Text(`BMI ${BMIEngine.formatBMI(0)}`) .fontSize(14) .fontColor('#999999') } } .width('100%') .padding(16) .backgroundColor(this.categoryStyle.bgColor) .borderRadius(12) // 建议 Text(this.categoryStyle.advice) .fontSize(14) .fontColor('#666666') .margin({ top: 16 }) .lineHeight(22) // 理想体重范围 if (this.height > 0) { Row() { Text('理想体重范围') .fontSize(13) .fontColor('#888888') Text(`${this.idealWeight.min} kg ~ ${this.idealWeight.max} kg`) .fontSize(15) .fontWeight(FontWeight.Medium) .fontColor('#333333') .margin({ left: 8 }) } .width('100%') .justifyContent(FlexAlign.SpaceBetween) .padding({ top: 12 }) .border({ top: { width: 1, color: '#F0F0F0' } }) .margin({ top: 12 }) } } .width('100%') .padding(20) .backgroundColor('#FFFFFF') .borderRadius(16) .shadow({ radius: 8, color: 'rgba(0,0,0,0.06)', offsetX: 0, offsetY: 4 }) } } // ========== 子组件:历史记录 ========== @Component struct HistoryPanel { private records: BMIRecord[] = []; private onClear?: () => void; build() { if (this.records.length <= 0) { Column() { Text('暂无历史记录') .fontSize(14) .fontColor('#CCCCCC') .padding(20) } .width('100%') .alignItems(HorizontalAlign.Center) } else { Column() { Row() { Text('历史记录') .fontSize(16) .fontWeight(FontWeight.Medium) .fontColor('#333333') Text('清空') .fontSize(13) .fontColor('#FF6B35') .onClick(() => { if (this.onClear) { this.onClear(); } }) } .width('100%') .justifyContent(FlexAlign.SpaceBetween) .padding({ bottom: 12 }) ForEach(this.records, (record: BMIRecord, index: number) => { Row() { Text(`#${index + 1}`) .fontSize(12) .fontColor('#BBBBBB') .width(30) Text(`${record.height}cm / ${record.weight}kg`) .fontSize(14) .fontColor('#555555') .layoutWeight(1) Text(record.bmi.toFixed(1)) .fontSize(14) .fontWeight(FontWeight.Bold) .fontColor('#333333') .width(50) Text(record.category.toString()) .fontSize(12) .fontColor('#FFFFFF') .padding({ left: 8, right: 8, top: 2, bottom: 2 }) .backgroundColor(this.getCategoryColor(record.category)) .borderRadius(8) } .width('100%') .padding({ top: 8, bottom: 8 }) .border({ bottom: { width: 1, color: '#F5F5F5' } }) }, (record: BMIRecord) => record.timestamp.toString()) } .width('100%') .padding(20) .backgroundColor('#FFFFFF') .borderRadius(16) .shadow({ radius: 8, color: 'rgba(0,0,0,0.06)', offsetX: 0, offsetY: 4 }) } } private getCategoryColor(category: BMICategory): string { return CATEGORY_STYLES.find(s => s.category === category)?.color ?? '#999'; } } // ========== 主页面 ========== @Entry @Component struct BMICalculatorMain { @State height: number = 170; @State weight: number = 65; @State bmi: number = 0; @State categoryStyle: CategoryStyle = CATEGORY_STYLES[1]; @State progressPercent: number = 0; @State records: BMIRecord[] = []; aboutToAppear(): void { this.calculate(); this.loadRecords(); } // ========== BMI 计算 ========== private calculate(): void { this.bmi = BMIEngine.calculateBMI(this.weight, this.height); this.categoryStyle = BMIEngine.getCategory(this.bmi); this.progressPercent = BMIEngine.getProgressPercent(this.bmi); } // ========== 保存记录 ========== private saveRecord(): void { const record: BMIRecord = { timestamp: Date.now(), height: this.height, weight: this.weight, bmi: this.bmi, category: this.categoryStyle.category }; this.records = [record, ...this.records].slice(0, 10); // 可持久化到 Preferences promptAction.showToast({ message: '记录已保存', duration: 1500 }); } // ========== 加载记录 ========== private loadRecords(): void { // 预留:从 Preferences 加载 // 当前演示使用空数组 } // ========== 清空记录 ========== private clearRecords(): void { this.records = []; promptAction.showToast({ message: '历史记录已清空', duration: 1500 }); } // ========== 获取理想体重 ========== private get idealWeight(): { min: number, max: number } { return BMIEngine.getIdealWeightRange(this.height); } build() { Column() { // ---- 顶部标题 ---- Row() { Text('⚕️ BMI 计算器') .fontSize(22) .fontWeight(FontWeight.Bold) .fontColor('#333333') } .width('100%') .padding({ top: 48, bottom: 16, left: 20 }) Scroll() { Column() { // BMI 圆形进度 gauge BMIGauge({ bmi: this.bmi, color: this.categoryStyle.color, percent: this.progressPercent }) .margin({ bottom: 16 }) // 输入面板 InputPanel({ height: this.height, weight: this.weight, onHeightChange: (val: number) => { this.height = val; this.calculate(); }, onWeightChange: (val: number) => { this.weight = val; this.calculate(); } }) .margin({ bottom: 16 }) // 分类卡片 CategoryCard({ categoryStyle: this.categoryStyle, idealWeight: this.idealWeight, height: this.height }) .margin({ bottom: 16 }) // 保存按钮 Button() { Text('📝 保存记录') .fontSize(16) .fontColor('#FFFFFF') .fontWeight(FontWeight.Medium) } .width('90%') .height(48) .backgroundColor('#4A90D9') .borderRadius(24) .onClick(() => this.saveRecord()) .margin({ bottom: 16 }) // 历史记录 HistoryPanel({ records: this.records, onClear: () => this.clearRecords() }) } .width('100%') .padding({ left: 16, right: 16 }) } .layoutWeight(1) } .width('100%') .height('100%') .backgroundColor('#F8F9FA') } }

3.2 核心代码分析

(1)BMI 计算引擎(纯函数类)
class BMIEngine { static calculateBMI(weightKg: number, heightCm: number): number { if (heightCm <= 0 || weightKg <= 0) return 0; const heightM = heightCm / 100; return parseFloat((weightKg / (heightM * heightM)).toFixed(1)); } static getCategory(bmi: number): CategoryStyle { return CATEGORY_STYLES.find(s => bmi >= s.minBMI && bmi < s.maxBMI) ?? CATEGORY_STYLES[1]; } static getProgressPercent(bmi: number): number { return Math.min(100, Math.max(0, (bmi / 35) * 100)); } }

设计范式

  • 全部使用static方法,无需实例化
  • 输入清洗:处理零值和负值
  • 纯函数风格:相同输入永远返回相同输出,无副作用
  • 区间查找:使用find+ 有序数组,比 switch-case 更简洁
(2)彩色环形进度条
Stack() { Circle() .width(180).height(180) .stroke('#E8E8E8') .strokeWidth(12) .fillOpacity(0) Circle() .width(180).height(180) .stroke(this.color) .strokeWidth(12) .fillOpacity(0) .strokeLineCap(LineCapStyle.Round) .percent(this.percent * 0.75) .animation({ duration: 600, curve: Curve.EaseOut }) }

技术要点

  • 使用两层Circle叠加:底层灰色背景 + 顶层彩色前景
  • .strokeLineCap(LineCapStyle.Round)使进度条端点圆形化,视觉效果更柔和
  • .percent(percent * 0.75)将 0~100 映射到 0~270°,留下 90° 缺口使环不闭合,更具设计感
  • .animation()直接绑定在 Circle 上,属性变化时自动过渡
(3)分类卡片的动态着色
Text(this.categoryStyle.category) .fontSize(24) .fontWeight(FontWeight.Bold) .fontColor(this.categoryStyle.color) Row() .backgroundColor(this.categoryStyle.bgColor) .borderRadius(12)

每次 BMI 值变化 →getCategory()返回新的CategoryStyle→ 卡片颜色、图标、建议文字全部联动更新。这是@State 响应式的典型应用场景。

(4)历史记录管理
@State records: BMIRecord[] = []; private saveRecord(): void { const record: BMIRecord = { timestamp: Date.now(), height: this.height, weight: this.weight, bmi: this.bmi, category: this.categoryStyle.category }; this.records = [record, ...this.records].slice(0, 10); }
  • 每次保存时新记录插入头部,保留最近 10 条
  • 使用slice(0, 10)限制数量,防止无限增长
  • Date.now()作为唯一标识,同时提供排序依据

四、HarmonyOS 特色功能深度剖析

4.1 Circle 组件与 strokeLineCap

ArkTS 的Circle组件不仅用于绘制实心圆,还可以通过strokestrokeWidth绘制环形:

Circle() .stroke('#4CAF50') // 描边颜色 .strokeWidth(12) // 描边宽度 .fillOpacity(0) // 填充透明 .strokeLineCap(LineCapStyle.Round) // 端点圆形 .percent(75) // 进度百分比

percent属性是 ArkTS 独特的环形进度语法,它基于当前组件的周长计算绘制长度,无需手动计算弧长或角度。

4.2 Stack 层叠布局

Stack() { Circle() // 底层:灰色背景环 Circle() // 上层:彩色进度环 Column() // 最上层:BMI 文字 } .align(Alignment.Center)

Stack类似 CSS 的position: absolute + relative组合,所有子组件在 Z 轴方向层叠。结合.align(Alignment.Center)实现完美的居中对齐,无需手动计算偏移。

4.3 单位制扩展性

当前使用公制(cm/kg),扩展英制(ft/lb)只需增加换算逻辑:

// 单位制配置 enum UnitSystem { METRIC, IMPERIAL } // 换算系数 const CONVERSION = { cmToFt: 0.0328084, kgToLb: 2.20462 };

这种设计遵循了开闭原则:对扩展开放,对修改封闭。


五、UI/UX 设计思路

5.1 信息层级

页面采用F 形视线流

  1. 顶部:标题 + 环形 BMI 值(核心信息)
  2. 中部:身高/体重输入(交互操作区)
  3. 中下部:分类卡片 + 建议(解读与指导)
  4. 底部:操作按钮 + 历史记录(次要信息)

5.2 色彩心理学应用

分类颜色心理暗示
偏瘦蓝色 🔵冷静、需要补充
正常绿色 🟢健康、安全
偏胖橙色 🟠警示、需要行动
肥胖红色 🔴危险、需要干预

颜色选择符合交通灯色彩语义,用户无需读文字即可通过颜色判断健康状况。

5.3 响应式交互

  1. 滑块 + 数字输入:两种方式调节,满足不同用户偏好
  2. 即时反馈:任何参数变化,BMI 值和颜色立即更新
  3. 记录保存:点击按钮将当前结果存入历史
  4. 视觉过渡:Circle percet 变化有 600ms 动画,不突兀

六、最佳实践与性能优化

6.1 编码最佳实践

✅ 推荐做法

// 1. 纯函数计算引擎 vs 在 UI 层计算 class BMIEngine { static calculateBMI(...) { ... } } // 2. 使用 find() 替代 if-else 链 const result = CATEGORY_STYLES.find(s => bmi >= s.minBMI && bmi < s.maxBMI); // 3. 使用 getter 简化模板 private get idealWeight() { ... } // 4. 数组不可变操作 this.records = [newRecord, ...this.records].slice(0, MAX_RECORDS); // 5. 使用 enum 管理分类 enum BMICategory { ... }

❌ 避免的做法

// 1. 在 UI 组件中直接写计算逻辑 build() { const bmi = this.weight / ((this.height/100) ** 2); // 计算逻辑混在 UI 中,难以测试和维护 } // 2. 使用深层 if-else if (bmi < 18.5) { ... } else if (bmi < 24) { ... } else if (bmi < 28) { ... } else { ... } // 3. 直接修改数组 this.records.push(newRecord);

6.2 性能优化

优化点措施
减少 @State 数量将关联数据聚合为对象
避免不必要计算calculate()只在 height/weight 变化时调用
列表 keyForEach 使用唯一 timestamp 作为 key
动画 GPU 加速使用 transform/opacity 属性,避免 layout 变化

6.3 数据持久化

// 使用 Preferences 存储历史记录 import { preferences } from '@kit.ArkData'; async function saveRecords(context, records: BMIRecord[]) { const store = await preferences.getPreferences(context, 'bmi_prefs'); await store.put('history', JSON.stringify(records)); await store.flush(); }

七、扩展与演进方向

7.1 功能扩展

  1. 图表趋势:使用 LineChart 展示 BMI 历史变化曲线
  2. 多用户:支持家庭多成员独立管理
  3. 目标设定:设定目标 BMI,展示差值和建议
  4. 体脂估算:输入腰围/年龄/性别,估算体脂率
  5. 导出报告:生成 PDF 健康报告

7.2 鸿蒙特有能力

  • 服务卡片:桌面卡片直接展示最新 BMI 数据和分类
  • 健康数据联动:读取系统 Health Kit 中的体重数据
  • 穿戴设备:连接手表同步体重数据
  • 分布式:手机测量、平板查看历史趋势

八、总结

本文构建了一个基于 ArkTS 的 BMI 计算器,核心技术收获:

  1. 纯函数计算引擎:将 BMI 计算、分类判定、进度映射等逻辑封装为静态方法
  2. 环形进度条:利用 Circle + stroke + percent 实现圆环进度展示
  3. 颜色编码系统:四色分类(蓝/绿/橙/红)直观反映健康状况
  4. 组件化拆分:InputPanel、BMIGauge、CategoryCard、HistoryPanel 各司其职
  5. 响应式数据流:@State → 计算 → UI 自动更新,代码简洁清晰

BMI 计算器虽然数学原理简单,但 ArkTS 的声明式框架让 UI 与逻辑的结合变得优雅而高效。


参考链接

  • ArkTS Circle 组件
  • ArkTS Stack 布局
  • HarmonyOS Preferences 数据存储