cubegao

Flutter 组件化架构设计实践

2024-07-11

一、一个编译时间超过 8 分钟的 Monorepo

2024 年初,我们的企业 IM 项目已经运营了两年,代码仓库膨胀到了一个危险的状态:

  • 单仓库包含 300+ 页面、60+ 业务模块
  • flutter pub get 耗时 2 分钟以上
  • 增量编译时间 3-5 分钟,全量编译超过 8 分钟
  • 任何一行代码改动都需要等待数分钟才能验证效果
  • 合并冲突成为日常——多人同时修改不同模块的代码,但都在同一个仓库里

更致命的是,编译缓存经常失效。Flutter 的热重载在 300+ 页面的仓库里已经形同虚设——改动一行代码后热重载需要 15-20 秒,比冷启动好不了多少。

这不是技术能力的问题,而是项目规模超过了单体仓库的承载上限。经过评估,我们启动了组件化拆分,将单体仓库拆解为 12 个独立组件。本文将复盘整个拆分过程,包括组件拆分策略、通信机制、集成调试和生产环境效果。

二、组件化的目标与原则

在动手之前,我们先对齐了组件化的核心目标:

  1. 编译速度:单组件独立编译,修改一个组件不影响其他组件的缓存。
  2. 团队独立开发:不同业务组可以在各自的组件仓库里独立开发,减少 merge conflict。
  3. 复用性:通用组件(如选人组件、转发组件)可以被多个上层模块引用。
  4. 稳定性:组件升级只影响自身,不会导致整个 App 崩溃。

基于这四个目标,我们制定了拆分原则:

  • 按业务域拆分:不是按技术层级(UI/数据/网络),而是按业务功能(消息/通讯录/会议/工作台)。
  • 组件即产品:每个组件有独立的入口页面、独立的路由、独立的数据层。它可以独立运行——不依赖宿主 App 的任何代码。
  • 稳定接口,不稳定实现:组件对外暴露的接口越少越好,内部实现可以频繁变更。

三、拆分策略:从三层到十二个组件

3.1 三层架构图

1
2
3
4
5
6
7
8
9
10
┌───────────────────────────────────────────────┐
│ 主工程 (App Shell) │
│ 路由注册、组件加载、全局配置、主题与国际化 │
├──────────┬──────────┬──────────┬──────────────┤
│ core_im │ core_ui │ core_net │ core_storage │ ← 基础组件
├──────────┼──────────┼──────────┼──────────────┤
│ chat │ contact │ calendar │ meeting │ ← 业务组件
├──────────┼──────────┼──────────┼──────────────┤
│ selector │ forward │ webview │ profile │ ← 功能组件
└──────────┴──────────┴──────────┴──────────────┘

3.2 基础组件层(4 个)

core_im:消息核心层,封装 IM SDK 的消息收发、会话管理、消息同步、离线消息处理。这是整个 App 的「心脏」,所有业务组件都依赖它。

core_ui:统一 UI 组件库。消息气泡、头像、Loading、Empty 状态、通用弹窗等。在 300+ 页面中,有大量 UI 重复——比如头像组件在 40+ 个页面中被重复实现。统一提取为核心 UI 组件后,不仅减少了代码量,还保证了一致的交互体验。

core_net:网络层封装。基于 dio 做统一拦截器链(Token 刷新、请求重试、日志记录、加解密)。所有业务组件共享同一套网络策略,避免每个组件各自封装一遍。

core_storage:本地存储层。统一数据库操作、Key-Value 缓存、文件存储接口。这是为后续可能的数据库迁移(如 sqflite → Drift)做的隔离。

3.3 业务组件层(4 个)

chat:聊天页面核心功能——消息列表渲染、消息发送/撤回/删除/转发、输入框、附件发送(图片/文件/位置/语音)、消息搜索。

contact:通讯录模块——组织架构树、联系人列表、部门列表、外部联系人。

calendar:日程与待办——日历视图、日程创建与提醒、待办任务管理。

meeting:会议系统——会议创建、加入、屏幕共享、会议管理。

3.4 功能组件层(4 个)

selector:选人组件。聊天页面、会议邀请、转发都需要选人,这是一个典型的高复用组件。

forward:消息转发组件。从聊天页面、收藏列表、搜索页面都可以触发转发,需要独立的转发流程。

webview:轻应用容器。工作台中的 OA 审批、报表、公告等轻应用都在 WebView 中承载。

profile:个人资料卡。所有人员头像和姓名的点击都会唤起资料卡,显示详细信息。

四、组件间通信:路由与数据流

4.1 路由解耦

组件化的核心挑战之一:A 组件如何跳转到 B 组件的页面,而不直接引用 B 组件的代码?

