Flutter 计算器小应用适配 OpenHarmony:calculator
Flutter 计算器小应用适配 OpenHarmony:calculator 从状态设计到工程验证
前言
在 Flutter 适配 OpenHarmony 的实践里,最适合作为第一篇拆解对象的项目,往往不是功能最复杂的应用,而是链路足够短、状态变化足够清晰、运行结果足够直观的小工具。calculator 就是这样一个项目:它用 Flutter 实现了一个基础计算器,同时保留了 ohos 平台工程目录,可以同时观察 Dart 层 UI、业务状态、按钮交互和 OpenHarmony 工程配置。
这篇文章基于项目中的真实源码展开,重点分析 lib/main.dart 中的入口结构、计算状态、输入分发、四则运算、异常保护、数字格式化、按钮组件封装,以及 ohos/entry/src/main/module.json5 中的 OpenHarmony 模块配置。读完之后,可以掌握一个 Flutter 小应用从代码实现到跨平台验证的完整思路。
本文适合三类读者:
- 正在学习 Flutter
StatefulWidget、setState和组件拆分的开发者。 - 想把 Flutter 小应用迁移或运行到 OpenHarmony 工程中的开发者。
- 需要通过真实项目理解 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 的优势之一是用声明式组件树描述界面。计算器虽然不是复杂页面,但它同样依赖 Scaffold、SafeArea、Column、Row、Expanded、Container、Text、FilledButton 等组件组合完成布局。下面这张 Flutter 官方布局示例图可以帮助理解 Flutter 页面通常如何把图片、文本、按钮和内容区域组合成稳定的视觉结构。
效果图如下:
| Flutter 页面区域 | 官方布局示例 | 本项目计算器页面 |
|---|---|---|
| 顶部信息 | 图片或标题区 | AppBar(title: Text('Calculator')) |
| 主内容 | 文本说明区 | _display 与 _expression 显示区 |
| 操作入口 | CALL、ROUTE、SHARE |
C、DEL、%、四则运算按钮 |
| 响应式处理 | 根据屏幕组织内容 | Expanded、LayoutBuilder、FittedBox |
这也是跨平台适配时要优先观察的地方:同一套 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 |
统一按钮颜色、尺寸、文本和点击行为 |
整体交互流程可以概括为:
这个结构的优点是阅读成本低。对于一个入门级计算器项目来说,将状态和 UI 放在同一个文件中并不算问题,反而有助于读者快速理解完整链路。后续如果功能增加,再拆分成 calculator_logic.dart、calculator_display.dart、calculator_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';
}
当显示为 0 或 Error 时,不进行正负号切换。这个判断能避免生成 -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,显示2。 - 再点击
+,先计算1 + 2,显示3,并把3作为新的左值。 - 点击
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 |
对于计算器这类工具应用,结果显示的细节会直接影响用户体验。这个方法虽然短,但对应用品质提升很明显。
十三、页面布局与响应式显示
页面主体使用 Scaffold、SafeArea、Padding 和 Column 组合:
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 |
白色 |
| 功能键 | C、DEL、%、+/- |
浅灰 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.home和action.system.home。requestPermissions里声明了ohos.permission.INTERNET。
对于当前计算器项目来说,业务本身不需要网络权限。如果实际发布时继续保持该权限,需要确认它是否来自 Flutter/OpenHarmony 模板或构建链路要求;如果应用完全离线运行,也可以评估是否移除,以保持权限最小化。
十六、平台适配检查清单
Flutter 小应用迁移到 OpenHarmony 时,不应只看能否启动,还要看交互细节是否符合预期。calculator 的检查点可以拆成五类。
| 检查类别 | 检查项 | 通过标准 | 关联文件 |
|---|---|---|---|
| 启动链路 | Ability 是否能拉起 Flutter 页面 | 点击桌面图标后进入计算器首页 | EntryAbility.ets、module.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.json5、EntryAbility.ets |
| 页面启动但空白 | Flutter 页面未挂载或构建异常 | CalculatorApp、CalculatorHomePage |
| AppBar 显示但按钮区异常 | 布局空间被压缩 | Column、Expanded、SafeArea |
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 分配比例、按钮 padding、visualDensity 和文字 FittedBox。当前项目使用 tapTargetSize: MaterialTapTargetSize.shrinkWrap 与 visualDensity: 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 侧优先检查三件事:
- 应用是否能从
EntryAbility正常启动。 - Flutter 页面是否能正确渲染并响应点击。
- 字体、按钮间距、显示区缩放是否在目标设备上保持可读。
二十一、适配经验总结
calculator 的价值不在于算法复杂,而在于它把一个 Flutter 小工具的关键工程点都集中到了一起:
MaterialApp负责应用壳和主题。StatefulWidget负责承载可变状态。_handleInput统一分发按钮行为。_runOperation把四则运算和除零保护集中处理。_formatNumber负责改善显示体验。_CalculatorButton把按钮样式和按钮类型判断封装起来。module.json5提供 OpenHarmony 入口模块和 Ability 配置。
对于第一篇 Flutter/OpenHarmony 适配文章来说,这个项目的层次刚刚好。它足够小,可以逐行讲清楚;也足够完整,可以覆盖入口、状态、UI、测试和平台工程配置。真正的经验并不是“计算器怎么写”,而是如何把一个小应用拆成入口、状态、事件、显示、验证和平台配置几条线索,再逐条确认它在 OpenHarmony 环境中的表现。
从工程角度看,calculator 后续最值得做的三件事是:抽离计算逻辑、补齐更多自动化测试、评估网络权限是否必要。完成这些之后,它就不只是一个 Flutter 入门 Demo,也可以作为 Flutter 小应用适配 OpenHarmony 的最小验证样板。
参考资料
更多推荐


所有评论(0)