AtomGit Flutter 鸿蒙客户端:呼吸练习的完整生命周期

一个健壮的呼吸练习需要处理:开始、暂停、恢复、结束确认、超时完成。本文详解 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 的原因:

  1. UI 分支极简_isRunning ? sessionView : setupView 一行搞定 body 切换;_isPaused ? playIcon : pauseIcon 一行搞定按钮图标。如果用 enum,需要 4 路 switch。
  2. 暂停逻辑是"层叠"而非"并列":PAUSED 是 RUNNING 的子状态------暂停时界面仍然是 sessionView,只是动画和计时暂停。这种层级关系用 bool 表达比 enum 更自然。
  3. 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 调用中完成三件事:

  1. 状态跃迁_isRunning = true + _isPaused = false,从 SETUP 进入 RUNNING。
  2. 计时器初始化_remainingSeconds = _selectedMinutes * 60,将分钟转为秒数。这个值在 UI 上实时显示为 "剩余 mm:ss"
  3. 偏好持久化 :通过 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 在暂停期间仍然每秒触发一次回调。每次回调执行:

  1. 读取 _isPaused 字段(bool 比较,1 次内存访问)
  2. 条件跳转(if 分支预测几乎总是命中)
  3. 函数返回

在任意现代设备上(包括低端鸿蒙设备),这个开销在微秒级别以下。即使 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------会出现:

  1. 弹窗时 BreathingCircle 仍在 widget 树中
  2. BreathingCircle 的 Timer 已 cancel(在 _startTimer 回调中 timer.cancel()
  3. 但 AnimationController 仍持有 vsync 资源
  4. 用户关闭弹窗后,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_startTimeronComplete 调用前已经 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();  // 公开方法
    }
  });
}

_totalElapsedSecondsBreathingCircleState 的私有字段,外部不可访问------这是正确的封装。如果父组件需要读取这些数据,应该通过回调(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

相关推荐
Zeeland1 小时前
构建护城河:自学习 Agent
学习
brave_zhao2 小时前
nginx的进程架构
java·学习·nginx
枫零NET2 小时前
跟着OpenCode学习Pi Coding Agent-05-Agent的类型系统
人工智能·学习
Day(AKA Elin)2 小时前
【Day】MTP(Multi Token Prediction)技术学习
python·深度学习·学习·llama
枫零NET2 小时前
跟着OpenCode学习Pi Coding Agent-06-Agent循环
学习
绝世番茄2 小时前
登录表单布局:从 Column 到完整表单 —— 鸿蒙 HarmonyOS ArkTS 原生学习指南
华为·list·harmonyos·鸿蒙
不才难以繁此生2 小时前
HarmonyOS ArkTS 实战|中式美食用户行为数据:Preferences 持久化、搜索缺口与候选排序
harmonyos·arkts·用户行为分析·preferences·中式美食·arkui v2
智塑未来3 小时前
鸿蒙系统对比安卓、iOS,核心优势是什么?
android·ios·harmonyos
小弥儿3 小时前
GitHub今日热榜 | 2026-07-09:OfficeCLI 以 92% 日增幅入围
学习·开源·github