Flutter 计算器小应用适配 OpenHarmony:calculator 从状态设计到工程验证

前言

在 Flutter 适配 OpenHarmony 的实践里,最适合作为第一篇拆解对象的项目,往往不是功能最复杂的应用,而是链路足够短、状态变化足够清晰、运行结果足够直观的小工具。calculator 就是这样一个项目:它用 Flutter 实现了一个基础计算器,同时保留了 ohos 平台工程目录,可以同时观察 Dart 层 UI、业务状态、按钮交互和 OpenHarmony 工程配置。

这篇文章基于项目中的真实源码展开,重点分析 lib/main.dart 中的入口结构、计算状态、输入分发、四则运算、异常保护、数字格式化、按钮组件封装,以及 ohos/entry/src/main/module.json5 中的 OpenHarmony 模块配置。读完之后,可以掌握一个 Flutter 小应用从代码实现到跨平台验证的完整思路。

本文适合三类读者:

  1. 正在学习 Flutter StatefulWidgetsetState 和组件拆分的开发者。
  2. 想把 Flutter 小应用迁移或运行到 OpenHarmony 工程中的开发者。
  3. 需要通过真实项目理解 Flutter 小应用工程结构的读者。

计算器项目看起来简单,但它包含输入、状态、分支、异常、格式化和 UI 响应式处理。用它做第一篇 OpenHarmony 适配文章,可以把核心工程链路讲清楚。

一、项目定位与功能边界

calculator 是一个轻量级 Flutter 应用,核心功能集中在一个页面内完成。用户可以点击数字、运算符和功能键,完成加、减、乘、除、百分比、正负号切换、删除、清空等操作。

从源码看,当前项目的主要能力包括:

  • 数字输入:支持 0-9 和小数点。
  • 基础运算:支持 +-x/
  • 连续运算:选择新运算符时,如果左值和运算符已存在,会先计算前一次结果。
  • 异常保护:除数为 0 时显示 Error
  • 输入修正:支持 DEL 删除最后一位。
  • 符号切换:支持 +/- 切换当前数字正负。
  • 百分比:支持 % 将当前数字除以 100。
  • 显示优化:结果会去掉多余小数位,避免展示 2.0000000000 这类不友好的文本。

项目没有引入复杂业务依赖,pubspec.yaml 中除了 Flutter SDK 之外,只使用了 cupertino_icons 和常规测试、Lint 依赖。这种依赖结构让它很适合作为 Flutter/OpenHarmony 环境验证项目。

文件或目录 作用 本文关注点
lib/main.dart 应用入口、计算状态、UI 布局和按钮组件 重点拆解
pubspec.yaml Flutter 项目元信息、SDK 约束和依赖 说明环境边界
test/widget_test.dart Widget 测试用例 验证 2 + 3 = 5
ohos/ OpenHarmony 平台工程 检查模块、Ability、权限和资源
README.md 项目说明 当前仍是 Flutter 模板内容

二、视觉参考:从 Flutter 布局示例理解页面组织

Flutter 的优势之一是用声明式组件树描述界面。计算器虽然不是复杂页面,但它同样依赖 ScaffoldSafeAreaColumnRowExpandedContainerTextFilledButton 等组件组合完成布局。下面这张 Flutter 官方布局示例图可以帮助理解 Flutter 页面通常如何把图片、文本、按钮和内容区域组合成稳定的视觉结构。
效果图如下:
在这里插入图片描述

Flutter 页面区域 官方布局示例 本项目计算器页面
顶部信息 图片或标题区 AppBar(title: Text('Calculator'))
主内容 文本说明区 _display_expression 显示区
操作入口 CALLROUTESHARE CDEL%、四则运算按钮
响应式处理 根据屏幕组织内容 ExpandedLayoutBuilderFittedBox

这也是跨平台适配时要优先观察的地方:同一套 Flutter 布局在不同平台上是否保持可读、可点、可理解。对于 OpenHarmony 侧运行效果,计算器这类密集按钮页面尤其适合检查字体缩放、按钮间距、点击区域和安全区处理。

三、环境与版本说明

项目的 pubspec.yaml 中定义了应用名、版本号和 Dart SDK 约束:

name: calculator
description: "A new Flutter project."
publish_to: 'none'
version: 1.0.0+1

environment:
  sdk: ^3.9.2

这里有两个细节值得注意。

第一,publish_to: 'none' 表示这个项目不会被误发布到 pub.dev,符合示例应用和私有应用的常见配置。第二,sdk: ^3.9.2 说明项目面向较新的 Dart SDK,写文章或复现时应该优先保持本地 Flutter/Dart 工具链与该约束一致。

依赖部分保持得很轻:

dependencies:
  flutter:
    sdk: flutter

  cupertino_icons: ^1.0.8

dev_dependencies:
  flutter_test:
    sdk: flutter

  flutter_lints: ^5.0.0

这种依赖组合对 OpenHarmony 适配很友好。项目没有依赖相机、定位、蓝牙、数据库、网络插件等平台能力,迁移风险主要集中在 Flutter 运行环境、OpenHarmony 工程配置和 UI 表现,而不是插件兼容性。

从时效性角度看,本文所有 Dart 层分析均以当前项目文件为准,关键版本边界如下:

维度 当前项目值 对适配的影响
应用版本 1.0.0+1 适合作为第一版示例应用
Dart SDK 约束 ^3.9.2 复现时需要匹配较新的 Dart/Flutter 工具链
Flutter 组件风格 useMaterial3: true 按钮、主题、AppBar 遵循 Material 3
OpenHarmony 设备类型 phone 当前模块主要面向手机形态验证
第三方平台插件 适配风险集中在工程和 UI,不在插件兼容

四、整体架构:一个文件串起入口、状态和 UI

calculator 的业务代码集中在 lib/main.dart。从结构上看,它可以分为四层:

层级 代码对象 职责
应用入口 main() 启动 Flutter 应用
应用壳 CalculatorApp 配置 MaterialApp、主题和首页
页面状态 CalculatorHomePage / _CalculatorHomePageState 管理显示值、表达式、运算符和输入状态
按钮组件 _CalculatorButton 统一按钮颜色、尺寸、文本和点击行为

整体交互流程可以概括为:

数字

小数点

运算符

等号

功能键

用户点击按钮

_CalculatorButton.onPressed

_handleInput value

按钮类型

_appendDigit

_appendDecimal

_chooseOperator

_calculate

_clear / _deleteLast / _toggleSign / _applyPercent

setState 刷新显示

这个结构的优点是阅读成本低。对于一个入门级计算器项目来说,将状态和 UI 放在同一个文件中并不算问题,反而有助于读者快速理解完整链路。后续如果功能增加,再拆分成 calculator_logic.dartcalculator_display.dartcalculator_keyboard.dart 会更合适。

五、应用入口与 Material 主题配置

入口函数非常简洁:

void main() {
  runApp(const CalculatorApp());
}

runApp 会把 CalculatorApp 挂载到 Flutter 渲染树根部。项目没有做初始化任务,也没有异步依赖,因此入口不需要额外封装。

应用壳使用 StatelessWidget

class CalculatorApp extends StatelessWidget {
  const CalculatorApp({super.key});

  
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'Calculator',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF2563EB)),
        useMaterial3: true,
      ),
      home: const CalculatorHomePage(),
    );
  }
}

这里有三个设计点。

第一,debugShowCheckedModeBanner: false 关闭了右上角 Debug 标识,让应用截图更接近正式效果。第二,主题色使用 ColorScheme.fromSeed 生成,种子色是 0xFF2563EB,页面里的运算符按钮也使用了相近蓝色,整体视觉比较统一。第三,useMaterial3: true 让 Flutter 组件采用 Material 3 风格,适合现代移动端界面。

六、页面状态设计:计算器的核心数据模型

页面使用 StatefulWidget

class CalculatorHomePage extends StatefulWidget {
  const CalculatorHomePage({super.key});

  
  State<CalculatorHomePage> createState() => _CalculatorHomePageState();
}

真正的计算状态保存在 _CalculatorHomePageState 中:

class _CalculatorHomePageState extends State<CalculatorHomePage> {
  String _display = '0';
  String _expression = '';
  double? _leftValue;
  String? _operator;
  bool _startNewNumber = true;
}

