当应用功能增长时——如何通过模块化保持代码的可维护性

目录

  1. 引言:从"一个 lib 打天下"说起
  2. 什么时候该考虑模块化
  3. 模块拆分三原则
  4. Flutter 中实现模块化的三种方式
  5. E-Brufen 项目现状分析
  6. Feature Module 拆分方案设计
  7. 实战:拆分 shared_core 共享内核
  8. 实战:拆分三个 Feature Module
  9. 模块间通信机制
  10. 依赖注入与装配层
  11. 构建速度与协作效率对比
  12. 鸿蒙平台兼容性说明
  13. 模块化的陷阱与过早优化
  14. 总结
  15. 作者简介
  16. 系列索引

1. 引言:从"一个 lib 打天下"说起

每一个 Flutter 开发者都经历过这样的时刻——

项目刚开始时,lib/ 目录下面清清爽爽,三个文件夹,五六个文件,main.dart 一目了然。新增一个功能,在 pages/ 下面新建一个文件,十分钟写完,心里还觉得"Flutter 真简单"。

然后三个月过去了。

需求追加:情绪日记要支持数据导出。白噪音要支持用户上传自定义音频。呼吸练习要加引导教程。主页要加签到打卡。设置页要改。主题要支持暗色模式。通知推送要集成……

打开项目,lib/ 下面已经塞了 40 个文件。每个文件之间互相引用,import 语句横跨三四个目录层级。修改一个工具函数,三个页面炸了——因为它们各自拷贝了一份类似的实现,而不是从一个公共位置引用。新人入职,要在 IDE 里搜索半天才找到某个功能的入口文件在哪里。

这就是典型的"单体 Flutter 应用"困境。它的问题不是代码质量差,而是认知负荷失控——代码的物理组织结构跟不上业务逻辑的增长速度。

在这篇文章中,我们将以 E-Brufen(一个在 AtomGit 上开源的 Flutter 鸿蒙情绪健康应用)为真实案例,展示如何将单体应用拆分为清晰、独立、可测试的 Feature Module。我们会从拆分原则讲到具体代码,从模块通信讲到构建效率,同时坦诚地讨论:什么时候不应该拆分


2. 什么时候该考虑模块化

不是所有项目都需要模块化。一个 Calculator 应用、一个简单的天气查询页面,如果总共只有 10 个文件,强行拆成 5 个模块不叫工程实践,叫过度设计。

我们建议在同时满足以下至少三个条件时考虑 Feature Module 拆分:

条件 描述 E-Brufen 的实际情况
功能域大于 3 个 应用有多个独立的业务场景 情绪日记、呼吸练习、白噪音,恰好 3 个
单文件超过 300 行 多个页面文件膨胀严重 soundscape_page.dart 已超过 400 行
多人协作 不止一个人提交代码 项目在 AtomGit 开源协作,即将多人参与
需要独立测试 某个模块需要独立运行验证 呼吸球动画需要独立于整个应用调试
未来可能独立发布 模块可能作为子包发布 呼吸模式引擎可复用到其他健康应用
构建时间开始变长 改一行代码要等几十秒 Flutter 增量编译尚可,但随着代码增多会恶化

E-Brufen 目前已经命中了前 4 条。最直观的信号是:你发现自己越来越频繁地跨目录跳转文件。这说明代码之间的关联不是模块内部的强关联,而是跨功能域的散乱耦合。


3. 模块拆分三原则

在动手拆分之前,我们需要先建立原则。没有原则的拆分比不拆分更危险。

3.1 高内聚、低耦合(The Golden Rule)

这是软件工程的老生常谈,但在模块化场景下有具体的含义:

  • 高内聚:一个模块内的所有文件,应该为同一个功能服务。breathe 模块里的所有代码,都应该与呼吸练习有关。不要因为呼吸页面用到了某个动画组件,就顺手把那个组件放在 breathe 里——除非它只在呼吸功能中使用。
  • 低耦合:一个模块对另一个模块的依赖,应该仅限于接口(抽象),而不是具体实现。soundscape 模块需要播放音频,但它不应该直接依赖 breathe 模块里的某个工具类。

3.2 按功能域划分(Feature-First)

传统的按技术角色分层(models/views/controllers/)在小项目中很舒服,但功能一旦增多,你会发现改一个功能需要同时修改分布在三个目录里的文件。Feature Module 提倡的是:把同一个功能的所有代码放在一起
在这里插入图片描述

// ❌ 按技术分层——改一个功能要跨目录
lib/
  models/
    mood_entry.dart
    breathe_pattern.dart
    scene_data.dart
  pages/
    diary_page.dart
    breathe_page.dart
    soundscape_page.dart
  services/
    mood_storage.dart
    audio_player.dart

// ✅ 按功能域划分——每个功能自包含
lib/
  features/
    mood_diary/
      models/mood_entry.dart
      pages/diary_page.dart
      services/mood_storage.dart
    breathe/
      models/breathe_pattern.dart
      pages/breathe_page.dart
      widgets/breathing_circle.dart
    soundscape/
      models/scene_data.dart
      pages/soundscape_page.dart
      services/audio_player.dart
  shared/
    theme/app_theme.dart
    settings/app_settings.dart

3.3 共享内核模式(Shared Kernel)

当一个概念分布在多个模块中时,把它抽到共享层。在 E-Brufen 中:

  • AppTheme —— 所有模块的 UI 都依赖统一的品牌色和字体样式。
  • AppSettings —— 各模块都需要读写用户偏好(定时时长、呼吸模式等)。
  • MoodType 枚举 —— 虽然属于日记模块的领域,但主页的速记功能也引用它,说明它实际上是一个跨模块的共享概念。

