在这里插入图片描述

单位转换是日常生活和工作中常见的需求。用户可以快速进行各种单位的转换。本文将详细讲解如何实现单位转换工具。

单位转换工具的应用

单位转换在多个领域都有应用。在日常生活中,用户可能需要转换长度、重量、温度等单位。在工程和科学领域,单位转换是必不可少的。

在工具页面中,单位转换工具已经定义在工具列表中:

{'icon': Icons.straighten, 'title': '单位转换'},

说明

这里我把它当成“工具箱”里的一个入口项来做,和文件转换、编码转换这种工具同级。入口层只负责展示,不要在这里塞任何换算逻辑,后面维护会很痛。

单位转换对话框的实现

实际项目里我一般会把“弹窗 UI”与“换算逻辑”分开:弹窗只做交互与数据流转,换算规则集中放在一个纯 Dart 的工具类里,这样后续要加重量/体积/温度时不会改一堆 UI。

先给工具卡片/列表绑定点击事件。入口方法尽量薄一点:

void _onToolTap(BuildContext context, String title) {
  if (title == '单位转换') {
    _showUnitConverterDialog(context);
    return;
  }
  _showToolDetail(context, title);
}

说明

这里用 title 做分发只是为了把示例讲清楚;真实项目里我更倾向用枚举或 ToolType,避免字符串写错导致“点了没反应”。

接下来是弹窗内部的状态准备。输入框、结果框都用 TextEditingController 管起来,配合下拉框的当前选择值,随时触发重新计算:

final _inputCtrl = TextEditingController();
final _resultCtrl = TextEditingController();

String _fromUnit = '米';
String _toUnit = '厘米';

说明

_resultCtrl 设置为只读,统一从计算结果写入,避免用户手动改结果造成状态不同步。单位选择用两个字符串就够了,后面扩展到“单位类型”时再加一层分类。

弹窗框架用 showDialog + StatefulBuilder,这样不需要为了一个弹窗专门写 StatefulWidget:

void _showUnitConverterDialog(BuildContext context) {
  showDialog(
    context: context,
    builder: (context) {
      return StatefulBuilder(
        builder: (context, setState) {
          return AlertDialog(
            title: const Text('单位转换'),
            content: _buildUnitConverterBody(setState),
            actions: _buildUnitConverterActions(context),
          );
        },
      );
    },
  );
}

说明

这种写法的优点是“就地管理弹窗内的状态”,不会污染页面级别的 setState。缺点也明显:弹窗复杂到一定程度,还是建议抽成独立组件,顺便加单测.

下面把内容区域拆成几个小块:输入、单位选择、结果展示。先放输入框,输入变化时立刻触发计算:

Widget _buildInputField(VoidCallback recalc) {
  return TextField(
    controller: _inputCtrl,
    keyboardType: TextInputType.number,
    decoration: InputDecoration(
      hintText: '输入数值',
      border: OutlineInputBorder(borderRadius: BorderRadius.circular(8.r)),
    ),
    onChanged: (_) => recalc(),
  );
}

说明

我习惯把 recalc 传进来,而不是在控件内部直接引用外部方法名,这样后续要接防抖、要改触发时机(比如失焦再算)会更顺手.

单位选择部分也拆开写,避免把一堆 DropdownButtonFormField 堆在同一个方法里:

Widget _buildUnitDropdown({
  required String value,
  required List<String> items,
  required ValueChanged<String?> onChanged,
}) {
  return DropdownButtonFormField<String>(
    value: value,
    items: items
        .map((u) => DropdownMenuItem(value: u, child: Text(u)))
        .toList(),
    onChanged: onChanged,
    decoration: InputDecoration(
      border: OutlineInputBorder(borderRadius: BorderRadius.circular(8.r)),
    ),
  );
}

说明

这个小组件是为了“复用+一致样式”。只要边框、圆角统一,弹窗看起来会干净很多,也方便以后把单位列表替换成从配置读取.