这些字段各自承担明确职责:

字段 类型 作用
_display String 当前主显示区内容,初始值为 0
_expression String 辅助表达式显示,例如 2 + 3 =
_leftValue double? 已选择运算符前的左操作数
_operator String? 当前等待执行的运算符
_startNewNumber bool 控制下一次数字输入是否开启新数字

这个状态模型足够小,但已经覆盖了计算器最关键的状态变化。比如点击 2 + 3 = 时,状态会经历下面的变化:

操作 _display _leftValue _operator _startNewNumber
初始 0 null null true
点击 2 2 null null false
点击 + 2 2.0 + true
点击 3 3 2.0 + false
点击 = 5 null null true

对于 Flutter 入门读者来说,这个表能帮助理解为什么计算器不能只保存一个显示字符串,还需要保存左值、运算符和输入阶段。

七、输入分发:用 switch 管理按钮行为

所有按钮点击最终都会进入 _handleInput

void _handleInput(String value) {
  setState(() {
    switch (value) {
      case 'C':
        _clear();
        return;
      case 'DEL':
        _deleteLast();
        return;
      case '+/-':
        _toggleSign();
        return;
      case '%':
        _applyPercent();
        return;
      case '+':
      case '-':
      case 'x':
      case '/':
        _chooseOperator(value);
        return;
      case '=':
        _calculate();
        return;
      case '.':
        _appendDecimal();
        return;
      default:
        _appendDigit(value);
        return;
    }
  });
}

这段代码体现了 Flutter 小项目里很常见的写法:在一次 setState 中完成状态修改,修改完成后触发 UI 重建。

按钮分发逻辑可以分为五类:

  • 清理类:C
  • 编辑类:DEL+/-%
  • 运算符类:+-x/
  • 计算类:=
  • 输入类:数字和小数点。

这种写法比在每个按钮里直接写逻辑更容易维护。按钮组件只负责把 label 传进来,真正的业务分支都集中在 _handleInput 里。

八、清空、删除、正负号和百分比

清空逻辑会把所有状态恢复到初始值:

void _clear() {
  _display = '0';
  _expression = '';
  _leftValue = null;
  _operator = null;
  _startNewNumber = true;
}

这段代码的价值在于“重置完整”。如果只重置 _display,旧的 _leftValue_operator 仍然可能影响下一次计算。

删除逻辑处理了三个边界:

void _deleteLast() {
  if (_startNewNumber || _display.length == 1) {
    _display = '0';
    _startNewNumber = true;
    return;
  }

  if (_display.length == 2 && _display.startsWith('-')) {
    _display = '0';
    _startNewNumber = true;
    return;
  }

  _display = _display.substring(0, _display.length - 1);
}

如果当前处于新数字阶段,或者显示内容只剩一位,删除后回到 0。如果显示内容是类似 -5 的两位负数,删除后也回到 0,避免留下单独的负号。

正负号切换也做了异常保护:

void _toggleSign() {
  if (_display == '0' || _display == 'Error') {
    return;
  }

  _display = _display.startsWith('-') ? _display.substring(1) : '-$_display';
}

当显示为 0Error 时,不进行正负号切换。这个判断能避免生成 -0-Error 这类无意义显示。

百分比逻辑使用 double.tryParse,比 double.parse 更稳:

void _applyPercent() {
  final value = double.tryParse(_display);
  if (value == null) {
    return;
  }

  _display = _formatNumber(value / 100);
}

如果当前显示无法解析为数字,方法会直接返回,不会抛出异常。

九、数字输入与小数点处理

小数点输入逻辑如下:

void _appendDecimal() {
  if (_display == 'Error' || _startNewNumber) {
    _display = '0.';
    _startNewNumber = false;
    return;
  }

  if (!_display.contains('.')) {
    _display = '$_display.';
  }
}

这段代码解决了两个常见问题。第一,如果刚刚完成计算或当前是错误状态,再点击小数点会开启 0.。第二,同一个数字只允许出现一个小数点,避免生成 1.2.3 这样的非法数字。

数字输入逻辑同样包含边界控制:

