AtomGit Flutter 鸿蒙客户端:呼吸练习的三种科学模式

呼吸练习是 E-Brufen 最复杂的交互模块。本文详解三种呼吸模式的领域建模、选择 UI、动画驱动与设置持久化,涵盖 Dart 3 Record 类型、AnimationController 状态机、Timer 生命周期管理等关键技术点。


一、整体架构

在深入细节之前,先俯瞰呼吸模块的依赖层级:

复制代码
┌─────────────────────────────────────────────────────────┐
│                     BreathePage                          │
│  ┌──────────────┐  ┌──────────────┐  ┌───────────────┐  │
│  │ 模式选择 UI  │  │ 时长选择 UI   │  │  开始/退出     │  │
│  │ RadioListTile│  │ ChoiceChip   │  │  ElevatedBtn  │  │
│  └──────┬───────┘  └──────┬───────┘  └───────┬───────┘  │
│         │                 │                   │          │
│         ▼                 ▼                   ▼          │
│  ┌──────────────────────────────────────────────────┐    │
│  │              AppSettings (Hive CE)               │    │
│  │   breatheMode: String   breatheMinutes: int      │    │
│  └──────────────────────────────────────────────────┘    │
│                          │                               │
│                          ▼                               │
│  ┌──────────────────────────────────────────────────┐    │
│  │             BreathingCircle (StatefulWidget)     │    │
│  │  ┌────────────────┐  ┌────────────────────────┐  │    │
│  │  │ AnimationCtrl  │  │  Timer (periodic 1s)   │  │    │
│  │  │ forward/rev    │  │  阶段切换 / 总时长检查  │  │    │
│  │  └───────┬────────┘  └───────────┬────────────┘  │    │
│  │          │                       │               │    │
│  │          ▼                       ▼               │    │
│  │  ┌──────────────────────────────────────────┐    │    │
│  │  │          BreathePattern                   │    │    │
│  │  │  List<({BreathePhase, int})>  sequence   │    │    │
│  │  └──────────────────────────────────────────┘    │    │
│  └──────────────────────────────────────────────────┘    │
└─────────────────────────────────────────────────────────┘

分层职责:

文件 职责
数据模型 breathing_circle.dart enum BreathePhase, class BreathePattern, class BreathePatterns
动画组件 breathing_circle.dart BreathingCircle widget: AnimationController + Timer 状态机
页面编排 breathe_page.dart BreathePage: 模式/时长选择, 会话生命周期
持久化 settings.dart AppSettings: Hive box 读写, 默认值兜底

二、呼吸模式的领域建模

每种呼吸法本质上是一个阶段序列(phase sequence)------即按照固定顺序依次执行吸气、屏息、呼气等动作,每个动作持续指定秒数。这个模型的巧妙之处在于:用一个极简的数据结构表达了所有呼吸变体。

2.1 BreathePhase 枚举

dart 复制代码
// lib/widgets/breathing_circle.dart

enum BreathePhase { inhale, hold, exhale, holdAfterExhale }

四个阶段覆盖了常见呼吸法的全部动作:

枚举值 中文 含义
inhale 吸气 通过鼻子缓慢吸入
hold 屏住 吸气后保持(肺充盈)
exhale 呼气 通过嘴巴缓慢呼出
holdAfterExhale 屏住 呼气后保持(肺排空)

设计决策 ────────────────────────────

为什么单独定义 holdAfterExhale 而不是复用 hold?因为盒式呼吸的四个阶段需要在动画上有不同的表现:吸气和呼气分别对应动画的正向和反向播放,而两个 hold 阶段都需要保持当前动画位置不变。如果合并为一个 hold,动画控制器将无法区分"屏在吸满状态"还是"屏在呼空状态"。

─────────────────────────────────────────

2.2 BreathePattern 类与 Dart 3 Record 类型

dart 复制代码
class BreathePattern {
  final String name;
  final List<({BreathePhase phase, int seconds})> sequence;

  const BreathePattern(this.name, this.sequence);

  int get totalCycleSeconds =>
      sequence.fold(0, (sum, s) => sum + s.seconds);