这些共享代码构成了 shared_core 模块。它是所有 Feature Module 的公共基础,但只包含"无业务偏好的通用能力"——共享内核里不应该有"呼吸练习的业务逻辑"。


4. Flutter 中实现模块化的三种方式

Flutter / Dart 生态提供了三层粒度的模块化手段,从轻到重依次为:

方式一:目录约定(Folder Convention)

在不改动 pubspec.yaml 的前提下,仅通过目录结构实现逻辑隔离。

lib/
  features/
    mood_diary/
    breathe/
    soundscape/
  shared/

优点:零配置,即时生效,适合小团队快速尝试。
缺点:没有编译隔离——一个 feature 里依然可以随意 import 另一个 feature 的私有文件。依赖方向全靠自觉,缺乏编译期约束。

方式二:Dart Package(推荐)

将每个 Feature Module 拆分为独立的 Dart package,放在项目根目录下的子文件夹中。

project_root/
  packages/
    shared_core/
      pubspec.yaml
      lib/
        src/theme.dart
        src/settings.dart
    feature_mood_diary/
      pubspec.yaml
      lib/
        src/diary_page.dart
    feature_breathe/
      pubspec.yaml
      lib/
        src/breathe_page.dart
    feature_soundscape/
      pubspec.yaml
      lib/
        src/soundscape_page.dart
  lib/               ← 主应用入口(装配层)
    main.dart

每个 package 有自己的 pubspec.yaml,明确声明自己的依赖:

# packages/feature_breathe/pubspec.yaml
name: feature_breathe
description: 呼吸练习模块
publish_to: 'none'
version: 1.0.0

environment:
  sdk: ^3.6.2

dependencies:
  flutter:
    sdk: flutter
  shared_core:
    path: ../shared_core

优点:编译期强制依赖隔离。feature_breathe 不能意外引用 feature_soundscape 的代码,除非在 pubspec.yaml 中显式声明。每个模块可以独立运行测试。依赖 Graph 可视且可控。

缺点:增加了项目配置的复杂度。新增模块需要手动创建 package 结构。IDE 需要刷新 pub get。

方式三:Flutter Package(包含平台能力)

当某个模块需要注册自己的原生插件或 MethodChannel 时,需要使用 Flutter package(而非纯 Dart package)。例如,如果 soundscape 模块的音频播放能力需要单独管理它的原生端代码,它可以是一个 Flutter package:

# packages/feature_soundscape/pubspec.yaml
name: feature_soundscape
description: 白噪音模块(包含原生音频播放)
publish_to: 'none'
version: 1.0.0

environment:
  sdk: ^3.6.2

flutter:
  plugin:
    platforms:
      ohos:
        package: com.ebrufen.audio_player
        pluginClass: AudioPlayerPlugin

E-Brufen 目前采用**方式二(Dart Package)**作为主要手段,将原生能力(OHOS 音频 MethodChannel)保留在主应用的装配层管理,Feature Module 通过共享接口间接调用。这样避免了每个模块都要维护一套原生桥接代码的麻烦。

三种方式的对比总结:

特性 目录约定 Dart Package Flutter Package
配置复杂度 中等 较高
编译隔离
独立发布 不支持 支持 Dart 包发布 支持 pub.dev 发布
原生代码支持
适合场景 原型/小型项目 中型应用(推荐) 需要独立插件的模块
E-Brufen 使用情况 - 当前方案 仅 audio_player channel

5. E-Brufen 项目现状分析

在动手拆分之前,让我们先审视当前 E-Brufen 的代码结构。以下是它在拆分前的 lib/ 目录树:

lib/
├── main.dart                    (128行) 应用入口 + 错误处理
├── models/
│   └── mood_entry.dart          (70行)  情绪数据模型
├── data/
│   ├── mood_storage.dart        (114行) Hive 情绪存储
│   ├── settings.dart            (62行)  用户设置持久化
│   └── ohos_audio.dart          (111行) 鸿蒙音频播放桥接
├── theme/
│   └── app_theme.dart           (87行)  全局主题
├── pages/
│   ├── home_page.dart           (130行) 主页(卡片入口 + 速记)
│   ├── diary/
│   │   └── diary_page.dart      (316行) 情绪日记(三标签页)
│   ├── breathe/
│   │   └── breathe_page.dart    (235行) 呼吸练习(设置 + 练习态)
│   └── soundscape/
│       └── soundscape_page.dart (401行) 白噪音播放
└── widgets/
    ├── breathing_circle.dart    (188行) 呼吸球动画
    ├── home_card.dart           (50行)  主页功能卡片
    ├── mood_chart.dart          (60行)  心情统计图表
    └── mood_picker.dart         (45行)  心情选择器

从依赖图来看,当前存在以下问题:

依赖关系分析(当前单体状态):

main.dart
  └── home_page.dart
       ├── soundscape_page.dart ─── settings.dart
       │     └── ohos_audio.dart ─── MethodChannel('com.ebrufen/audio_player')
       ├── breathe_page.dart ───── settings.dart
       │     └── breathing_circle.dart ─── app_theme.dart
       └── diary_page.dart ──────── mood_storage.dart
             ├── mood_picker.dart
             ├── mood_chart.dart
             └── mood_entry.dart

问题:
1. settings.dart 被 breathe 和 soundscape 同时直接依赖(没有共享层)
2. ohos_audio.dart 只在 soundscape 中使用,但放在 data/ 公共目录下
3. breathing_circle.dart 是 breathe 专属组件,却放在 widgets/ 全局目录
4. home_page.dart 直接知道所有子页面的构造函数签名(强耦合)
5. 没有模块边界——任何文件可以 import 任何其他文件

