一个健壮的呼吸练习需要处理:开始、暂停、恢复、结束确认、超时完成。本文详解 E-Brufen 的会话状态管理------用 2 个 bool 标志驱动 4 态状态机,零外部依赖。
一、整体架构概览
在深入细节之前,先看清组件树和职责划分:
┌──────────────────────────────────────────────────────────┐
│ BreathePage (Stateful) │
│ ┌────────────────────────────────────────────────────┐ │
│ │ 会话状态机 │ │
│ │ _isRunning : bool _isPaused : bool │ │
│ │ _remainingSeconds _selectedMinutes │ │
│ │ _selectedPattern _currentPhase │ │
│ └────────────────────────────────────────────────────┘ │
│ │
│ build() ──[_isRunning?]──→ _buildSessionView() │
│ │ │ │
│ NO ├── BreathingCircle │
│ ↓ │ (GlobalKey 跨组件) │
│ _buildSetupView() │ │
│ ├── 模式选择 (RadioListTile) │ ├── AnimationController│
│ ├── 时长选择 (ChoiceChip) │ ├── Timer.periodic │
│ └── 开始按钮 ───────────────→ │ ├── _isPaused 检查 │
│ │ └── onComplete 回调 │
│ │ │
│ ├── FloatingActionButton │
│ │ (pause / resume) │
│ └── TextButton (退出) │
└──────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ BreathingCircle (Stateful) │
│ ├── AnimationController (SingleTickerProviderStateMixin) │
│ ├── Timer? _timer │
│ ├── _isPaused : bool ← 独立的暂停标志 │
│ ├── _phaseIndex ← 序列内阶段指针 │
│ ├── _totalElapsedSeconds ← 跨阶段累计秒数 │
│ └── _secondsInPhase ← 当前阶段内秒数 │
└──────────────────────────────────────────────────────────┘
关键设计决策:BreathePage 和 BreathingCircle 各自持有一个 _isPaused 标志 。BreathePage 的 _isPaused 驱动 UI(图标切换、文本显示),而 BreathingCircle 的 _isPaused 驱动 Timer 回调的短路逻辑。两者通过 GlobalKey<BreathingCircleState> 和 pause()/resume() 公开方法同步。