  String labelFor(BreathePhase phase) => switch (phase) {
        BreathePhase.inhale          => '吸气',
        BreathePhase.hold            => '屏住',
        BreathePhase.exhale          => '呼气',
        BreathePhase.holdAfterExhale => '屏住',
      };
}

★ Insight ─────────────────────────────────────

Dart 3 的 Record 类型 ({BreathePhase phase, int seconds}) 在这里非常优雅------不需要为"阶段 + 时长"创建一个新的小类。Record 是匿名的、不可变的、值相等的,刚好满足这个场景。如果用传统方式定义一个 PhaseEntry 类,会产生大量样板代码(构造函数、字段、== 运算符覆盖),而 Record 一行搞定。

Record 的适用条件判断清单:

  1. 数据只有 2-3 个字段
  2. 不需要类方法(行为附着在数据上)
  3. 只需要在当前模块内使用,不跨层传递
  4. 不需要 JSON 序列化

如果以上任意一条不满足,就应该用命名类代替 Record。

─────────────────────────────────────────────────

labelFor 使用 Dart 3 的 switch 表达式(exhaustiveness checked):编译器会检查是否覆盖了所有枚举值,如果遗漏某个 case 会直接报错------这是比传统 switch 语句安全得多的地方。

2.3 三种预设模式

dart 复制代码
class BreathePatterns {
  // 4-7-8 呼吸法(美国海军海豹突击队推荐入睡法)
  static final BreathePattern fourSevenEight = BreathePattern(
    '4-7-8 呼吸法',
    [
      (phase: BreathePhase.inhale, seconds: 4),
      (phase: BreathePhase.hold,   seconds: 7),
      (phase: BreathePhase.exhale, seconds: 8),
    ],
  );

  // 盒式呼吸(特种部队压力管理技术)
  static final BreathePattern box = BreathePattern(
    '盒式呼吸',
    [
      (phase: BreathePhase.inhale,          seconds: 4),
      (phase: BreathePhase.hold,            seconds: 4),
      (phase: BreathePhase.exhale,          seconds: 4),
      (phase: BreathePhase.holdAfterExhale, seconds: 4),
    ],
  );

  // 放松呼吸(最简单的减压法)
  static final BreathePattern relaxed = BreathePattern(
    '放松呼吸',
    [
      (phase: BreathePhase.inhale, seconds: 4),
      (phase: BreathePhase.exhale, seconds: 6),
    ],
  );

  static final List<BreathePattern> all = [fourSevenEight, box, relaxed];
}

2.4 三种模式的科学背景

4-7-8 呼吸法 (Dr. Andrew Weil, 哈佛医学院)
属性
单周期 19 秒
阶段数 3
核心原理 长呼气激活副交感神经,降低心率
适用场景 入睡困难、焦虑发作、夜间惊醒

生理机制: 呼气时长(8秒)远大于吸气时长(4秒),这触发了哺乳动物的"潜水反射"------身体感知到长呼气后会自动降低心率、放松血管。中间的 7 秒屏息让肺部充分进行气体交换,提高血氧饱和度。Weil 博士的研究表明,持续练习 4-8 周后,入睡时间平均缩短 55%。

注意: 4-7-8 模式没有 holdAfterExhale 阶段------呼气结束后直接进入下一个吸气,形成一个三拍循环。

属性
单周期 16 秒
阶段数 4
核心原理 四相等长带来强烈的节律感和掌控感
适用场景 高压会议前、公开演讲、战术决策

生理机制: 四个阶段各 4 秒,形成完美的正方形节律。这种对称性不仅容易记忆和跟随,更重要的是给大脑一个"可预测"的信号------在高压环境中,可预测性本身就是抗焦虑剂。SEALs 队员在进入高危任务前使用此技术,将心率从 120+ bpm 降至 80 bpm 以下。

盒式呼吸是唯一使用全部四个 BreathePhase 值的模式,因此它是最完整的实现。

放松呼吸 (Diaphragmatic Breathing)
属性
单周期 10 秒
阶段数 2
核心原理 呼气长于吸气,触发副交感神经优势
适用场景 日常减压、工作间隙、冥想入门

生理机制: 这是最简单的呼吸模式------只有吸气和呼气两个阶段。关键在于呼气(6秒)比吸气(4秒)长 50%。迷走神经在长呼气期间被激活,向心脏发送"减速"信号。没有屏息阶段降低了认知负荷,适合初次接触呼吸练习的用户。