然后把两个下拉框排成一行,变化时更新状态并计算:

Widget _buildUnitRow(void Function(void Function()) setState, VoidCallback recalc) {
  const units = ['米', '厘米', '毫米', '英尺', '英寸'];
  return Row(
    children: [
      Expanded(
        child: _buildUnitDropdown(
          value: _fromUnit,
          items: units,
          onChanged: (v) {
            if (v == null) return;
            setState(() => _fromUnit = v);
            recalc();
          },
        ),
      ),
      SizedBox(width: 8.w),
      const Icon(Icons.arrow_forward),
      SizedBox(width: 8.w),
      Expanded(
        child: _buildUnitDropdown(
          value: _toUnit,
          items: units,
          onChanged: (v) {
            if (v == null) return;
            setState(() => _toUnit = v);
            recalc();
          },
        ),
      ),
    ],
  );
}

说明

这里我没有做“from/to 不能相同”的限制,因为相同单位转换对用户也有意义(等于做一次格式化)。如果你希望交互更强一点,可以在相同单位时自动交换或直接提示.

结果框保持只读,所有结果由计算函数写进去:

Widget _buildResultField() {
  return TextField(
    controller: _resultCtrl,
    readOnly: true,
    decoration: InputDecoration(
      hintText: '转换结果',
      border: OutlineInputBorder(borderRadius: BorderRadius.circular(8.r)),
    ),
  );
}

说明

只读的好处是避免“结果框被编辑后你还得反推状态”。对工具类 App 来说,减少状态分支就是减少 bug.

把内容区串起来,顺便把 recalc 定义在同一个作用域里,保证它能拿到最新的单位选择值:

Widget _buildUnitConverterBody(void Function(void Function()) setState) {
  void recalc() {
    final raw = _inputCtrl.text.trim();
    final value = double.tryParse(raw);
    if (value == null) {
      _resultCtrl.text = '';
      return;
    }

    final result = UnitConverter.convertLength(value, _fromUnit, _toUnit);
    _resultCtrl.text = _formatNumber(result);
  }

  return SingleChildScrollView(
    child: Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        _buildInputField(recalc),
        SizedBox(height: 12.h),
        _buildUnitRow(setState, recalc),
        SizedBox(height: 12.h),
        _buildResultField(),
      ],
    ),
  );
}

说明

这里的处理比较“实用主义”:输入不是数字就清空结果,不弹错、不打断用户。你如果要更严格,可以在输入非法时给一个轻提示(比如 SnackBar),但别每敲一个字就弹一次.

按钮部分保持简单:关闭与复制。复制时从 _resultCtrl.text 取值最稳:

List<Widget> _buildUnitConverterActions(BuildContext context) {
  return [
    TextButton(
      onPressed: () => Navigator.pop(context),
      child: const Text('关闭'),
    ),
    TextButton(
      onPressed: () {
        final text = _resultCtrl.text.trim();
        if (text.isNotEmpty) {
          Clipboard.setData(ClipboardData(text: text));
        }
        Navigator.pop(context);
      },
      child: const Text('复制'),
    ),
  ];
}

说明

复制逻辑最好“只复制结果本身”,不要把单位前后缀硬拼进去,不然用户粘贴到别处还要删。真要带单位的话,可以做一个开关选项.

单位转换的实现

单位转换最核心的点就两个:

  • 单位的“基准值”如何定义(统一换算到米、千克这类基础单位)
  • 计算结果如何展示(精度、格式化、异常输入处理)

长度这种线性单位最简单:统一换算到“米”作为基准即可.

class UnitConverter {
  static const Map<String, double> lengthUnits = {
    '米': 1.0,
    '厘米': 0.01,
    '毫米': 0.001,
    '英尺': 0.3048,
    '英寸': 0.0254,
  };
}

说明