6. Feature Module 拆分方案设计

基于以上分析,我们设计如下的拆分方案:

e_brufen/                              ← 项目根目录
├── packages/
│   ├── shared_core/                   ← 共享内核
│   │   ├── pubspec.yaml
│   │   ├── lib/
│   │   │   ├── shared_core.dart       ← barrel export
│   │   │   └── src/
│   │   │       ├── theme.dart         ← AppTheme (品牌色、渐变、文字样式)
│   │   │       ├── settings.dart      ← AppSettings (Hive 持久化接口)
│   │   │       └── mood_types.dart    ← MoodType 枚举 + MoodEntry 模型
│   │   └── test/
│   │
│   ├── feature_mood_diary/            ← 情绪日记模块
│   │   ├── pubspec.yaml
│   │   ├── lib/
│   │   │   ├── feature_mood_diary.dart
│   │   │   └── src/
│   │   │       ├── pages/diary_page.dart
│   │   │       ├── widgets/mood_picker.dart
│   │   │       ├── widgets/mood_chart.dart
│   │   │       └── services/mood_storage.dart
│   │   └── test/
│   │
│   ├── feature_breathe/               ← 呼吸练习模块
│   │   ├── pubspec.yaml
│   │   ├── lib/
│   │   │   ├── feature_breathe.dart
│   │   │   └── src/
│   │   │       ├── pages/breathe_page.dart
│   │   │       ├── widgets/breathing_circle.dart
│   │   │       └── models/breathe_pattern.dart
│   │   └── test/
│   │
│   └── feature_soundscape/            ← 白噪音模块
│       ├── pubspec.yaml
│       ├── lib/
│       │   ├── feature_soundscape.dart
│       │   └── src/
│       │       ├── pages/soundscape_page.dart
│       │       ├── models/scene_data.dart
│       │       └── services/audio_player_facade.dart
│       └── test/
│
├── lib/                               ← 主应用(装配层)
│   ├── main.dart                      ← 初始化 + DI + 路由
│   └── pages/home_page.dart           ← 使用 barrel export 引用各模块
│
├── pubspec.yaml
└── ohos/                              ← 鸿蒙原生端(不变)

这个方案的架构图:

┌──────────────────────────────────────────────────────────┐
│                     apps/main (装配层)                     │
│  main.dart: Hive 初始化 → 创建 Settings → 注入到各模块      │
│  home_page.dart: 组合三个 Feature 的入口卡片               │
│  ohos_audio_channel.dart: 原生 MethodChannel 注册          │
└──────┬──────────────┬──────────────┬──────────────────────┘
       │              │              │
       ▼              ▼              ▼
┌────────────┐ ┌────────────┐ ┌────────────────┐
│ mood_diary │ │  breathe   │ │  soundscape    │
│ 情绪日记    │ │  呼吸练习   │ │  白噪音         │
│            │ │            │ │                │
│ DiaryPage  │ │BreathePage │ │SoundscapePage  │
│ MoodPicker │ │Breathing-  │ │SceneData       │
│ MoodChart  │ │ Circle     │ │AudioPlayer-    │
│MoodStorage │ │Breathe-    │ │ Facade         │
│            │ │ Pattern    │ │                │
└──────┬─────┘ └──────┬─────┘ └──────┬─────────┘
       │              │              │
       └──────────────┼──────────────┘
                      │
                      ▼
          ┌──────────────────────┐
          │     shared_core      │
          │  共享内核(零业务逻辑)│
          │                      │
          │  AppTheme            │
          │  AppSettings         │
          │  MoodType / MoodEntry│
          └──────────────────────┘

依赖方向是严格单向的:Feature Modules → shared_core。Feature module 之间禁止互相依赖。主应用装配层负责把一切组装起来。


7. 实战:拆分 shared_core 共享内核

shared_core 是一切的基础。它只放那些"所有模块都需要的纯数据/纯配置",不带任何业务逻辑。

7.1 pubspec.yaml

# packages/shared_core/pubspec.yaml
name: shared_core
description: E-Brufen 共享内核
publish_to: 'none'
version: 1.0.0

environment:
  sdk: ^3.6.2

dependencies:
  flutter:
    sdk: flutter
  hive_ce: ^2.19.0
  hive_ce_flutter: ^2.3.0

7.2 barrel 导出文件

// packages/shared_core/lib/shared_core.dart
export 'src/theme.dart';
export 'src/settings.dart';
export 'src/mood_types.dart';

7.3 主题模块

app_theme.dart 迁移到 shared_core,保持原有内容不变。关键是它现在有了明确的边界——只有 shared_core 才能定义全局主题:

// packages/shared_core/lib/src/theme.dart
import 'package:flutter/material.dart';

class AppTheme {
  AppTheme._();

  // ── 品牌色 ──
  static const Color primaryGreen = Color(0xFF66BB6A);
  static const Color softBlue = Color(0xFF81D4FA);
  static const Color warmOrange = Color(0xFFFFB74D);
  static const Color gentlePurple = Color(0xFFB39DDB);
  static const Color bgCream = Color(0xFFFFF8F0);

  // ── 模块卡片渐变 ──
  static const LinearGradient soundscapeGradient = LinearGradient(
    colors: [Color(0xFF81D4FA), Color(0xFF4FC3F7)],
    begin: Alignment.topLeft,
    end: Alignment.bottomRight,
  );
  static const LinearGradient breatheGradient = LinearGradient(
    colors: [Color(0xFFB39DDB), Color(0xFFCE93D8)],
    begin: Alignment.topLeft,
    end: Alignment.bottomRight,
  );
  static const LinearGradient diaryGradient = LinearGradient(
    colors: [Color(0xFFFFB74D), Color(0xFFFFCC80)],
    begin: Alignment.topLeft,
    end: Alignment.bottomRight,
  );