三种模式的周期对比:

复制代码
4-7-8:   [吸气 4s] [屏住 7s] [呼气 8s]         总 19s
         ████      ███████    ████████

盒式:    [吸气 4s] [屏住 4s] [呼气 4s] [屏住 4s] 总 16s
         ████      ████      ████      ████

放松:    [吸气 4s] [呼气 6s]                      总 10s
         ████      ██████

三、动画状态机:BreathingCircle 内部原理

BreathingCircle 是呼吸动画的核心组件,它同时管理动画控制器定时器两个时间源------这带来了微妙的状态同步问题。

3.1 AnimationController 与 Timer 的双时间源协作

复制代码
                ┌─────────────────────┐
                │       Timer         │
                │  periodic: 1s tick  │
                └──────────┬──────────┘
                           │
              ┌────────────▼────────────┐
              │   每秒检查:              │
              │   1. 总时长是否到期?    │
              │   2. 当前阶段是否结束?  │
              └────────────┬────────────┘
                           │
              ┌────────────▼────────────┐
              │ 阶段切换时:              │
              │   inhale → forward()    │
              │   hold   → stop()       │
              │   exhale → reverse()    │
              │   holdA  → stop()       │
              └─────────────────────────┘

关键代码:

dart 复制代码
void _animatePhase(int durationSec) {
  _controller.stop();
  final isInhale =
      widget.pattern.sequence[_phaseIndex].phase == BreathePhase.inhale;
  if (isInhale) {
    _controller.forward(from: 0.0);   // 球从小变大
  } else {
    _controller.reverse(from: 1.0);   // 球从大变小
  }
  _startTimer(durationSec);
}

设计要点: forward(from: 0.0)reverse(from: 1.0) 确保每次阶段切换时动画从端点重新开始,而不是从中间某个位置继续。await _controller.forward() 的返回值无法用来检测阶段切换,因为动画时长(1秒)不等于阶段时长(4-8秒),所以必须用独立的 Timer 来控制阶段节奏。

3.2 Timer 阶段循环逻辑

dart 复制代码
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));
    }
  });
}

状态流转图:

复制代码
     ┌──────────┐    onComplete     ┌──────────┐
     │  RUNNING │──────────────────▶│   DONE   │
     └────┬─────┘                   └──────────┘
          │
    ┌─────▼─────┐
    │  _secondsInPhase++            _phaseIndex++
    │  _totalElapsedSeconds++       _animatePhase(next)
    │                              onPhaseChange(label)
    └───────────┘

四、模式选择 UI:RadioListTile

dart 复制代码
// breathe_page.dart --- _buildSetupView()

...BreathePatterns.all.map((pattern) => Card(
  child: RadioListTile<BreathePattern>(
    value: pattern,
    groupValue: _selectedPattern,
    title: Text(pattern.name),
    subtitle: Text(
      pattern == BreathePatterns.fourSevenEight
        ? '吸气4秒 → 屏住7秒 → 呼气8秒'
        : pattern == BreathePatterns.box
          ? '吸气4秒 → 屏住4秒 → 呼气4秒 → 屏住4秒'
          : '吸气4秒 → 呼气6秒'
    ),
    onChanged: (v) => setState(() => _selectedPattern = v!),
    shape: RoundedRectangleBorder(
      borderRadius: BorderRadius.circular(16)),
  ),
))

为什么选择 RadioListTile 而不是其他组件?

候选方案 优点 缺点
RadioListTile 原生单选行为,内置 title/subtitle,支持 shape 圆角 无法完全自定义布局
ListTile + Radio 完全自由布局 手写 groupValue 逻辑,代码冗余
SegmentedButton Material 3 紧凑风格 不支持 subtitle,信息密度不够

RadioListTile<BreathePattern> 的泛型参数直接使用 BreathePattern 对象作为值------这简化了 onChanged 回调,无需通过名称字符串反查模式。

常见陷阱 ────────────────────────────