void _appendDigit(String digit) {
  if (_display == 'Error' || _startNewNumber) {
    _display = digit;
    _startNewNumber = false;
    return;
  }

  if (_display == '0') {
    _display = digit;
    return;
  }

  if (_display.replaceAll('-', '').replaceAll('.', '').length >= 12) {
    return;
  }

  _display = '$_display$digit';
}

这里最值得关注的是 12 位长度限制。计算器 UI 的显示区域有限,如果不限制输入长度,长数字会压缩到难以阅读,甚至破坏布局。项目后面又使用了 FittedBox 做显示区缩放,两者配合能提升小屏设备上的可用性。

十、运算符选择与连续计算

运算符选择是整个计算器逻辑中最关键的一段:

void _chooseOperator(String operator) {
  final current = double.tryParse(_display);
  if (current == null) {
    return;
  }

  if (_leftValue != null && _operator != null && !_startNewNumber) {
    final result = _runOperation(_leftValue!, current, _operator!);
    if (result == null) {
      _showError();
      return;
    }
    _leftValue = result;
    _display = _formatNumber(result);
  } else {
    _leftValue = current;
  }

  _operator = operator;
  _expression = '${_formatNumber(_leftValue!)} $operator';
  _startNewNumber = true;
}

当用户第一次点击运算符时,当前显示值会被保存为 _leftValue。当用户在已有左值和运算符的情况下继续点击新的运算符,并且已经输入了右值时,项目会先执行前一次运算,再把结果作为新的左值。

这意味着项目可以处理类似下面的连续输入:

1 + 2 + 3 =

执行过程不是一次性解析完整表达式,而是按顺序滚动计算:

  1. 点击 1,显示 1
  2. 点击 +,保存左值 1
  3. 点击 2,显示 2
  4. 再点击 +,先计算 1 + 2,显示 3,并把 3 作为新的左值。
  5. 点击 3=,得到 6

这种策略不支持复杂优先级,但非常适合基础计算器,也更容易在文章里讲清楚。

十一、等号计算与除零保护

点击等号时,项目会解析右操作数并执行运算:

void _calculate() {
  final rightValue = double.tryParse(_display);
  if (_leftValue == null || _operator == null || rightValue == null) {
    return;
  }

  final result = _runOperation(_leftValue!, rightValue, _operator!);
  if (result == null) {
    _showError();
    return;
  }

  _expression =
      '${_formatNumber(_leftValue!)} $_operator ${_formatNumber(rightValue)} =';
  _display = _formatNumber(result);
  _leftValue = null;
  _operator = null;
  _startNewNumber = true;
}

真正的四则运算由 _runOperation 完成:

double? _runOperation(double left, double right, String operator) {
  switch (operator) {
    case '+':
      return left + right;
    case '-':
      return left - right;
    case 'x':
      return left * right;
    case '/':
      if (right == 0) {
        return null;
      }
      return left / right;
  }
  return null;
}

这里用 null 表示无法得到合法结果,主要用于除零保护。调用方收到 null 后会进入 _showError

void _showError() {
  _display = 'Error';
  _expression = '';
  _leftValue = null;
  _operator = null;
  _startNewNumber = true;
}

这比直接在屏幕上显示 Infinity 更符合普通计算器的用户预期。

十二、结果格式化:让显示更像真实计算器

计算结果由 _formatNumber 统一处理:

String _formatNumber(double value) {
  if (value.isNaN || value.isInfinite) {
    return 'Error';
  }

  final rounded = value.toStringAsFixed(10);
  return rounded
      .replaceFirst(RegExp(r'\.?0+$'), '')
      .replaceFirst(RegExp(r'^-0$'), '0');
}

这段代码做了三件事:

  • 如果结果是 NaN 或无限值,返回 Error
  • 使用 toStringAsFixed(10) 控制小数最大展示长度。
  • 通过正则去掉末尾多余的 0,并把 -0 修正为 0

例如:

运算 原始可能显示 格式化后
1 + 1 2.0000000000 2
1 / 4 0.2500000000 0.25
0 - 0 -0 0

对于计算器这类工具应用,结果显示的细节会直接影响用户体验。这个方法虽然短,但对应用品质提升很明显。

十三、页面布局与响应式显示

页面主体使用 ScaffoldSafeAreaPaddingColumn 组合:

return Scaffold(
  backgroundColor: const Color(0xFFF5F7FB),
  appBar: AppBar(
    title: const Text('Calculator'),
    centerTitle: true,
    backgroundColor: const Color(0xFFF5F7FB),
    foregroundColor: const Color(0xFF172033),
    elevation: 0,
  ),
  body: SafeArea(
    child: Padding(
      padding: const EdgeInsets.all(16),
      child: Column(
        children: [
          // 显示区
          // 按钮区
        ],
      ),
    ),
  ),
);

显示区使用了 LayoutBuilder 判断高度,再通过 FittedBox 处理长数字缩放:

Expanded(
  child: LayoutBuilder(
    builder: (context, constraints) {
      final compact = constraints.maxHeight < 120;

      return Container(
        width: double.infinity,
        padding: EdgeInsets.all(compact ? 8 : 24),
        alignment: Alignment.bottomRight,
        decoration: BoxDecoration(
          color: Colors.white,
          borderRadius: BorderRadius.circular(8),
          boxShadow: const [
            BoxShadow(
              color: Color(0x14000000),
              blurRadius: 18,
              offset: Offset(0, 8),
            ),
          ],
        ),
        child: Align(
          alignment: Alignment.bottomRight,
          child: FittedBox(
            fit: BoxFit.scaleDown,
            alignment: Alignment.bottomRight,
            child: Column(
              mainAxisSize: MainAxisSize.min,
              crossAxisAlignment: CrossAxisAlignment.end,
              children: [
                if (_expression.isNotEmpty) ...[
                  Text(
                    _expression,
                    maxLines: 1,
                    overflow: TextOverflow.ellipsis,
                    style: const TextStyle(
                      color: Color(0xFF6B7280),
                      fontSize: 20,
                    ),
                  ),
                  const SizedBox(height: 8),
                ],
                Text(
                  _display,
                  key: const ValueKey('calculator-display'),
                  style: const TextStyle(
                    color: Color(0xFF111827),
                    fontSize: 56,
                    fontWeight: FontWeight.w700,
                  ),
                ),
              ],
            ),
          ),
        ),
      );
    },
  ),
)

这段布局有两个亮点。

第一,ValueKey('calculator-display') 给测试定位提供了稳定锚点。测试代码可以直接找到显示区,而不必依赖文本搜索。第二,FittedBox 可以在显示内容较长时自动缩小,避免溢出。

按钮区使用二维数组驱动:

final buttons = <List<String>>[
  ['C', 'DEL', '%', '/'],
  ['7', '8', '9', 'x'],
  ['4', '5', '6', '-'],
  ['1', '2', '3', '+'],
  ['+/-', '0', '.', '='],
];

然后通过循环生成行和按钮:

for (final row in buttons) ...[
  Expanded(
    child: Row(
      children: [
        for (final label in row)
          Expanded(
            child: Padding(
              padding: const EdgeInsets.all(5),
              child: _CalculatorButton(
                label: label,
                onPressed: () => _handleInput(label),
              ),
            ),
          ),
      ],
    ),
  ),
],

这种写法比手写 20 个按钮更清晰,也更方便后续调整键盘布局。

十四、按钮组件封装

按钮组件 _CalculatorButton 只关心展示和点击:

class _CalculatorButton extends StatelessWidget {
  const _CalculatorButton({required this.label, required this.onPressed});

  final String label;
  final VoidCallback onPressed;

  bool get _isOperator => const {'/', 'x', '-', '+', '='}.contains(label);
  bool get _isUtility => const {'C', 'DEL', '%', '+/-'}.contains(label);

  
  Widget build(BuildContext context) {
    final backgroundColor = _isOperator
        ? const Color(0xFF2563EB)
        : _isUtility
        ? const Color(0xFFE5E7EB)
        : Colors.white;
    final foregroundColor = _isOperator
        ? Colors.white
        : _isUtility
        ? const Color(0xFF1F2937)
        : const Color(0xFF111827);

    return FilledButton(
      onPressed: onPressed,
      style: FilledButton.styleFrom(
        backgroundColor: backgroundColor,
        foregroundColor: foregroundColor,
        minimumSize: Size.zero,
        shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
        padding: EdgeInsets.zero,
        tapTargetSize: MaterialTapTargetSize.shrinkWrap,
        visualDensity: VisualDensity.compact,
      ),
      child: FittedBox(
        fit: BoxFit.scaleDown,
        child: Text(
          label,
          style: const TextStyle(fontSize: 26, fontWeight: FontWeight.w700),
        ),
      ),
    );
  }
}

