一、从一次模块重构看模块化的必要性
2024 年 9 月,我们收到一个需求:在聊天页面中增加「语音转文字」功能。这本应是一个独立的功能模块——有自己的一套 UI、数据流、网络请求和缓存策略。
然而,当开发人员打开聊天页面的代码时,发现根本没有「干净的地方」可以插入这个功能。消息列表渲染逻辑、输入框逻辑、附件选择逻辑、消息操作菜单逻辑全部耦合在 ChatPage 和 ChatBloc 中。增加「语音转文字」意味着在一个 1200+ 行的 StatefulWidget 和 800+ 行的 Bloc 中继续叠加代码。
这不仅是开发体验的问题。一个月后,「语音转文字」的调整导致了消息列表中表情渲染的一个回归 bug。两个毫无关联的功能为什么会互相影响?因为它们在同一个类中共享状态。
这个案例促使我们启动了模块化改革。本文将总结我们在这条路上的方法论、工具链和踩坑经验。
二、模块化的四个层次
模块化不是「把代码分到不同的文件夹」,它是一套分层的工程实践:
层级一:文件级模块化
最低要求:按功能拆分文件。
1 | chat/ |
文件级模块化是最基础的实践。如果一个功能散落在 5 个没有拆分的大文件中,任何后续重构都无法入手。
层级二:功能级模块化
每个功能是一个独立的 Dart 包或 package:
1 | # pubspec.yaml |
关键约束:功能模块不应该直接相互依赖。voice_to_text 和 sticker_panel 之间没有依赖关系,它们通过宿主页面组合在一起。这是模块化的核心——高内聚、低耦合。
层级三:业务级模块化
我们在组件化文章中详细介绍了这一层:按业务域拆分为独立仓库,有独立的 example 工程。
层级四:平台级模块化
将跨平台共享代码与平台特定代码分离:
1 | voice_to_text/ |
在企业 IM 中,语音转文字在不同平台上的实现完全不同:iOS 使用 Speech.framework,Android 使用 SpeechRecognizer API,鸿蒙使用 Core Speech Kit。模块化设计让平台差异被封装在各自的实现文件中,上层业务代码完全无感。
三、模块间通信的三条通道
3.1 通信通道一:Pub/Sub 事件总线
最松散的通信方式。适用于一对多广播——一个模块发布事件,多个模块独立消费。
1 | class ModuleEventBus { |
典型场景:新消息到达时,core_im 发布 NewMessageEvent,聊天页面、会话列表、未读计数、消息搜索索引各自监听并独立处理。
3.2 通信通道二:共享 Service 接口
模块定义服务接口,由宿主 App 在初始化时注入具体实现:
1 | // 语音转文字模块定义服务接口 |
比事件总线的耦合度高,但提供了类型安全和编译时检查。适用于一对一调用——语音转文字模块只需要一个语音识别服务,不需要通知其他模块。
3.3 通信通道三:路由参数
模块间通过路由传递结构化的参数:
1 | // 聊天页面需要将消息转发给选人组件 |
路由传递需要被传递的数据可序列化,适用于跨页面的模块通信。限制是只能传递一次数据,不适合持续通信。
四、模块间依赖的管理策略
4.1 依赖方向:向下依赖
模块依赖必须自顶向下。聊天页面模块(level 2)可以依赖 core_im(level 1),但不能反向依赖。
如果不控制依赖方向,最终会形成循环依赖:A 依赖 B,B 又依赖 A,导致编译失败或运行时死锁。
4.2 循环依赖的检测
我们在 CI 中添加了依赖检测脚本:
1 | # 检测是否存在循环依赖 |
CI 中发现循环依赖时直接阻断合并,强迫开发者通过引入共同接口或事件总线来打破循环。
4.3 公共接口的接口隔离原则
一个模块对外暴露的接口越少越好。在企业 IM 中,core_im 模块内部有 50+ 个类,但对外只暴露 5 个接口:
1 | export 'package:core_im/api/message_api.dart'; |
其他内部类(如消息编解码器、数据库 DAO、缓存管理器)全部标记为 part of 或不导出。外部模块如果试图 import unexported 的文件,CI 会报错。
五、实战效果与踩坑总结
5.1 量化收益
| 指标 | 模块化前 | 模块化后 |
|---|---|---|
| 单功能模块开发周期 | 14 天(受全局耦合影响) | 5 天(独立开发) |
| 模块间回归 bug 比例 | 30% | 5% |
| 新功能平均新增代码行 | 800 行(包括修补耦合) | 350 行(独立模块) |
| 模块独立测试覆盖率 | 无法统计 | 70%+ |
5.2 踩过的坑
坑一:过度拆分。团队初期按「一个类一个模块」的思路拆分,导致出现了 message_time_formatter 这种只有一个类的模块。模块粒度太细带来的管理成本远大于收益。后来定了规则:一个模块至少包含 3 个以上的关联类或一个独立功能入口,否则不拆。
坑二:模块间共享模型。Message 实体在聊天模块、搜索模块、选人模块中都要使用。初期我们让每个模块定义自己的 Message 类,结果在模块边界处频繁做数据转换,代码冗余严重。后来将 Message 等核心模型提升到 core_im 中作为共享类型,模块间传递的是同一个对象引用。
坑三:模块初始化顺序问题。ModuleRegistry 的初始化顺序被隐式依赖在不同模块的 register 调用中。A 模块的 init 依赖 B 模块已经注册的 Service,但 A 的 init 先执行了,导致运行时找不到 Service。解决方案是引入「依赖声明 + 拓扑排序」:模块在配置中声明依赖哪些 Service,ModuleRegistry 在初始化时按拓扑序依次执行。
六、总结
模块化的核心是建立边界。以下四个问题是判断边界是否清晰的试金石:
- 能不能独立测试:去掉 Flutter 框架和宿主 App 的上下文,这个模块的纯逻辑能不能用
dart test跑起来? - 能不能独立开发:新来的开发者在只看这个模块的代码和文档的前提下,能不能完成一个需求的开发?
- 能不能独立编译:这个模块的代码改动后,编译时间是否只和这个模块的大小相关,而不是整个项目的规模?
- 能不能独立部署:如果是业务插件,能不能在不发版的前提下更新这个模块?
如果以上四个问题的答案都是「能」,那么模块化就算是达到了基本的目标。
模块化不是一次性工程,而是一种持续的约束和纪律。团队需要 CI 上约束、Code Review 上提醒、架构文档上明确边界,才能让模块化的收益持续而非「拆完又耦合回去」。
扫描二维码,分享此文章