groupValue 必须与 value 使用同一对象引用(或通过 == 可比较的对象)。由于 BreathePattern 没有覆盖 == 运算符,这里的比较依赖于 Dart 的默认引用相等。当前代码可以工作是因为 _selectedPattern 直接赋值为 BreathePatterns.all 中的某个成员,引用是相同的。但如果将来从 Hive 反序列化重建出一个新的 BreathePattern 对象,RadioListTile 将无法正确高亮选中项。建议: 要么覆盖 ==hashCode,要么通过模式名称字符串来管理选中状态。

─────────────────────────────────────────


五、时长选择 UI:ChoiceChip

dart 复制代码
final List<int> _durations = [1, 3, 5, 10];

Row(
  children: _durations.map((d) => Padding(
    padding: const EdgeInsets.only(right: 8),
    child: ChoiceChip(
      label: Text('$d 分钟'),
      selected: _selectedMinutes == d,
      onSelected: (_) => setState(() => _selectedMinutes = d),
    ),
  )).toList(),
)

ChoiceChip 提供"切换按钮"式的单选体验,比 RadioListTile 更适合紧凑的时长选择。关键区别:

维度 RadioListTile ChoiceChip
空间占用 整行宽度 自适应内容宽度
选中视觉 圆点填充 背景色 + 勾选图标
适用场景 较长的描述性选项 短标签(数字、单词)
布局方向 垂直列表 水平排列

为什么选择 1/3/5/10 分钟?

  • 1 分钟:快速体验,新用户低门槛入门
  • 3 分钟:默认值,研究表明 3 分钟即可产生可测量的心率变异度(HRV)改善
  • 5 分钟:推荐练习时长,足以完成 15-30 个呼吸周期
  • 10 分钟:深度练习,适合有经验的用户

常见陷阱 ────────────────────────────

ChoiceChiponSelected 回调参数是 bool(表示被选中还是取消),而非所选的值。这意味着你需要通过闭包捕获当前迭代中的 d 值,而不是从 onSelected 的回调参数中读取。正确写法:

dart 复制代码
onSelected: (_) => setState(() => _selectedMinutes = d),  // ✅ 闭包捕获 d

错误写法:

dart 复制代码
onSelected: (selected) => setState(() => _selectedMinutes = selected),  // ❌ selected 是 bool

─────────────────────────────────────────


六、设置持久化:Hive CE 实现

6.1 AppSettings 存储层

dart 复制代码
// lib/data/settings.dart

import 'package:hive_ce_flutter/hive_flutter.dart';

class AppSettings {
  static const String _boxName = 'settings';
  static const String _keyBreatheMode = 'breatheMode';
  static const String _keyBreatheMinutes = 'breatheMinutes';

  Box? _box;

  Future<void> init() async {
    _box = await Hive.openBox(_boxName);
  }

  bool get isReady => _box != null && _box!.isOpen;

  String get breatheMode {
    if (_box == null || !_box!.isOpen) return '盒式呼吸';
    return _box!.get(_keyBreatheMode, defaultValue: '盒式呼吸');
  }

  set breatheMode(String v) {
    if (_box != null && _box!.isOpen) _box!.put(_keyBreatheMode, v);
  }

  int get breatheMinutes {
    if (_box == null || !_box!.isOpen) return 3;
    return _box!.get(_keyBreatheMinutes, defaultValue: 3);
  }

  set breatheMinutes(int v) {
    if (_box != null && _box!.isOpen) _box!.put(_keyBreatheMinutes, v);
  }
}

6.2 读写时序

复制代码
  应用启动
     │
     ▼
  AppSettings.init()  ──▶  Hive.openBox('settings')
     │
     ▼
  BreathePage.initState()
     │
     ├── 读取 breatheMode ──▶ _selectedPattern
     │    (fallback: '盒式呼吸')
     │
     └── 读取 breatheMinutes ──▶ _selectedMinutes
          (fallback: 3)
     │
     ▼
  用户点击"开始练习"
     │
     ▼
  _startSession()
     │
     ├── settings.breatheMode = _selectedPattern.name     ──▶  Hive.put()
     └── settings.breatheMinutes = _selectedMinutes       ──▶  Hive.put()

6.3 为什么"开始练习时"才保存而非"选择时"保存?

这是一个有意的设计决策:

策略 写入频率 优点 缺点
选择时保存 每次切换 即时持久化,不怕崩溃 频繁 I/O,用户可能只是浏览选项
开始时保存 每次会话 减少无效写入 选择后若未开始即退出则丢失

选择"开始时保存"的理由:

  1. 避免 setState 中触发 Hive 写入------虽然 Hive 是同步 put,但在低端设备上累积的微小延迟会影响滑动流畅度
  2. 用户的浏览行为(切换 RadioListTile 对比模式)不应产生持久化副作用
  3. 防止写入中间状态(例如用户快速连续切换三个模式)

常见陷阱 ────────────────────────────

使用 Hive CE 时,_boxinit() 完成前为 null。如果在 init() 之前访问任何 getter,代码会走到 fallback 分支返回默认值------这不会崩溃,但可能导致状态不一致。确保在 main()await settings.init() 之后才 runApp()

dart 复制代码
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final settings = AppSettings();
  await settings.init();       // ← 必须 await
  runApp(MyApp(settings: settings));
}

─────────────────────────────────────────

6.4 恢复上次设置(initState)

dart 复制代码
@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;
}

这里使用 firstWhere + orElse 提供了双重保障:

  • 如果之前保存的模式名称存在于 all 列表中,返回对应模式
  • 如果名称不匹配(例如未来版本删除了某个模式),回退到 box(盒式呼吸)

七、完整的会话生命周期

复制代码
  ┌─────────┐   选择模式+时长    ┌─────────┐   点击开始    ┌───────────┐
  │  SETUP  │───────────────────▶│  SETUP  │──────────────▶│  RUNNING  │
  │  (初始)  │                   │  (调整)  │               │  (动画中)  │
  └─────────┘                   └─────────┘               └─────┬─────┘
       ▲                                                         │
       │                                          ┌──────────────┼──────────────┐
       │                                          │              │              │
       │                                    时间到/退出     暂停⏸ / 继续▶   退出
       │                                          │              │              │
       │                                          ▼              ▼              │
       │                                    ┌─────────┐   ┌──────────┐         │
       └────────────────────────────────────│  完成   │   │  PAUSED  │─────────┘
                                            │ 弹窗    │   │  (冻结)   │  退出
                                            └─────────┘   └──────────┘

暂停/恢复的实现:

dart 复制代码
// BreathingCircleState
void pause()  => setState(() => _isPaused = true);
void resume() => setState(() => _isPaused = false);

// Timer 回调中的检查
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
  if (_isPaused) return;  // ← 暂停时跳过所有状态推进
  // ...
});

暂停时 AnimationController 不停止------它已经处于 forwardreverse 完成状态(因为动画时长只有 1 秒,阶段切换间已经跑完)。真正被冻结的是 Timer 回调,跳过了 _secondsInPhase++_totalElapsedSeconds++


八、性能考量

8.1 Timer 精度

Timer.periodic(Duration(seconds: 1), ...) 的精度约为 ±10ms 到 ±50ms,取决于设备负载。对于 4-19 秒的阶段时长来说,这个误差完全可以忽略。但如果将来扩展到快速呼吸模式 (例如 1 秒吸 / 1 秒呼),累积误差可能变得明显。解决方案是使用 Stopwatch 记录实际经过时间,而非依赖 Timer 的 tick 计数。

8.2 AnimationController 资源

dart 复制代码
@override
void dispose() {
  _timer?.cancel();
  _controller.dispose();
  super.dispose();
}

AnimationController 持有 Ticker,而 Ticker 会注册到 SchedulerBinding。如果忘记 dispose(),Ticker 会继续尝试每帧回调,即使 Widget 已经从树中移除------这会导致内存泄漏和后台 CPU 消耗。

8.3 setState 粒度

当前代码在 _startTimer 中每秒调用 onPhaseChangeBreathePage._onPhaseChangesetState,这会重建整个 BreathePage 的 UI。对于呼吸练习这种低频更新(每秒一次),影响可以忽略。但如果将来添加更多实时指示器(如心率曲线),应考虑将 _currentPhase 下沉到独立的 ValueNotifier,或者在 BreathingCircle 内部用 AnimatedBuilder 局部刷新。

8.4 Hive 写入开销