按钮颜色根据类型自动切换:

类型 示例 背景色 前景色
运算符 /x-+= 蓝色 0xFF2563EB 白色
功能键 CDEL%+/- 浅灰 0xFFE5E7EB 深灰
数字键 0-9. 白色 深色

这里没有把按钮行为写死在组件里,而是通过 onPressed 从外部传入。这样 _CalculatorButton 可以专注 UI,计算逻辑仍然留在页面状态类中,职责边界比较清晰。

十五、OpenHarmony 工程配置检查

项目包含 ohos 目录,说明它已经具备 OpenHarmony 平台工程结构。模块配置位于:

ohos/entry/src/main/module.json5

核心配置如下:

{
  "module": {
    "name": "entry",
    "type": "entry",
    "description": "$string:module_desc",
    "mainElement": "EntryAbility",
    "deviceTypes": [
      "phone"
    ],
    "deliveryWithInstall": true,
    "installationFree": false,
    "pages": "$profile:main_pages",
    "abilities": [
      {
        "name": "EntryAbility",
        "srcEntry": "./ets/entryability/EntryAbility.ets",
        "description": "$string:EntryAbility_desc",
        "icon": "$media:icon",
        "label": "$string:EntryAbility_label",
        "startWindowIcon": "$media:icon",
        "startWindowBackground": "$color:start_window_background",
        "exported": true,
        "skills": [
          {
            "entities": [
              "entity.system.home"
            ],
            "actions": [
              "action.system.home"
            ]
          }
        ]
      }
    ],
    "requestPermissions": [
      {"name" :  "ohos.permission.INTERNET"},
    ]
  }
}

这段配置里有几个适配关注点:

  • type: "entry" 表示这是应用入口模块。
  • mainElement: "EntryAbility" 指向主 Ability。
  • deviceTypes: ["phone"] 表示当前主要面向手机设备。
  • srcEntry 指向 EntryAbility.ets
  • skills 中包含桌面入口相关的 entity.system.homeaction.system.home
  • requestPermissions 里声明了 ohos.permission.INTERNET

对于当前计算器项目来说,业务本身不需要网络权限。如果实际发布时继续保持该权限,需要确认它是否来自 Flutter/OpenHarmony 模板或构建链路要求;如果应用完全离线运行,也可以评估是否移除,以保持权限最小化。

十六、平台适配检查清单

Flutter 小应用迁移到 OpenHarmony 时,不应只看能否启动,还要看交互细节是否符合预期。calculator 的检查点可以拆成五类。

检查类别 检查项 通过标准 关联文件
启动链路 Ability 是否能拉起 Flutter 页面 点击桌面图标后进入计算器首页 EntryAbility.etsmodule.json5
页面渲染 AppBar、显示区、按钮区是否完整 无空白、无遮挡、无溢出 lib/main.dart
点击交互 每个按钮是否响应 数字、运算符、功能键都能触发状态变化 _handleInput
异常状态 除零和错误输入是否稳定 显示 Error 后可通过 C 或新数字恢复 _showError_appendDigit
权限配置 是否存在多余权限 离线计算器通常不依赖网络权限 module.json5

这里最值得单独记录的是权限。当前 module.json5 声明了 ohos.permission.INTERNET,但计算器业务没有网络访问需求。示例工程中保留该权限不会影响功能分析,但如果面向正式应用,应优先遵循最小权限原则,减少不必要的用户疑虑。

十七、测试用例与可验证行为

项目的 test/widget_test.dart 中已经有一个基础 Widget 测试:

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

import 'package:calculator/main.dart';

