Flutter 实战:sleep_timer 睡眠定时器的预设时长、倒计时循环与鸿蒙适配解析
前言
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
睡眠定时器是一个非常适合拆解 Flutter 状态流转的小项目。它既有预设时长选择,也有秒级倒计时;既要展示圆形进度,也要处理 Start、Stop、完成弹窗和统计信息。相比普通静态页面,定时器更考验异步循环、生命周期判断、状态同步和UI 反馈的一致性。
sleep_timer的核心逻辑很清楚:用户选择 5 到 120 分钟之间的预设时长,点击 Start 后进入倒计时状态;页面每秒减少剩余秒数,并更新圆形进度与mm:ss文本;倒计时结束后弹出 Sleep Time 提醒,同时累计完成会话次数。
定时器类应用的关键不只是每秒减 1,还要保证启动、停止、完成、弹窗和页面销毁这些状态都能正确收口。
图示说明:上图展示 Flutter 页面在移动端的布局组织方式。sleep_timer的实际界面由圆形倒计时、开始/停止按钮、预设时长列表和底部统计栏组成。
一、项目定位与功能边界
1.1 应用定位
sleep_timer是一个轻量睡眠倒计时提醒工具,适合用于睡前计时、休息提醒、冥想结束提醒等场景。它不接入系统闹钟、后台服务或音频播放,重点是展示 Flutter 前台倒计时的实现方式。
项目当前支持:
- 选择预设定时时长。
- 启动倒计时。
- 每秒刷新剩余时间。
- 展示圆形进度。
- 运行中隐藏预设选择。
- 支持手动停止。
- 倒计时完成后弹出提醒。
- 统计完成会话次数。
- 统计运行过程中的分钟字段。
1.2 功能模块
| 功能模块 | 页面表现 | 源码实现 |
|---|---|---|
| 预设时长 | 横向分钟卡片 | _presetMinutes |
| 当前选择 | 选中卡片高亮 | _selectedMinutes |
| 倒计时状态 | Ready / mm:ss | _isActive、_remainingSeconds |
| 圆形进度 | CircularProgressIndicator | _remainingSeconds / totalSeconds |
| 启动停止 | Start / Stop 按钮 | _startTimer()、_stopTimer() |
| 完成提醒 | AlertDialog | _completeTimer() |
| 底部统计 | Sessions、Minutes | _totalSessions、_totalMinutes |
1.3 技术栈
| 技术点 | 使用位置 | 价值 |
|---|---|---|
| Flutter | 页面、按钮、弹窗、进度条 | 构建跨端 UI |
| Dart | 异步循环、时间格式化 | 控制定时逻辑 |
| Material 3 | 主题与组件风格 | useMaterial3: true |
| StatefulWidget | 管理倒计时状态 | 响应启动、停止和完成 |
| Future.doWhile | 秒级循环 | 实现前台倒计时 |
二、工程结构与运行环境
2.1 工程结构
sleep_timer是标准 Flutter 工程,核心代码集中在lib/main.dart。
| 文件或目录 | 作用 |
|---|---|
lib/main.dart | 应用入口、倒计时状态、循环逻辑和 UI 构建 |
pubspec.yaml | Flutter SDK 与测试依赖声明 |
test/widget_test.dart | Widget 测试入口 |
ohos/ | 鸿蒙平台工程目录 |
analysis_options.yaml | Dart 静态分析规则 |
2.2 运行命令
flutter doctor flutter pub get flutter run当前项目没有复杂三方依赖,主要使用 Flutter SDK 内置组件和 Dart 异步能力。
2.3 依赖声明
dependencies:flutter:sdk:fluttercupertino_icons:^1.0.8dev_dependencies:flutter_test:sdk:flutterflutter_lints:^5.0.0这种依赖结构适合做前台定时器和鸿蒙侧 UI 验证,重点观察倒计时刷新、弹窗行为、进度条渲染和生命周期状态。
三、应用入口与主题配置
3.1 main 函数
Flutter 应用从main()进入:
import'package:flutter/material.dart';voidmain(){runApp(constSleepTimerApp());}入口函数只负责启动根组件,不包含倒计时逻辑。
3.2 根组件
classSleepTimerAppextendsStatelessWidget{constSleepTimerApp({super.key});@overrideWidgetbuild(BuildContextcontext){returnMaterialApp(title:'Sleep Timer',theme:ThemeData(colorScheme:ColorScheme.fromSeed(seedColor:Colors.indigo),useMaterial3:true,),home:constSleepTimerHomePage(title:'Sleep Timer'),);}}根组件负责应用标题、主题和首页。运行状态、剩余秒数和统计字段都在首页 State 中维护。
3.3 主题色
colorScheme:ColorScheme.fromSeed(seedColor:Colors.indigo)靛蓝色适合睡眠、夜间和放松类应用。源码中运行状态、圆形进度、按钮和统计栏都围绕这个颜色展开。
四、StatefulWidget 与核心状态
4.1 首页组件
classSleepTimerHomePageextendsStatefulWidget{constSleepTimerHomePage({super.key,requiredthis.title});finalStringtitle;@overrideState<SleepTimerHomePage>createState()=>_SleepTimerHomePageState();}首页需要响应预设选择、启动、停止、倒计时刷新和完成弹窗,因此使用StatefulWidget。
4.2 状态字段
bool _isActive=false;int _selectedMinutes=30;int _remainingSeconds=0;int _totalSessions=0;int _totalMinutes=0;| 字段 | 类型 | 作用 |
|---|---|---|
_isActive | bool | 定时器是否运行 |
_selectedMinutes | int | 当前选择的分钟数 |
_remainingSeconds | int | 剩余秒数 |
_totalSessions | int | 完成的会话次数 |
_totalMinutes | int | 运行中累计的字段 |
4.3 预设时长
finalList<int>_presetMinutes=[5,10,15,30,45,60,90,120];项目内置 8 个常用时长,覆盖短休息到长时间睡眠提醒。
五、启动倒计时逻辑
5.1 _startTimer 方法
void_startTimer(){if(_isActive)return;setState((){_isActive=true;_remainingSeconds=_selectedMinutes*60;});Future.doWhile(()async{awaitFuture.delayed(constDuration(seconds:1));if(!mounted||!_isActive)returnfalse;setState((){_remainingSeconds--;_totalMinutes++;});if(_remainingSeconds<=0){_completeTimer();returnfalse;}returntrue;});}启动方法承担三件事:防止重复启动、初始化剩余秒数、进入每秒循环。
5.2 防重复启动
if(_isActive)return;如果计时器已经运行,再次点击不会重复启动新的循环。
5.3 分钟转秒
_remainingSeconds=_selectedMinutes*60;UI 选择的是分钟,倒计时内部使用秒,转换关系非常直接。
六、Future.doWhile 秒级循环
6.1 循环结构
Future.doWhile(()async{awaitFuture.delayed(constDuration(seconds:1));if(!mounted||!_isActive)returnfalse;// update statereturntrue;});Future.doWhile会在回调返回 true 时继续下一轮,返回 false 时停止。
6.2 生命周期判断
if(!mounted||!_isActive)returnfalse;mounted用来判断页面是否仍在组件树中。_isActive用来判断用户是否手动停止。任一条件不满足,循环都会结束。
6.3 每秒状态更新
setState((){_remainingSeconds--;_totalMinutes++;});每秒减少剩余秒数,并增加_totalMinutes。这里的_totalMinutes按源码真实表现是每秒加 1,变量名虽然叫 Minutes,但当前增量发生在秒级循环里。
技术文章要按源码真实行为解释:当前
_totalMinutes更像运行 tick 计数,不是严格的自然分钟累计。
七、停止与完成逻辑
7.1 手动停止
void_stopTimer(){setState((){_isActive=false;});}停止操作只需要把_isActive置为 false。下一轮Future.doWhile检测到状态后会退出。
7.2 自动完成
if(_remainingSeconds<=0){_completeTimer();returnfalse;}剩余秒数小于等于 0 时,调用完成方法并结束循环。
7.3 完成状态
setState((){_isActive=false;_totalSessions++;_remainingSeconds=0;});完成后会关闭运行状态、增加完成会话次数,并把剩余秒数归零。
八、完成弹窗设计
8.1 showDialog
showDialog(context:context,builder:(context)=>AlertDialog(title:constRow(children:[Icon(Icons.bedtime,color:Colors.indigo),SizedBox(width:8),Text('Sleep Time!'),],),content:Column(mainAxisSize:MainAxisSize.min,children:[constIcon(Icons.nights_stay,size:80,color:Colors.indigo),constSizedBox(height:16),Text('Time to sleep! Sweet dreams.',style:constTextStyle(fontSize:16)),],),actions:[ElevatedButton(onPressed:()=>Navigator.pop(context),child:constText('OK'),),],),);倒计时完成后弹出对话框,用图标和文案提醒用户。
8.2 弹窗结构
| 区域 | 内容 |
|---|---|
| Title | bedtime 图标 + Sleep Time |
| Content | nights_stay 图标 + 提醒文案 |
| Actions | OK 按钮关闭弹窗 |
8.3 前台限制
当前项目使用前台弹窗提醒,没有接入系统通知或后台定时能力。因此应用进入后台后的行为取决于平台调度和 Flutter 前台生命周期,不应把它理解为系统级闹钟。
九、时间格式化
9.1 _formatTime 方法
String_formatTime(){finalminutes=_remainingSeconds~/60;finalseconds=_remainingSeconds%60;return'${minutes.toString().padLeft(2, '0')}:''${seconds.toString().padLeft(2, '0')}';}方法把剩余秒数拆成分钟和秒,并补齐两位。
9.2 整除和取余
| 表达式 | 含义 |
|---|---|
_remainingSeconds ~/ 60 | 剩余分钟 |
_remainingSeconds % 60 | 剩余秒 |
padLeft(2, '0') | 不足两位前面补 0 |
9.3 示例
| 剩余秒数 | 显示 |
|---|---|
| 300 | 05:00 |
| 75 | 01:15 |
| 9 | 00:09 |
| 0 | 00:00 |
十、圆形进度展示
10.1 CircularProgressIndicator
CircularProgressIndicator(value:_isActive?_remainingSeconds/(_selectedMinutes*60):0,strokeWidth:12,backgroundColor:Colors.grey.shade200,valueColor:AlwaysStoppedAnimation(_isActive?Colors.indigo:Colors.grey.shade300,),)进度值是剩余秒数除以总秒数,因此倒计时刚开始接近 1,结束时接近 0。
10.2 圆环尺寸
SizedBox(width:220,height:220,child:CircularProgressIndicator(...),)固定 220 的宽高让圆形进度在页面中心保持稳定。
10.3 中心内容
Column(children:[Icon(_isActive?Icons.nights_stay:Icons.bedtime_outlined),Text(_isActive?_formatTime():'Ready'),if(_isActive)constText('remaining'),],)圆环中间展示图标、时间或 Ready 文案,运行时还会显示 remaining。
十一、开始与停止按钮
11.1 未运行状态
ElevatedButton.icon(onPressed:_startTimer,icon:constIcon(Icons.play_arrow),label:Text('Start$_selectedMinutesmin'),style:ElevatedButton.styleFrom(padding:constEdgeInsets.all(20),backgroundColor:Colors.indigo,),)未运行时按钮用于启动当前选择的分钟数。
11.2 运行状态
ElevatedButton.icon(onPressed:_stopTimer,icon:constIcon(Icons.stop),label:constText('Stop'),style:ElevatedButton.styleFrom(padding:constEdgeInsets.all(20),backgroundColor:Colors.red,),)运行时按钮切换为 Stop,并使用红色强调停止动作。
11.3 状态表
| 状态 | 按钮文案 | 图标 | 动作 |
|---|---|---|---|
| Ready | Start N min | play_arrow | 启动倒计时 |
| Active | Stop | stop | 停止倒计时 |
十二、预设时长选择
12.1 只在未运行时展示
if(!_isActive)...[constText('Set Timer Duration'),SizedBox(height:60,child:ListView.builder(...),),]运行时隐藏预设选择,避免倒计时过程中修改总时长导致进度混乱。
12.2 横向列表
ListView.builder(scrollDirection:Axis.horizontal,padding:constEdgeInsets.all(8),itemCount:_presetMinutes.length,itemBuilder:(context,index){finalmins=_presetMinutes[index];finalisSelected=_selectedMinutes==mins;returnGestureDetector(onTap:()=>setState(()=>_selectedMinutes=mins),child:Container(...),);},)横向列表适合展示多个预设分钟值,不占用太多竖向空间。
12.3 预设值表
| 预设分钟 | 使用场景 |
|---|---|
| 5 | 短休息 |
| 10 | 小憩提醒 |
| 15 | 放松训练 |
| 30 | 默认睡前计时 |
| 45 | 中等时长 |
| 60 | 一小时提醒 |
| 90 | 睡眠周期参考 |
| 120 | 长时段提醒 |
十三、底部统计栏
13.1 统计区域
Container(padding:constEdgeInsets.all(16),color:Colors.indigo.shade50,child:Row(mainAxisAlignment:MainAxisAlignment.spaceAround,children:[// Sessions// Minutes],),)底部统计栏固定展示会话次数和累计字段。
13.2 Sessions
Column(children:[constIcon(Icons.timer,color:Colors.indigo),Text('$_totalSessions'),constText('Sessions'),],)只有倒计时自然完成时_totalSessions才会增加。
13.3 Minutes
Column(children:[constIcon(Icons.access_time,color:Colors.indigo),Text('${_totalMinutes}'),constText('Minutes'),],)源码中_totalMinutes每秒增加 1,所以当前展示文案和实际累加单位存在差异。发布文章中应如实说明这一点。
十四、边界场景与真实限制
14.1 重复点击启动
_startTimer()开头有_isActive判断,运行中不会重复启动多个倒计时循环。
14.2 手动停止
点击 Stop 后_isActive变为 false,下一轮循环检测到状态后结束,不会弹出完成提醒。
14.3 页面销毁
Future.doWhile中检查mounted,页面不在组件树中时循环结束,避免无效setState()。
14.4 后台计时
当前项目没有使用系统通知、后台任务或闹钟 API。它适合前台计时演示,不等同于系统级后台睡眠提醒。
十五、Widget 测试设计
15.1 基础渲染测试
import'package:flutter_test/flutter_test.dart';import'../lib/main.dart';voidmain(){testWidgets('sleep timer renders ready state',(tester)async{awaittester.pumpWidget(constSleepTimerApp());expect(find.text('Sleep Timer'),findsWidgets);expect(find.text('Ready'),findsOneWidget);expect(find.text('Start 30 min'),findsOneWidget);});}这个测试验证默认 Ready 状态和启动按钮。
15.2 预设切换测试
testWidgets('select preset changes start button text',(tester)async{awaittester.pumpWidget(constSleepTimerApp());awaittester.tap(find.text('5'));awaittester.pump();expect(find.text('Start 5 min'),findsOneWidget);});这个测试覆盖预设分钟选择。
15.3 启动状态测试
testWidgets('start button enters active state',(tester)async{awaittester.pumpWidget(constSleepTimerApp());awaittester.tap(find.text('Start 30 min'));awaittester.pump();expect(find.text('Stop'),findsOneWidget);expect(find.text('remaining'),findsOneWidget);});这个测试验证启动后按钮和状态文案变化。
15.4 测试命令
fluttertest保持测试中的根组件名称与实际源码一致,可以避免默认模板测试残留造成编译失败。
十六、鸿蒙适配观察
16.1 适配优势
sleep_timer的主要逻辑由 Dart 异步循环和 Flutter Widget 完成,没有复杂原生插件,适合验证鸿蒙侧前台计时 UI。
| 维度 | 当前项目情况 | 鸿蒙侧关注点 |
|---|---|---|
| 倒计时循环 | Future.doWhile | 前台刷新稳定性 |
| 圆形进度 | CircularProgressIndicator | 渲染和动画平滑度 |
| 弹窗 | AlertDialog | 完成提醒展示 |
| 横向预设 | ListView.builder | 小屏滚动和选中态 |
| 状态统计 | 底部 Container | 文案和数字布局 |
16.2 构建命令参考
flutter clean flutter pub get flutter build hap具体命令取决于所使用的鸿蒙 Flutter 适配环境。这个项目主要验证前台倒计时、按钮切换、弹窗和横向预设列表。
16.3 运行验证要点
- 应用能正常启动到 Ready 状态。
- 横向预设分钟可以点击切换。
- Start 后显示倒计时和 Stop。
- Stop 后倒计时循环停止。
- 倒计时完成后能弹出提醒框。
- 页面销毁或返回时不出现异常刷新。
鸿蒙适配中,定时器类页面要特别关注前后台生命周期、弹窗展示、进度刷新和按钮状态切换。
十七、性能与可维护性
17.1 性能特征
项目每秒刷新一次,计算量很小。
| 维度 | 当前表现 |
|---|---|
| 刷新频率 | 每秒一次 |
| 状态更新 | 剩余秒数和统计字段 |
| UI 核心 | 圆形进度 + 时间文本 |
| 预设数量 | 8 个 |
| 完成提示 | AlertDialog |
17.2 当前结构优点
- 状态字段少,职责清晰。
- 启动、停止、完成方法分离。
mounted判断处理生命周期。- 预设时长列表集中管理。
- UI 根据
_isActive自动切换。
17.3 可演进方向
可以把时间格式化抽成纯函数,方便单元测试:
StringformatSeconds(int value){finalminutes=value~/60;finalseconds=value%60;return'${minutes.toString().padLeft(2, '0')}:''${seconds.toString().padLeft(2, '0')}';}也可以把_totalMinutes改成按分钟累计,或重命名为 ticks 以匹配当前行为。
十八、常见问题与优化建议
18.1 为什么使用Future.doWhile
它可以用异步循环表达“每秒执行一次,直到条件结束”的逻辑,适合前台轻量倒计时。
18.2 为什么启动时要检查_isActive
防止用户重复点击 Start 导致多个循环同时运行。这是定时器类应用必须处理的边界。
18.3 为什么运行中隐藏预设选择
倒计时进行中如果修改总时长,会影响进度比例和剩余时间语义。隐藏预设可以保持状态稳定。
18.4 为什么完成时使用弹窗
弹窗能明确告诉用户倒计时结束,并要求用户点击 OK 关闭,适合前台提醒。
18.5 为什么_totalMinutes需要注意
源码里_totalMinutes++发生在每秒循环中,因此当前数值不是严格分钟数。如果要展示真实分钟,应按 60 秒换算或在完成后累加_selectedMinutes。
18.6 为什么适合做鸿蒙适配示例
它覆盖前台异步循环、圆形进度、横向预设、弹窗、按钮状态和生命周期判断,都是 Flutter 定时器页面在鸿蒙侧常见的验证点。
总结
sleep_timer用一个 Flutter 单页实现了睡眠倒计时的基本闭环:选择预设分钟,点击 Start 启动每秒循环,页面展示圆形进度和剩余时间;点击 Stop 可以终止倒计时;自然完成后弹出 Sleep Time 提醒,并累计完成会话次数。
从工程角度看,这个项目适合学习 Flutter 前台定时器的状态设计。它把启动、停止、完成、格式化和 UI 展示拆分成清晰的方法,代码阅读成本低。
从鸿蒙适配角度看,重点是验证前台倒计时稳定性、mounted生命周期判断、圆形进度渲染、完成弹窗和预设时长列表的交互表现。处理好这些细节后,定时器类工具页面就能获得比较可靠的跨端体验。
如果这篇文章对你有帮助,欢迎点赞、收藏、关注,你的支持是我持续创作的动力!
相关资源:
- Flutter 官方文档
- Dart Future 文档
- Flutter CircularProgressIndicator 文档
- Flutter AlertDialog 文档
- Flutter ElevatedButton 文档
- Flutter ListView 文档
- Flutter StatefulWidget 文档
- Flutter 测试文档
- OpenHarmony 官网
- OpenHarmony CrossPlatform 社区