Flutter三方库适配OpenHarmony【bmi_calculator】BMI 计算器项目完整实战
前言
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
abmi_calculator 是一个基于 Flutter 实现的 BMI Calculator 身体质量指数计算器 。项目核心源码位于 lib/main.dart,同时保留了 ohos OpenHarmony 平台工程目录,适合用来学习 Flutter 表单输入 、数字解析 、BMI 公式计算 、结果分类 、渐变结果卡片 和 OpenHarmony 平台承载。
BMI 是常见的体重筛查指标。本文只讨论当前项目的工程实现、UI 结构和 OpenHarmony 适配,不提供医疗诊断结论。成人 BMI 分类可参考 CDC 的成人 BMI 分类页面:https://www.cdc.gov/bmi/adult-calculator/bmi-categories.html。实际健康评估还需要结合年龄、体脂率、肌肉量、病史、腰围等更多信息。
本文重点回答三个问题:
bmi_calculator如何通过TextEditingController管理身高和体重输入。- Flutter 如何使用
double.tryParse、BMI 公式和setState完成计算与结果展示。 - 这个项目适配 OpenHarmony 时,如何验证输入法、滚动布局、结果卡片和分类说明。

图示说明:本文聚焦 Flutter BMI 计算器的输入、计算、分类和结果展示,核心源码位于 lib/main.dart。
一、背景与目标
1.1 项目功能概览
bmi_calculator 是一个轻量健康工具 UI。用户输入身高和体重后,点击 Calculate BMI,页面会计算 BMI 数值,并根据结果展示 Underweight、Normal、Overweight、Obese 四类状态。
当前项目真实支持的功能包括:
- 默认身高输入为
170cm。 - 默认体重输入为
65kg。 - 使用两个
TextEditingController管理输入框。 - 输入框使用
TextInputType.number数字键盘。 - 点击
Calculate BMI执行计算。 - 使用
double.tryParse安全解析输入。 - 校验身高和体重大于 0。
- 使用 BMI 公式计算结果。
- 按 BMI 数值分类为 Underweight、Normal、Overweight、Obese。
- 根据分类设置蓝、绿、橙、红状态色。
- 使用渐变结果卡片展示 BMI 和分类。
- 底部展示 BMI 分类说明卡片。
1.2 技术关键词
| 关键词 | 在项目中的作用 |
|---|---|
TextEditingController |
管理身高和体重输入 |
double.tryParse |
安全解析输入文本 |
height / 100 |
将厘米转换为米 |
weight / (heightInMeters * heightInMeters) |
BMI 计算公式 |
setState |
更新 BMI、状态文案和状态色 |
LinearGradient |
根据状态色生成结果卡片背景 |
SingleChildScrollView |
适配不同屏幕高度和输入法弹出 |
TextInputType.number |
弹出数字键盘 |
withValues(alpha: ...) |
生成透明度渐变色 |
1.3 BMI 工具的工程边界
| 能力 | 当前是否实现 | 说明 |
|---|---|---|
| 身高输入 | 是 | 单位 cm |
| 体重输入 | 是 | 单位 kg |
| BMI 计算 | 是 | 使用标准公式 |
| BMI 分类 | 是 | 四档分类 |
| 健康诊断 | 否 | 当前只是计算工具 |
| 历史记录 | 否 | 当前不保存结果 |
| 网络请求 | 否 | 不依赖 API |
| OpenHarmony 权限 | 否 | 当前不需要额外权限 |
关键点:BMI 计算器可以作为健康筛查工具的工程示例,但不能把结果写成诊断结论。文章应聚焦公式、输入校验、分类逻辑和 UI 展示。
二、环境准备
2.1 项目目录结构
当前项目采用 Flutter 标准目录,同时包含 OpenHarmony 平台工程。
bash
bmi_calculator/
├── lib/
│ └── main.dart
├── ohos/
│ ├── AppScope/
│ └── entry/
├── test/
│ └── widget_test.dart
├── pubspec.yaml
├── analysis_options.yaml
└── README.md
2.2 核心文件说明
| 文件或目录 | 作用 | 本文重点 |
|---|---|---|
lib/main.dart |
应用入口、输入、BMI 计算、分类和 UI | 核心源码 |
pubspec.yaml |
项目名称、版本、依赖和 Flutter 配置 | 环境依赖 |
analysis_options.yaml |
Dart 静态分析规则 | 代码规范 |
test/ |
Flutter 测试目录 | 公式和分类验证 |
ohos/ |
OpenHarmony 平台工程 | 平台承载 |
2.3 pubspec 基础配置
项目名称是 bmi_calculator,版本是 1.0.0+1,Dart SDK 约束是 ^3.9.2。
yaml
name: bmi_calculator
description: "A new Flutter project."
publish_to: 'none'
version: 1.0.0+1
environment:
sdk: ^3.9.2
publish_to: 'none' 表示这是应用工程,不会被误发布到 pub.dev。
2.4 项目依赖
当前项目只使用 Flutter SDK 和基础图标库。
yaml
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.8
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0
BMI 计算不需要网络、数据库或第三方状态管理库,因此重点在输入校验、公式计算和结果展示。
2.5 常用命令
bash
flutter pub get
flutter analyze
flutter test
flutter run
| 命令 | 作用 |
|---|---|
flutter pub get |
获取项目依赖 |
flutter analyze |
执行静态分析 |
flutter test |
运行测试 |
flutter run |
启动调试运行 |
flutter build hap |
构建 OpenHarmony HAP 包 |
三、应用入口与主题配置
3.1 main 函数
Flutter 应用从 main() 函数启动。
dart
import 'package:flutter/material.dart';
void main() {
runApp(const BmiCalculatorApp());
}
runApp(const BmiCalculatorApp()) 将 BmiCalculatorApp 放入 Flutter 渲染树根节点。
3.2 BmiCalculatorApp
dart
class BmiCalculatorApp extends StatelessWidget {
const BmiCalculatorApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'BMI Calculator',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.pink),
useMaterial3: true,
),
home: const BmiCalculatorHomePage(title: 'BMI Calculator'),
);
}
}
这里有三个关键信息:
- 应用标题是
BMI Calculator。 - 主题种子色使用
Colors.pink。 - 首页是
BmiCalculatorHomePage,输入和结果状态从首页 State 中维护。
3.3 Material 3 主题
dart
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.pink),
useMaterial3: true,
),
useMaterial3: true 会影响 AppBar、TextField、ElevatedButton、Card 等组件的默认风格。当前项目再通过粉色按钮和状态色卡片强化视觉层级。
四、页面状态设计
4.1 BmiCalculatorHomePage
dart
class BmiCalculatorHomePage extends StatefulWidget {
const BmiCalculatorHomePage({super.key, required this.title});
final String title;
@override
State<BmiCalculatorHomePage> createState() => _BmiCalculatorHomePageState();
}
用户输入、BMI 结果、状态文案和状态颜色都会变化,因此页面使用 StatefulWidget。
4.2 核心状态字段
dart
class _BmiCalculatorHomePageState extends State<BmiCalculatorHomePage> {
final TextEditingController _heightController = TextEditingController(text: '170');
final TextEditingController _weightController = TextEditingController(text: '65');
double? _bmi;
String _status = '';
Color _statusColor = Colors.grey;
}
| 字段 | 类型 | 作用 |
|---|---|---|
_heightController |
TextEditingController |
管理身高输入,默认 170 |
_weightController |
TextEditingController |
管理体重输入,默认 65 |
_bmi |
double? |
保存 BMI 结果,未计算时为 null |
_status |
String |
保存分类文案 |
_statusColor |
Color |
保存分类颜色 |
4.3 状态到 UI 的关系
#mermaid-svg-S1mSq7HUilWf9r4m{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-S1mSq7HUilWf9r4m .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-S1mSq7HUilWf9r4m .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-S1mSq7HUilWf9r4m .error-icon{fill:#552222;}#mermaid-svg-S1mSq7HUilWf9r4m .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-S1mSq7HUilWf9r4m .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-S1mSq7HUilWf9r4m .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-S1mSq7HUilWf9r4m .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-S1mSq7HUilWf9r4m .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-S1mSq7HUilWf9r4m .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-S1mSq7HUilWf9r4m .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-S1mSq7HUilWf9r4m .marker{fill:#333333;stroke:#333333;}#mermaid-svg-S1mSq7HUilWf9r4m .marker.cross{stroke:#333333;}#mermaid-svg-S1mSq7HUilWf9r4m svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-S1mSq7HUilWf9r4m p{margin:0;}#mermaid-svg-S1mSq7HUilWf9r4m .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-S1mSq7HUilWf9r4m .cluster-label text{fill:#333;}#mermaid-svg-S1mSq7HUilWf9r4m .cluster-label span{color:#333;}#mermaid-svg-S1mSq7HUilWf9r4m .cluster-label span p{background-color:transparent;}#mermaid-svg-S1mSq7HUilWf9r4m .label text,#mermaid-svg-S1mSq7HUilWf9r4m span{fill:#333;color:#333;}#mermaid-svg-S1mSq7HUilWf9r4m .node rect,#mermaid-svg-S1mSq7HUilWf9r4m .node circle,#mermaid-svg-S1mSq7HUilWf9r4m .node ellipse,#mermaid-svg-S1mSq7HUilWf9r4m .node polygon,#mermaid-svg-S1mSq7HUilWf9r4m .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-S1mSq7HUilWf9r4m .rough-node .label text,#mermaid-svg-S1mSq7HUilWf9r4m .node .label text,#mermaid-svg-S1mSq7HUilWf9r4m .image-shape .label,#mermaid-svg-S1mSq7HUilWf9r4m .icon-shape .label{text-anchor:middle;}#mermaid-svg-S1mSq7HUilWf9r4m .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-S1mSq7HUilWf9r4m .rough-node .label,#mermaid-svg-S1mSq7HUilWf9r4m .node .label,#mermaid-svg-S1mSq7HUilWf9r4m .image-shape .label,#mermaid-svg-S1mSq7HUilWf9r4m .icon-shape .label{text-align:center;}#mermaid-svg-S1mSq7HUilWf9r4m .node.clickable{cursor:pointer;}#mermaid-svg-S1mSq7HUilWf9r4m .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-S1mSq7HUilWf9r4m .arrowheadPath{fill:#333333;}#mermaid-svg-S1mSq7HUilWf9r4m .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-S1mSq7HUilWf9r4m .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-S1mSq7HUilWf9r4m .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-S1mSq7HUilWf9r4m .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-S1mSq7HUilWf9r4m .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-S1mSq7HUilWf9r4m .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-S1mSq7HUilWf9r4m .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-S1mSq7HUilWf9r4m .cluster text{fill:#333;}#mermaid-svg-S1mSq7HUilWf9r4m .cluster span{color:#333;}#mermaid-svg-S1mSq7HUilWf9r4m div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-S1mSq7HUilWf9r4m .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-S1mSq7HUilWf9r4m rect.text{fill:none;stroke-width:0;}#mermaid-svg-S1mSq7HUilWf9r4m .icon-shape,#mermaid-svg-S1mSq7HUilWf9r4m .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-S1mSq7HUilWf9r4m .icon-shape p,#mermaid-svg-S1mSq7HUilWf9r4m .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-S1mSq7HUilWf9r4m .icon-shape .label rect,#mermaid-svg-S1mSq7HUilWf9r4m .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-S1mSq7HUilWf9r4m .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-S1mSq7HUilWf9r4m .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-S1mSq7HUilWf9r4m :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} height input
_calculateBmi
weight input
_bmi
_status
_statusColor
BMI 数值文本
分类标签
结果卡片颜色
_bmi、_status 和 _statusColor 是结果区域的三个核心状态。
五、输入区实现
5.1 整体滚动容器
页面主体使用 SingleChildScrollView。
dart
body: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 输入区、按钮、结果卡片和分类说明
],
),
)
滚动容器可以适配小屏幕和输入法弹出场景,避免内容被遮挡。
5.2 身高输入框
dart
Row(
children: [
const Icon(Icons.height, size: 32, color: Colors.pink),
const SizedBox(width: 16),
Expanded(
child: TextField(
controller: _heightController,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: 'Height (cm)',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
filled: true,
),
),
),
],
)
身高输入框使用 Icons.height 图标,标签为 Height (cm),输入键盘类型为数字。
5.3 体重输入框
dart
Row(
children: [
const Icon(Icons.monitor_weight, size: 32, color: Colors.pink),
const SizedBox(width: 16),
Expanded(
child: TextField(
controller: _weightController,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: 'Weight (kg)',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
filled: true,
),
),
),
],
)
体重输入框使用 Icons.monitor_weight 图标,标签为 Weight (kg)。
5.4 输入配置表
| 输入项 | 默认值 | 单位 | Controller | 图标 |
|---|---|---|---|---|
| 身高 | 170 | cm | _heightController |
Icons.height |
| 体重 | 65 | kg | _weightController |
Icons.monitor_weight |
六、BMI 计算逻辑
6.1 _calculateBmi 方法
BMI 计算集中在 _calculateBmi() 中。
dart
void _calculateBmi() {
final height = double.tryParse(_heightController.text);
final weight = double.tryParse(_weightController.text);
if (height != null && weight != null && height > 0 && weight > 0) {
final heightInMeters = height / 100;
final bmi = weight / (heightInMeters * heightInMeters);
setState(() {
_bmi = bmi;
if (bmi < 18.5) {
_status = 'Underweight';
_statusColor = Colors.blue;
} else if (bmi < 25) {
_status = 'Normal';
_statusColor = Colors.green;
} else if (bmi < 30) {
_status = 'Overweight';
_statusColor = Colors.orange;
} else {
_status = 'Obese';
_statusColor = Colors.red;
}
});
}
}
这个方法包含输入解析、有效性校验、单位转换、公式计算、分类判断和状态更新。
6.2 输入解析
dart
final height = double.tryParse(_heightController.text);
final weight = double.tryParse(_weightController.text);
double.tryParse 比 double.parse 更安全。输入非法时,它会返回 null,不会直接抛出异常。
6.3 有效性校验
dart
if (height != null && weight != null && height > 0 && weight > 0) {
// 执行计算
}
只有身高和体重都能解析为数字,并且都大于 0,才会继续计算。
6.4 BMI 公式
dart
final heightInMeters = height / 100;
final bmi = weight / (heightInMeters * heightInMeters);
BMI 公式为:
text
BMI = weight(kg) / height(m)^2
项目输入的身高单位是厘米,因此需要先除以 100 转换成米。
七、BMI 分类逻辑
7.1 分类代码
dart
if (bmi < 18.5) {
_status = 'Underweight';
_statusColor = Colors.blue;
} else if (bmi < 25) {
_status = 'Normal';
_statusColor = Colors.green;
} else if (bmi < 30) {
_status = 'Overweight';
_statusColor = Colors.orange;
} else {
_status = 'Obese';
_statusColor = Colors.red;
}
当前项目使用四档分类:Underweight、Normal、Overweight、Obese。
7.2 分类表
| BMI 范围 | 状态文案 | 状态颜色 |
|---|---|---|
< 18.5 |
Underweight | 蓝色 |
18.5 - 24.9 |
Normal | 绿色 |
25 - 29.9 |
Overweight | 橙色 |
>= 30 |
Obese | 红色 |
7.3 CDC 分类参考
CDC 成人 BMI 分类页面说明成人 BMI 可分为 underweight、healthy weight、overweight 和 obesity 等类别。当前项目文案使用 Normal 表达健康体重范围,并与代码中的 18.5、25、30 三个阈值保持一致。
7.4 状态更新
dart
setState(() {
_bmi = bmi;
_status = 'Normal';
_statusColor = Colors.green;
});
setState 会触发页面重建,让结果卡片显示最新数值、文案和颜色。
八、计算按钮实现
8.1 按钮源码
dart
ElevatedButton(
onPressed: _calculateBmi,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.all(16),
backgroundColor: Colors.pink,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Text(
'Calculate BMI',
style: TextStyle(fontSize: 18, color: Colors.white),
),
)
按钮点击后调用 _calculateBmi()。
8.2 视觉配置
| 属性 | 当前值 | 作用 |
|---|---|---|
padding |
16 |
提升点击区域 |
backgroundColor |
Colors.pink |
与主题色一致 |
borderRadius |
12 |
圆角按钮 |
fontSize |
18 |
保持按钮文字清晰 |
8.3 点击链路
#mermaid-svg-r6HE5xg1r1WnsTuS{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-r6HE5xg1r1WnsTuS .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-r6HE5xg1r1WnsTuS .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-r6HE5xg1r1WnsTuS .error-icon{fill:#552222;}#mermaid-svg-r6HE5xg1r1WnsTuS .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-r6HE5xg1r1WnsTuS .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-r6HE5xg1r1WnsTuS .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-r6HE5xg1r1WnsTuS .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-r6HE5xg1r1WnsTuS .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-r6HE5xg1r1WnsTuS .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-r6HE5xg1r1WnsTuS .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-r6HE5xg1r1WnsTuS .marker{fill:#333333;stroke:#333333;}#mermaid-svg-r6HE5xg1r1WnsTuS .marker.cross{stroke:#333333;}#mermaid-svg-r6HE5xg1r1WnsTuS svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-r6HE5xg1r1WnsTuS p{margin:0;}#mermaid-svg-r6HE5xg1r1WnsTuS .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-r6HE5xg1r1WnsTuS .cluster-label text{fill:#333;}#mermaid-svg-r6HE5xg1r1WnsTuS .cluster-label span{color:#333;}#mermaid-svg-r6HE5xg1r1WnsTuS .cluster-label span p{background-color:transparent;}#mermaid-svg-r6HE5xg1r1WnsTuS .label text,#mermaid-svg-r6HE5xg1r1WnsTuS span{fill:#333;color:#333;}#mermaid-svg-r6HE5xg1r1WnsTuS .node rect,#mermaid-svg-r6HE5xg1r1WnsTuS .node circle,#mermaid-svg-r6HE5xg1r1WnsTuS .node ellipse,#mermaid-svg-r6HE5xg1r1WnsTuS .node polygon,#mermaid-svg-r6HE5xg1r1WnsTuS .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-r6HE5xg1r1WnsTuS .rough-node .label text,#mermaid-svg-r6HE5xg1r1WnsTuS .node .label text,#mermaid-svg-r6HE5xg1r1WnsTuS .image-shape .label,#mermaid-svg-r6HE5xg1r1WnsTuS .icon-shape .label{text-anchor:middle;}#mermaid-svg-r6HE5xg1r1WnsTuS .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-r6HE5xg1r1WnsTuS .rough-node .label,#mermaid-svg-r6HE5xg1r1WnsTuS .node .label,#mermaid-svg-r6HE5xg1r1WnsTuS .image-shape .label,#mermaid-svg-r6HE5xg1r1WnsTuS .icon-shape .label{text-align:center;}#mermaid-svg-r6HE5xg1r1WnsTuS .node.clickable{cursor:pointer;}#mermaid-svg-r6HE5xg1r1WnsTuS .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-r6HE5xg1r1WnsTuS .arrowheadPath{fill:#333333;}#mermaid-svg-r6HE5xg1r1WnsTuS .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-r6HE5xg1r1WnsTuS .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-r6HE5xg1r1WnsTuS .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-r6HE5xg1r1WnsTuS .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-r6HE5xg1r1WnsTuS .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-r6HE5xg1r1WnsTuS .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-r6HE5xg1r1WnsTuS .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-r6HE5xg1r1WnsTuS .cluster text{fill:#333;}#mermaid-svg-r6HE5xg1r1WnsTuS .cluster span{color:#333;}#mermaid-svg-r6HE5xg1r1WnsTuS div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-r6HE5xg1r1WnsTuS .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-r6HE5xg1r1WnsTuS rect.text{fill:none;stroke-width:0;}#mermaid-svg-r6HE5xg1r1WnsTuS .icon-shape,#mermaid-svg-r6HE5xg1r1WnsTuS .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-r6HE5xg1r1WnsTuS .icon-shape p,#mermaid-svg-r6HE5xg1r1WnsTuS .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-r6HE5xg1r1WnsTuS .icon-shape .label rect,#mermaid-svg-r6HE5xg1r1WnsTuS .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-r6HE5xg1r1WnsTuS .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-r6HE5xg1r1WnsTuS .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-r6HE5xg1r1WnsTuS :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 否
是
点击 Calculate BMI
_calculateBmi()
tryParse 身高和体重
输入有效?
不更新结果
计算 heightInMeters
计算 BMI
分类并设置颜色
setState 重建结果卡片
九、结果卡片实现
9.1 展示条件
结果卡片只在 _bmi != null 时展示。
dart
if (_bmi != null)
Card(...)
页面初始状态下不展示结果卡片,只有用户点击计算并得到有效结果后才出现。
9.2 Card 容器
dart
Card(
elevation: 8,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
child: Container(...),
)
Card 使用阴影和 20 像素圆角,让结果区域独立于输入区域。
9.3 渐变背景
dart
gradient: LinearGradient(
colors: [
_statusColor.withValues(alpha: 0.2),
_statusColor.withValues(alpha: 0.1),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
渐变颜色来自 _statusColor,不同 BMI 分类会让卡片呈现不同色彩氛围。
9.4 结果内容
dart
Column(
children: [
const Text(
'Your BMI',
style: TextStyle(fontSize: 18, color: Colors.grey),
),
const SizedBox(height: 8),
Text(
_bmi!.toStringAsFixed(1),
style: TextStyle(
fontSize: 64,
fontWeight: FontWeight.bold,
color: _statusColor,
),
),
Container(...),
],
)
BMI 数值使用 64 号大字,并通过 _statusColor 强化分类结果。
十、分类标签与说明卡片
10.1 分类标签
dart
Container(
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 8,
),
decoration: BoxDecoration(
color: _statusColor,
borderRadius: BorderRadius.circular(20),
),
child: Text(
_status,
style: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
)
分类标签使用纯色背景,文案为白色,能够突出 Underweight、Normal、Overweight 或 Obese。
10.2 底部说明卡片
dart
Card(
color: Colors.grey.shade100,
child: const Padding(
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'BMI Categories:',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
SizedBox(height: 8),
Text('• Underweight: < 18.5'),
Text('• Normal: 18.5 - 24.9'),
Text('• Overweight: 25 - 29.9'),
Text('• Obese: >= 30'),
],
),
),
)
这张说明卡片让用户不用查看代码,也能理解 BMI 分类边界。
10.3 分类说明表
| 文案 | 显示内容 |
|---|---|
| 标题 | BMI Categories: |
| 第一行 | Underweight: < 18.5 |
| 第二行 | Normal: 18.5 - 24.9 |
| 第三行 | Overweight: 25 - 29.9 |
| 第四行 | Obese: >= 30 |
十一、OpenHarmony 适配边界
11.1 Flutter 层职责
当前项目的业务逻辑全部在 Flutter 层完成。
text
Flutter 层:
BmiCalculatorApp
BmiCalculatorHomePage
_BmiCalculatorHomePageState
TextEditingController
TextField
_calculateBmi
Card
LinearGradient
OpenHarmony 层:
AppScope
entry
EntryAbility
Index
resources
Flutter 层负责输入、计算、分类和页面展示;OpenHarmony 层负责应用启动、模块配置和页面承载。
11.2 平台侧文件作用
| 层级 | 文件 | 作用 |
|---|---|---|
| 应用级 | AppScope/app.json5 |
描述应用整体信息 |
| 模块级 | entry/src/main/module.json5 |
描述模块入口和页面 |
| Ability | EntryAbility.ets |
应用启动入口 |
| 页面 | Index.ets |
承载 Flutter 页面 |
| 资源 | resources/base |
图标、字符串和配置资源 |
| 插件 | GeneratedPluginRegistrant.ets |
插件注册入口 |
11.3 BMI 计算器的适配特点
当前项目不需要网络、定位、相机、传感器、数据库或文件读写权限。OpenHarmony 侧主要验证:
- 输入框是否能正常唤起数字键盘。
- 页面在小屏幕和键盘弹出时能否滚动。
- 计算按钮点击是否生效。
- 结果卡片和分类卡片是否正常展示。
- 颜色、圆角、渐变和文字布局是否稳定。
十二、测试与验证
12.1 页面验证路径
BMI 计算器的核心验证路径如下:
- 启动应用,标题显示
BMI Calculator。 - 身高输入框默认显示
170。 - 体重输入框默认显示
65。 - 点击
Calculate BMI。 - 页面展示
Your BMI结果卡片。 - BMI 数值保留 1 位小数。
- 分类标签显示
Normal。 - 底部分类说明卡片正常展示。
- 修改输入为不同数值后重新计算。
- 结果颜色随分类变化。
12.2 Widget 测试示例
下面的测试验证页面基础内容。
dart
testWidgets('shows bmi calculator page content', (WidgetTester tester) async {
await tester.pumpWidget(const BmiCalculatorApp());
expect(find.text('BMI Calculator'), findsOneWidget);
expect(find.text('Enter your details'), findsOneWidget);
expect(find.text('Calculate BMI'), findsOneWidget);
expect(find.text('BMI Categories:'), findsOneWidget);
});
12.3 公式测试示例
可以将 BMI 公式抽成纯函数后测试。
dart
double calculateBmi(double heightCm, double weightKg) {
final heightInMeters = heightCm / 100;
return weightKg / (heightInMeters * heightInMeters);
}
测试用例:
dart
test('calculates bmi correctly', () {
final bmi = calculateBmi(170, 65);
expect(bmi.toStringAsFixed(1), '22.5');
});
12.4 分类测试示例
dart
String classifyBmi(double bmi) {
if (bmi < 18.5) return 'Underweight';
if (bmi < 25) return 'Normal';
if (bmi < 30) return 'Overweight';
return 'Obese';
}
测试用例:
dart
test('classifies bmi ranges', () {
expect(classifyBmi(18.0), 'Underweight');
expect(classifyBmi(22.0), 'Normal');
expect(classifyBmi(27.0), 'Overweight');
expect(classifyBmi(31.0), 'Obese');
});
十三、常见问题与优化建议
13.1 当前项目是否提供医疗诊断
不提供。当前项目只是一个 BMI 计算器 Demo,用于展示输入、计算和结果分类。BMI 是筛查指标,不等同于医疗诊断。
13.2 为什么使用 double.tryParse
输入框内容本质是字符串。double.tryParse 可以安全处理非法输入,避免因为用户输入异常内容导致应用崩溃。
13.3 为什么身高要除以 100
输入单位是厘米,而 BMI 公式要求身高单位是米。因此需要:
dart
final heightInMeters = height / 100;
13.4 为什么结果只保留一位小数
BMI 展示到一位小数已经足够清晰。当前项目使用:
dart
_bmi!.toStringAsFixed(1)
这样能避免结果显示过多小数影响阅读。
13.5 为什么使用 SingleChildScrollView
输入法弹出后会占用屏幕高度。SingleChildScrollView 能让页面滚动,降低小屏设备上的遮挡风险。
13.6 输入无效时有什么表现
当前代码在输入无效时不会更新结果,也不会弹出错误提示。后续可以增加错误文案、SnackBar 或表单校验提示。
13.7 UI 层可以如何继续优化
- 增加输入为空或非法时的错误提示。
- 为身高和体重增加合理范围校验。
- 把 BMI 公式和分类逻辑抽成纯函数,方便测试。
- 增加历史记录和趋势图。
- 增加本地化文案,支持中文分类展示。
- 添加免责声明区域,提醒用户 BMI 仅作筛查参考。
十四、完整业务链路复盘
14.1 输入链路
- 用户编辑身高输入框。
_heightController.text保存身高文本。- 用户编辑体重输入框。
_weightController.text保存体重文本。- 点击
Calculate BMI。 _calculateBmi()读取两个 Controller 的文本。
14.2 计算链路
double.tryParse解析身高。double.tryParse解析体重。- 判断身高和体重是否有效。
- 身高厘米转米。
- 使用 BMI 公式计算结果。
- 根据 BMI 分类。
- 设置
_bmi、_status和_statusColor。 setState触发页面重建。
14.3 展示链路
_bmi != null条件成立。- 页面展示结果卡片。
Your BMI作为结果标题。_bmi!.toStringAsFixed(1)显示数值。_statusColor控制数值颜色和渐变背景。_status显示分类标签。- 底部分类说明卡片始终展示。
14.4 数据到 UI 的映射
| 数据 | 处理方式 | UI 输出 |
|---|---|---|
_heightController.text |
double.tryParse |
身高数字 |
_weightController.text |
double.tryParse |
体重数字 |
height / 100 |
单位转换 | 米 |
weight / height² |
BMI 公式 | _bmi |
_bmi |
阈值判断 | _status |
_status |
分类映射 | _statusColor |
_statusColor |
渐变和文字颜色 | 结果卡片 |
十五、核心源码总览
下面集中展示当前项目最关键的 BMI 计算代码。
dart
void _calculateBmi() {
final height = double.tryParse(_heightController.text);
final weight = double.tryParse(_weightController.text);
if (height != null && weight != null && height > 0 && weight > 0) {
final heightInMeters = height / 100;
final bmi = weight / (heightInMeters * heightInMeters);
setState(() {
_bmi = bmi;
if (bmi < 18.5) {
_status = 'Underweight';
_statusColor = Colors.blue;
} else if (bmi < 25) {
_status = 'Normal';
_statusColor = Colors.green;
} else if (bmi < 30) {
_status = 'Overweight';
_statusColor = Colors.orange;
} else {
_status = 'Obese';
_statusColor = Colors.red;
}
});
}
}
结果卡片的渐变背景如下:
dart
gradient: LinearGradient(
colors: [
_statusColor.withValues(alpha: 0.2),
_statusColor.withValues(alpha: 0.1),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
输入框使用数字键盘:
dart
TextField(
controller: _heightController,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: 'Height (cm)',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
filled: true,
),
)
这些代码体现了项目主干:读取输入、计算 BMI、分类结果,并把结果映射到卡片 UI。
总结
bmi_calculator 是一个结构清晰的 Flutter 表单计算类项目。它通过两个 TextEditingController 管理身高和体重输入,通过 double.tryParse 安全解析数字,通过 BMI 公式计算结果,再用四档分类逻辑生成状态文案和状态颜色。
从源码结构看,项目的技术重点集中在三条线:第一条是输入链路,TextField 和 Controller 保存用户输入;第二条是计算链路,_calculateBmi() 完成解析、校验、单位转换和分类;第三条是展示链路,结果卡片通过 _bmi、_status 和 _statusColor 生成清晰的视觉反馈。
从 OpenHarmony 适配角度看,当前项目业务逻辑保持在 Flutter 层,ohos 目录负责平台工程承载。这个应用不需要额外权限,但需要重点验证数字键盘、滚动布局、按钮点击、渐变卡片和分类说明在 OpenHarmony 设备上的展示效果。
如果这篇文章对你有帮助,欢迎点赞、收藏、关注,你的支持是我持续创作的动力!
相关资源:
-
Flutter 官方文档:https://docs.flutter.dev/
-
OpenHarmony 官网:https://www.openharmony.cn/