lengthUnits 的值表示“该单位对应多少米”。比如 1 英尺 = 0.3048 米,所以是 0.3048. 这样做的好处是:所有单位互转都能通过“先归一再换算”完成.

真正的转换方法最好不要用 ! 强行断言;实际项目里用户可能会切到别的单位类型,或者单位列表更新了但老缓存还在。更稳妥的写法是先判空:

static double convertLength(double value, String fromUnit, String toUnit) {
  final from = lengthUnits[fromUnit];
  final to = lengthUnits[toUnit];
  if (from == null || to == null) return value;

  final baseInMeter = value * from;
  return baseInMeter / to;
}

说明

这里我用“乘以 from、除以 to”的写法,是因为 lengthUnits 定义的是“1 单位等于多少米”。你也可以反过来定义成“1 米等于多少单位”,但别混用,混一次就容易算错.

结果展示上,我一般会做一个很轻量的格式化:保留一段合理精度,同时把末尾多余的 0 去掉:

String _formatNumber(double v) {
  final s = v.toStringAsPrecision(12);
  return s.replaceFirst(RegExp(r'\.0+'), '');
}

说明

UI 上“看起来舒服”比数学上无限精确更重要.toStringAsPrecision(12) 通常够用,既不会太长,也能覆盖常见单位换算的误差.

多种单位类型的支持

多单位类型我不建议一上来就全堆在一个 Map 里。更适合的方式是:

  • UI 上先让用户选“单位类型”(长度/重量/温度)
  • 再根据类型切换不同的单位列表与换算方法

如果你想先快速落地,可以先只做长度,然后按相同方式扩展重量:

static const Map<String, double> weightUnits = {
  '千克': 1.0,
  '克': 0.001,
  '磅': 0.45359237,
};

static double convertWeight(double value, String fromUnit, String toUnit) {
  final from = weightUnits[fromUnit];
  final to = weightUnits[toUnit];
  if (from == null || to == null) return value;
  final baseInKg = value * from;
  return baseInKg / to;
}

说明

重量和长度一样属于“线性比例换算”,可以完全复用同一套思路。后面你只要把 UI 的 units 列表换成重量单位,并把 convertLength 替换成 convertWeight 即可.

温度转换的特殊处理

温度是个例外:它不是纯比例关系,有“零点偏移”。所以温度不要走系数表,直接写公式更直观:

static double convertTemperature(double value, String from, String to) {
  double c;
  switch (from) {
    case '摄氏度':
      c = value;
      break;
    case '华氏度':
      c = (value - 32) * 5 / 9;
      break;
    case '开尔文':
      c = value - 273.15;
      break;
    default:
      c = value;
  }

  switch (to) {
    case '摄氏度':
      return c;
    case '华氏度':
      return c * 9 / 5 + 32;
    case '开尔文':
      return c + 273.15;
    default:
      return c;
  }
}

说明

我先把输入统一转成摄氏度,再从摄氏度转出去。逻辑虽然啰嗦一点,但非常不容易出错,也方便以后加兰氏度之类的扩展.

精度和舍入

单位转换结果经常会出现很长的小数(尤其英制/公制互转)。我的经验是:

  • 默认不要强行固定成 2 位或 4 位
  • 先给一个合理精度上限(比如 10-12 位有效数字)
  • 再把视觉噪音(末尾的 0)清掉

如果你需要“可配置精度”,可以给对话框加一个简单的设置项,例如 DropdownButton 选择“保留 2/4/6 位”,然后把 _formatNumber 改成 toStringAsFixed(n).

总结

单位转换这个功能看起来小,但只要你把结构搭顺了(入口薄、弹窗只管交互、换算规则集中管理),后续扩展单位类型会非常省心.

我建议你先把“长度”这条链路打磨顺:输入体验、结果展示、复制、异常输入处理都稳定了,再按相同套路加重量/温度/体积.


欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

Logo

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

更多推荐