cubegao

企业级 Flutter 项目分层架构设计

2024-02-15

一、从一段「无法测试」的代码说起

我们的企业 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 个页面。

这就是我们启动分层架构重构的直接原因。本文将复盘整个重构过程,包括架构设计、模块边界、依赖关系和落地效果。

二、分层架构的设计目标

在动手之前,我们把目标拆解为四个具体指标:

  1. 可测试性:每一层可以独立进行单元测试,不依赖 Flutter 框架、不依赖真实数据库和网络。
  2. 关注点分离:UI 只管渲染,业务逻辑只管规则,数据层只管存取,三者不互相渗透。
  3. 可替换性:更换数据库实现(如从 sqflite 迁移到 Drift)或者更换网络库(如从 dio 换成 http)时,上层代码不受影响。
  4. 团队协作边界:不同模块可以由不同小组并行开发,减少代码冲突。

基于这四个目标,我们设计了四层架构。

三、四层架构设计

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
// domain/entities/message.dart
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,
});
}

// domain/repositories/message_repository.dart
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
// data/repositories/message_repository_impl.dart
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 {
// 1. 先查缓存
final cached = _cache.getMessages(conversationId, page);
if (cached != null && cached.isNotEmpty) return cached;

// 2. 缓存未命中,查本地数据库
final local = await _localDataSource.getMessages(conversationId, page: page, pageSize: pageSize);
if (local.isNotEmpty) {
_cache.setMessages(conversationId, page, local);
return local;
}

// 3. 本地也没有,请求远程
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
// application/chat/chat_bloc.dart
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 {
// DataSources
sl.registerLazySingleton<MessageRemoteDataSource>(() => MessageRemoteDataSourceImpl(dio: sl()));
sl.registerLazySingleton<MessageLocalDataSource>(() => MessageLocalDataSourceImpl(database: sl()));

// Repositories
sl.registerLazySingleton<MessageRepository>(
() => MessageRepositoryImpl(
remoteDataSource: sl(),
localDataSource: sl(),
cache: sl(),
),
);

// Blocs
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 项目中,四层架构的收益集中在三个点上:

  1. Domain 层的零依赖设计让核心业务逻辑可以被纯 Dart 测试覆盖,不受 Flutter 框架、数据库、网络的约束。
  2. Repository 接口隔离了 Data 层的实现细节,让我们在项目中期从 sqflite 迁移到 Drift 时,Application 层和 Presentation 层一行代码都没改。
  3. Application 层的业务流程封装让复杂的状态转换(如「发送消息 → 更新本地数据库 → 同步服务端 → 更新未读数」)从 Widget 中剥离,UI 层不再关心流程编排。

分层架构的本质不是「把代码分到不同文件夹」,而是为变化建立隔离带。当数据库实现发生变化时,变化止步于 Data 层;当 UI 交互变化时,变化止步于 Presentation 层。每一层的变化都被限制在自己的边界内,这是分层架构最核心的价值。

扫描二维码,分享此文章