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 适合学习的点

这个项目虽然代码不长,但适合学习以下内容:

  1. 如何把时分秒统一折算为秒。
  2. 如何用 getter 派生格式化时间和进度值。
  3. 如何在 Flutter 中安全执行异步倒计时循环。
  4. 如何在运行中隐藏设置区,减少误操作。
  5. 如何面向鸿蒙等多端环境检查 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();
  });
}

重置会做四件事:

  1. 停止运行。
  2. 小时恢复为 0。
  3. 分钟恢复为 5。
  4. 秒数恢复为 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;
}

进度值范围为 01。当剩余秒数等于总秒数时,进度为 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 鸿蒙设备上的验证点

适配时建议重点验证:

  1. Start、Stop、Reset 按钮点击反馈是否稳定。
  2. 圆形进度是否每秒更新。
  3. 页面退到后台再返回时显示是否符合预期。
  4. 横屏或折叠屏展开时布局是否拥挤。
  5. 时分秒按钮在触摸设备上是否容易点击。

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.orangeColors.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 小工具开发的开发者来说,它是一个清晰、实用、容易扩展的练习案例。

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


相关资源:

Logo

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

更多推荐