Hive CE 的 put 操作是同步的,但在主 isolate 中执行。对于字符串和 int 类型的数据,写入耗时 < 1ms(取决于存储引擎)。当前设计在每次会话开始时只执行两次 put,完全可以接受。


九、常见开发陷阱总结

编号 陷阱 影响 解决方案
1 BreathePattern== 覆盖,RadioListTile 可能高亮失效 反序列化后 UI 状态错误 覆盖 == / hashCode,或按 name 比较
2 ChoiceChip.onSelected 参数是 bool 不是值 选错时长 通过闭包捕获正确的 d
3 Hive box 在 init() 前为 null,访问返回默认值而非真实数据 静默状态不一致 确保 await settings.init()runApp() 之前
4 忘记 _timer?.cancel() + _controller.dispose() 内存泄漏 dispose 中双清理
5 AnimationControllerTimer 不同步 动画与文字不匹配 Timer 驱动阶段切换,AnimationController 只负责视觉
6 WidgetsBinding.instance.addPostFrameCallback 中未检查 mounted 页面快速退出时崩溃 回调中检查 if (mounted)

十、可扩展方向

当前数据模型为未来的扩展预留了清晰的空间:

  1. 自定义呼吸模式 :用户可创建自己的 List<({BreathePhase, int})> 序列,存入 Hive(需要序列化支持,此时应改用命名类替代 Record)
  2. 呼吸节奏音效 :在 _startTimer 的阶段切换点触发音频提示
  3. 历史记录 :session 完成后将模式、时长、完成时间写入 Box<List<int>>,生成周报
  4. 振动反馈:利用 HarmonyOS 的振动 API 在阶段切换时给予触觉提示

小结

三个简单的数据结构(BreathePhase 枚举 + BreathePattern 类 + Dart 3 Record 序列)撑起了整个呼吸练习功能。好的数据建模让后续的动画、UI 和持久化逻辑变得自然流畅。关键架构原则:

  • 数据与表现分离BreathePattern 是纯数据,BreathingCircle 负责动画和计时
  • 双时间源协作:Timer 控制阶段节奏,AnimationController 控制视觉表现
  • 延迟持久化:只在会话开始时保存设置,避免无效写入
  • 防御性默认值:每个 getter 都有 fallback,确保 null-safety 下的健壮性

作者简介 :E-Brufen Dev,Flutter & 鸿蒙开发者,专注于跨平台移动应用开发与心理健康数字化,项目地址:AtomGit - E-Brufen

相关推荐
三品吉他手会点灯1 小时前
嵌入式机器学习 - 学习笔记1.0.2 - 学习路线导览:从机器学习基础到 TinyML应用
人工智能·笔记·嵌入式硬件·学习·机器学习
FrameNotWork2 小时前
HarmonyOS 6.1 Tabs组件深度定制 — 底部导航栏从入门到踩坑
华为·交互·harmonyos
yuanlaile2 小时前
前端转 Flutter 自学完整规划,避开跨栈思维转换误区
前端·flutter·flutter跨平台开发
姑苏倾城客2 小时前
Flutter中,html 与 dart 桥接沟通
javascript·flutter·html
不才难以繁此生2 小时前
HarmonyOS ArkUI NavPathStack 实战:中式美食详情页多入口返回栈怎么设计
harmonyos·arkts·arkui·navigation·中式美食·navpathstack·路由栈
不才难以繁此生3 小时前
ArkTS 本地数据库实战|中式美食购物清单:按菜谱来源合并食材数量
harmonyos·arkts·repository·rdb·中式美食·购物清单·本地数据库
ZZZMMM.zip3 小时前
基于鸿蒙HarmonyOS NEXT开发AI书籍推荐应用:智能阅读新体验与鸿蒙Flutter框架跨端实践
人工智能·flutter·华为·harmonyos·鸿蒙
不才难以繁此生3 小时前
HarmonyOS ResourceStr 实战:中式美食菜谱封面怎么统一注册、兜底和缓存
harmonyos·arkts·image·arkui·中式美食·resourcestr·图片缓存
三品吉他手会点灯3 小时前
嵌入式机器学习 - 学习笔记1.0.1 - 从 Alexa 到 TinyML:为什么机器学习开始跑进微控制器?
人工智能·笔记·嵌入式硬件·学习·机器学习