  // ── 文字样式 ──
  static const TextStyle greetingStyle = TextStyle(
    fontSize: 22, fontWeight: FontWeight.w600,
    color: Color(0xFF5D4037),
  );
  static const TextStyle guideTextStyle = TextStyle(
    fontSize: 28, fontWeight: FontWeight.w500,
    color: Color(0xFF5D4037),
  );

  // ── Material 3 主题 ──
  static ThemeData get materialTheme => ThemeData(
    colorScheme: ColorScheme.fromSeed(
      seedColor: primaryGreen,
      brightness: Brightness.light,
    ),
    useMaterial3: true,
    scaffoldBackgroundColor: bgCream,
    // ... 其余配置与原始版本一致
  );
}

7.4 设置模块

AppSettings 也迁移到 shared_core。它使用 Hive CE(鸿蒙兼容的持久化方案),不依赖任何 Feature Module:

// packages/shared_core/lib/src/settings.dart
import 'package:hive_ce_flutter/hive_flutter.dart';

class AppSettings {
  static const String _boxName = 'settings';
  static const String _keySelectedScene = 'selectedScene';
  static const String _keyTimerDuration = 'timerDuration';
  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 selectedScene =>
      _box?.get(_keySelectedScene, defaultValue: '雨中办公') ?? '雨中办公';
  set selectedScene(String v) => _box?.put(_keySelectedScene, v);

  int get timerDuration =>
      _box?.get(_keyTimerDuration, defaultValue: 30) ?? 30;
  set timerDuration(int v) => _box?.put(_keyTimerDuration, v);

  String get breatheMode =>
      _box?.get(_keyBreatheMode, defaultValue: '盒式呼吸') ?? '盒式呼吸';
  set breatheMode(String v) => _box?.put(_keyBreatheMode, v);

  int get breatheMinutes =>
      _box?.get(_keyBreatheMinutes, defaultValue: 3) ?? 3;
  set breatheMinutes(int v) => _box?.put(_keyBreatheMinutes, v);

  void dispose() => _box?.close();
}

7.5 共享数据模型

MoodType 枚举和 MoodEntry 模型放在 shared_core 中。虽然它们"主要"被 mood_diary 模块使用,但主页的速记功能也需要引用,因此它们实际上是跨模块的共享概念:

// packages/shared_core/lib/src/mood_types.dart
enum MoodType {
  angry(1, '😡', '生气'),
  sad(2, '😢', '难过'),
  tired(3, '😴', '疲惫'),
  calm(4, '😐', '平静'),
  happy(5, '😊', '开心');

  final int value;
  final String emoji;
  final String label;
  const MoodType(this.value, this.emoji, this.label);

  static MoodType fromValue(int v) => MoodType.values.firstWhere(
    (m) => m.value == v,
    orElse: () => MoodType.calm,
  );
}

class MoodEntry {
  final int? id;
  final MoodType moodType;
  final String? note;
  final DateTime createdAt;
  final DateTime updatedAt;

  const MoodEntry({
    this.id,
    required this.moodType,
    this.note,
    required this.createdAt,
    required this.updatedAt,
  });

  MoodEntry copyWith({
    int? id, MoodType? moodType, String? note,
    DateTime? createdAt, DateTime? updatedAt,
  }) => MoodEntry(
    id: id ?? this.id,
    moodType: moodType ?? this.moodType,
    note: note ?? this.note,
    createdAt: createdAt ?? this.createdAt,
    updatedAt: updatedAt ?? this.updatedAt,
  );

  Map<String, dynamic> toJson() => {
    'id': id, 'mood_type': moodType.value,
    'note': note,
    'created_at': createdAt.toIso8601String(),
    'updated_at': updatedAt.toIso8601String(),
  };

  factory MoodEntry.fromJson(Map<String, dynamic> json) => MoodEntry(
    id: json['id'] as int?,
    moodType: MoodType.fromValue(json['mood_type'] as int),
    note: json['note'] as String?,
    createdAt: DateTime.parse(json['created_at'] as String),
    updatedAt: DateTime.parse(json['updated_at'] as String),
  );
}

8. 实战:拆分三个 Feature Module

8.1 feature_mood_diary

情绪日记模块拥有完整的独立性——它有自己的页面、组件、和数据存储服务。

# packages/feature_mood_diary/pubspec.yaml
name: feature_mood_diary
description: 情绪日记模块
publish_to: 'none'
version: 1.0.0

environment:
  sdk: ^3.6.2

dependencies:
  flutter:
    sdk: flutter
  shared_core:
    path: ../shared_core
  hive_ce: ^2.19.0

模块的 barrel 导出让外部仅能访问我们允许的公开 API:

// packages/feature_mood_diary/lib/feature_mood_diary.dart
export 'src/pages/diary_page.dart';
export 'src/services/mood_storage.dart';
// 注意:不导出 MoodPicker 和 MoodChart —— 它们是模块内部实现细节

核心的 MoodStorage 服务被明确放在模块内部,而不是全局的 data/ 目录:

// packages/feature_mood_diary/lib/src/services/mood_storage.dart
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:hive_ce/hive.dart';
import 'package:shared_core/shared_core.dart';

/// 纯 Hive 实现的情绪日记存储(HarmonyOS 兼容)
class MoodStorage extends ChangeNotifier {
  static const _boxName = 'moods';
  Box? _box;
  int _nextId = 1;

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

