Flutter 实战:countdown_timer 倒计时器的时分秒状态、进度环与鸿蒙适配解析
Flutter 实战:countdown_timer 倒计时器的时分秒状态、进度环与鸿蒙适配解析
前言
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
countdown_timer 是一个用 Flutter 实现的轻量倒计时器。应用支持小时、分钟、秒三个维度的时间设置,提供 Start、Stop、Reset 三个核心操作,并通过圆形进度条和 HH:mm:ss 文本同步展示剩余时间。
本文基于项目真实源码展开,重点分析 状态字段设计、剩余秒数计算、Future.doWhile 倒计时循环、CircularProgressIndicator 进度展示、按钮状态切换 和 鸿蒙适配注意点。文章内容可直接发布到 CSDN,不包含面向作者的检查说明。
倒计时器看起来简单,但它同时涉及时间状态、异步循环、界面刷新、按钮互斥和生命周期判断。把这些细节拆清楚,能帮助我们更好地理解 Flutter 小工具应用的工程闭环。

图示说明:本文围绕 Flutter 倒计时器的状态更新、进度展示和跨端适配流程展开,适合用于鸿蒙、Android、iOS 等平台的小工具应用开发复盘。
一、项目定位与功能概览
1.1 应用主题
countdown_timer 的定位是一个 时分秒倒计时工具。用户可以分别调整小时、分钟和秒数,点击 Start 后开始倒计时,点击 Stop 可以暂停,点击 Reset 可以恢复到默认 5 分钟。
核心功能可以概括为:
| 功能 | 页面表现 | 源码实现 |
|---|---|---|
| 设置小时 | Hours 步进器 | _hours |
| 设置分钟 | Minutes 步进器 | _minutes |
| 设置秒数 | Seconds 步进器 | _seconds |
| 开始倒计时 | Start 按钮 | _startTimer() |
| 停止倒计时 | Stop 按钮 | _stopTimer() |
| 重置倒计时 | Reset 按钮 | _resetTimer() |
| 展示进度 | 圆形进度环 | _progress |
| 展示剩余时间 | 00:05:00 格式 |
_formattedTime |
1.2 技术栈特点
项目使用 Flutter 标准组件完成所有功能,没有引入计时器插件或平台通道:
| 技术点 | 使用方式 | 价值 |
|---|---|---|
StatefulWidget |
管理倒计时状态 | 适合交互型单页应用 |
Future.doWhile |
实现异步循环 | 不依赖额外定时器类 |
CircularProgressIndicator |
展示剩余进度 | 提供直观视觉反馈 |
IconButton |
实现时分秒增减 | 交互简洁 |
SingleChildScrollView |
支持小屏滚动 | 有利于多端适配 |
1.3 适合学习的点
这个项目虽然代码不长,但适合学习以下内容:
- 如何把时分秒统一折算为秒。
- 如何用 getter 派生格式化时间和进度值。
- 如何在 Flutter 中安全执行异步倒计时循环。
- 如何在运行中隐藏设置区,减少误操作。
- 如何面向鸿蒙等多端环境检查 UI 适配。
二、工程结构与运行方式
2.1 目录结构
项目是标准 Flutter 工程,核心逻辑集中在 lib/main.dart:
| 文件或目录 | 作用 | 说明 |
|---|---|---|
lib/main.dart |
应用入口和页面实现 | 包含倒计时核心逻辑 |
pubspec.yaml |
依赖声明 | 使用 Flutter SDK 与 Material 图标 |
test/widget_test.dart |
Widget 测试入口 | 可扩展页面和计时测试 |
ohos/ |
鸿蒙平台工程目录 | 用于跨端构建和适配 |
2.2 依赖声明
项目依赖非常轻,主要使用 Flutter SDK:
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.8
这种依赖结构对鸿蒙适配比较友好,因为业务逻辑都在 Dart 层,不依赖复杂原生能力。
2.3 本地运行命令
常用命令如下:
flutter pub get
flutter analyze
flutter test
flutter run
| 命令 | 作用 | 适用场景 |
|---|---|---|
flutter pub get |
获取依赖 | 首次运行或依赖变化 |
flutter analyze |
静态分析 | 检查代码规范和潜在问题 |
flutter test |
执行测试 | 验证 Widget 行为 |
flutter run |
启动调试 | 查看倒计时页面效果 |
三、应用入口与主题配置
3.1 main 函数
应用入口保持 Flutter 标准写法:
void main() {
runApp(const MyApp());
}
main() 只做一件事:启动根组件 MyApp。这类纯 UI 与 Dart 逻辑应用不需要复杂初始化,入口越简单越容易维护。
3.2 MyApp 根组件
MyApp 负责创建 MaterialApp:
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
return MaterialApp(
title: 'Countdown Timer',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.orange),
),
home: const MyHomePage(title: 'Countdown Timer'),
);
}
}
这里有三个关键信息:
- 应用标题是
Countdown Timer。 - 主题种子色是
Colors.orange。 - 首页是
MyHomePage。
3.3 主题色为什么选择橙色
橙色很适合倒计时场景,因为它既有提醒感,又不像红色那样强烈。源码中圆形进度、计时图标和 Start 按钮都使用橙色,形成了比较统一的视觉主线。
当前源码没有显式开启
useMaterial3: true,因此文章以实际代码为准。后续如果想统一 Material 3 视觉,可以在ThemeData中补充该配置。
四、页面状态字段设计
4.1 状态字段总览
倒计时页面的全部核心状态都在 _MyHomePageState 中:
int _hours = 0;
int _minutes = 5;
int _seconds = 0;
int _remainingSeconds = 0;
bool _isRunning = false;
字段含义如下:
| 字段 | 类型 | 默认值 | 作用 |
|---|---|---|---|
_hours |
int |
0 |
用户设置的小时数 |
_minutes |
int |
5 |
用户设置的分钟数 |
_seconds |
int |
0 |
用户设置的秒数 |
_remainingSeconds |
int |
0 |
当前剩余秒数 |
_isRunning |
bool |
false |
倒计时是否运行中 |
4.2 为什么要保留两类时间
源码中同时保存了“设置时间”和“剩余时间”:
| 类型 | 字段 | 用途 |
|---|---|---|
| 设置时间 | _hours、_minutes、_seconds |
用于用户配置和计算总时长 |
| 剩余时间 | _remainingSeconds |
用于倒计时递减和页面展示 |
这样做的好处是,开始倒计时后可以只递减 _remainingSeconds,而不破坏用户设置的原始时长。圆形进度也可以用“剩余秒数 / 总秒数”计算。
4.3 初始状态
默认值是 5 分钟:
int _hours = 0;
int _minutes = 5;
int _seconds = 0;
这意味着页面首次加载后展示 00:05:00,适合厨房计时、学习休息、短任务提醒等场景。
五、初始化与剩余秒数计算
5.1 initState 初始化
页面初始化时调用 _updateRemainingSeconds():
void initState() {
super.initState();
_updateRemainingSeconds();
}
这一步会把默认的小时、分钟、秒转换为总秒数,让 UI 一开始就能显示正确倒计时。
5.2 秒数换算公式
换算逻辑如下:
void _updateRemainingSeconds() {
_remainingSeconds = _hours * 3600 + _minutes * 60 + _seconds;
setState(() {});
}
公式很直观:
remainingSeconds = hours * 3600 + minutes * 60 + seconds;
5.3 默认值计算示例
默认状态下:
| 字段 | 值 | 折算秒数 |
|---|---|---|
_hours |
0 |
0 |
_minutes |
5 |
300 |
_seconds |
0 |
0 |
最终 _remainingSeconds 为:
0 * 3600 + 5 * 60 + 0 = 300
也就是 5 分钟。
5.4 setState 的维护细节
当前 _updateRemainingSeconds() 内部直接调用 setState()。这让外部调用更方便,但在某些嵌套调用中可能产生重复刷新。对于当前应用规模,这不会造成明显问题;如果后续要进一步优化,可以把“计算”和“刷新”拆开。
六、开始、停止与重置逻辑
6.1 开始倒计时
开始按钮触发 _startTimer():
void _startTimer() {
if (_remainingSeconds <= 0) return;
setState(() {
_isRunning = true;
});
_runTimer();
}
这里先判断剩余秒数是否大于 0。如果用户把时长设置为 0,点击 Start 不会启动倒计时。
6.2 停止倒计时
停止按钮触发 _stopTimer():
void _stopTimer() {
setState(() {
_isRunning = false;
});
}
停止逻辑只需要把 _isRunning 设为 false。异步循环在下一次判断时会自然结束。
6.3 重置倒计时
重置按钮触发 _resetTimer():
void _resetTimer() {
setState(() {
_isRunning = false;
_hours = 0;
_minutes = 5;
_seconds = 0;
_updateRemainingSeconds();
});
}
重置会做四件事:
- 停止运行。
- 小时恢复为 0。
- 分钟恢复为 5。
- 秒数恢复为 0,并重新计算剩余秒数。
6.4 控制按钮状态
Start 和 Stop 共用一个按钮:
ElevatedButton.icon(
onPressed: _isRunning ? _stopTimer : _startTimer,
icon: Icon(_isRunning ? Icons.stop : Icons.play_arrow),
label: Text(_isRunning ? 'Stop' : 'Start'),
)
这种写法避免了同时显示 Start 和 Stop 两个按钮,让用户当前能执行的主操作更明确。
七、Future.doWhile 倒计时循环
7.1 核心循环代码
倒计时核心在 _runTimer():
void _runTimer() {
Future.doWhile(() async {
if (!_isRunning) return false;
await Future.delayed(const Duration(seconds: 1));
if (!mounted) return false;
setState(() {
if (_remainingSeconds > 0) {
_remainingSeconds--;
} else {
_isRunning = false;
}
});
return _isRunning;
});
}
这段代码每隔 1 秒执行一次,直到 _isRunning 变为 false。
7.2 循环流程
可以把它理解成如下流程:
进入 Future.doWhile
-> 如果未运行,结束循环
-> 等待 1 秒
-> 如果组件已卸载,结束循环
-> 剩余秒数大于 0,则减 1
-> 否则停止运行
-> 根据 _isRunning 决定是否继续
7.3 mounted 判断的意义
mounted 用于判断当前 State 是否仍然挂载在组件树上:
if (!mounted) return false;
异步任务最怕页面已经销毁后继续调用 setState()。这行代码可以避免这类生命周期问题。
在倒计时、网络请求、延迟任务等异步场景里,调用
setState()前检查mounted是非常实用的习惯。
7.4 与 Timer.periodic 的差异
Flutter 中倒计时也常用 Timer.periodic 实现。当前项目选择 Future.doWhile,两者对比如下:
| 方案 | 优点 | 注意点 |
|---|---|---|
Future.doWhile |
代码集中,不需要保存 Timer 对象 | 需要避免重复启动多个循环 |
Timer.periodic |
语义更贴近定时器 | 需要在停止和销毁时 cancel |
当前实现适合入门项目。如果后续功能更复杂,例如后台暂停、应用生命周期感知、通知提醒等,可以考虑切换到更明确的 Timer 管理方式。
八、时间格式化实现
8.1 formattedTime getter
页面显示的 HH:mm:ss 字符串由 _formattedTime 生成:
String get _formattedTime {
final hours = _remainingSeconds ~/ 3600;
final minutes = (_remainingSeconds % 3600) ~/ 60;
final seconds = _remainingSeconds % 60;
return '${hours.toString().padLeft(2, '0')}:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
}
8.2 整除与取余
这里使用了两个关键运算:
| 运算 | 作用 |
|---|---|
~/ |
整除,得到小时或分钟 |
% |
取余,得到剩余分钟或秒 |
例如 _remainingSeconds = 3661 时:
final hours = 3661 ~/ 3600; // 1
final minutes = (3661 % 3600) ~/ 60; // 1
final seconds = 3661 % 60; // 1
最终显示为 01:01:01。
8.3 padLeft 补零
padLeft(2, '0') 可以保证小时、分钟、秒都至少两位:
hours.toString().padLeft(2, '0')
显示效果如下:
| 原始值 | 展示值 |
|---|---|
0 |
00 |
5 |
05 |
12 |
12 |
8.4 为什么使用 getter
_formattedTime 是派生状态,它不需要单独存储。每次 build() 时根据 _remainingSeconds 计算即可。
这种写法的优点是:
- 避免状态重复。
- 不会出现剩余秒数和文本显示不一致。
- 逻辑集中,便于测试。
九、进度环计算与展示
9.1 progress getter
圆形进度条使用 _progress:
double get _progress {
final total = _hours * 3600 + _minutes * 60 + _seconds;
return total > 0 ? _remainingSeconds / total : 0;
}
进度值范围为 0 到 1。当剩余秒数等于总秒数时,进度为 1;倒计时结束时,进度趋近于 0。
9.2 进度计算示例
| 总时长 | 剩余时长 | 进度值 |
|---|---|---|
| 300 秒 | 300 秒 | 1.0 |
| 300 秒 | 150 秒 | 0.5 |
| 300 秒 | 60 秒 | 0.2 |
| 300 秒 | 0 秒 | 0 |
9.3 CircularProgressIndicator
页面用 CircularProgressIndicator 展示进度:
CircularProgressIndicator(
value: _progress,
strokeWidth: 12,
backgroundColor: Colors.grey.shade200,
valueColor: const AlwaysStoppedAnimation<Color>(Colors.orange),
)
关键配置如下:
| 属性 | 作用 |
|---|---|
value |
当前进度 |
strokeWidth |
圆环粗细 |
backgroundColor |
背景轨道颜色 |
valueColor |
前景进度颜色 |
9.4 Stack 居中布局
进度环和时间文本通过 Stack 叠放:
Stack(
alignment: Alignment.center,
children: [
SizedBox(
width: 200,
height: 200,
child: CircularProgressIndicator(...),
),
Column(
children: [
Text(_formattedTime),
Text(_isRunning ? 'Running' : 'Ready'),
],
),
],
)
Stack 很适合“外层图形 + 中心信息”的场景,倒计时器、仪表盘、健康数据环都可以采用类似结构。
十、时分秒步进器组件
10.1 buildTimePicker 方法
页面用 _buildTimePicker() 复用小时、分钟、秒三个步进器:
Widget _buildTimePicker(String label, int value, Function(int) onChanged, int max) {
return Column(
children: [
Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Container(
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.remove),
onPressed: value > 0 ? () => onChanged(value - 1) : null,
),
SizedBox(
width: 50,
child: Text(
value.toString().padLeft(2, '0'),
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
),
IconButton(
icon: const Icon(Icons.add),
onPressed: value < max ? () => onChanged(value + 1) : null,
),
],
),
),
],
);
}
10.2 参数设计
这个方法的参数非常实用:
| 参数 | 类型 | 含义 |
|---|---|---|
label |
String |
展示名称,如 Hours |
value |
int |
当前数值 |
onChanged |
Function(int) |
数值变化回调 |
max |
int |
最大可选值 |
10.3 边界控制
减少按钮和增加按钮都有边界判断:
onPressed: value > 0 ? () => onChanged(value - 1) : null
onPressed: value < max ? () => onChanged(value + 1) : null
当数值到达边界时,按钮会变成不可点击状态,避免出现负数或超过上限。
10.4 三组步进器的差异
页面调用方式如下:
_buildTimePicker('Hours', _hours, (val) {
setState(() {
_hours = val;
_updateRemainingSeconds();
});
}, 23)
分钟和秒的上限为 59:
_buildTimePicker('Minutes', _minutes, (val) {
setState(() {
_minutes = val;
_updateRemainingSeconds();
});
}, 59)
| 维度 | 最小值 | 最大值 |
|---|---|---|
| Hours | 0 | 23 |
| Minutes | 0 | 59 |
| Seconds | 0 | 59 |
十一、运行中隐藏设置区的交互设计
11.1 条件渲染
当倒计时运行时,时分秒设置区会被隐藏:
if (!_isRunning) ...[
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildTimePicker('Hours', _hours, ...),
_buildTimePicker('Minutes', _minutes, ...),
_buildTimePicker('Seconds', _seconds, ...),
],
),
const SizedBox(height: 24),
]
11.2 为什么运行中不允许改时间
这是一种简洁的交互策略。倒计时运行中如果允许修改小时、分钟、秒,可能带来几个问题:
- 总时长变化后进度环如何计算。
- 剩余时间是否需要同步变化。
- 用户误触后是否容易恢复。
- 测试用例是否会复杂化。
隐藏设置区可以避免这些分歧,让运行态更加稳定。
11.3 Ready 与 Running 状态
状态文案由 _isRunning 决定:
Text(
_isRunning ? 'Running' : 'Ready',
style: TextStyle(color: Colors.grey.shade600),
)
这行小文本很有价值。它让用户无需观察按钮,也能快速判断当前计时器状态。
11.4 按钮颜色反馈
主按钮颜色根据状态变化:
backgroundColor: _isRunning ? Colors.red : Colors.orange
| 状态 | 文案 | 图标 | 颜色 |
|---|---|---|---|
| 未运行 | Start | Icons.play_arrow |
橙色 |
| 运行中 | Stop | Icons.stop |
红色 |
这种状态差异能降低误操作概率。
十二、布局结构与响应式适配
12.1 页面整体结构
页面使用 Scaffold + SingleChildScrollView:
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
children: [
// 进度卡片
// 时间设置区
// 控制按钮区
],
),
),
);
这种结构对移动端很友好。内容高度超过屏幕时可以滚动,避免底部按钮被遮挡。
12.2 进度卡片
进度卡片使用较大的内边距:
Card(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
children: [
const Icon(Icons.timer, size: 48, color: Colors.orange),
const SizedBox(height: 24),
Stack(...),
],
),
),
)
倒计时器的核心信息集中在这张卡片里,视觉层级比较清楚。
12.3 控制按钮区
底部按钮使用一行布局:
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
ElevatedButton.icon(...),
ElevatedButton.icon(...),
],
)
按钮采用 ElevatedButton.icon,既有图标也有文字,适合计时器这种需要明确操作意图的应用。
12.4 小屏适配注意点
在鸿蒙手机或小尺寸设备上,需要重点观察:
| 区域 | 可能问题 | 当前设计 |
|---|---|---|
| 进度环 | 宽高固定 200 | 大多数手机可正常展示 |
| 时分秒步进器 | 三列排列可能拥挤 | Row 等距分布 |
| 按钮区 | 文案和内边距可能占宽 | 两个按钮并排 |
| 页面高度 | 内容可能超出 | 使用滚动容器 |
十三、鸿蒙适配重点
13.1 为什么适配风险较低
countdown_timer 没有使用摄像头、定位、蓝牙、文件系统、通知等原生能力。它主要依赖 Flutter 标准组件和 Dart 异步逻辑,因此跨端适配的主要风险集中在 UI 和生命周期表现。
| 模块 | 是否依赖平台能力 | 适配关注度 |
|---|---|---|
| 倒计时逻辑 | 否 | 中 |
| 圆形进度 | 否 | 低 |
| 步进按钮 | 否 | 低 |
| 页面滚动 | 否 | 低 |
| 后台计时 | 当前未涉及 | 高 |
13.2 前台计时与后台计时
当前倒计时逻辑适合前台使用。如果应用进入后台,系统调度、页面生命周期和延迟任务执行可能受到影响。对于严肃提醒类应用,需要进一步设计后台能力。
本项目当前是前台倒计时工具,不应把它理解为具备系统级闹钟或后台提醒能力的应用。
13.3 鸿蒙设备上的验证点
适配时建议重点验证:
- Start、Stop、Reset 按钮点击反馈是否稳定。
- 圆形进度是否每秒更新。
- 页面退到后台再返回时显示是否符合预期。
- 横屏或折叠屏展开时布局是否拥挤。
- 时分秒按钮在触摸设备上是否容易点击。
13.4 颜色和字号
中心时间文本字号为 40:
style: const TextStyle(
fontSize: 40,
fontWeight: FontWeight.bold,
)
在常规手机上显示清晰。如果在小屏设备上出现换行或溢出,可以考虑用 FittedBox 包裹时间文本。
十四、测试设计与默认测试改造
14.1 当前测试入口的问题
项目中的测试文件仍是 Flutter 默认计数器测试。对于倒计时器应用,更合理的测试应该围绕页面元素、默认时间和按钮交互展开。
14.2 页面初始渲染测试
可以先验证页面是否展示核心元素:
testWidgets('countdown timer renders initial state', (WidgetTester tester) async {
await tester.pumpWidget(const MyApp());
expect(find.text('Countdown Timer'), findsWidgets);
expect(find.text('00:05:00'), findsOneWidget);
expect(find.text('Ready'), findsOneWidget);
expect(find.text('Start'), findsOneWidget);
expect(find.text('Reset'), findsOneWidget);
});
14.3 步进器测试
默认分钟为 5,点击分钟增加按钮后应变成 6 分钟。实际测试时可以根据按钮位置或局部查找进一步精确定位。
testWidgets('can increase minutes before running', (WidgetTester tester) async {
await tester.pumpWidget(const MyApp());
expect(find.text('05'), findsOneWidget);
await tester.tap(find.byIcon(Icons.add).at(1));
await tester.pump();
expect(find.text('06'), findsOneWidget);
expect(find.text('00:06:00'), findsOneWidget);
});
14.4 Start 按钮测试
点击 Start 后,文案应切换为 Stop,状态应切换为 Running:
testWidgets('start button changes running state', (WidgetTester tester) async {
await tester.pumpWidget(const MyApp());
await tester.tap(find.text('Start'));
await tester.pump();
expect(find.text('Running'), findsOneWidget);
expect(find.text('Stop'), findsOneWidget);
});
14.5 倒计时递减测试
可以通过 pump 模拟时间推进:
testWidgets('timer decreases after one second', (WidgetTester tester) async {
await tester.pumpWidget(const MyApp());
await tester.tap(find.text('Start'));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(find.text('00:04:59'), findsOneWidget);
});
这类测试能直接覆盖异步循环和 UI 刷新,是倒计时器项目最关键的自动化用例之一。
十五、可维护性优化思路
15.1 抽离时间格式化函数
当前 _formattedTime 写在 State 中,适合小项目。若要提升可测试性,可以抽成纯函数:
String formatSeconds(int remainingSeconds) {
final hours = remainingSeconds ~/ 3600;
final minutes = (remainingSeconds % 3600) ~/ 60;
final seconds = remainingSeconds % 60;
return '${hours.toString().padLeft(2, '0')}:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
}
这样就能不启动 Widget,直接测试格式化逻辑。
15.2 抽离总秒数计算
总秒数也可以抽成函数:
int toTotalSeconds({
required int hours,
required int minutes,
required int seconds,
}) {
return hours * 3600 + minutes * 60 + seconds;
}
对应测试会非常简单:
expect(toTotalSeconds(hours: 0, minutes: 5, seconds: 0), 300);
expect(toTotalSeconds(hours: 1, minutes: 1, seconds: 1), 3661);
15.3 避免重复启动循环
当前点击 Start 后 _isRunning 会变成 true,按钮随即切换成 Stop。正常交互下不会连续触发多个 Start 循环。但如果未来增加快捷键或外部调用,可以在 _startTimer() 中增加保护:
void _startTimer() {
if (_isRunning) return;
if (_remainingSeconds <= 0) return;
setState(() {
_isRunning = true;
});
_runTimer();
}
15.4 优化重置方法
当前 _resetTimer() 内部调用 _updateRemainingSeconds(),而 _updateRemainingSeconds() 又调用 setState()。可以改成只在外层刷新一次:
void _resetTimer() {
setState(() {
_isRunning = false;
_hours = 0;
_minutes = 5;
_seconds = 0;
_remainingSeconds = _hours * 3600 + _minutes * 60 + _seconds;
});
}
这属于维护层面的简化,不改变功能表现。
十六、功能扩展方向
16.1 增加快捷时间
倒计时器常见快捷选项包括 1 分钟、5 分钟、10 分钟、25 分钟。可以设计为一组按钮:
final quickDurations = <String, int>{
'1 min': 60,
'5 min': 300,
'10 min': 600,
'25 min': 1500,
};
16.2 增加倒计时完成提示
倒计时结束后可以增加视觉提示:
if (_remainingSeconds == 0) {
_isRunning = false;
// 展示完成提示
}
对于鸿蒙等移动端,还可以进一步研究系统通知或声音提示,但那会涉及平台能力和权限策略。
16.3 增加暂停后继续
当前 Stop 实际上就是暂停,因为它不会重置 _remainingSeconds。用户再次点击 Start,会从剩余时间继续倒计时。
| 操作 | 剩余时间 | 说明 |
|---|---|---|
| Stop | 保留 | 暂停 |
| Start | 继续递减 | 继续 |
| Reset | 恢复默认 | 重置为 5 分钟 |
16.4 增加深色模式适配
当前颜色多处直接使用 Colors.orange、Colors.grey。如果要完善深色模式,可以更多依赖主题:
final colorScheme = Theme.of(context).colorScheme;
然后将按钮、图标、进度条颜色统一从 colorScheme 中获取。
十七、工程风险与边界场景
17.1 0 秒启动
源码中已经处理了 0 秒不能启动:
if (_remainingSeconds <= 0) return;
这可以避免倒计时进入没有意义的运行态。
17.2 页面销毁
异步循环中使用 mounted 检查,避免页面卸载后调用 setState()。
if (!mounted) return false;
这是 Flutter 异步 UI 更新中很重要的安全保护。
17.3 长时间计时
当前小时最大为 23,所以最长倒计时为 23:59:59。对于普通小工具足够使用。如果要支持更长时间,可以增加天数或允许用户输入自定义小时。
17.4 多次点击
按钮文案会在运行态切换为 Stop,正常用户操作不会重复点击 Start。但对于自动化脚本、快捷键或未来新增入口,仍建议在 _startTimer() 中判断 _isRunning。
十八、常见问题与优化建议
18.1 为什么不用 Timer.periodic
当前项目使用 Future.doWhile 可以完成需求,而且代码比较集中。Timer.periodic 更适合需要显式保存和取消定时器对象的场景。两种方案都可行,关键是要处理停止和页面销毁。
18.2 为什么运行中隐藏时间选择器
运行中隐藏设置区可以避免用户一边倒计时一边修改总时长,减少进度计算和交互语义的复杂度。
18.3 为什么默认是 5 分钟
5 分钟是比较常见的短倒计时长度,适合休息、提醒、厨房计时等轻量场景。源码中通过 _minutes = 5 设置默认值。
18.4 为什么圆形进度会逐渐减少
_progress 使用 _remainingSeconds / total 计算。剩余秒数不断减少,进度值也会同步减小,因此圆环逐渐退回。
18.5 鸿蒙适配时最应该关注什么
主要关注前台计时稳定性、按钮触摸反馈、页面尺寸变化和进入后台后的表现。当前项目不包含后台通知能力,不能把它当作系统闹钟使用。
十九、完整流程复盘
19.1 页面启动流程
main()
-> runApp(MyApp)
-> MaterialApp
-> MyHomePage
-> initState()
-> _updateRemainingSeconds()
-> build()
-> 显示 00:05:00
19.2 用户设置时间流程
点击加减按钮
-> 触发 onChanged
-> 更新 hours/minutes/seconds
-> _updateRemainingSeconds()
-> 重新计算剩余秒数
-> 刷新格式化时间和进度
19.3 用户启动倒计时流程
点击 Start
-> _startTimer()
-> _isRunning = true
-> _runTimer()
-> 每秒递减 _remainingSeconds
-> UI 持续刷新
19.4 用户停止或重置流程
点击 Stop
-> _isRunning = false
-> 循环结束
-> 保留剩余时间
点击 Reset
-> _isRunning = false
-> 恢复 00:05:00
-> 页面回到 Ready
二十、相关资源与继续学习
20.1 Flutter 学习资源
倒计时器涉及 Widget、布局、异步和测试,建议结合官方文档学习:
| 资源 | 内容 |
|---|---|
| Flutter Docs | Flutter 应用开发基础 |
| Dart async | Dart 异步编程 |
| Widget catalog | Flutter 常用组件 |
| Flutter testing | Widget 测试与交互模拟 |
20.2 倒计时器可继续增强的方向
后续可以增加:
- 快捷时间按钮。
- 倒计时完成动画。
- 声音或震动提醒。
- 深色模式适配。
- 横屏和平板布局优化。
- 后台计时与通知能力。
20.3 跨端适配经验
这类小工具应用很适合作为跨端适配起点。因为它不依赖复杂平台能力,可以先把注意力集中在 UI 渲染、事件响应、异步行为和测试验证上。
总结
countdown_timer 用简洁的 Flutter 代码实现了一个完整倒计时器。它通过 _hours、_minutes、_seconds 描述用户设置,通过 _remainingSeconds 驱动倒计时和进度环,通过 _isRunning 控制按钮状态和异步循环。
从工程角度看,这个项目最值得学习的是把时间计算、格式化展示、按钮交互和生命周期保护组合在一起。面向鸿蒙适配时,项目本身依赖较轻,主要需要验证前台计时、触摸反馈、页面布局和生命周期表现。对于想掌握 Flutter 小工具开发的开发者来说,它是一个清晰、实用、容易扩展的练习案例。
如果这篇文章对你有帮助,欢迎点赞、收藏、关注,你的支持是我持续创作的动力!
相关资源:
更多推荐

所有评论(0)