一、从一段「无法测试」的代码说起 我们的企业 IM 项目在早期阶段,聊天页面的代码大概是这样的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 class ChatPage extends StatefulWidget { @override _ChatPageState createState() => _ChatPageState(); } class _ChatPageState extends State <ChatPage > { List <Message> _messages = []; @override void initState() { super .initState(); _loadMessages(); } Future<void > _loadMessages() async { final db = await Database.open('im.db' ); final rows = await db.query('SELECT * FROM messages WHERE conversation_id = ?' , [widget.conversationId]); final apiMessages = await ApiService.fetchMessages(widget.conversationId); await db.batchInsert(apiMessages); setState(() { _messages = rows.map(Message.fromDb).toList()..addAll(apiMessages); }); } Future<void > _sendMessage(String text) async { final msg = Message(text: text, time: DateTime .now()); final result = await ApiService.sendMessage(msg); final db = await Database.open('im.db' ); await db.insert(result); setState(() => _messages.add(result)); } @override Widget build(BuildContext context) { return ListView.builder( itemCount: _messages.length, itemBuilder: (context, index) => MessageBubble(_messages[index]), ); } }
这段代码能跑,但当我们试图为它写单元测试时,问题全部暴露了:数据库操作、网络请求、UI 状态管理全部耦合在 State 里。你没法单独测试消息加载逻辑,没法 mock 网络层,也没法验证数据库写入是否正确。更糟糕的是,随着项目膨胀到 300+ 页面、50+ 开发者的规模,这种「面条式」代码让新需求变得极其危险——改一个 _loadMessages 的实现可能意外影响 10 个页面。
这就是我们启动分层架构重构的直接原因。本文将复盘整个重构过程,包括架构设计、模块边界、依赖关系和落地效果。
二、分层架构的设计目标 在动手之前,我们把目标拆解为四个具体指标:
可测试性 :每一层可以独立进行单元测试,不依赖 Flutter 框架、不依赖真实数据库和网络。
关注点分离 :UI 只管渲染,业务逻辑只管规则,数据层只管存取,三者不互相渗透。
可替换性 :更换数据库实现(如从 sqflite 迁移到 Drift)或者更换网络库(如从 dio 换成 http)时,上层代码不受影响。
团队协作边界 :不同模块可以由不同小组并行开发,减少代码冲突。
基于这四个目标,我们设计了四层架构。
三、四层架构设计 1 2 3 4 5 6 7 8 9 10 11 12 13 14 ┌─────────────────────────────────────────────┐ │ Presentation 层 │ │ Widget / Page / 路由导航 / 主题与样式 │ ├─────────────────────────────────────────────┤ │ Application 层 │ │ State Management / UseCase / 业务流程编排 │ ├─────────────────────────────────────────────┤ │ Domain 层 │ │ Entity / Repository 接口 / 业务规则 │ ├─────────────────────────────────────────────┤ │ Data 层 │ │ Repository 实现 / DataSource / DTO / Mapper │ │ (远程 API、本地数据库、缓存、文件存储) │ └─────────────────────────────────────────────┘
3.1 Domain 层:绝对核心,零依赖 Domain 层是整个架构的中心,不依赖任何其他层,也不依赖任何第三方框架 。它只包含纯 Dart 代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 class Message { final String id; final String conversationId; final String content; final MessageType type; final DateTime createdAt; final MessageStatus status; const Message({ required this .id, required this .conversationId, required this .content, required this .type, required this .createdAt, this .status = MessageStatus.sending, }); } abstract class MessageRepository { Future<List <Message>> getMessages(String conversationId, {int page, int pageSize}); Future<Message> sendMessage(Message message); Future<void > markAsRead(String messageId); Stream<List <Message>> observeMessages(String conversationId); }
关键设计决策:Entity 使用不可变对象(const 构造函数),所有字段都是 final。这不仅避免了意外的状态修改,也让 Entity 天然支持 == 比较和哈希——在状态管理框架中进行 diff 时极其重要。
Repository 接口定义在 Domain 层,但实现在 Data 层。这是「依赖倒置」的经典应用:高层模块(Domain)定义接口,低层模块(Data)实现接口。
3.2 Data 层:实现细节的聚集地 Data 层负责实现 Domain 层定义的 Repository 接口,同时处理数据源的选择策略:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 class MessageRepositoryImpl implements MessageRepository { final MessageRemoteDataSource _remoteDataSource; final MessageLocalDataSource _localDataSource; final MessageCache _cache; MessageRepositoryImpl(this ._remoteDataSource, this ._localDataSource, this ._cache); @override Future<List <Message>> getMessages(String conversationId, {int page = 1 , int pageSize = 20 }) async { final cached = _cache.getMessages(conversationId, page); if (cached != null && cached.isNotEmpty) return cached; final local = await _localDataSource.getMessages(conversationId, page: page, pageSize: pageSize); if (local.isNotEmpty) { _cache.setMessages(conversationId, page, local); return local; } final remote = await _remoteDataSource.fetchMessages(conversationId, page: page, pageSize: pageSize); await _localDataSource.saveMessages(remote); return remote; } }
这里有一个重要的缓存策略设计:我们采用「缓存优先,本地兜底,远程补全」的三级策略。在 IM 消息列表这种高频访问场景中,缓存的命中率可以达到 85% 以上,大幅减少了数据库查询和网络请求。
3.3 Application 层:业务流程的编排者 Application 层不持有任何数据源,它只编排 Domain 层的 Entity 和 Repository,产出可供 Presentation 层直接使用的状态:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 class ChatBloc extends Bloc <ChatEvent , ChatState > { final MessageRepository _messageRepository; final ConversationRepository _conversationRepository; ChatBloc(this ._messageRepository, this ._conversationRepository) : super (ChatInitial()) { on <LoadMessages>(_onLoadMessages); on <SendMessage>(_onSendMessage); on <ReceiveMessage>(_onReceiveMessage); } Future<void > _onLoadMessages(LoadMessages event, Emitter<ChatState> emit) async { emit(ChatLoading()); try { final messages = await _messageRepository.getMessages(event.conversationId); final conversation = await _conversationRepository.getById(event.conversationId); emit(ChatLoaded(messages: messages, conversation: conversation)); } catch (e) { emit(ChatError(e.toString())); } } }
Application 层的核心价值在于:它把「加载消息 → 标记已读 → 更新会话列表未读数」这类跨多个 Repository 的复杂业务流程,封装为单一的状态转换序列。Presentation 层只需要发送事件、监听状态,完全不需要知道背后的协调逻辑。
3.4 Presentation 层:纯 UI 渲染 经过分层后,Presentation 层变得极其轻量:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 class ChatPage extends StatelessWidget { @override Widget build(BuildContext context) { return BlocProvider( create: (_) => sl<ChatBloc>()..add(LoadMessages(conversationId)), child: const ChatView(), ); } } class ChatView extends StatelessWidget { @override Widget build(BuildContext context) { return BlocBuilder<ChatBloc, ChatState>( builder: (context, state) { return state.when ( initial: () => const SizedBox.shrink(), loading: () => const Center(child: CircularProgressIndicator()), loaded: (messages, conversation) => MessageListView(messages: messages), error: (message) => ErrorView(message: message), ); }, ); } }
Widget 不再调用任何 Repository,不访问数据库,不发起网络请求。它只做一件事:根据状态渲染 UI。
四、依赖注入:粘合剂 四层之间如何连接?我们使用 get_it 作为服务定位器,在应用启动时统一注册:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 Future<void > setupDependencies() async { sl.registerLazySingleton<MessageRemoteDataSource>(() => MessageRemoteDataSourceImpl(dio: sl())); sl.registerLazySingleton<MessageLocalDataSource>(() => MessageLocalDataSourceImpl(database: sl())); sl.registerLazySingleton<MessageRepository>( () => MessageRepositoryImpl( remoteDataSource: sl(), localDataSource: sl(), cache: sl(), ), ); sl.registerFactory(() => ChatBloc(sl<MessageRepository>(), sl<ConversationRepository>())); }
选择 get_it 而非 Provider 做 DI,是因为分层架构中的依赖注入应该与 UI 解耦。Provider 本质上是 InheritedWidget 的封装,天然绑定 Widget 树,不适合在非 UI 环境(如单元测试)中使用。
五、实战效果与踩坑总结 5.1 可测试性提升 重构前,给聊天页面写测试需要启动 Flutter 测试环境、mock 数据库和网络层。重构后:
1 2 3 4 5 6 7 8 9 test('ChatBloc should emit loaded state when messages are fetched' , () { final mockRepo = MockMessageRepository(); when (mockRepo.getMessages('conv_123' )).thenAnswer((_) async => [testMessage]); final bloc = ChatBloc(mockRepo, mockConversationRepo); bloc.add(LoadMessages('conv_123' )); expectLater(bloc.stream, emitsInOrder([ChatLoading(), ChatLoaded(messages: [testMessage], ...)])); });
纯 Dart 测试,不需要 Flutter 框架,运行时间从秒级降到毫秒级。
5.2 团队协作效率 分层后,三组开发者可以并行工作:
A 组:开发 Presentation 层的新 UI 组件
B 组:优化 Data 层的数据库查询性能
C 组:在 Application 层添加新的业务流程
只要 Domain 层的接口契约不变,三组代码不会互相冲突。
5.3 踩过的坑 坑一:Domain 层实体膨胀 。初期我们把所有字段都放进 Entity,导致 Message Entity 膨胀到 30+ 个字段。后来引入「领域对象 vs 展示对象」分离:Domain 层只保留核心业务字段,展示需要的衍生字段(如「发送时间格式化字符串」)由 Application 层计算。
坑二:过度抽象 Repository 。我们曾为每个 Entity 都定义了一个 Repository 接口,后来发现部分 Entity(如表情包、贴纸)实际上不需要 Repository——它们只是值对象,不涉及持久化逻辑。抽象要有度,只为真正需要数据存取行为的领域对象定义 Repository。
坑三:Bloc 粒度控制 。一个聊天页面初期只有一个 ChatBloc,承载了消息加载、发送、撤回、删除、转发等所有事件。随着业务增长,Bloc 膨胀到 800+ 行。后来的做法是按功能域拆分为 MessageBloc、InputBloc、AttachmentBloc,通过 BlocListener 跨 Bloc 通信。
六、总结 分层架构不是银弹,也不是越分越细越好。在企业 IM 项目中,四层架构的收益集中在三个点上:
Domain 层的零依赖设计 让核心业务逻辑可以被纯 Dart 测试覆盖,不受 Flutter 框架、数据库、网络的约束。
Repository 接口 隔离了 Data 层的实现细节,让我们在项目中期从 sqflite 迁移到 Drift 时,Application 层和 Presentation 层一行代码都没改。
Application 层的业务流程封装 让复杂的状态转换(如「发送消息 → 更新本地数据库 → 同步服务端 → 更新未读数」)从 Widget 中剥离,UI 层不再关心流程编排。
分层架构的本质不是「把代码分到不同文件夹」,而是为变化建立隔离带 。当数据库实现发生变化时,变化止步于 Data 层;当 UI 交互变化时,变化止步于 Presentation 层。每一层的变化都被限制在自己的边界内,这是分层架构最核心的价值。