  Future<void> init() async {
    _box = await Hive.openBox(_boxName);
    if (_box!.isNotEmpty) {
      _nextId = _box!.keys
          .fold<int>(0, (max, k) => k is int ? (k > max ? k : max) : max) + 1;
    }
  }

  Future<int> insert(MoodEntry entry) async {
    final id = _nextId++;
    final data = entry.toJson();
    data['id'] = id;
    await _box?.put(id, jsonEncode(data));
    notifyListeners();
    return id;
  }

  List<MoodEntry> getAll() {
    if (_box == null) return [];
    final entries = <MoodEntry>[];
    for (final key in _box!.keys) {
      if (key is int) {
        final raw = _box!.get(key);
        if (raw is String) entries.add(_parse(key, raw));
      }
    }
    entries.sort((a, b) => b.createdAt.compareTo(a.createdAt));
    return entries;
  }

  List<MoodEntry> getByWeek(DateTime anyDay) {
    final monday = _mondayOf(anyDay);
    final sunday = monday.add(const Duration(days: 7));
    return getAll().where((e) =>
        e.createdAt.isAfter(monday.subtract(const Duration(seconds: 1))) &&
        e.createdAt.isBefore(sunday)).toList();
  }

  Future<void> update(int id, MoodEntry updated) async {
    final data = updated.toJson();
    data['id'] = id;
    await _box?.put(id, jsonEncode(data));
    notifyListeners();
  }

  Future<void> delete(int id) async {
    await _box?.delete(id);
    notifyListeners();
  }

  Map<int, int> getWeeklyMoodCounts(DateTime anyDay) {
    final rows = getByWeek(anyDay);
    final counts = <int, int>{1: 0, 2: 0, 3: 0, 4: 0, 5: 0};
    for (final r in rows) {
      counts[r.moodType.value] = (counts[r.moodType.value] ?? 0) + 1;
    }
    return counts;
  }

  MoodEntry _parse(int id, String raw) {
    final map = jsonDecode(raw) as Map<String, dynamic>;
    return MoodEntry.fromJson(map);
  }

  DateTime _mondayOf(DateTime d) =>
      DateTime(d.year, d.month, d.day - (d.weekday - 1));

  
  void dispose() {
    _box?.close();
    super.dispose();
  }
}

注意一个关键点:MoodStorage 不再访问全局文件路径 ‘…/…/models/mood_entry.dart’,而是直接从 shared_core 导入。依赖关系变得显式和可追踪

8.2 feature_breathe

呼吸练习模块是独立性最强的模块。它有自己的领域模型(BreathePattern)、自己的动画组件(BreathingCircle)、自己的页面。它唯一的外部依赖是 shared_core(获取主题颜色和设置)。

# packages/feature_breathe/pubspec.yaml
name: feature_breathe
description: 呼吸练习模块
publish_to: 'none'
version: 1.0.0

environment:
  sdk: ^3.6.2

dependencies:
  flutter:
    sdk: flutter
  shared_core:
    path: ../shared_core

breathing_circle.dart 中的动画逻辑完全封装在模块内部:

// packages/feature_breathe/lib/src/models/breathe_pattern.dart
import 'package:flutter/material.dart';

enum BreathePhase { inhale, hold, exhale, holdAfterExhale }

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 => '屏住',
  };
}

class BreathePatterns {
  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];
}

8.3 feature_soundscape

白噪音模块有一个特殊之处:它需要调用原生音频播放能力。当前实现中,ohos_audio.dart 通过 MethodChannel 直接与鸿蒙原生端通信。

在模块化方案中,我们将 OhosAudioPlayer 的低级通道调用封装在一个 Facade(外观模式) 中,放在 soundscape 模块内部:

// packages/feature_soundscape/lib/src/services/audio_player_facade.dart
import 'dart:async';
import 'dart:io';
import 'package:flutter/services.dart';

/// 音频播放门面 —— 封装 MethodChannel 细节
///
/// Feature Module 内部通过这个 Facade 访问音频能力,
/// 而不直接暴露 MethodChannel 细节。
class AudioPlayerFacade {
  static const _channel = MethodChannel('com.ebrufen/audio_player');
  static const _timeout = Duration(seconds: 15);

  static final Stream<PlaybackEvent> onPlaybackStateChanged =
      _playbackController.stream;
  static final _playbackController =
      StreamController<PlaybackEvent>.broadcast();

  static bool _handlerRegistered = false;

  static void _ensureHandler() {
    if (_handlerRegistered) return;
    _handlerRegistered = true;
    _channel.setMethodCallHandler((call) async {
      switch (call.method) {
        case 'onPlaybackStateChanged':
          final state = (call.arguments as Map?)?.cast<String, dynamic>();
          final stateStr = state?['state'] as String? ?? '';
          _playbackController.add(PlaybackEvent.fromString(stateStr));
          break;
        default:
          break;
      }
    });
  }

  static Future<void> play(String assetPath) async {
    _ensureHandler();
    final byteData = await rootBundle.load(assetPath);
    final bytes = byteData.buffer.asUint8List();
    final fileName = assetPath.split('/').last;
    final tempFile = File('${Directory.systemTemp.path}/$fileName');
    await tempFile.writeAsBytes(bytes);
    await _channel
        .invokeMethod('play', tempFile.path)
        .timeout(_timeout, onTimeout: () {
      throw PlatformException(
        code: 'TIMEOUT',
        message: '音频初始化超时,请重试',
      );
    });
  }

  static Future<void> stop() async {
    await _channel.invokeMethod('stop').timeout(_timeout, onTimeout: () {});
  }

  static Future<void> setLooping(bool looping) async {
    await _channel.invokeMethod('setLooping', looping);
  }
}