二、会话状态机 ------ 4 态、2 标志、0 依赖
2.1 状态定义
完整的呼吸会话经历 4 个状态:
┌──────────────────────────────────────┐
│ │
▼ │
┌─────────┐ start() ┌─────────┐ pause() ┌─────────┐ │
│ SETUP │ ────────→ │ RUNNING │ ────────→ │ PAUSED │ │
│ (配置页) │ │ (练习中) │ │ (暂停) │ │
└─────────┘ └─────────┘ └─────────┘ │
▲ │ │ │
│ │ resume() │ │
│ └──────────────────────┘ │
│ │
│ exitConfirm() │
└───────────────────────────────────────────────────┘
┌─────────┐ onComplete() ┌────────────┐
│ RUNNING │ ───────────────→ │ COMPLETED │
│ PAUSED │ (总时长到) │ (完成弹窗) │
└─────────┘ └────────────┘
│
dismiss()
│
▼
┌─────────┐
│ SETUP │
└─────────┘
2.2 布尔标志编码
仅用 2 个成员变量,编码 4 种合法状态:
dart
// breathe_page.dart --- _BreathePageState
bool _isRunning = false;
bool _isPaused = false;
_isRunning |
_isPaused |
状态 | 含义 |
|---|---|---|---|
false |
false |
SETUP | 显示模式/时长选择页,用户尚未开始 |
true |
false |
RUNNING | 呼吸练习进行中,Timer 正常计数 |
true |
true |
PAUSED | 练习暂停,Timer 继续触发但回调短路 |
false |
true |
(非法) | 逻辑上不应出现,构造函数和所有 setState 路径保证不会产生此组合 |
设计笔记:为什么不用 enum?
对于 4 态状态机,
enum SessionState { setup, running, paused, completed }当然是更"正统"的写法。但当前项目选择两个 bool 的原因:
- UI 分支极简 :
_isRunning ? sessionView : setupView一行搞定 body 切换;_isPaused ? playIcon : pauseIcon一行搞定按钮图标。如果用 enum,需要 4 路 switch。- 暂停逻辑是"层叠"而非"并列":PAUSED 是 RUNNING 的子状态------暂停时界面仍然是 sessionView,只是动画和计时暂停。这种层级关系用 bool 表达比 enum 更自然。
- setState 路径安全 :所有修改
_isRunning和_isPaused的代码都在同一个setState调用中,不会产生中间不一致状态。当状态数量增长到 5+ 或状态转换需要守卫条件时,再切换到 enum + 显式状态机不迟。
2.3 状态转换表
| 转换 | 触发条件 | setState 操作 | 伴随效果 |
|---|---|---|---|
| SETUP → RUNNING | 用户点击"开始练习" | _isRunning=true; _isPaused=false; _remainingSeconds=... |
持久化 breatheMode / breatheMinutes 到 Hive |
| RUNNING → PAUSED | 用户点击暂停 FAB | _isPaused=true |
调用 _circleKey.currentState?.pause() |
| PAUSED → RUNNING | 用户点击播放 FAB | _isPaused=false |
调用 _circleKey.currentState?.resume() |
| RUNNING/PAUSED → COMPLETED | _totalElapsedSeconds >= totalSec |
_isRunning=false; _isPaused=false |
弹出完成弹窗 |
| RUNNING/PAUSED → SETUP | 用户确认退出 | _isRunning=false |
弹出退出确认 AlertDialog |
三、启动会话 ------ 设置持久化 + 状态跃迁
dart
// breathe_page.dart : _BreathePageState
void _startSession() {
setState(() {
_isRunning = true;
_isPaused = false;
_remainingSeconds = _selectedMinutes * 60;
widget.settings.breatheMode = _selectedPattern.name;
widget.settings.breatheMinutes = _selectedMinutes;
});
}
这段代码在同一个 setState 调用中完成三件事:
- 状态跃迁 :
_isRunning = true+_isPaused = false,从 SETUP 进入 RUNNING。 - 计时器初始化 :
_remainingSeconds = _selectedMinutes * 60,将分钟转为秒数。这个值在 UI 上实时显示为"剩余 mm:ss"。 - 偏好持久化 :通过
AppSettings的 setter 写入 Hive Box,确保用户下次打开呼吸页时恢复上次的模式和时长选择。
dart
// 对应的 initState 恢复逻辑
@override
void initState() {
super.initState();
final savedMode = widget.settings.breatheMode;
_selectedPattern = BreathePatterns.all.firstWhere(
(p) => p.name == savedMode,
orElse: () => BreathePatterns.box,
);
_selectedMinutes = widget.settings.breatheMinutes;
}
四、UI 分支 ------ body 级别状态切换
BreathePage 的 build() 方法用单行三元表达式完成整页切换:
dart
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('呼吸练习')),
body: _isRunning ? _buildSessionView() : _buildSetupView(),
);
}
这是 Flutter 页面级状态切换的极简模式。相比 Navigator.push / pop 或 PageView:
| 方案 | 代码量 | 动画 | 内存 | 适用场景 |
|---|---|---|---|---|
| build 内条件分支(本文) | 极少 | 无过渡 | 只构建当前页面 | 同一页面内的视图切换 |
| Navigator.push | 中等 | 平台默认 | 两页同时在栈中 | 独立的子页面 |
| AnimatedSwitcher | 中等 | 可自定义 | 只构建当前页面 | 需要过渡动画的视图切换 |
对于呼吸练习场景,SETUP 和 SESSION 是同一上下文的两种状态,不需要独立的路由条目,用条件分支最合适。
五、暂停/恢复 ------ FloatingActionButton 驱动的双组件同步
5.1 FAB 按钮实现
dart
// breathe_page.dart : _buildSessionView() 中的控制栏
FloatingActionButton(
onPressed: () {
setState(() {
_isPaused = !_isPaused;
if (_isPaused) {
_circleKey.currentState?.pause();
} else {
_circleKey.currentState?.resume();
}
});
},
backgroundColor: AppTheme.gentlePurple,
child: Icon(
_isPaused ? Icons.play_arrow : Icons.pause,
color: Colors.white,
),
)
这个按钮在单个 setState 中完成三件事:
┌─────────────────────────────────────────────────────────────────┐
│ FAB onPressed 时序 │
│ │
│ 1. _isPaused = !_isPaused │
│ │ │
│ ├── true (刚暂停) ──→ 调用 _circleKey.currentState?.pause()│
│ │ ├── BreathingCircle._isPaused = true │
│ │ ├── Timer 保持运行,回调短路 │
│ │ └── AnimationController 停在当前位置 │
│ │ │
│ └── false (刚恢复) ──→ 调用 _circleKey.currentState?.resume()│
│ ├── BreathingCircle._isPaused = false │
│ ├── Timer 回调恢复正常逻辑 │
│ └── AnimationController 继续播放 │
│ │
│ 2. setState → 重建 FAB,图标从 pause 切换为 play_arrow(或反之)│
│ │
│ 3. BreathingCircle 内部也 setState → 圆的缩放动画停止/恢复 │
└─────────────────────────────────────────────────────────────────┘
5.2 为什么使用 GlobalKey 跨组件调用?
dart
final GlobalKey<BreathingCircleState> _circleKey = GlobalKey();
// 在 build 中绑定
BreathingCircle(
key: _circleKey,
pattern: _selectedPattern,
totalMinutes: _selectedMinutes,
onComplete: _onComplete,
onPhaseChange: _onPhaseChange,
)
之所以不通过构造参数传入 isPaused:
- 职责分离 :暂停/恢复是 BreathingCircle 的内部行为,由它自己的
_isPaused字段管理。父组件只需要说"暂停"或"恢复",不需要知道内部如何实现。 - 避免多余的 rebuild :如果 BreathePage 通过 props 传入
isPaused,每次切换都会触发 BreathingCircle 的完整 rebuild(包括 AnimationController 重建),而用 GlobalKey + 方法调用只触发 BreathingCircle 内部的一次 setState。 - Timer 生命周期归属清晰:Timer 由 BreathingCircle 创建和管理,暂停判断也理应在同一作用域内。
5.3 常见替代方案对比
| 方案 | 优点 | 缺点 |
|---|---|---|
| GlobalKey + public method (本文) | 职责清晰,不重建子组件,调用精确 | GlobalKey 是 escape hatch,滥用会破坏组件封装 |
| props 传入 isPaused | 声明式,符合 React/Flutter 哲学 | 每次切换触发 BreathingCircle 完整 rebuild |
| InheritedWidget / Provider | 解耦,适合深层组件 | 过度设计------只有一个子组件需要这个状态 |
回调:父传 onPause / onResume |
反向控制,父组件完全掌握状态 | 循环依赖:父需要知道暂停状态 → 子需要接收暂停指令 |
在仅有一个子组件需要同步暂停状态的场景下,GlobalKey 是最务实的方案。
六、BreathingCircle 内部 ------ Timer 回调短路的暂停机制
6.1 完整实现
dart
// breathing_circle.dart : BreathingCircleState
class BreathingCircleState extends State<BreathingCircle>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
int _phaseIndex = 0;
int _secondsInPhase = 0;
int _totalElapsedSeconds = 0;
Timer? _timer;
bool _isPaused = false;
void pause() => setState(() => _isPaused = true);
void resume() => setState(() => _isPaused = false);
void _startTimer(int durationSec) {
_timer?.cancel();
_secondsInPhase = 0;
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (_isPaused) return; // ← 核心:暂停时短路,不推进任何状态
_secondsInPhase++;
_totalElapsedSeconds++;
// 检查总时长
final totalSec = widget.totalMinutes * 60;
if (_totalElapsedSeconds >= totalSec) {
timer.cancel();
_timer = null;
widget.onComplete();
return;
}
// 当前阶段结束,进入下一阶段
if (_secondsInPhase >= durationSec) {
_phaseIndex++;
if (_phaseIndex >= widget.pattern.sequence.length) {
_phaseIndex = 0;
}
final nextPhase = widget.pattern.sequence[_phaseIndex];
_animatePhase(nextPhase.seconds);
widget.onPhaseChange(widget.pattern.labelFor(nextPhase.phase));
}
});
}
@override
void dispose() {
_timer?.cancel();
_controller.dispose();
super.dispose();
}
}
6.2 暂停机制的时序分析
Timer.periodic 每秒触发一次回调
时间线 (RUNNING 状态):
t=0s 回调 → _secondsInPhase: 1, _totalElapsedSeconds: 1
t=1s 回调 → _secondsInPhase: 2, _totalElapsedSeconds: 2
t=2s 回调 → _secondsInPhase: 3, _totalElapsedSeconds: 3
t=3s 回调 → ... 持续推进
时间线 (PAUSED 状态,假设在 t=4s 暂停):
t=4s 回调 → if (_isPaused) return; ← 短路
t=5s 回调 → if (_isPaused) return; ← 短路
t=6s 回调 → if (_isPaused) return; ← 短路
... 每秒钟一次空回调,直到 resume()
时间线 (resume 后):
t=Ns 回调 → _secondsInPhase: 4, _totalElapsedSeconds: 4 ← 从暂停前位置继续
t=Ns+1 回调 → _secondsInPhase: 5, _totalElapsedSeconds: 5
由于 _secondsInPhase、_totalElapsedSeconds、_phaseIndex 在暂停期间都不增加,恢复后从精确的暂停位置继续。
6.3 AnimationController 的暂停
dart
void pause() => setState(() => _isPaused = true);
void resume() => setState(() => _isPaused = false);
这里 没有显式调用 _controller.stop() 。AnimationController 本身继续运行------它在每个阶段开始时通过 _startTimer → _animatePhase 被 _controller.forward() 或 _controller.reverse() 启动,持续时间精确匹配当前阶段的秒数。
暂停时,Timer 回调短路 → 不会触发 _animatePhase → AnimationController 的当前动画继续播放到结束(按阶段时长),然后因为没有新的 _animatePhase 调用来启动下一阶段,动画自然停在当前阶段结束时的状态。
这是一种被动暂停:不主动停止 AnimationController,而是通过切断阶段推进来间接冻结动画。优点是无需管理 AnimationController 的 stop/resume 生命周期,缺点是无法在阶段中间暂停------动画总是会完成当前阶段才停下(对于呼吸练习,这个短暂的延迟是用户可接受的)。
性能笔记:每秒一次的空回调开销
Timer.periodic在暂停期间仍然每秒触发一次回调。每次回调执行:
- 读取
_isPaused字段(bool 比较,1 次内存访问)- 条件跳转(
if分支预测几乎总是命中)- 函数返回
在任意现代设备上(包括低端鸿蒙设备),这个开销在微秒级别以下。即使 10 分钟的练习全程暂停(600 次空回调),总 CPU 时间增量 < 1ms。
相比之下,
_timer.cancel()+ 恢复时重新Timer.periodic涉及:
- Dart VM 的 Timer 堆操作(O(log n))
- 对象分配/GC(Timer 对象的创建和销毁)
- 额外的错误处理(确保 cancel 和 recreate 之间没有竞态)
短路方案更简单、更安全,且没有可测量的性能差异。
七、退出确认 ------ AlertDialog 防误触机制
7.1 实现
dart
// breathe_page.dart : _BreathePageState
void _exitSession() {
showDialog(
context: context,
builder: (_) => AlertDialog(
title: const Text('结束练习'),
content: const Text('确定结束本次呼吸练习吗?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('继续'),
),
TextButton(
onPressed: () {
Navigator.pop(context);
setState(() => _isRunning = false);
},
child: const Text('确定结束'),
),
],
),
);
}
7.2 交互流程
用户点击"退出" TextButton
│
▼
showDialog(AlertDialog)
│
├── 点击"继续" → Navigator.pop → 用户回到练习页面
│ (练习状态不变,继续运行或保持暂停)
│
└── 点击"确定结束" → Navigator.pop → setState(_isRunning = false)
│
▼
build() 重新执行
_isRunning == false
│
▼
body 切换到 _buildSetupView()
(回到模式/时长选择页)
7.3 设计要点
- 破坏性操作必须有二次确认。呼吸练习是用户主动发起的正念行为,意外退出会打断体验。弹窗的两个按钮文案明确:"继续"(安全操作,在左侧)和"确定结束"(破坏性操作,需要用户确认)。
- 退出后不清除设置 :
_isRunning = false只重置运行状态,_selectedPattern和_selectedMinutes保持用户上次的选择------再次点击"开始练习"即可用相同配置重新开始。 - 退出前不询问保存:呼吸练习不产生需要保存的数据(练习时长由 BreathingCircle 内部追踪,不持久化),因此 AlertDialog 不需要"保存并退出"的分支。
7.4 常见反模式
dart
// ❌ 错误:退出时忘记取消 Timer
void _exitSession() {
setState(() => _isRunning = false);
// Timer 仍在后台运行!可能导致:
// 1. 内存泄漏(Timer 持有 State 引用)
// 2. onComplete 回调在 setup 页面弹出对话框
}
// ✅ 正确:通过 BreathingCircle 的 dispose 自动清理
// 当 _isRunning 变为 false 后,build() 重建为 _buildSetupView(),
// BreathingCircle 从 widget 树中移除,
// 触发 State.dispose() → _timer?.cancel() → _controller.dispose()
//
// BreathePage 本身不需要手动清理 Timer,
// 因为 Timer 生命周期完全归 BreathingCircle 管理。
关键洞察:Widget 树的卸载就是最好的资源清理机制 。当 _isRunning = false → _buildSetupView() 替换 _buildSessionView() → BreathingCircle 被 dispose → Timer 自动取消。不需要在 _exitSession 中显式清理。
八、完成弹窗 ------ emoji 鼓励 + 状态重置
8.1 实现
dart
// breathe_page.dart : _BreathePageState
void _onComplete() {
setState(() {
_isRunning = false;
_isPaused = false;
});
showDialog(
context: context,
builder: (_) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('✨', style: TextStyle(fontSize: 48)),
const SizedBox(height: 12),
const Text('练习完成!',
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w600)),
const SizedBox(height: 4),
const Text('你真棒,感觉好一点了吗?'),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('结束'),
),
],
),
);
}
8.2 与退出弹窗的差异
| 维度 | 退出确认 AlertDialog | 完成弹窗 AlertDialog |
|---|---|---|
| 触发时机 | 用户主动点击"退出" | BreathingCircle 检测到 _totalElapsedSeconds >= totalSec |
| _isRunning 状态 | 仅在用户确认后才置 false | 先置 false,再弹窗 |
| 弹窗内容 | 中性确认:"确定结束吗?" | 正向鼓励:大号 ✨ + "你真棒" |
| 标题 | title: '结束练习' |
无 title,全靠 content |
| 按钮 | "继续" + "确定结束" | 仅"结束"(练习已完成,无需"继续") |
| 形状 | 默认 Material AlertDialog 形状 | 自定义 BorderRadius.circular(20) |
| 按钮文案 | 左侧"继续"(安全),右侧"确定结束"(确认后行动) | 仅"结束" |
8.3 setState 时序的重要性
dart
void _onComplete() {
// 第一步:先重置状态,再弹窗
setState(() {
_isRunning = false;
_isPaused = false;
});
// 此时 build() 已经重建,body 切换回 _buildSetupView()
// BreathingCircle 已从树中移除并 dispose
// 第二步:在 setup 页面上弹出完成对话框
showDialog(...);
}
如果顺序反了------先 showDialog 再 setState------会出现:
- 弹窗时 BreathingCircle 仍在 widget 树中
- BreathingCircle 的 Timer 已 cancel(在
_startTimer回调中timer.cancel()) - 但 AnimationController 仍持有 vsync 资源
- 用户关闭弹窗后,body 才切换,BreathingCircle 才 dispose
虽然功能上也能工作,但先 setState 再 showDialog 保证弹窗时的页面状态是干净的 SETUP 页面,逻辑更清晰。
九、剩余时间显示 ------ 格式化的细节
dart
// breathe_page.dart : _BreathePageState
String _formatTime(int seconds) {
final m = seconds ~/ 60;
final s = seconds % 60;
return '剩余 ${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}';
}
// 示例:
// _formatTime(155) → "剩余 02:35"
// _formatTime(5) → "剩余 00:05"
// _formatTime(3600) → "剩余 60:00"
注意几个细节:
~/整数除法 :避免double转换开销,直接截断。padLeft(2, '0'):保证两位数字显示,00:05而非0:5。- 前缀"剩余":明确告知用户这是倒计时,而非已用时间。
9.1 性能考量:_formatTime 的调用频率
_formatTime 本身是纯计算函数,O(1) 时间复杂度,无任何问题。但它的调用方需要关注:
dart
// _buildSessionView() 中
Text(
_formatTime(_remainingSeconds),
style: const TextStyle(fontSize: 18, color: Colors.grey),
),
_remainingSeconds 由谁更新?回顾代码------BreathePage 的 _remainingSeconds 只在 _startSession() 中初始化,之后从未被更新!
这是当前实现的一个已知限制:BreathePage 的 _remainingSeconds 显示的是总时长,不是实时剩余时间。实时计时由 BreathingCircle 的 _totalElapsedSeconds 管理,通过 onComplete 回调通知父组件。
如果需要实时更新 _remainingSeconds,有两种方案:
方案 A:BreathingCircle 增加 onTick 回调
dart
// breathing_circle.dart 修改
final ValueChanged<int> onTick; // 新增回调
// _startTimer 回调中
_totalElapsedSeconds++;
widget.onTick(widget.totalMinutes * 60 - _totalElapsedSeconds);
方案 B:BreathePage 维护自己的 Timer(当前架构更倾向)
dart
// BreathePage 中
void _startCountdown() {
_timer = Timer.periodic(Duration(seconds: 1), (t) {
if (_isPaused) return; // 与 BreathingCircle 同步暂停
if (_remainingSeconds <= 0) { t.cancel(); return; }
setState(() => _remainingSeconds--);
});
}
当前项目选择不实时更新(_remainingSeconds 显示初始值),是因为倒计时数字的频繁变化可能分散用户对呼吸节奏的注意力------这是刻意的 UX 选择而非 bug。
十、常见陷阱与调试指南
10.1 陷阱一:dispose 后调用 setState
dart
// ❌ 典型的异步陷阱
void _onComplete() {
_timer?.cancel(); // Timer 已取消
widget.onComplete(); // 触发父组件的 _onComplete()
// 父组件的 setState 可能导致 BreathingCircle 被 dispose
// 如果下面还有 setState 调用,会报错:
// setState() called after dispose()
}
保护措施 :在异步边界后检查 mounted。
dart
void _onComplete() {
_timer?.cancel();
if (!mounted) return; // ← 关键
widget.onComplete();
}
当前 breathing_circle.dart 中 _startTimer 的 onComplete 调用前已经 cancel 了 Timer,且 BreathingCircle 的 onComplete 回调触发 BreathePage 的 _onComplete(),后者通过 setState 将 _isRunning 置为 false,导致 BreathingCircle 从树中移除。这个时序是正确的------Timer 已 cancel,不再有回调触发。
10.2 陷阱二:GlobalKey 在 setState 前的引用
dart
// ❌ 如果在 setState 之前通过 GlobalKey 读取状态:
void _exitSession() {
final elapsed = _circleKey.currentState?._totalElapsedSeconds; // 不可访问(private)
setState(() => _isRunning = false);
}
// ✅ GlobalKey 应该只用于调用公开方法:
void _togglePause() {
setState(() {
_isPaused = !_isPaused;
if (_isPaused) {
_circleKey.currentState?.pause(); // 公开方法
} else {
_circleKey.currentState?.resume(); // 公开方法
}
});
}
_totalElapsedSeconds 是 BreathingCircleState 的私有字段,外部不可访问------这是正确的封装。如果父组件需要读取这些数据,应该通过回调(onTick)而不是 GlobalKey。
10.3 陷阱三:Timer 泄漏
dart
// ❌ 忘记在 dispose 中清理
@override
void dispose() {
_controller.dispose();
super.dispose();
// _timer 未 cancel!页面销毁后 Timer 仍在运行,
// 回调试图访问已 disposed 的 State,导致异常。
}
// ✅ 当前实现
@override
void dispose() {
_timer?.cancel(); // 先清理 Timer
_controller.dispose(); // 再清理 AnimationController
super.dispose();
}
10.4 陷阱四:暂停时退出导致的状态残留
场景:用户在 PAUSED 状态下点击"退出"并确认
期望结果:回到 SETUP 页面
当前行为:✅ 正确
原因分析:
_exitSession → Navigator.pop → setState(_isRunning = false)
build() 重建 → _buildSetupView()
BreathingCircle dispose → _timer?.cancel()
_isPaused 字段:虽然 BreathePage 的 _isPaused 在退出时未显式重置,
但下次 _startSession() 会将其设为 false。BreathingCircle 的 _isPaused
随组件 dispose 销毁,不存在持久化问题。
10.5 调试技巧:状态日志
在开发阶段,可以在每个状态变更点打印日志:
dart
// 调试用(正式发布前移除)
void _logState(String event) {
debugPrint('[BreathePage] $event | '
'_isRunning=$_isRunning _isPaused=$_isPaused '
'remaining=$_remainingSeconds');
}
void _startSession() {
setState(() { ... });
_logState('START');
}
void _onComplete() {
setState(() { ... });
_logState('COMPLETE');
}
十一、完整时序图(PlantUML 风格 ASCII)
┌────────────┐ ┌────────────┐ ┌───────────────────┐ ┌─────────────┐
│ User │ │BreathePage │ │ BreathingCircle │ │ Hive Box │
│ │ │ (State) │ │ (State) │ │ (settings) │
└─────┬──────┘ └─────┬──────┘ └────────┬──────────┘ └──────┬──────┘
│ │ │ │
│ 点击"开始练习" │ │ │
│─────────────────→│ │ │
│ │ │ │
│ │ _startSession() │ │
│ │ ├─ setState: │ │
│ │ │ _isRunning=true │ │
│ │ │ _isPaused=false │ │
│ │ │ _remainingSec=.. │ │
│ │ ├─ settings.put ───────────────────────────→│
│ │ └─ build → sessionView │
│ │ │ │
│ │ BreathingCircle │ │
│ │ initState │ │
│ │ _startCycle() │ │
│ │────────────────────→│ │
│ │ │ Timer.periodic(1s) │
│ │ │ Animation.forward │
│ │ onPhaseChange ───→│ │
│ │←────────────────────│ │
│ │ │ │
│ 点击暂停 FAB │ │ │
│─────────────────→│ │ │
│ │ setState: │ │
│ │ _isPaused=true │ │
│ │ │ │
│ │ circle.pause() │ │
│ │────────────────────→│ │
│ │ │ _isPaused=true │
│ │ │ Timer 回调短路 │
│ │ │ │
│ ... 10 秒后 ... │ │ │
│ │ │ 每秒: if(_isPaused) │
│ │ │ return; │
│ │ │ │
│ 点击播放 FAB │ │ │
│─────────────────→│ │ │
│ │ setState: │ │
│ │ _isPaused=false │ │
│ │ │ │
│ │ circle.resume() │ │
│ │────────────────────→│ │
│ │ │ _isPaused=false │
│ │ │ Timer 回调继续计数 │
│ │ │ │
│ ... 练习完成 ... │ │ │
│ │ │ _totalElapsedSec >= │
│ │ │ totalSec │
│ │ │ timer.cancel() │
│ │ onComplete() │ │
│ │←────────────────────│ │
│ │ │ │
│ │ setState: │ │
│ │ _isRunning=false │ │
│ │ _isPaused=false │ │
│ │ build → setupView │ │
│ │ │ │
│ │ showDialog(完成弹窗) │ │
│ ✨ "你真棒!" │ │ │
│←─────────────────│ │ │
│ │ │ │
│ 点击"结束" │ │ │
│─────────────────→│ │ │
│ │ Navigator.pop │ │
│ │ │ │
十二、与系统生命周期的交互(鸿蒙特化)
在 HarmonyOS 上,应用可能因系统资源回收而被后台杀死。考虑以下场景:
用户启动 5 分钟呼吸练习 (RUNNING)
→ 系统来电,应用进入后台
→ 鸿蒙 ArkUI 框架可能暂停 Flutter Engine
→ 5 分钟后,用户切回应用
→ Timer 的剩余时间已经不准了
当前实现的处理 :未做特殊处理------Flutter 的 Timer.periodic 在 Engine 暂停期间不会触发回调,恢复后继续从暂停位置计时。这意味着用户的练习时间被"冻结"在后台,总时长会超过预期。对于以放松为目的的呼吸练习,这种"后台不计时"的行为反而是合理的 UX------用户不会因为接了个电话就错过了整段练习。
如果未来需要精确计时,应改用 Stopwatch + DateTime 差值计算,而不是依赖 Timer 的周期回调累加。
十三、代码度量
| 指标 | 数值 |
|---|---|
| BreathePage 总行数 | 229 |
| 状态管理相关代码 | ~61 行 |
| BreathingCircle 总行数 | 185 |
| 核心状态字段 | 4 个 (_isRunning, _isPaused, _remainingSeconds, _currentPhase) |
| 状态转换路径 | 7 条 |
| 外部依赖(状态管理库) | 0 |
| AlertDialog 弹窗 | 2 个(退出确认 + 完成弹窗) |
| GlobalKey 跨组件调用 | 1 处 |
| Timer 实例 | 1 个(由 BreathingCircle 管理) |
十四、小结
用 61 行状态管理代码 + 2 个布尔标志,构建了一个完整的呼吸会话生命周期:
- 4 态状态机:SETUP / RUNNING / PAUSED / COMPLETED,转换路径清晰,无非法状态。
- 暂停机制:Timer 回调短路而非 cancel + recreate------更简单、更安全、性能差异可忽略。
- 退出确认:AlertDialog 二次确认防止误触,widget 树卸载自动清理 Timer。
- 完成弹窗:先重置状态再弹窗,保持页面干净;emoji + 鼓励文案提升用户体验。
- 跨组件协调:GlobalKey 连接 BreathePage 和 BreathingCircle,职责分离,封装完好。
最简单的解决方案往往最可靠------不需要状态机库,不需要复杂的事件系统,不需要额外的 package,两个 bool 已经足够。
作者简介 :E-Brufen Dev,Flutter & 鸿蒙开发者,专注于跨平台移动应用开发与心理健康数字化,项目地址:AtomGit - E-Brufen。