Skip to main content
mobile2025年12月28日6 分钟阅读

超越教程的 Flutter 应用架构

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

flutterarchitecturemobile
超越教程的 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 会产生几个问题:

  1. 缓存逻辑渗透到业务逻辑中。 你会在 API 调用之前检查缓存吗?缓存的有效期是多久?这是数据层的问题,而不是状态管理的问题。
  2. 离线支持变成重写。 如果你的 Bloc 直接调用 http.get,添加离线支持意味着修改每个 Bloc。使用仓库,你可以在一个地方添加缓存和回退逻辑。
  3. 测试需要 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。构建生成增加了开发步骤,并且在大型代码库上可能会很慢。

过度抽象的诱惑。 当你拥有一个清晰的架构时,很容易“以防万一”地添加抽象。为一个数据源创建一个仓库接口,而该数据源将永远只有一个实现,这会增加复杂性而没有价值。我只在有具体的第二个实现时才创建抽象,而不是在此之前。

当你的应用拥有超过大约十个屏幕、不止一个开发者,或者生命周期超过几个月时,这种架构是值得的。对于黑客马拉松项目或概念验证,使用最简单可行的方法即可。但当你知道应用需要成长时,早期投资于结构可以在后期节省数倍的成本。

DU

Danil Ulmashev

Full Stack Developer

有兴趣一起合作吗?