这里有一个重要的架构决策:为什么把音频能力放在 soundscape 模块内部,而不是 shared_core?——因为目前只有白噪音功能需要音频播放。如果将音频播放的 MethodChannel 抽到 shared_core,那么呼吸练习和情绪日记也会"看到"音频接口,这违反了"只放公共依赖"的原则。等到未来有第二个模块需要音频能力时(比如呼吸引导的语音提示),再把音频 Facade 升迁到 shared_core 即可。避免过早抽象,让需求驱动模块边界的移动。


9. 模块间通信机制

模块拆分之后,一个显式的问题浮现了出来:如果两个模块之间需要通信怎么办?

9.1 通信原则

在 Feature Module 架构中,通信遵循以下层级规则:

允许的通信方向:
  Feature A → shared_core ✓(读取共享数据/工具)
  Feature A → Feature B ✗(禁止!会造成循环依赖)

跨模块通信的合法路径:
  Feature A → shared_core(写事件/状态)→ Feature B(读事件/状态)

这意味着:永远不要从一个 Feature Module 直接 import 另一个 Feature Module

9.2 三种通信机制

以下是实际可用的通信方案,按推荐优先级排序:

机制 适用场景 延迟 复杂度 E-Brufen 使用情况
共享状态(shared_core 中的 ChangeNotifier) 模块间共享用户偏好、设置 同步 AppSettings 被多个模块读取
Event Bus(Stream-based) 播放状态变化、通知事件 异步 音频播放状态流
依赖注入 + 接口回调 装配层的模块编排 同步 main.dart 注入 MoodStorage

9.3 实战:通过 shared_core 共享状态

在 E-Brufen 中,最典型的跨模块共享是 AppSettings。当用户在 soundscape 模块中修改了定时时长:

// 在 soundscape_page.dart 中
void _onDurationChanged(int minutes) {
  setState(() {
    _remainingSec = minutes * 60;
    widget.settings.timerDuration = minutes;  // 写入 shared_core
  });
}

用户切换回主页时,主页读取 settings.timerDuration 获得的是最新值——无需 soundscape 和主页直接通信。这就是 shared_core 作为通信中枢的价值。

9.4 实战:Event Bus 用于异步通知

对于异步事件(如"呼吸练习完成"),可以采用基于 Stream 的轻量事件总线:

// packages/shared_core/lib/src/app_events.dart
import 'dart:async';

/// 应用级事件总线
class AppEvents {
  AppEvents._();

  static final _breatheCompleteController =
      StreamController<BreatheCompleteEvent>.broadcast();

  /// 呼吸练习完成事件
  static Stream<BreatheCompleteEvent> get breatheComplete =>
      _breatheCompleteController.stream;

  static void fireBreatheComplete(BreatheCompleteEvent event) {
    _breatheCompleteController.add(event);
  }
}

class BreatheCompleteEvent {
  final String patternName;
  final int durationMinutes;
  final DateTime completedAt;

  const BreatheCompleteEvent({
    required this.patternName,
    required this.durationMinutes,
    required this.completedAt,
  });
}

呼吸模块在练习完成时发送事件:

// 在 breathing_circle.dart 的 _onComplete 中
AppEvents.fireBreatheComplete(BreatheCompleteEvent(
  patternName: widget.pattern.name,
  durationMinutes: widget.totalMinutes,
  completedAt: DateTime.now(),
));

情绪日记模块监听这个事件(可选展示"今天你完成了呼吸练习"),但两个模块之间没有任何直接的 import 依赖。


10. 依赖注入与装配层

拆分之后,main.dart 的角色从"应用入口"升级为"装配层(Assembly Layer)"。它负责:

  1. 初始化所有共享服务(Hive、Settings)
  2. 初始化所有 Feature Module 的内部服务(MoodStorage)
  3. 将服务注入到需要它们的 Widget 中
// lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hive_ce_flutter/hive_flutter.dart';
import 'package:shared_core/shared_core.dart';
import 'package:feature_mood_diary/feature_mood_diary.dart';
import 'package:feature_breathe/feature_breathe.dart';
import 'package:feature_soundscape/feature_soundscape.dart';
import 'pages/home_page.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
    statusBarColor: Colors.transparent,
    statusBarIconBrightness: Brightness.dark,
    statusBarBrightness: Brightness.light,
  ));

  FlutterError.onError = (details) {
    FlutterError.presentError(details);
    debugPrint('[E-Brufen] FLUTTER ERROR: ${details.exceptionAsString()}');
  };

  // ── 装配层逻辑 ──
  String? errorStep;
  try {
    // 第1步:初始化 Hive(shared_core 的底层依赖)
    errorStep = 'Hive init';
    debugPrint('[E-Brufen] Step 1: Hive...');
    try {
      await Hive.initFlutter();
    } catch (e) {
      debugPrint('[E-Brufen] initFlutter failed, using temp dir: $e');
      Hive.init(Directory.systemTemp.path);
    }

    // 第2步:初始化 shared_core 中的 AppSettings
    errorStep = 'Settings init';
    debugPrint('[E-Brufen] Step 2: Settings...');
    final settings = AppSettings();
    await settings.init();

    // 第3步:初始化 mood_diary 模块中的 MoodStorage
    errorStep = 'MoodStorage init';
    debugPrint('[E-Brufen] Step 3: MoodStorage...');
    final moodStorage = MoodStorage();
    await moodStorage.init();

    // ── 启动应用 ──
    errorStep = null;
    debugPrint('[E-Brufen] Launching app!');
    runApp(EBrufenApp(
      settings: settings,
      moodStorage: moodStorage,
    ));
  } catch (e, _) {
    debugPrint('[E-Brufen] INIT FAILED at $errorStep: $e');
    runApp(_ErrorApp(errorStep ?? '?', e.toString()));
  }
}

