Flutter三方库适配OpenHarmony【bmi_calculator】BMI 计算器项目完整实战

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。实际健康评估还需要结合年龄、体脂率、肌肉量、病史、腰围等更多信息。

本文重点回答三个问题:

  1. bmi_calculator 如何通过 TextEditingController 管理身高和体重输入。
  2. Flutter 如何使用 double.tryParse、BMI 公式和 setState 完成计算与结果展示。
  3. 这个项目适配 OpenHarmony 时,如何验证输入法、滚动布局、结果卡片和分类说明。

图示说明:本文聚焦 Flutter BMI 计算器的输入、计算、分类和结果展示,核心源码位于 lib/main.dart


一、背景与目标

1.1 项目功能概览

bmi_calculator 是一个轻量健康工具 UI。用户输入身高和体重后,点击 Calculate BMI,页面会计算 BMI 数值,并根据结果展示 Underweight、Normal、Overweight、Obese 四类状态。

当前项目真实支持的功能包括:

  • 默认身高输入为 170 cm。
  • 默认体重输入为 65 kg。
  • 使用两个 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'),
    );
  }
}

这里有三个关键信息:

  1. 应用标题是 BMI Calculator
  2. 主题种子色使用 Colors.pink
  3. 首页是 BmiCalculatorHomePage,输入和结果状态从首页 State 中维护。

3.3 Material 3 主题

dart 复制代码
theme: ThemeData(
  colorScheme: ColorScheme.fromSeed(seedColor: Colors.pink),
  useMaterial3: true,
),

useMaterial3: true 会影响 AppBarTextFieldElevatedButtonCard 等组件的默认风格。当前项目再通过粉色按钮和状态色卡片强化视觉层级。


四、页面状态设计

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.tryParsedouble.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,
    ),
  ),
)

分类标签使用纯色背景,文案为白色,能够突出 UnderweightNormalOverweightObese

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 侧主要验证:

  1. 输入框是否能正常唤起数字键盘。
  2. 页面在小屏幕和键盘弹出时能否滚动。
  3. 计算按钮点击是否生效。
  4. 结果卡片和分类卡片是否正常展示。
  5. 颜色、圆角、渐变和文字布局是否稳定。

十二、测试与验证

12.1 页面验证路径

BMI 计算器的核心验证路径如下:

  1. 启动应用,标题显示 BMI Calculator
  2. 身高输入框默认显示 170
  3. 体重输入框默认显示 65
  4. 点击 Calculate BMI
  5. 页面展示 Your BMI 结果卡片。
  6. BMI 数值保留 1 位小数。
  7. 分类标签显示 Normal
  8. 底部分类说明卡片正常展示。
  9. 修改输入为不同数值后重新计算。
  10. 结果颜色随分类变化。

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 输入链路

  1. 用户编辑身高输入框。
  2. _heightController.text 保存身高文本。
  3. 用户编辑体重输入框。
  4. _weightController.text 保存体重文本。
  5. 点击 Calculate BMI
  6. _calculateBmi() 读取两个 Controller 的文本。

14.2 计算链路

  1. double.tryParse 解析身高。
  2. double.tryParse 解析体重。
  3. 判断身高和体重是否有效。
  4. 身高厘米转米。
  5. 使用 BMI 公式计算结果。
  6. 根据 BMI 分类。
  7. 设置 _bmi_status_statusColor
  8. setState 触发页面重建。

14.3 展示链路

  1. _bmi != null 条件成立。
  2. 页面展示结果卡片。
  3. Your BMI 作为结果标题。
  4. _bmi!.toStringAsFixed(1) 显示数值。
  5. _statusColor 控制数值颜色和渐变背景。
  6. _status 显示分类标签。
  7. 底部分类说明卡片始终展示。

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 设备上的展示效果。

如果这篇文章对你有帮助,欢迎点赞、收藏、关注,你的支持是我持续创作的动力!


相关资源:

相关推荐
慧海灵舟1 小时前
Flutter × OpenHarmony 开发日记 Day1:从克隆仓库到环境就绪
flutter·写文章,赢小鸿ai
大雷神2 小时前
第41篇|补光与水印:效果选项如何参与最终照片记录
harmonyos
大雷神3 小时前
第39篇|拍摄模式切换:单拍、双拍、顺序拍的 UI 逻辑
harmonyos
yuegu7773 小时前
HarmonyOS应用<节气通>开发第10篇:测验记录与错题本
华为·harmonyos
G_dou_3 小时前
Flutter三方库适配OpenHarmony【tip_calculator】小费计算器项目完整实战
flutter·harmonyos
yuegu7774 小时前
HarmonyOS应用<节气通>开发第6篇:节气详情页(下)——诗词与养生
华为·harmonyos
慧海灵舟4 小时前
鸿蒙南向开发教程Day 2:创建自己的 Hello World 工程
华为·harmonyos·写文章,赢小鸿ai
小铁-Android4 小时前
Visual Studio Code创建Flutter项目时包名组织名更改
vscode·flutter