Flutter 基础体系 · 第 21/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。
Flutter 大型应用架构:分层、Feature、依赖注入和多端边界
大型 Flutter 应用的难点通常不是“如何把页面画出来”,而是如何让功能持续增加时,代码仍然满足以下条件:
- 一个业务规则可以独立测试,不必启动 Flutter 引擎;
- 一个 Feature 可以独立修改,不会牵连整个应用;
- 数据源可以从 REST API 替换为本地数据库或 Fake,而不改变页面;
- Android、iOS、桌面和 Web 的差异集中在边界内,而不是散落在 Widget 中;
- 异步加载、重复请求、页面销毁和错误恢复具有明确的生命周期;
- 依赖关系可以从代码结构中直接看出来,而不是依赖团队约定。
这几个问题分别对应四个架构概念:
- 分层(Layering):限制不同职责之间的依赖方向;
- Feature:以业务能力组织代码,而不是只按文件类型组织代码;
- 依赖注入(Dependency Injection,DI):由外部组装对象及其依赖,避免业务代码自行寻找全局服务;
- 多端边界:把平台 API、插件和端能力隔离在稳定的抽象之后。
它们不是四种互斥的架构,而是四个互相配合的约束。分层决定“谁可以依赖谁”,Feature 决定“代码按什么业务边界聚合”,依赖注入决定“对象如何被创建和替换”,多端边界决定“平台差异放在哪里”。
一、先建立架构问题的模型
1. 分层解决的是依赖方向
设应用中的模块集合为:
如果模块 的源代码直接引用了模块 ,则存在一条编译依赖边:
一个可维护的分层架构,至少应满足:
- 依赖图尽量是有向无环图;
- 依赖方向从具体实现指向稳定抽象;
- 高层业务规则不依赖低层框架细节;
- 跨层访问只能经过明确的接口或边界。
例如:
UI / Presentation
↓
Application
↓
Domain
↑
Data / Infrastructure
这里的箭头表示“源代码依赖”。Data 层实现 Domain 定义的仓储接口,因此运行时数据流可以是:
UI → Application → Repository interface ← Repository implementation → HTTP / DB
源代码依赖和运行时调用方向并不完全相同。Application 调用的是 TaskRepository 接口;真正执行的是由外部注入的 HttpTaskRepository。这就是依赖倒置的核心:业务代码依赖稳定的抽象,而不是 HTTP 客户端。
2. Feature 解决的是代码聚合边界
按文件类型组织大型应用,常见结构如下:
lib/
models/
screens/
widgets/
services/
repositories/
这种结构在功能少时很直观,但一个“订单详情”功能可能同时修改:
models/order.dart
repositories/order_repository.dart
services/payment_service.dart
screens/order_detail_page.dart
widgets/order_summary.dart
随着功能增加,同一目录中的文件会属于不同业务上下文,依赖关系变得难以追踪。
Feature 组织方式则把一个业务能力的代码放在一起:
lib/
app/
app.dart
composition_root.dart
core/
error/
result/
platform/
features/
tasks/
domain/
application/
data/
presentation/
profile/
domain/
application/
data/
presentation/
Feature 不是“一个页面目录”的同义词。它应当代表一个可以被产品、路由或权限识别的业务能力,例如:
- 任务列表;
- 账户资料;
- 订单支付;
- 消息收件箱。
一个 Feature 内部仍然可以分层;“Feature-first”和“Layered architecture”是两个不同维度:
Feature-first:
features/tasks/
features/profile/
Layered inside a Feature:
tasks/
domain/
application/
data/
presentation/
因此大型应用通常采用:
每个 Feature 内部遵守自己的依赖方向,Feature 之间通过公开的用例、路由参数或共享领域类型通信,而不是直接访问对方的数据库、Controller 或 Widget。
3. 依赖注入解决的是对象组装问题
依赖注入是指:对象需要什么依赖,由创建者从外部传入,而不是对象自己创建或从全局容器查找。
不利于测试的代码:
class TaskController {
final repository = HttpTaskRepository(
client: ApiClient(baseUrl: 'https://example.com'),
);
}
这个 Controller 同时决定了:
- 使用哪个 HTTP 客户端;
- 使用哪个服务器地址;
- 如何创建仓储;
- 测试时如何替换仓储。
构造函数注入后:
class TaskController {
TaskController(this.repository);
final TaskRepository repository;
}
创建责任移动到应用入口:
final repository = HttpTaskRepository(client: apiClient);
final controller = TaskController(repository);
测试时可以传入:
final controller = TaskController(FakeTaskRepository());
依赖注入不等于“必须使用某个 DI 框架”。手动构造、Provider、Riverpod、GetIt、BLoC 工厂等都可以承载注入。关键在于依赖是否由外部控制,以及生命周期是否明确。
4. 多端边界解决的是环境差异问题
Android、iOS、桌面和 Web 并不提供完全相同的运行时能力:
dart:io不能直接用于 Web;- 浏览器有 Web Storage、Service Worker、浏览器权限和 URL 能力;
- 移动端有系统权限、后台限制和原生生命周期;
- 桌面端有文件系统、窗口和键鼠交互;
- Flutter Web 的 URL、刷新、浏览器历史和资源加载方式不同;
- 第三方 Flutter 插件可能只支持部分平台。
如果在 Widget 中到处出现:
if (kIsWeb) {
// ...
} else if (Platform.isAndroid) {
// ...
}
平台差异会进入业务流程、页面和测试,最终形成条件分支网络。更稳定的方式是让 Feature 依赖一个业务能力接口:
abstract interface class FilePickerPort {
Future<PickedFile?> pick();
}
class PickedFile {
const PickedFile({
required this.name,
required this.bytes,
});
final String name;
final List<int> bytes;
}
页面只知道“选择文件”,不知道文件来自浏览器、Android 原生选择器还是桌面文件系统。
二、一个可落地的分层模型
下面以 tasks Feature 为例。它包含一个加载任务列表的用例。
features/tasks/
domain/
task.dart
task_repository.dart
application/
load_tasks.dart
task_controller.dart
data/
in_memory_task_repository.dart
http_task_repository.dart
presentation/
task_scope.dart
task_page.dart
1. Domain:稳定的业务概念和规则
Domain 层是业务领域层,包含不依赖 Flutter 的类型和规则。它不应该导入:
package:flutter/...
dart:io
具体 HTTP 客户端
数据库 SDK
某个状态管理框架
任务实体:
// features/tasks/domain/task.dart
class Task {
const Task({
required this.id,
required this.title,
required this.completed,
});
final String id;
final String title;
final bool completed;
Task toggle() {
return Task(
id: id,
title: title,
completed: !completed,
);
}
}
仓储接口是 Domain 对外部数据源提出的需求,也称为端口(Port):
// features/tasks/domain/task_repository.dart
import 'task.dart';
abstract interface class TaskRepository {
Future<List<Task>> loadTasks();
Future<Task> updateTask(Task task);
}
这里定义的是业务需要的能力,而不是 HTTP 细节。接口没有暴露:
Future<Response> get(String path)
Future<void> saveToSql(String table, ...)
因为这些是实现方式,不是任务领域的需求。
2. Application:编排用例和状态转换
Application 层负责用例编排,例如:
- 加载任务;
- 更新任务;
- 组合多个仓储;
- 执行权限检查;
- 把领域结果转换为页面可用状态。
一个最小的加载用例:
// features/tasks/application/load_tasks.dart
import '../domain/task.dart';
import '../domain/task_repository.dart';
class LoadTasks {
const LoadTasks(this.repository);
final TaskRepository repository;
Future<List<Task>> call() {
return repository.loadTasks();
}
}
用例看起来可能只是转发调用,但它建立了一个重要边界:页面不直接调用仓储。将来如果加载任务需要合并本地草稿、检查登录状态或进行排序,变化集中在 Application 层,不需要让页面知道这些规则。
3. Data:实现仓储和外部数据映射
为了让示例可运行,先实现一个内存仓储:
// features/tasks/data/in_memory_task_repository.dart
import '../domain/task.dart';
import '../domain/task_repository.dart';
class InMemoryTaskRepository implements TaskRepository {
InMemoryTaskRepository([List<Task>? initialTasks])
: _tasks = List<Task>.from(
initialTasks ??
const [
Task(id: '1', title: '阅读架构文档', completed: false),
Task(id: '2', title: '补充 Widget 测试', completed: true),
],
);
final List<Task> _tasks;
@override
Future<List<Task>> loadTasks() async {
await Future<void>.delayed(const Duration(milliseconds: 50));
return List<Task>.unmodifiable(_tasks);
}
@override
Future<Task> updateTask(Task task) async {
final index = _tasks.indexWhere((item) => item.id == task.id);
if (index < 0) {
throw StateError('Task not found: ${task.id}');
}
_tasks[index] = task;
return task;
}
}
这个实现遵守 TaskRepository,所以 Application 层不需要知道它是内存实现、HTTP 实现还是数据库实现。
真实 HTTP 实现通常会增加 DTO 映射:
class TaskDto {
const TaskDto({
required this.id,
required this.title,
required this.completed,
});
factory TaskDto.fromJson(Map<String, Object?> json) {
return TaskDto(
id: json['id']! as String,
title: json['title']! as String,
completed: json['completed']! as bool,
);
}
final String id;
final String title;
final bool completed;
Task toDomain() {
return Task(
id: id,
title: title,
completed: completed,
);
}
}
DTO 的作用是隔离外部数据格式。例如服务端把 completed 改名为 is_done,只需要修改 Data 层的解析,不应该把 JSON 字段名传播到 Domain 和 Presentation。
4. Presentation:把状态呈现为 Widget
Presentation 层包括:
- 页面;
- 可复用 Widget;
- 页面状态;
- 用户事件到用例的映射;
- 路由和生命周期相关代码。
它不应该直接构造 HTTP 客户端,也不应把 JSON 解析放在 build 中。
三、状态管理与架构边界
状态管理工具解决的是“状态保存、通知和读取”问题,不会自动解决分层和依赖方向。
可以这样区分:
| 工具或机制 | 主要解决的问题 | 不自动解决的问题 |
|---|---|---|
InheritedWidget |
在 Widget 树中向下提供数据 | 仓储接口设计、异步用例、平台隔离 |
Provider |
基于 InheritedWidget 管理对象和生命周期 | 业务分层 |
Riverpod |
声明式依赖图、状态监听和覆盖 | 是否把业务逻辑放在正确边界 |
BLoC |
事件到状态的显式转换 | 数据源和平台实现的抽象 |
ChangeNotifier |
可变状态与通知 | 并发语义和错误模型 |
因此,TaskController 可以使用 ChangeNotifier,但仓储接口仍然属于 Domain,Controller 仍然属于 Application 或 Presentation 之间的应用状态边界。
1. 明确状态集合
不要只使用一个 isLoading 和一个可空的错误字段来表达所有状态。至少应区分:
enum TaskStatus {
idle,
loading,
data,
failure,
}
完整状态还需要数据和错误:
class TaskState {
const TaskState({
this.status = TaskStatus.idle,
this.tasks = const [],
this.error,
});
final TaskStatus status;
final List<Task> tasks;
final Object? error;
TaskState copyWith({
TaskStatus? status,
List<Task>? tasks,
Object? error,
bool clearError = false,
}) {
return TaskState(
status: status ?? this.status,
tasks: tasks ?? this.tasks,
error: clearError ? null : (error ?? this.error),
);
}
}
状态转换可以形式化为:
其中:
- 是当前状态;
- 是事件,例如
load、toggle或retry; - 是状态转换函数。
例如加载流程:
idle
└── load ──> loading
├── 成功 ──> data(tasks)
└── 失败 ──> failure(error)
加载失败后重试时,failure 再次转为 loading。如果已有旧数据,也可以设计为:
data(oldTasks)
└── refresh ──> loading(oldTasks)
├── 成功 ──> data(newTasks)
└── 失败 ──> failure(error, oldTasks)
是否保留旧数据必须成为显式状态设计,而不是由 Widget 根据几个零散字段猜测。
2. 处理异步竞态
最容易被忽略的问题是:请求返回顺序可能与发起顺序相反。
请求 A:先发出,较慢
请求 B:后发出,较快
B 返回新数据
A 返回旧数据
如果两个请求都直接写入状态,旧请求 A 会覆盖新请求 B。
一种简单的“最新请求获胜”策略是请求序号:
// features/tasks/application/task_controller.dart
import 'package:flutter/foundation.dart';
import '../domain/task.dart';
import '../domain/task_repository.dart';
import 'load_tasks.dart';
enum TaskStatus {
idle,
loading,
data,
failure,
}
class TaskState {
const TaskState({
this.status = TaskStatus.idle,
this.tasks = const [],
this.error,
});
final TaskStatus status;
final List<Task> tasks;
final Object? error;
}
class TaskController extends ChangeNotifier {
TaskController(this._repository) : _loadTasks = LoadTasks(_repository);
final TaskRepository _repository;
final LoadTasks _loadTasks;
TaskState _state = const TaskState();
int _requestId = 0;
bool _disposed = false;
TaskState get state => _state;
Future<void> load() async {
final int requestId = ++_requestId;
_setState(TaskState(
status: TaskStatus.loading,
tasks: _state.tasks,
));
try {
final tasks = await _loadTasks();
// 旧请求返回时,不再覆盖较新的请求结果。
if (_disposed || requestId != _requestId) {
return;
}
_setState(TaskState(
status: TaskStatus.data,
tasks: List<Task>.unmodifiable(tasks),
));
} catch (error) {
if (_disposed || requestId != _requestId) {
return;
}
_setState(TaskState(
status: TaskStatus.failure,
tasks: _state.tasks,
error: error,
));
}
}
Future<void> toggle(Task task) async {
try {
final updated = await _repository.updateTask(task.toggle());
final nextTasks = [
for (final item in _state.tasks)
if (item.id == updated.id) updated else item,
];
_setState(TaskState(
status: TaskStatus.data,
tasks: List<Task>.unmodifiable(nextTasks),
));
} catch (error) {
_setState(TaskState(
status: TaskStatus.failure,
tasks: _state.tasks,
error: error,
));
}
}
void _setState(TaskState next) {
if (_disposed) {
return;
}
_state = next;
notifyListeners();
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
}
这里的 _disposed 不是为了取消已经无法取消的 Future,而是防止对象销毁后继续更新状态。请求序号也不等于取消网络请求;如果底层客户端支持取消,还可以把取消令牌传入仓储。
ChangeNotifier 的生命周期要求是:调用 addListener 的对象不能在 dispose 后继续通知。将 Controller 放到 Provider、InheritedNotifier 或 Riverpod Provider 中时,应确认创建者负责何时销毁它。
3. 使用 InheritedNotifier 完成最小依赖注入
下面不依赖第三方包,使用 Flutter 自带的 InheritedNotifier:
// features/tasks/presentation/task_scope.dart
import 'package:flutter/widgets.dart';
import '../application/task_controller.dart';
class TaskScope extends InheritedNotifier<TaskController> {
const TaskScope({
super.key,
required TaskController controller,
required Widget child,
}) : super(notifier: controller, child: child);
static TaskController of(BuildContext context) {
final scope =
context.dependOnInheritedWidgetOfExactType<TaskScope>();
assert(scope != null, 'TaskScope was not found in the widget tree.');
return scope!.notifier!;
}
}
读取方式:
class TaskPage extends StatefulWidget {
const TaskPage({super.key});
@override
State<TaskPage> createState() => _TaskPageState();
}
class _TaskPageState extends State<TaskPage> {
@override
void didChangeDependencies() {
super.didChangeDependencies();
// 示例中只在首次进入时加载。
// 若依赖可能变化,应使用一个布尔值或更明确的生命周期策略。
}
@override
Widget build(BuildContext context) {
final controller = TaskScope.of(context);
final state = controller.state;
if (state.status == TaskStatus.loading && state.tasks.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (state.status == TaskStatus.failure && state.tasks.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('加载失败:${state.error}'),
ElevatedButton(
onPressed: controller.load,
child: const Text('重试'),
),
],
),
);
}
return ListView(
children: [
if (state.status == TaskStatus.loading)
const LinearProgressIndicator(),
for (final task in state.tasks)
CheckboxListTile(
value: task.completed,
title: Text(task.title),
onChanged: (_) => controller.toggle(task),
),
],
);
}
}
InheritedNotifier 的机制是:
TaskScope持有TaskController;- Controller 调用
notifyListeners(); InheritedNotifier感知通知;- 依赖
TaskScope的 Widget 重新构建; - Widget 读取新的
TaskState。
如果使用 Provider 或 Riverpod,树上的提供方式会不同,但架构原则不变:状态对象不应反过来寻找页面,仓储不应依赖 Widget 上下文。
四、组合根:依赖应在哪里创建
**组合根(Composition Root)**是应用中负责创建对象图的地方。Flutter 应用通常将它放在:
main.dart;app/composition_root.dart;- 测试入口;
- 某个明确的 Feature 子树入口。
例如:
// app/composition_root.dart
import 'package:flutter/widgets.dart';
import '../features/tasks/data/in_memory_task_repository.dart';
import '../features/tasks/application/task_controller.dart';
import '../features/tasks/presentation/task_scope.dart';
import '../features/tasks/presentation/task_page.dart';
Widget buildApp() {
final repository = InMemoryTaskRepository();
final controller = TaskController(repository);
return TaskScope(
controller: controller,
child: const TaskPage(),
);
}
完整启动:
// main.dart
import 'package:flutter/material.dart';
import 'app/composition_root.dart';
void main() {
runApp(
MaterialApp(
home: buildApp(),
),
);
}
这个例子有一个生命周期问题:controller 是手动创建的,但没有在 Widget 树销毁时调用 dispose()。生产代码可以让一个 Stateful Widget 管理它:
class TaskFeatureRoot extends StatefulWidget {
const TaskFeatureRoot({super.key});
@override
State<TaskFeatureRoot> createState() => _TaskFeatureRootState();
}
class _TaskFeatureRootState extends State<TaskFeatureRoot> {
late final TaskController controller;
@override
void initState() {
super.initState();
final repository = InMemoryTaskRepository();
controller = TaskController(repository);
controller.load();
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return TaskScope(
controller: controller,
child: const TaskPage(),
);
}
}
如果使用 Provider:
ChangeNotifierProvider(
create: (_) => TaskController(repository)..load(),
child: const TaskPage(),
)
由 create 创建的对象通常由 Provider 负责释放;如果传入外部已经拥有的对象,应使用与该生命周期语义匹配的提供方式,不能机械地混用 create 和 .value。具体 API 行为应以当前 Provider 版本文档为准。
如果使用 Riverpod,Provider 本身可以描述依赖图:
final taskRepositoryProvider = Provider<TaskRepository>((ref) {
return InMemoryTaskRepository();
});
final taskControllerProvider =
ChangeNotifierProvider<TaskController>((ref) {
final repository = ref.watch(taskRepositoryProvider);
return TaskController(repository);
});
Riverpod 的优势是依赖覆盖和测试替换较方便,但它不能阻止开发者把 HTTP 解析、导航或平台判断全部写进 Notifier。状态管理工具是实现机制,不是架构本身。
Service Locator 与依赖注入的差异
Service Locator 允许对象主动查找服务:
final repository = locator<TaskRepository>();
构造函数注入则显式声明依赖:
TaskController(this.repository);
两者都可以工作,但故障表现不同:
- 构造函数注入下,缺少依赖通常在编译期或创建对象时暴露;
- Service Locator 下,依赖关系隐藏在方法内部,运行时注册顺序错误才暴露;
- 构造函数注入更容易阅读对象图;
- Service Locator 在大型应用中可能减少构造样板,但需要严格限制访问边界。
一个实用的折中是:允许 Service Locator 只存在于组合根,进入 Feature 后转换为构造函数注入。
五、Feature 之间如何协作
Feature 之间不应任意互相引用内部实现。
不推荐:
// profile Feature 直接访问 tasks Feature 的内部状态
import '../tasks/presentation/task_controller.dart';
这种依赖会导致:
tasks的页面状态成为公开 API;- 修改 Controller 字段可能破坏
profile; - 测试某个 Feature 时需要组装另一个 Feature 的 UI 状态;
- 形成循环依赖。
更合适的方式有三类。
1. 通过稳定的领域类型通信
如果两个 Feature 共享的是业务概念,可以把稳定类型放入明确的共享模块:
core/domain/
user_id.dart
money.dart
但 core 不能变成“所有东西都可以放进去”的垃圾桶。共享类型应满足:
- 多个 Feature 确实需要;
- 类型具有稳定业务含义;
- 不携带具体页面状态;
- 不依赖 Flutter。
2. 通过应用服务或公开用例通信
例如订单支付需要当前用户身份,可以依赖:
abstract interface class CurrentUser {
UserId? get id;
}
而不是依赖 ProfilePage 或 ProfileController。
3. 通过路由参数传递身份
页面之间经常只需要一个 ID:
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => OrderDetailPage(orderId: orderId),
),
);
OrderDetailPage 根据 orderId 加载数据,而不是接收另一个页面的全部 Controller。这降低了页面之间的生命周期耦合。
六、平台边界:条件导入与能力抽象
1. 为什么不能直接在共享代码中导入 dart:io
以下代码在 Web 编译目标上会失败:
import 'dart:io';
String localPath() => Directory.current.path;
即使运行时分支不会执行,编译器仍然需要解析导入的库。因此平台相关代码必须通过条件导入、插件或平台接口隔离。
一个最小的条件导入结构:
lib/core/platform/
platform_info.dart
platform_info_stub.dart
platform_info_io.dart
platform_info_web.dart
公共入口:
// platform_info.dart
import 'platform_info_stub.dart'
if (dart.library.io) 'platform_info_io.dart'
if (dart.library.html) 'platform_info_web.dart';
PlatformInfo createPlatformInfo() => createPlatformInfoImpl();
abstract interface class PlatformInfo {
bool get supportsLocalFileSystem;
}
默认实现:
// platform_info_stub.dart
import 'platform_info.dart';
PlatformInfo createPlatformInfoImpl() => const StubPlatformInfo();
class StubPlatformInfo implements PlatformInfo {
const StubPlatformInfo();
@override
bool get supportsLocalFileSystem => false;
}
IO 实现:
// platform_info_io.dart
import 'dart:io';
import 'platform_info.dart';
PlatformInfo createPlatformInfoImpl() => const IoPlatformInfo();
class IoPlatformInfo implements PlatformInfo {
const IoPlatformInfo();
@override
bool get supportsLocalFileSystem => !Platform.isFuchsia;
}
Web 实现:
// platform_info_web.dart
import 'platform_info.dart';
PlatformInfo createPlatformInfoImpl() => const WebPlatformInfo();
class WebPlatformInfo implements PlatformInfo {
const WebPlatformInfo();
@override
bool get supportsLocalFileSystem => false;
}
这里要区分三种事实:
- 规范保证:条件导入根据编译环境选择 URI;
- 常见实现:
dart.library.io通常覆盖移动端和桌面 IO 环境; - 项目事实:某个插件是否支持 Web、Linux 或 macOS,必须查看该插件当前版本的支持矩阵。
不要仅凭 Platform.isAndroid 推断“这一定是移动端”。Flutter 目标还包括 Windows、macOS、Linux、Web 等环境;而 Web 也不支持 dart:io。
2. 将平台能力建模为能力,而不是端名称
不推荐:
if (Platform.isAndroid) {
uploadWithAndroidPicker();
} else if (kIsWeb) {
uploadWithBrowserPicker();
}
更好的边界是:
abstract interface class ImageSource {
Future<Uint8List?> pickImage();
}
业务只依赖:
final bytes = await imageSource.pickImage();
if (bytes == null) {
return; // 用户取消
}
await avatarRepository.upload(bytes);
平台实现分别处理:
- 浏览器文件选择;
- Android/iOS 图片选择器;
- 桌面文件对话框;
- 不支持该能力时返回明确错误。
这种设计的关键是让“能力不可用”成为可处理的业务状态,而不是让页面知道所有平台名称。
3. Flutter UI 层仍然可以有平台差异
隔离平台差异不意味着所有 Widget 必须完全相同。以下差异通常合理:
- Material 与 Cupertino 的视觉组件;
- 桌面键盘快捷键;
- Web 的响应式布局;
- 移动端触摸交互;
- 平台惯用的返回行为。
区别在于:UI 差异应局限在 Presentation,而不应让 Domain 和 Application 依赖 TargetPlatform。
例如可以在页面选择不同的展示组件:
Widget buildAction(BuildContext context) {
switch (Theme.of(context).platform) {
case TargetPlatform.iOS:
case TargetPlatform.macOS:
return CupertinoButton(
onPressed: () {},
child: const Text('保存'),
);
default:
return ElevatedButton(
onPressed: () {},
child: const Text('保存'),
);
}
}
但“保存是否允许”“保存失败如何重试”仍应由应用状态和用例决定,而不是由 Cupertino 或 Material 按钮实现。
七、数据流、错误路径和事务边界
一个典型的单向数据流如下:
flowchart TD
U[用户操作] --> W[Widget]
W --> C[Controller / Notifier / BLoC]
C --> UC[Application Use Case]
UC --> P[Domain Port]
P --> R[Data Adapter]
R --> X[HTTP / Database / Platform API]
X --> R
R --> P
P --> UC
UC --> C
C --> W
关键路径是:
- 用户点击按钮;
- Widget 调用 Controller 的公开方法;
- Controller 调用用例;
- 用例只依赖 Domain 接口;
- Data Adapter 访问 HTTP、数据库或平台 API;
- 结果沿原路径返回;
- Controller 转换状态并通知 Widget;
- Widget 根据状态重建。
错误路径也必须沿边界传播,而不是在每层随意吞掉:
SocketException
→ Data 层转换为 NetworkFailure
→ Application 决定是否重试或展示旧数据
→ Presentation 展示“网络错误”和重试入口
如果 Data 层直接抛出平台库异常,Presentation 就会被迫认识 SocketException、数据库异常和插件异常。可以定义应用错误:
sealed class AppFailure implements Exception {
const AppFailure(this.message);
final String message;
}
class NetworkFailure extends AppFailure {
const NetworkFailure(super.message);
}
class UnsupportedCapabilityFailure extends AppFailure {
const UnsupportedCapabilityFailure(super.message);
}
Dart 3 的 sealed class 可以帮助限制错误层次,但是否强制穷举仍取决于具体代码结构。错误转换要保留诊断信息,例如原始异常和堆栈可以通过日志记录,用户展示信息则应稳定、可本地化。
乐观更新的失败回滚
如果点击勾选后立即更新页面,属于乐观更新:
旧状态:completed = false
本地立即显示:completed = true
网络保存失败:恢复 false,并显示错误
这要求 Controller 保存旧值:
final before = _state.tasks;
_setState(showOptimisticResult());
try {
await repository.updateTask(task.toggle());
} catch (error) {
_setState(TaskState(
status: TaskStatus.failure,
tasks: before,
error: error,
));
}
如果没有保存旧状态,失败后只能重新加载;重新加载又可能覆盖用户当前编辑,或者因为网络不可用而无法恢复。是否采用乐观更新,应根据业务对暂存、冲突和回滚的要求决定,而不是因为“界面更快”就默认使用。
八、测试边界:验证每一层,而不是只测页面
架构边界的价值必须通过测试体现,否则只是目录结构。
1. Unit Test:验证 Domain 和 Application
对内存仓储和用例的单元测试:
import 'package:flutter_test/flutter_test.dart';
import 'package:your_app/features/tasks/data/in_memory_task_repository.dart';
import 'package:your_app/features/tasks/application/load_tasks.dart';
void main() {
test('LoadTasks returns repository tasks', () async {
final repository = InMemoryTaskRepository();
final useCase = LoadTasks(repository);
final tasks = await useCase();
expect(tasks, hasLength(2));
expect(tasks.first.title, '阅读架构文档');
});
}
这类测试不需要构建 Widget 树,失败原因更接近领域或数据逻辑。
2. 使用 Fake 测试竞态和错误
如果仓储接口是稳定的,可以写一个可控 Fake:
class DelayedTaskRepository implements TaskRepository {
DelayedTaskRepository(this.results);
final List<Future<List<Task>>> results;
int calls = 0;
@override
Future<List<Task>> loadTasks() {
return results[calls++];
}
@override
Future<Task> updateTask(Task task) async => task;
}
测试“后发请求先返回”:
test('older request cannot overwrite newer request', () async {
final first = Completer<List<Task>>();
final second = Completer<List<Task>>();
final repository = DelayedTaskRepository([
first.future,
second.future,
]);
final controller = TaskController(repository);
final firstLoad = controller.load();
final secondLoad = controller.load();
second.complete(const [
Task(id: 'new', title: '新结果', completed: false),
]);
await secondLoad;
first.complete(const [
Task(id: 'old', title: '旧结果', completed: false),
]);
await firstLoad;
expect(controller.state.tasks.single.id, 'new');
controller.dispose();
});
这里的验证重点不是“调用了几次”,而是状态转换的因果关系:旧请求虽然完成,但不能覆盖新请求。
3. Widget Test:验证状态到 UI 的映射
Widget 测试应验证:
- loading 是否展示进度指示器;
- data 是否展示任务;
- failure 是否展示错误和重试按钮;
- 用户操作是否调用正确的 Controller 方法。
页面不应在 Widget 测试中真的访问网络。把 Fake Controller 或 Fake Repository 注入组合根,可以让测试控制所有结果。
4. Golden Test:验证稳定的视觉契约
Golden 测试适合验证:
- 关键状态下的视觉布局;
- 浅色和深色主题;
- 不同屏幕尺寸下的关键组件;
- loading、empty、error、data 等固定状态。
但 Golden 不是跨平台像素完全一致的保证。字体、渲染器、操作系统和设备像素比可能导致差异。应固定测试环境,并避免把网络动画、时间和随机数据放进 Golden。
5. Integration Test:验证真实边界
Integration 测试适合验证:
- 启动应用;
- 登录流程;
- 路由跳转;
- 平台插件;
- 真实序列化和网络服务;
- Android、iOS、桌面或 Web 的端到端行为。
它们比 Unit 和 Widget 测试更慢,也更容易受到环境影响,因此不应承担所有逻辑验证。
6. Mock 的边界
Mock 应优先放在稳定的外部边界:
HTTP client
database adapter
platform capability
clock
UUID generator
不建议 Mock 每一个内部对象。过度 Mock 会让测试验证“调用顺序”,而不是验证行为;内部重构只要改变一次方法调用,测试就大量失败。
一个时间接口示例:
abstract interface class Clock {
DateTime now();
}
class SystemClock implements Clock {
@override
DateTime now() => DateTime.now();
}
业务规则依赖 Clock 后,测试可以传入固定时间,避免直接依赖系统时间。这个接口应存在于确实需要可控时间的边界,不应为了形式上的抽象给所有简单函数都增加接口。
九、常见错误及其失败原因
错误一:把所有代码都放进 core
表现:
core/
user_service.dart
order_controller.dart
home_page.dart
api.dart
utils.dart
core 本应承载跨 Feature 且稳定的基础能力。把所有共享代码都放入其中,会形成隐形全局模块,任何 Feature 都可以依赖它,最终失去边界。
诊断方法是问:
- 这个类型是否被多个 Feature 使用?
- 它是否具有稳定的跨业务语义?
- 它是否依赖具体页面或某个 Feature?
- 删除一个 Feature 后,
core是否仍然有意义?
如果答案是否定的,应把代码放回所属 Feature。
错误二:Repository 直接返回 JSON Map
表现:
Future<List<Map<String, dynamic>>> loadTasks();
这样会让页面或 Controller 处理字段名、空值和类型转换。失败表现包括:
- JSON 字段修改导致多个层同时修改;
dynamic错误在运行时才发现;- 测试无法清楚区分网络格式和业务对象。
Repository 的公开接口应返回领域对象或应用层定义的结果类型,DTO 只在 Data 边界使用。
错误三:Controller 兼任所有职责
一个 Controller 同时负责:
- 读取 SharedPreferences;
- 发送 HTTP;
- 解析 JSON;
- 判断平台;
- 选择路由;
- 显示 SnackBar;
- 管理页面状态。
失败时很难判断是数据错误、业务错误还是 UI 生命周期错误。诊断方式是查看 Controller 是否导入了大量具体实现库,或者测试它是否必须构建完整 Widget 树。若是,应将外部访问移入 Data,将规则移入 Domain/Application,将展示移入 Presentation。
错误四:在 build 中启动副作用
不推荐:
@override
Widget build(BuildContext context) {
context.read<TaskController>().load();
return const Text('Loading');
}
build 可能因为状态变化、窗口尺寸、主题或父节点更新而多次执行。这样会产生重复请求,甚至形成:
build → load → notifyListeners → build → load → ...
副作用应放在明确的生命周期或事件处理处,例如 initState、路由进入回调或用户点击处理函数。若使用声明式 Provider/Riverpod,也应理解其 provider 的创建、刷新和自动销毁规则。
错误五:只用 kIsWeb 判断所有平台
kIsWeb 只能说明是否编译为 Web,不等于完整的平台能力判断。桌面和移动端也有不同能力,Web 的浏览器环境还受权限、协议和浏览器策略影响。
更可靠的做法是:
业务能力接口
→ 当前平台实现
→ 不支持时的明确失败
页面展示“当前平台不支持导出”,比在多处复制平台判断更容易测试和维护。
错误六:把架构层级当作越多越好
如果一个只有几个页面的应用被拆成十几个接口、工厂和中间层,代码阅读成本会高于业务复杂度。分层的目的不是增加文件数量,而是隔离变化。
可以用变化来源判断是否需要边界:
- HTTP 协议可能变化:隔离 Data;
- 业务规则复杂且需要独立测试:抽出 Domain/Application;
- 页面状态复杂且有并发:抽出 Controller/BLoC/Notifier;
- 平台能力不同:抽出 Platform Port;
- 简单静态页面且无外部依赖:不必强行建立完整仓储层。
十、如何逐步改造已有项目
大型项目通常不能一次性重写。更安全的迁移路径是沿着依赖边界逐步移动。
第一步:先画出现有依赖图
记录以下关系:
页面 → 哪些服务
服务 → 哪些 API 或数据库
状态对象 → 哪些 Widget
平台判断 → 分布在哪些文件
重点寻找反向依赖,例如:
Repository → BuildContext
Domain → Flutter
HTTP Client → 页面状态类
这些通常是最先需要修正的耦合点。
第二步:从一个高价值 Feature 建立边界
不要先改所有目录。选择一个具有代表性的功能,例如任务列表或订单详情,建立:
domain
application
data
presentation
把原页面依赖的服务通过构造函数传入,确保该 Feature 可以单独测试。
第三步:先抽接口,再替换实现
如果页面直接依赖具体 HTTP 服务:
final ApiTaskService service;
可以先定义:
abstract interface class TaskRepository {
Future<List<Task>> loadTasks();
}
然后让原 HTTP 服务实现这个接口。此时行为不变,但依赖方向已经改善。随后再逐步把 JSON 映射和重试策略移入 Data/Application。
第四步:把平台分支收敛
搜索:
kIsWeb
Platform.isAndroid
Platform.isIOS
dart:io
dart:html
逐个判断它们表达的是:
- 展示差异;
- 数据源差异;
- 平台能力差异;
- 业务规则差异。
展示差异可以留在 Presentation;平台能力应移入 Platform Adapter;业务规则不能由端名称直接决定。
第五步:为边界补测试
迁移完成的最低证据应包括:
- Domain 规则的 Unit Test;
- Application 状态转换的 Unit Test;
- Feature 主要状态的 Widget Test;
- 关键端到端流程的 Integration Test;
- 视觉稳定部分的 Golden Test;
- 外部系统通过 Fake 或 Mock 替换。
执行项目测试:
flutter test
预期结果是测试命令返回退出码 0,并显示所有测试通过。若只运行某个文件:
flutter test test/features/tasks/task_controller_test.dart
运行前提是项目已经执行过 flutter pub get,测试文件中的包导入路径与 pubspec.yaml 的包名一致。架构迁移中不应只看“编译通过”;还要验证请求竞态、销毁后回调、错误恢复和平台不支持路径。
十一、最终边界检查
一个 Feature 的依赖关系可以用下面的规则检查:
Presentation
可以依赖 Application、Domain 的公开类型
不应依赖 Data 的具体实现
Application
可以依赖 Domain port 和领域类型
不应依赖 Widget、BuildContext、具体 HTTP SDK
Domain
只依赖 Dart 语言和领域内稳定类型
不应依赖 Flutter、平台 API、数据库和网络库
Data
可以依赖 Domain 接口
负责 HTTP、数据库、序列化和外部错误转换
Platform
负责端特有 API
通过能力接口向上提供稳定行为
Composition Root
负责创建具体实现
负责决定生命周期、配置和测试替换
还需要检查三种不同的依赖是否被混淆:
- 编译依赖:文件是否直接导入另一个模块;
- 运行时依赖:对象调用时实际使用哪个实现;
- 生命周期依赖:谁创建对象,谁销毁对象,异步回调何时失效。
大型应用中的很多 bug 并不是算法错误,而是这三种依赖没有同时建模。例如页面已经销毁,但请求仍返回并通知 Controller;或者 Web 构建路径仍解析到 dart:io;或者测试替换了仓储,却因为 Controller 自己创建了另一个仓储而没有生效。
结语
Flutter 大型应用架构的核心不是目录名称,也不是选择某个状态管理库,而是建立可验证的边界:
- 用分层限制依赖方向;
- 用 Feature 聚合业务变化;
- 用 Domain 接口隔离外部实现;
- 用依赖注入把对象组装移到组合根;
- 用状态模型明确异步、错误和并发;
- 用平台能力接口收敛 Android、iOS、桌面和 Web 差异;
- 用 Unit、Widget、Golden 和 Integration 测试验证这些边界确实成立。
当一个 Feature 可以在不启动真实网络、不依赖具体平台、不加载无关页面的情况下完成主要业务测试时,架构才真正产生了价值。
系列导航与关联阅读
- 系列入口:Flutter 完整学习路线:从 Dart 与 Widget 到多端架构和应用发布
- 上一篇:Flutter 错误处理与可观测性:Zone、日志、Crash、性能和隐私
- 下一篇:Flutter 应用发布:签名、Flavor、商店、Web/桌面、灰度和回滚
- 延伸:Flutter 状态管理:InheritedWidget、Provider、Riverpod、BLoC 和边界
- 延伸:Flutter 测试体系:Unit、Widget、Golden、Integration 和 Mock 边界
官方资料
本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论