void main() {
  testWidgets('Calculator adds two numbers', (WidgetTester tester) async {
    await tester.pumpWidget(const CalculatorApp());

    expect(find.text('0'), findsOneWidget);

    await tester.tap(find.text('2'));
    await tester.tap(find.text('+'));
    await tester.tap(find.text('3'));
    await tester.tap(find.text('='));
    await tester.pumpAndSettle();

    final display = tester.widget<Text>(
      find.byKey(const ValueKey('calculator-display')),
    );
    expect(display.data, '5');
  });
}

这个测试覆盖了最基础的加法路径:启动应用、确认初始值、点击 2 + 3 =,最后通过 ValueKey('calculator-display') 读取显示区并断言结果为 5

如果继续完善测试,可以增加这些场景:

场景 输入 期望结果
减法 9 - 4 = 5
乘法 6 x 7 = 42
除法 8 / 2 = 4
除零 8 / 0 = Error
小数 1 . 5 + 2 = 3.5
删除 1 2 DEL 1
百分比 5 0 % 0.5
正负切换 8 +/- -8

在本地验证时,可以使用以下命令:

flutter pub get
flutter analyze
flutter test
flutter run

如果已经配置 OpenHarmony Flutter 工具链,再连接设备或模拟器运行:

flutter devices
flutter run -d <openharmony-device-id>

为了让验证更贴近真实使用,可以把手工测试路径补成下面这张矩阵。它覆盖了正常输入、连续运算、边界保护和显示格式化。

验证路径 操作序列 观察点 相关源码
初始状态 启动应用 显示区为 0 _display = '0'
加法 2 + 3 = 显示 5,表达式显示 2 + 3 = _calculate
连续运算 1 + 2 + 3 = 中间结果滚动计算,最终为 6 _chooseOperator
小数输入 1 . 5 + 2 = 显示 3.5 _appendDecimal
删除 1 2 DEL 显示从 12 回到 1 _deleteLast
百分比 5 0 % 显示 0.5 _applyPercent
正负号 8 +/- 显示 -8 _toggleSign
除零保护 8 / 0 = 显示 Error,内部状态重置 _runOperation_showError
长数字 连续输入超过 12 位 超出部分不再追加 _appendDigit

这张矩阵的意义在于把“能运行”拆成可以复验的行为。对于跨平台适配文章来说,读者真正关心的不是命令列表,而是每个关键交互是否能在目标平台上得到一致结果。

十八、常见问题与排查

18.1 启动后页面空白

如果应用启动后没有显示计算器页面,优先检查入口链路。Flutter 侧的首页是 home: const CalculatorHomePage(),OpenHarmony 侧的入口是 mainElement: "EntryAbility"。两边任意一侧配置异常,都可能导致页面无法进入预期状态。

现象 可能原因 排查位置
桌面图标点击无反应 Ability 入口配置异常 module.json5EntryAbility.ets
页面启动但空白 Flutter 页面未挂载或构建异常 CalculatorAppCalculatorHomePage
AppBar 显示但按钮区异常 布局空间被压缩 ColumnExpandedSafeArea

18.2 数字输入后显示不刷新

计算器依赖 setState 驱动 UI 更新。如果把 _handleInput 中的状态修改移出 setState,页面可能无法及时刷新。当前项目将所有按钮分支包在 setState(() { ... }) 内,这也是 Flutter 入门项目里最清晰的状态刷新方式。

18.3 小数点重复输入

小数点重复输入会导致字符串无法被 double.tryParse 正确解析。当前项目通过 if (!_display.contains('.')) 限制每个数字只出现一个小数点,这个判断应该保留。

18.4 除零后如何恢复

除零会触发 _showError,显示区变成 Error,并清空 _leftValue_operator。后续点击数字时,_appendDigit 会因为 _display == 'Error' 开启新的数字输入,因此用户不需要重启应用。

18.5 OpenHarmony 设备上按钮过小

如果在某些屏幕尺寸上按钮过小,重点检查 Expanded 分配比例、按钮 paddingvisualDensity 和文字 FittedBox。当前项目使用 tapTargetSize: MaterialTapTargetSize.shrinkWrapvisualDensity: VisualDensity.compact,适合让按钮在固定网格里更紧凑;如果目标设备更大,也可以适当增加外层间距。

十九、可维护性优化方向

当前项目把所有逻辑放在 main.dart,对第一版示例非常友好。但如果后续要继续演进,可以从下面几个方向拆分。

