Skip to main content
mobile28 декабря 2025 г.11 мин чтения

Архитектура Flutter-приложений, масштабируемая за пределы учебника

Паттерны архитектуры Flutter для продакшн-приложений — от управления состоянием до навигации, внедрения зависимостей и структуры проекта, ориентированной на фичи.

flutterarchitecturemobile
Архитектура Flutter-приложений, масштабируемая за пределы учебника

Учебные Flutter-приложения отлично работают, пока не достигают примерно двадцати экранов. Затем единственный файл main.dart со всеми маршрутами становится неуправляемым, вызовы setState создают состояния гонки, подход «просто передай через конструктор» приводит к деревьям виджетов с десятью параметрами, а каждая новая функция затрагивает пять существующих файлов. Я наблюдал это на нескольких проектах, включая свои собственные, и шаблон всегда один и тот же — команда сталкивается с проблемой примерно через три месяца и вынуждена либо реструктурировать, либо смириться с потерей скорости разработки.

Архитектура, которую я здесь описываю, — это то, что я использую для продакшн-приложений. Это не самый простой подход, и в этом вся суть. Самый простой подход — это то, чему учат в учебниках, и он перестает работать в тот момент, когда ваше приложение имеет реальные требования.

Почему архитектура из учебников терпит неудачу

Большинство учебников и курсов по 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(), нет способа заменить его на мок в тестах, нет способа настроить его по-разному для каждой среды и нет способа совместно использовать один экземпляр на экранах, которым нужны одни и те же данные.

Навигация становится паутиной жестко закодированных маршрутов. Глубокие ссылки, охранники аутентификации и условные потоки — все это прикрепляется к плоскому списку маршрутов. К тому времени, когда у вас есть тридцать маршрутов с охранниками и перенаправлениями, код навигации становится самой хрупкой частью приложения.

Тестирование становится непрактичным. Без внедрения зависимостей, виджет-тесты требуют мокирования на уровне HTTP, а не на уровне сервиса. Без четкого разделения бизнес-логики от UI, вы в конечном итоге тестируете детали реализации, а не поведение.

Структура проекта, ориентированная на фичи

Наиболее значимое архитектурное решение — это структура папок. Я использую подход, ориентированный на фичи, где каждая фича владеет своими экранами, виджетами, состоянием и моделями:

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 и сквозные аспекты. Общие виджеты, общие модели и провайдеры, от которых зависят несколько фич.

Barrel-файлы предоставляют публичный 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. Он обеспечивает строгий однонаправленный поток данных и генерирует предсказуемые, тестируемые конечные автоматы. Недостатком является многословность — простая функция требует класса события, класса состояния и класса блока, часто в трех файлах. Для приложений со сложной бизнес-логикой и несколькими разработчиками эта церемония окупается. Для небольших команд, работающих быстро, это может показаться излишним.

Provider (оригинальный пакет) хорошо работает для простого внедрения зависимостей, но испытывает трудности со сложным состоянием. Он зависит от дерева виджетов для определения области видимости, что означает, что жизненный цикл вашего состояния привязан к структуре вашего UI. Рефакторинг дерева виджетов может незаметно нарушить управление состоянием.

Riverpod решает фундаментальные ограничения 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 с генерацией кода устраняет шаблонный код ручного объявления провайдера. Запустите dart run build_runner build, чтобы сгенерировать код провайдера.

Когда 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;  // No redirect
},

Паттерн Репозиторий

Репозитории находятся между вашей бизнес-логикой и источниками данных. Они абстрагируют, откуда поступают данные: из 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

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));
  });
}

Механизм ProviderContainer и переопределения Riverpod делает тестирование чистым — вы переопределяете зависимости на уровне контейнера, а не на уровне дерева виджетов.

Практические компромиссы

Эта архитектура не бесплатна. Есть реальные издержки:

Больше файлов. Фича, которая в учебном приложении была бы одним файлом, в этой структуре может занимать от пяти до восьми файлов. Для небольших приложений (менее десяти экранов) это может показаться излишним.

Кривая обучения. Разработчику, новому в проекте, необходимо понять границы слоев и куда что помещать. Документация помогает, но все равно требуется время на освоение.

Генерация кода. Система аннотаций Riverpod, Freezed для неизменяемых моделей и сериализация JSON — все это требует build_runner. Генерация сборки добавляет шаг к разработке и может быть медленной на больших кодовых базах.

Искушение чрезмерной абстракции. Когда у вас есть чистая архитектура, легко добавлять абстракции «на всякий случай». Интерфейс репозитория для источника данных, который всегда будет иметь только одну реализацию, добавляет сложности без ценности. Я создаю абстракции, когда у меня есть конкретная вторая реализация, а не раньше.

Эта архитектура стоит того, когда ваше приложение имеет более десяти экранов, более одного разработчика или срок службы более нескольких месяцев. Для хакатон-проекта или доказательства концепции используйте самое простое, что работает. Но когда вы знаете, что приложению нужно расти, инвестиции в структуру на раннем этапе экономят многократные затраты позже.

DU

Danil Ulmashev

Full Stack Developer

Хотите работать вместе?