超越教程的 Flutter 应用架构
生产级 Flutter 架构模式——从状态管理到导航、依赖注入和功能优先的项目结构。

教程中的 Flutter 应用在屏幕数量达到二十个左右之前运行良好。但之后,包含所有路由的单个 main.dart 文件变得难以管理,setState 调用会产生竞态条件,“通过构造函数向下传递”的方法会生成带有十个参数的 widget 树,并且每个新功能都会触及五个现有文件。我已经在多个项目中(包括我自己的项目)观察到这种情况,模式总是相同的——团队在大约三个月时遇到瓶颈,不得不重构或接受开发速度的持续下降。
我这里描述的架构是我用于生产级应用的方法。它不是最简单的方法,而这正是重点。最简单的方法是教程所教的,但当你的应用有了实际需求时,它就会失效。
为什么教程架构会失败
大多数 Flutter 教程和课程都教授扁平的项目结构:
lib/
├── main.dart
├── home_screen.dart
├── detail_screen.dart
├── user_model.dart
├── api_service.dart
└── widgets/
├── custom_button.dart
└── loading_indicator.dart
这对于一个待办事项应用来说是可行的。但对于一个真实的应用,它会因可预测的原因而失败:
文件增长没有自然边界。 当 home_screen.dart 包含屏幕布局、状态管理、API 调用和业务逻辑时,它会迅速达到 500 多行。没有关于应该提取什么以及提取到何处的结构性指导。
依赖关系变得隐式。 当 home_screen.dart 直接实例化 ApiService() 时,无法在测试中将其替换为模拟对象,无法根据不同环境进行不同配置,也无法在需要相同数据的屏幕之间共享单个实例。
导航变成硬编码路由的网。 深度链接、身份验证守卫和条件流都被附加到一个扁平的路由列表中。当你拥有三十个带有守卫和重定向的路由时,导航代码是应用中最脆弱的部分。
测试变得不切实际。 没有依赖注入,widget 测试需要在 HTTP 层面而不是服务层面进行模拟。如果没有将业务逻辑与 UI 明确分离,你最终会测试实现细节而不是行为。
功能优先的项目结构
最具影响力的架构决策是文件夹结构。我采用功能优先的方法,每个功能都拥有自己的屏幕、widget、状态和模型:
lib/
├── app/
│ ├── app.dart # MaterialApp configuration
│ ├── router.dart # GoRouter configuration
│ └── theme.dart # Theme data
├── core/
│ ├── constants/
│ │ ├── api_constants.dart
│ │ └── app_constants.dart
│ ├── errors/
│ │ ├── app_exception.dart
│ │ └── error_handler.dart
│ ├── network/
│ │ ├── api_client.dart
│ │ ├── interceptors/
│ │ └── network_info.dart
│ └── utils/
│ ├── date_utils.dart
│ └── string_utils.dart
├── features/
│ ├── auth/
│ │ ├── data/
│ │ │ ├── auth_repository.dart
│ │ │ └── auth_remote_source.dart
│ │ ├── domain/
│ │ │ ├── user_model.dart
│ │ │ └── auth_state.dart
│ │ ├── presentation/
│ │ │ ├── screens/
│ │ │ │ ├── login_screen.dart
│ │ │ │ └── register_screen.dart
│ │ │ ├── widgets/
│ │ │ │ ├── login_form.dart
│ │ │ │ └── social_login_buttons.dart
│ │ │ └── providers/
│ │ │ └── auth_provider.dart
│ │ └── auth.dart # Barrel file for public API
│ ├── home/
│ │ ├── data/
│ │ ├── domain/
│ │ ├── presentation/
│ │ └── home.dart
│ └── settings/
│ ├── data/
│ ├── domain/
│ ├── presentation/
│ └── settings.dart
└── shared/
├── widgets/
│ ├── app_button.dart
│ ├── app_text_field.dart
│ ├── loading_overlay.dart
│ └── error_view.dart
├── models/
│ └── paginated_response.dart
└── providers/
└── connectivity_provider.dart
规则
功能之间绝不相互导入。 如果功能 A 需要功能 B 的数据,该数据应存在于共享模型或共享服务中,而不是功能 B 的文件夹中。这可以防止循环依赖,并使功能能够独立部署(如果你将来转向模块化单体仓库,这将非常有用)。
core/ 目录包含基础设施。 网络、错误处理、常量、工具类——这些是每个功能可能都需要但又不属于任何单个功能的东西。
shared/ 目录包含可重用的 UI 和横切关注点。 共享 widget、共享模型以及多个功能依赖的 provider。
桶文件(Barrel files)暴露每个功能的公共 API。 应用的其他部分从 features/auth/auth.dart 导入,而不是从内部文件导入。这允许你在不破坏外部消费者的情况下重构功能的内部结构。
// features/auth/auth.dart
export 'domain/user_model.dart';
export 'domain/auth_state.dart';
export 'presentation/providers/auth_provider.dart';
// Don't export internal implementation details
状态管理:Riverpod
我在生产项目中都使用过 Provider、Bloc 和 Riverpod。每种都有其权衡,选择更多取决于团队的熟悉程度而非技术上的优越性。话虽如此,我在新项目中倾向于选择 Riverpod 有其特定原因。
为什么选择 Riverpod 而不是 Bloc
Bloc 对于有企业背景的团队来说非常出色。事件/状态模式对于使用过 Redux 或 MVI 的开发者来说很熟悉。它强制执行严格的单向数据流,并生成可预测、可测试的状态机。缺点是冗长——一个简单的功能需要一个事件类、一个状态类和一个 bloc 类,通常分布在三个文件中。对于具有复杂业务逻辑和多个开发者的应用来说,这种仪式感是值得的。对于快速行动的小团队来说,这可能会感觉像是一种开销。
Provider(原始包)对于简单的依赖注入效果很好,但在处理复杂状态时却力不从心。它依赖于 widget 树进行作用域管理,这意味着你的状态生命周期与你的 UI 结构绑定。重构 widget 树可能会以微妙的方式破坏状态管理。
Riverpod 解决了 Provider 的根本局限性。Provider 是全局的(不依赖于 widget 树),编译时安全,并支持自动销毁、family provider 和异步状态等高级模式。它还比 Bloc 生成更少的样板代码,同时保持了可测试性。
Riverpod 实践
以下是我为典型功能构建状态管理的方式:
// features/auth/domain/auth_state.dart
import 'package:freezed_annotation/freezed_annotation.dart';
import 'user_model.dart';
part 'auth_state.freezed.dart';
@freezed
class AuthState with _$AuthState {
const factory AuthState.initial() = _Initial;
const factory AuthState.loading() = _Loading;
const factory AuthState.authenticated(User user) = _Authenticated;
const factory AuthState.unauthenticated() = _Unauthenticated;
const factory AuthState.error(String message) = _Error;
}
// features/auth/presentation/providers/auth_provider.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../data/auth_repository.dart';
import '../../domain/auth_state.dart';
part 'auth_provider.g.dart';
@riverpod
class AuthNotifier extends _$AuthNotifier {
@override
AuthState build() {
_checkAuthStatus();
return const AuthState.initial();
}
Future<void> _checkAuthStatus() async {
final repository = ref.read(authRepositoryProvider);
final user = await repository.getCurrentUser();
state = user != null
? AuthState.authenticated(user)
: const AuthState.unauthenticated();
}
Future<void> signIn(String email, String password) async {
state = const AuthState.loading();
final repository = ref.read(authRepositoryProvider);
try {
final user = await repository.signIn(email, password);
state = AuthState.authenticated(user);
} on AuthException catch (e) {
state = AuthState.error(e.message);
}
}
Future<void> signOut() async {
final repository = ref.read(authRepositoryProvider);
await repository.signOut();
state = const AuthState.unauthenticated();
}
}
使用 @riverpod 注解和代码生成消除了手动声明 provider 的样板代码。运行 dart run build_runner build 来生成 provider 代码。
何时 Bloc 更优
尽管我偏爱 Riverpod,但在某些情况下 Bloc 是更强的选择:
- 大型团队,强制模式可以防止不一致。
- 具有许多转换的复杂状态机——Bloc 的事件系统使这些转换明确且可测试。
- 当你需要重放/撤销功能时——Bloc 的事件日志使其变得简单明了。
- 当团队已经熟悉 Bloc 时。 代码库的一致性比两者之间的任何技术差异都更重要。
使用 GoRouter 进行导航
GoRouter 已成为 Flutter 导航的标准,这是有充分理由的。它开箱即用地支持声明式路由、深度链接、重定向和嵌套导航。
// app/router.dart
import 'package:go_router/go_router.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'router.g.dart';
@riverpod
GoRouter router(RouterRef ref) {
final authState = ref.watch(authNotifierProvider);
return GoRouter(
initialLocation: '/',
redirect: (context, state) {
final isAuthenticated = authState is Authenticated;
final isAuthRoute = state.matchedLocation.startsWith('/auth');
if (!isAuthenticated && !isAuthRoute) return '/auth/login';
if (isAuthenticated && isAuthRoute) return '/';
return null;
},
routes: [
GoRoute(
path: '/auth/login',
builder: (context, state) => const LoginScreen(),
),
GoRoute(
path: '/auth/register',
builder: (context, state) => const RegisterScreen(),
),
ShellRoute(
builder: (context, state, child) => AppShell(child: child),
routes: [
GoRoute(
path: '/',
builder: (context, state) => const HomeScreen(),
),
GoRoute(
path: '/settings',
builder: (context, state) => const SettingsScreen(),
),
GoRoute(
path: '/item/:id',
builder: (context, state) {
final id = state.pathParameters['id']!;
return ItemDetailScreen(itemId: id);
},
),
],
),
],
);
}
导航守卫
redirect 回调全局处理身份验证守卫。但你可能需要更复杂的逻辑——基于角色的访问、新手引导流程、功能标志。我通过重定向链来处理这些:
redirect: (context, state) {
// Authentication check
final isAuthenticated = authState is Authenticated;
if (!isAuthenticated && !state.matchedLocation.startsWith('/auth')) {
return '/auth/login';
}
// Onboarding check
if (isAuthenticated && !hasCompletedOnboarding && state.matchedLocation != '/onboarding') {
return '/onboarding';
}
// Already authenticated, redirect away from auth screens
if (isAuthenticated && state.matchedLocation.startsWith('/auth')) {
return '/';
}
return null; // 不重定向
},
仓库模式(Repository Pattern)
仓库(Repository)位于你的业务逻辑和数据源之间。它们抽象了数据是来自 REST API、本地数据库、缓存还是三者的组合。
// features/auth/data/auth_repository.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../domain/user_model.dart';
part 'auth_repository.g.dart';
@riverpod
AuthRepository authRepository(AuthRepositoryRef ref) {
return AuthRepository(
remoteSource: ref.read(authRemoteSourceProvider),
localSource: ref.read(authLocalSourceProvider),
);
}
class AuthRepository {
final AuthRemoteSource _remoteSource;
final AuthLocalSource _localSource;
AuthRepository({
required AuthRemoteSource remoteSource,
required AuthLocalSource localSource,
}) : _remoteSource = remoteSource,
_localSource = localSource;
Future<User?> getCurrentUser() async {
// Try local cache first
final cachedUser = await _localSource.getCachedUser();
if (cachedUser != null) return cachedUser;
// Fall back to remote
try {
final user = await _remoteSource.fetchCurrentUser();
if (user != null) await _localSource.cacheUser(user);
return user;
} catch (_) {
return null;
}
}
Future<User> signIn(String email, String password) async {
final user = await _remoteSource.signIn(email, password);
await _localSource.cacheUser(user);
await _localSource.saveToken(user.token);
return user;
}
Future<void> signOut() async {
await _remoteSource.signOut();
await _localSource.clearCache();
}
}
为什么不直接调用 API
从状态管理代码直接调用 API 会产生几个问题:
- 缓存逻辑渗透到业务逻辑中。 你会在 API 调用之前检查缓存吗?缓存的有效期是多久?这是数据层的问题,而不是状态管理的问题。
- 离线支持变成重写。 如果你的 Bloc 直接调用
http.get,添加离线支持意味着修改每个 Bloc。使用仓库,你可以在一个地方添加缓存和回退逻辑。 - 测试需要 HTTP 模拟。 模拟一个仓库是每个测试一个模拟。模拟 HTTP 需要为每个测试用例设置响应体、状态码和头部。
服务层
对于跨多个仓库或涉及复杂业务逻辑的操作,我使用服务层:
class OrderService {
final OrderRepository _orderRepo;
final PaymentRepository _paymentRepo;
final NotificationRepository _notificationRepo;
OrderService({
required OrderRepository orderRepo,
required PaymentRepository paymentRepo,
required NotificationRepository notificationRepo,
}) : _orderRepo = orderRepo,
_paymentRepo = paymentRepo,
_notificationRepo = notificationRepo;
Future<Order> placeOrder(Cart cart, PaymentMethod method) async {
// Validate stock
await _orderRepo.validateStock(cart.items);
// Process payment
final payment = await _paymentRepo.charge(method, cart.total);
// Create order
final order = await _orderRepo.create(
items: cart.items,
paymentId: payment.id,
);
// Send confirmation (fire and forget)
_notificationRepo.sendOrderConfirmation(order).ignore();
return order;
}
}
服务包含不属于 UI 或任何单个仓库的业务逻辑。状态管理层(Riverpod notifier, Bloc)调用服务,服务在仓库之间进行协调。
错误处理
一致的错误处理是 Flutter 架构中最容易被忽视的方面之一。我使用类型化异常和结果类型相结合的方式。
// core/errors/app_exception.dart
sealed class AppException implements Exception {
final String message;
final String? code;
const AppException(this.message, {this.code});
}
class NetworkException extends AppException {
const NetworkException([String message = 'Network error']) : super(message);
}
class ServerException extends AppException {
final int statusCode;
const ServerException(this.statusCode, String message) : super(message);
}
class AuthException extends AppException {
const AuthException([String message = 'Authentication failed']) : super(message);
}
class ValidationException extends AppException {
final Map<String, String> fieldErrors;
const ValidationException(this.fieldErrors) : super('Validation failed');
}
API 客户端捕获 HTTP 错误并将其转换为类型化异常:
// core/network/api_client.dart
class ApiClient {
final Dio _dio;
ApiClient(this._dio);
Future<T> get<T>(String path, {T Function(dynamic)? fromJson}) async {
try {
final response = await _dio.get(path);
return fromJson != null ? fromJson(response.data) : response.data as T;
} on DioException catch (e) {
throw _mapError(e);
}
}
AppException _mapError(DioException error) {
if (error.type == DioExceptionType.connectionTimeout ||
error.type == DioExceptionType.receiveTimeout) {
return const NetworkException('Connection timed out');
}
final statusCode = error.response?.statusCode;
if (statusCode == null) return const NetworkException();
return switch (statusCode) {
401 => const AuthException('Session expired'),
403 => const AuthException('Access denied'),
422 => ValidationException(
_parseValidationErrors(error.response?.data),
),
_ => ServerException(statusCode, 'Server error'),
};
}
}
测试架构
良好的架构使测试变得简单。每一层都有自己的测试策略:
仓库测试
void main() {
late AuthRepository repository;
late MockAuthRemoteSource mockRemote;
late MockAuthLocalSource mockLocal;
setUp(() {
mockRemote = MockAuthRemoteSource();
mockLocal = MockAuthLocalSource();
repository = AuthRepository(
remoteSource: mockRemote,
localSource: mockLocal,
);
});
group('getCurrentUser', () {
test('returns cached user when available', () async {
final user = User(id: '1', name: 'Test');
when(() => mockLocal.getCachedUser()).thenAnswer((_) async => user);
final result = await repository.getCurrentUser();
expect(result, equals(user));
verifyNever(() => mockRemote.fetchCurrentUser());
});
test('fetches from remote when cache is empty', () async {
final user = User(id: '1', name: 'Test');
when(() => mockLocal.getCachedUser()).thenAnswer((_) async => null);
when(() => mockRemote.fetchCurrentUser()).thenAnswer((_) async => user);
when(() => mockLocal.cacheUser(any())).thenAnswer((_) async {});
final result = await repository.getCurrentUser();
expect(result, equals(user));
verify(() => mockLocal.cacheUser(user)).called(1);
});
});
}
使用 Riverpod 进行 Provider 测试
void main() {
test('signIn updates state to authenticated', () async {
final user = User(id: '1', name: 'Test');
final mockRepository = MockAuthRepository();
when(() => mockRepository.signIn(any(), any()))
.thenAnswer((_) async => user);
final container = ProviderContainer(
overrides: [
authRepositoryProvider.overrideWithValue(mockRepository),
],
);
final notifier = container.read(authNotifierProvider.notifier);
await notifier.signIn('test@test.com', 'password');
final state = container.read(authNotifierProvider);
expect(state, AuthState.authenticated(user));
});
}
Riverpod 的 ProviderContainer 和覆盖机制使测试变得清晰——你在容器级别而不是 widget 树级别覆盖依赖项。
实际权衡
这种架构并非没有代价。存在实际成本:
更多文件。 在教程应用中可能只有一个文件的功能,在这种结构下可能会变成五到八个文件。对于小型应用(少于十个屏幕),这可能感觉像是过度设计。
学习曲线。 新加入项目的开发者需要理解层级边界以及如何放置内容。文档会有所帮助,但仍然需要适应时间。
代码生成。 Riverpod 的注解系统、用于不可变模型的 Freezed 以及 JSON 序列化都需要 build_runner。构建生成增加了开发步骤,并且在大型代码库上可能会很慢。
过度抽象的诱惑。 当你拥有一个清晰的架构时,很容易“以防万一”地添加抽象。为一个数据源创建一个仓库接口,而该数据源将永远只有一个实现,这会增加复杂性而没有价值。我只在有具体的第二个实现时才创建抽象,而不是在此之前。
当你的应用拥有超过大约十个屏幕、不止一个开发者,或者生命周期超过几个月时,这种架构是值得的。对于黑客马拉松项目或概念验证,使用最简单可行的方法即可。但当你知道应用需要成长时,早期投资于结构可以在后期节省数倍的成本。