优化方向 拆分目标 收益
抽离计算逻辑 新增 calculator_engine.dart 便于纯 Dart 单元测试
抽离显示组件 新增 calculator_display.dart UI 结构更清晰
抽离键盘组件 新增 calculator_keyboard.dart 按钮布局更易调整
增强测试 覆盖小数、除零、删除、百分比 提高回归稳定性
权限收敛 评估移除网络权限 更符合最小权限原则

例如可以把四则运算抽成纯函数:

double? runOperation(double left, double right, String operator) {
  switch (operator) {
    case '+':
      return left + right;
    case '-':
      return left - right;
    case 'x':
      return left * right;
    case '/':
      return right == 0 ? null : left / right;
    default:
      return null;
  }
}

抽离之后,测试就不必启动 Widget,可以直接覆盖计算逻辑:

import 'package:flutter_test/flutter_test.dart';

void main() {
  test('division by zero returns null', () {
    expect(runOperation(8, 0, '/'), isNull);
  });

  test('continuous addition base case', () {
    expect(runOperation(1, 2, '+'), 3);
  });
}

这种拆分对 OpenHarmony 适配也有帮助:UI 问题和计算逻辑问题可以分开定位,不会把所有现象都混在页面状态里。

二十、常见问题与优化方向

20.1 为什么使用 double.tryParse 而不是 double.parse

double.parse 在解析失败时会抛异常,double.tryParse 会返回 null。计算器输入状态比较多,例如 Error、空表达式、刚完成计算后的新输入等,都可能让字符串不适合直接解析。使用 tryParse 能让逻辑更稳。

20.2 为什么除零返回 null

_runOperation 的返回类型是 double?,当除数为 0 时返回 null

case '/':
  if (right == 0) {
    return null;
  }
  return left / right;

这样运算函数可以把“无法计算”传给调用方,由 _showError 统一处理显示状态和内部状态重置。

20.3 为什么需要 _startNewNumber

没有 _startNewNumber 时,点击运算符后继续输入数字,会把第二个操作数拼接到第一个操作数后面。例如输入 12 + 3 时,3 可能被拼成 123。这个布尔值就是为了区分“继续输入当前数字”和“开始输入新数字”。

20.4 为什么显示区要加 FittedBox

计算器结果可能比较长,尤其是除法和小数运算。如果只设置固定字号,长文本容易溢出。FittedBox(fit: BoxFit.scaleDown) 可以在空间不足时缩小显示内容,让 UI 在不同屏幕高度下更稳。

20.5 OpenHarmony 侧最应该先检查什么

这类纯 Flutter UI 项目,OpenHarmony 侧优先检查三件事:

  1. 应用是否能从 EntryAbility 正常启动。
  2. Flutter 页面是否能正确渲染并响应点击。
  3. 字体、按钮间距、显示区缩放是否在目标设备上保持可读。

二十一、适配经验总结

calculator 的价值不在于算法复杂,而在于它把一个 Flutter 小工具的关键工程点都集中到了一起:

  • MaterialApp 负责应用壳和主题。
  • StatefulWidget 负责承载可变状态。
  • _handleInput 统一分发按钮行为。
  • _runOperation 把四则运算和除零保护集中处理。
  • _formatNumber 负责改善显示体验。
  • _CalculatorButton 把按钮样式和按钮类型判断封装起来。
  • module.json5 提供 OpenHarmony 入口模块和 Ability 配置。

对于第一篇 Flutter/OpenHarmony 适配文章来说,这个项目的层次刚刚好。它足够小,可以逐行讲清楚;也足够完整,可以覆盖入口、状态、UI、测试和平台工程配置。真正的经验并不是“计算器怎么写”,而是如何把一个小应用拆成入口、状态、事件、显示、验证和平台配置几条线索,再逐条确认它在 OpenHarmony 环境中的表现。

从工程角度看,calculator 后续最值得做的三件事是:抽离计算逻辑、补齐更多自动化测试、评估网络权限是否必要。完成这些之后,它就不只是一个 Flutter 入门 Demo,也可以作为 Flutter 小应用适配 OpenHarmony 的最小验证样板。

参考资料

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