我们的方案是「路由协议 + 注册」:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// core_router 中定义路由协议
abstract class AppRouter {
Future<void> toChat(String conversationId);
Future<void> toContact(String userId);
Future<void> toSelector({required SelectorConfig config});
}

// 主工程中注册实现
class AppRouterImpl implements AppRouter {
@override
Future<void> toChat(String conversationId) {
return navigatorKey.currentState!.push(
MaterialPageRoute(builder: (_) => ChatPage(conversationId: conversationId)),
);
}
}

所有页面间的跳转都通过 AppRouter 接口进行,组件不直接引用其他组件的页面类。当聊天页面从 Flutter 原生实现改为混合栈时,只需修改 toChat 的实现,所有调用方无需改动。

4.2 数据共享

组件间的数据共享通过「发布订阅」模式实现:

1
2
3
4
5
6
7
8
9
// 消息数据总线
class MessageEventBus {
final _controller = StreamController<MessageEvent>.broadcast();
Stream<MessageEvent> get stream => _controller.stream;

void onNewMessage(Message message) {
_controller.add(NewMessageEvent(message));
}
}

比如:新消息到达时,core_im 通过事件总线发布 NewMessageEvent。会话列表组件、聊天组件、未读计数组件各自监听这个事件,独立更新自己的状态。这种模式下,数据生产者不关心消费者是谁,组件之间零耦合。

五、组件集成与调试

5.1 开发模式:组件独立运行

每个组件都有自己的 main.dart 入口和示例数据,可以脱离主工程独立运行:

1
2
3
4
5
6
7
chat_component/
├── lib/
│ └── chat/
├── example/
│ └── main.dart ← 独立运行入口
├── test/
└── pubspec.yaml

组件开发者只需运行 cd chat_component/example && flutter run,就能启动一个只包含聊天页面的 mini App。编译时间从 5 分钟降到 20 秒,开发体验大幅提升。

5.2 集成模式:组件依赖管理

主工程的 pubspec.yaml 通过 Git 依赖引用各组件:

1
2
3
4
5
6
7
8
9
dependencies:
core_im:
git:
url: [email protected]:company/im-core.git
ref: v2.3.1
chat:
git:
url: [email protected]:company/im-chat.git
ref: v1.8.0

通过指定 ref 锁定版本,避免「改了基础组件导致所有业务组件挂掉」的连锁反应。基础组件升级时,先在各自的 example 工程中验证,再逐步推送到业务组件。

5.3 版本管理与 CI/CD

每个组件独立发版,有独立的 CHANGELOG。CI 流水线验证:

  1. 组件自身的单元测试和 Widget 测试
  2. 组件 example 工程的编译检查
  3. 组件 API 的向后兼容检查(禁止删除已有公开接口)

只有三个检查全部通过,才允许合入主分支并发版。

六、实战效果

6.1 量化指标

指标 拆分前 拆分后
全量编译时间 8 分钟 3 分钟(主工程)
组件独立编译时间 N/A 15-25 秒
flutter pub get 2 分钟 30 秒
热重载延迟 15-20 秒 2-3 秒
单模块开发人员 全局锁 独立仓库,零冲突
代码复用率 未知 选人组件被 6 个业务复用

6.2 遇到的问题

问题一:组件边界模糊。拆分初期,有些开发者习惯性地在 chat 组件中直接 import contact 组件的代码——因为聊天页面需要展示联系人详情。这破坏了组件独立性。解决方案是在 CI 中增加依赖方向检查:禁止业务组件之间互相直接依赖。

问题二:资源文件冗余。拆分后,每个组件都带了一份自己的 pubspec.yaml,部分通用资源(如字体、图标)被多个组件重复声明。后期将这些通用资源统一放入 core_ui 组件,业务组件不再各自携带。

问题三:组件版本漂移。半年后,不同业务组使用的 core_im 版本从 v2.3 到 v3.1 不等,出现了接口不兼容。解决方案是引入「版本宪章」:基础组件的旧版本只维护 3 个月,超期后强制升级。

七、总结

组件化不是把代码分散到多个文件夹那么简单,它本质上是建立组织协作的边界。在 50+ 开发者的团队中,组件化最大的收益不是编译速度提升了多少,而是——你的改动不太可能搞崩我的模块

拆分的核心原则:

  1. 按业务域拆分,不按技术层拆分。技术层的变化频率不同,业务域的变化独立性更强。
  2. 组件独立可运行。如果一个组件没有自己的 example 工程,那它不是真正的组件——只是换了个文件夹存放代码。
  3. 接口优先,实现隐藏。组件对外暴露的 Dart API 就是它的契约,契约必须稳定。内部实现的重构不应影响调用方。

12 个组件拆完之后回头看,最大的感受是:组件化不是架构优化,是协作模式的变革。从「所有人改一棵树」变成「各自维护各自的模块」,这才是组件化真正的价值。

扫描二维码,分享此文章