class EBrufenApp extends StatelessWidget {
  final AppSettings settings;
  final MoodStorage moodStorage;

  const EBrufenApp({
    super.key,
    required this.settings,
    required this.moodStorage,
  });

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'E-Brufen',
      debugShowCheckedModeBanner: false,
      theme: AppTheme.materialTheme,
      home: HomePage(moodStorage: moodStorage, settings: settings),
    );
  }
}

而主页不再关心页面细节,它只做"拼装":

// lib/pages/home_page.dart
import 'package:flutter/material.dart';
import 'package:shared_core/shared_core.dart';
import 'package:feature_mood_diary/feature_mood_diary.dart';
import 'package:feature_breathe/feature_breathe.dart';
import 'package:feature_soundscape/feature_soundscape.dart';

class HomePage extends StatelessWidget {
  final MoodStorage moodStorage;
  final AppSettings settings;

  const HomePage({
    super.key,
    required this.moodStorage,
    required this.settings,
  });

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('🌿 E-Brufen')),
      body: SingleChildScrollView(
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            // ... 问候语 ...
            FeatureCard(
              emoji: '🎵',
              title: '白噪音',
              subtitle: '雨声 · 海浪 · 篝火 · 森林',
              gradient: AppTheme.soundscapeGradient,
              onTap: () => Navigator.push(context, MaterialPageRoute(
                builder: (_) => SoundscapePage(settings: settings),
              )),
            ),
            FeatureCard(
              emoji: '🫁',
              title: '呼吸练习',
              subtitle: '4-7-8 · 盒式 · 放松',
              gradient: AppTheme.breatheGradient,
              onTap: () => Navigator.push(context, MaterialPageRoute(
                builder: (_) => BreathePage(settings: settings),
              )),
            ),
            FeatureCard(
              emoji: '📓',
              title: '情绪日记',
              subtitle: '记录每一天的心情',
              gradient: AppTheme.diaryGradient,
              onTap: () => Navigator.push(context, MaterialPageRoute(
                builder: (_) => DiaryPage(moodStorage: moodStorage),
              )),
            ),
            // ... 今日速记 ...
          ],
        ),
      ),
    );
  }
}

注意看:home_page.dart 没有引用任何模块的内部路径(如 feature_mood_diary/src/widgets/mood_picker.dart),它全部通过 barrel 导出文件访问。模块的封装边界是清晰的


11. 构建速度与协作效率对比

11.1 构建时间

我们在 E-Brufen 项目上进行了对比测试(macOS Flutter 3.27, Intel i7, 16GB RAM):

指标 拆分前(单体) 拆分后(Feature Module) 变化
lib/.dart 文件数 14 3(仅装配层) -79%
flutter pub get 耗时 8.3s 12.1s +46% (首次)
修改 shared_core 后全量构建 18.2s 22.5s +24%
修改 feature_breathe 后增量构建 6.8s 3.1s -54%
修改 feature_mood_diary 后增量构建 7.1s 2.9s -59%
flutter test 全量运行 4.2s 6.5s +55%
flutter test 单模块运行 不支持 1.3s (feature_breathe) 新能力

关键洞察:

  • 首次 pub get 变慢了:因为需要解析 4 个 package 的依赖图而非 1 个。但在日常开发中,pub get 只需要在新依赖加入时执行,不是高频操作。
  • 单模块增量构建显著变快:这是模块化的最大收益。当你只修改呼吸练习模块时,Dart 编译器只需重新编译 feature_breathe 和装配层,feature_mood_diaryfeature_soundscape 完全不受影响。
  • 单模块测试成为可能:拆分后可以在 1.3 秒内跑完某一个 Feature 的全部测试,而不是每次都要加载整个项目。

11.2 团队协作效率

协作场景 拆分前 拆分后
新人理解项目结构 需要阅读全部 14 个文件才能理解全貌 只需阅读 shared_core + 所在模块的 3-5 个文件
两人同时开发不同功能 高概率在同一文件上产生 merge conflict 各自在自己的 package 中工作,几乎零冲突
代码 Review 需要 Review 整个 lib/ 的改动影响 Reviewer 可以只看被修改的 package
模块复用 需要手动拷贝代码文件到新项目 将整个 package 目录复制到新项目,改 pubspec.yaml 即可
CI/CD 时间 每次修改触发全量构建和测试 可以配置为"仅构建和测试受影响的模块"

12. 鸿蒙平台兼容性说明

E-Brufen 是一个同时面向 Android 和鸿蒙(HarmonyOS)的应用。在模块化拆分过程中,我们需要确保不会破坏鸿蒙兼容性。

好消息是:Dart Package 的模块化方式对平台层零侵入。所有的 Dart 代码隔离发生在 Dart VM 层面,不需要修改 ohos/ 目录下的任何原生代码。

12.1 MethodChannel 的处理

当前的 OhosAudioPlayer 使用 MethodChannel com.ebrufen/audio_player 与鸿蒙端的 AVPlayer 通信。在模块化方案中:

  • shared_core 中的代码不包含任何 MethodChannel 调用(纯 Dart)。
  • feature_soundscape 内部的 AudioPlayerFacade 使用 MethodChannel,但保持 Channel 名称不变:com.ebrufen/audio_player

这意味着 ohos/entry/src/main/ets/plugins/ 下的原生端代码完全不需要修改。MethodChannel 的注册依然发生在 main.dart 所在的主应用中。

12.2 Hive CE 的鸿蒙兼容

我们选择 Hive Community Edition (hive_ce) 而非 sqflite,正是因为 Hive CE 是纯 Dart 实现,无需任何原生 SQLite 绑定——在鸿蒙平台上不会遇到 libsqlite3.so 缺失的问题。

在模块化方案中,shared_corefeature_mood_diary 都依赖 hive_ce。只要各自的 pubspec.yaml 中版本约束一致(都使用 ^2.19.0),就不会出现版本冲突。

12.3 鸿蒙特定注意事项

注意事项 说明 应对
文件系统 API Directory.systemTemp 在鸿蒙上行为与 Android 一致 已测试,无需特殊处理
后台任务 feature_soundscape 的音频播放依赖鸿蒙的 backgroundTaskManager 这个能力在原生端配置,不影响 Dart 层
HAP 包大小 每个 Feature Module 不会增加最终 HAP 体积 Dart AOT 编译后只保留实际使用的代码
签名和权限 module.json5 中的权限声明不受影响 原生端配置无需变更

13. 模块化的陷阱与过早优化

在鼓励模块化的同时,我们也必须提醒几个常见陷阱。

13.1 "过早模块化"问题

“Premature optimization is the root of all evil.” —— Donald Knuth

如果你在写第一个页面的时候就开始设计模块结构,你大概率会设计出错误的边界。原因很简单:你还没有足够的信息来理解"什么属于一个功能域"

E-Brufen 的案例中,我们在三个功能全部稳定后才进行拆分——此时我们已经明确知道:

  • 呼吸练习和情绪日记之间没有直接的数据依赖。
  • 白噪音的音频能力目前只服务于自身。
  • 主题和设置是真正的跨模块共享概念。

如果我们在第一个页面(情绪日记)完成时就强行拆分,可能会犯以下错误:

  • MoodStorage 放在 shared_core 里(后来发现只有 mood_diary 用它)。
  • 为呼吸练习创建一个过度抽象的"练习引擎"接口(后来发现不需要)。
  • 为白噪音单独创建一个 Flutter Plugin(后来发现一个 Facade 就够了)。

13.2 模块粒度的黄金法则

一个合理的模块应该满足:

  1. 可以独立理解 —— 新人能只看这个模块的代码就理解它在做什么。
  2. 有明确的对外接口 —— barrel 文件中的 export 列表即 API 文档。
  3. 可以独立测试 —— cd packages/feature_breathe && flutter test 应该能跑通。
  4. 有一个清晰的理由 —— 如果能一句话说清楚"这个模块负责什么",粒度就对了。

如果出现以下信号,说明拆分过度了:

  • 一个模块只有 2 个文件、总共 80 行代码。
  • 修改一个功能需要改动 3 个以上的 package。
  • barrel 文件的 export 列表和模块内部文件列表几乎一样长(说明没有封装)。

13.3 模块边界需要演进

模块边界不是一成不变的。随着应用功能增长,会出现以下自然演进:

阶段1(当前E-Brufen):
  shared_core → { mood_diary, breathe, soundscape }

阶段2(预计未来):
  shared_core → { mood_diary, breathe, soundscape, sleep_tracker }
  breathe 中拆分出 animation_engine(如果呼吸动画被其他模块复用)

阶段3(如果要做:
  shared_core
    → feature_mood_diary(独立包,可发布)
    → feature_breathe
    → feature_soundscape(可能升迁为 Flutter Plugin)
    → feature_sleep_tracker
    → shared_audio(从 soundscape 中的 AudioFacade 升迁而来)

关键点:让代码告诉你什么时候该拆分,而不是让 PPT 告诉你。


14. 总结

模块化不是银弹,但它是在应用复杂度达到临界点后最可靠的逃生通道。

在本文中,我们以 E-Brufen 为例,展示了一条清晰的路径:

  1. 先让功能跑起来 —— 在单体架构中完成所有功能的开发与验证。
  2. 识别共享内核 —— 抽出 AppTheme、AppSettings、MoodType 等跨模块概念。
  3. 按功能域拆分 —— 将每个 Feature 封装为独立的 Dart Package。
  4. 建立通信规则 —— Feature 之间不直接依赖,通过 shared_core 或 Event Bus 通信。
  5. 装配层负责编排 —— main.dart 作为依赖注入和组装的唯一入口。
  6. 持续演进边界 —— 模块的职责和数量随着业务需求而调整。

最终的效果是:

  • 编译隔离:改呼吸练习不影响情绪日记的增量编译。
  • 认知降负:新人只需关注自己负责的模块。
  • 测试提速:单模块测试从 4.2 秒降到 1.3 秒。
  • 复用可能:Feature Module 可以整体迁移到另一个 Flutter 项目。

但同样重要的是知道什么时候不做:如果你的应用只有两个功能、三个页面、一个开发者,请尽情享受单体架构的简单和高效。模块化是为增长准备的——等到你真的需要它的那天,本文会成为你值得参考的作战手册。


15. 作者简介

一个专注于 Flutter 跨平台开发与鸿蒙(HarmonyOS)生态适配的移动开发者。持续在 AtomGit 上维护 E-Brufen 开源项目——一个面向情绪健康的 Flutter 鸿蒙客户端,探索从架构设计到原生桥接的全链路实践。擅长将复杂的架构概念转化为可落地的代码方案,主张"需求驱动架构演进"的务实工程美学。

  • E-Brufen 项目地址https://atomgit.com/e-brufen/firstproject
  • 技术栈:Flutter / Dart / HarmonyOS / Hive CE / MethodChannel
  • 关注领域:移动端架构设计、模块化工程实践、跨平台开发

持续更新中,欢迎关注 AtomGit 上的 E-Brufen 项目获取最新进展。

Logo

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

更多推荐