5.14. Архитектурные фреймворки: преимущества и опасности

Иногда при разработке на Flutter мы можем применять дополнительные библиотеки — GetX или Riverpod. Они повышают удобство и скорость разработки, но вместе с тем вносят собственные архитектурные паттерны и диктуют правила, выходящие за рамки типичного Flutter-кода. То есть создают собственный «диалект» поверх Flutter: будь то реактивные контроллеры GetX или провайдеры Riverpod, требующие описывать бизнес-логику внутри них.

Всё это пронизывает кодовую базу и порождает так называемый vendor lock — жесткую завязку на конкретный фреймворк, а как следствие — сложность миграции и нарастающую зависимость от экосистемы фреймворка.

В этом параграфе мы:

  • изучим, как именно такие библиотеки могут вызвать vendor lock;

  • найдём альтернативы, которые помогут устранить эти проблемы;

  • придумаем, как минимизировать риски vendor lock при выборе библиотек.

Удобство против независимости

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

Flutter — это фреймворк, потому что он определяет стандартные компоненты, архитектурные паттерны и структуру, по которой строится приложение. В результате проект на Flutter будет значительно отличаться от того же проекта на чистом Dart или другом фреймворке, например AngularDart или Jaspr. Dart задаёт базовый синтаксис и правила, а Flutter и другие фреймворки надстраиваются над этими правилами, добавляя свои ограничения и возможности.

То есть фреймворк — это набор ограничений, которые структурируют код.

Но когда вы используете библиотеку внутри библиотеки (например, GetX или Riverpod внутри Flutter), вы принимаете двойные ограничения:

  1. Правила Flutter (виджеты, контекст, жизненный цикл).
  2. Правила дочернего фреймворка (контроллеры GetX, провайдеры Riverpod).

Например:

  • GetX заменяет нативную навигацию Flutter (Navigator) на Get.to(), а StatefulWidget — на GetXController.
  • Riverpod вводит WidgetRef и StateProvider, которые становятся обязательными для доступа к состоянию.

Такие решения упрощают разработку, но создают кодовую базу с собственными правилами, отличными от чистого Flutter. Как следствие:

  1. Код нельзя переиспользовать в другом проекте без глубокой переработки.

  2. Усложняется интеграция с другими инструментами и библиотеками.

  3. Усложняется миграция при мажорных изменениях фреймворка.

  4. Появляется риск стремительного устаревания проекта вместе с фреймворком, если он перестанет поддерживаться.

  5. Может быть разнородное использование подходов: как фреймворка, так и классического Flutter, что может приводить к не ожидаемому поведению

В противовес плюсом таких фреймворков становится простота и удобство использования, краткосрочная скорость разработки.

Далее давайте проанализируем примеры vendor lock у архитектурных фреймворков — GetX и Riverpod

Архитектурные фреймворки

Давайте чуть конкретнее взглянем на несколько примеров архитектурных фреймворков, доступных в экосистеме Flutter.

GetX

Вокруг библиотеки GetX бушуют неутихающие споры — о выбранных технических решениях и её применимости для крупных проектов/ Подробнее с аргументами за и против можно познакомиться, например, на дебатах на Flutter Voronezh Meetup, здесь же мы сфокусируемся на том, чтобы продемонстрировать описанные выше сложности.

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

1// GetX-специфика:
2Get.to(ProfileScreen());
  • Все экраны проекта зависят от Get.to().
  • Переход на другой роутер (например, GoRouter) потребует полной замены всех вызовов навигации.
  • Модификация аргументов (например, добавление анимации) будет ограничена возможностями GetX.

Глобальные контроллеры через Get.put()

1class AuthController extends GetxController {
2  final _isLogged = false.obs;
3  bool get isLogged => _isLogged.value;
4}  
5
6// Инициализация:
7Get.put(AuthController());
8// Использование в любом месте:
9Get.find<AuthController>().isLogged;
  • Бизнес-логика жестко привязана к GetxController и глобальному контексту GetX.
  • Если нужно мигрировать на другой подход к связыванию зависимостей, потребуется переписывать все контроллеры во всём проекте и заменять Get.find() на другой инструмент.
  • Тестирование невозможно без инициализации GetX-контекста, что усложняет модульные тесты.

Встроенный Dependency Injection

1// Регистрация сервиса:
2Get.lazyPut(() => ApiService());
3// Получение зависимости:
4final api = Get.find<ApiService>();
  • Инъекция зависимостей через GetX проникает во все слои приложения.
  • Попытка заменить GetX потребует:
    • Переписать все Get.find().
    • Пересобрать граф зависимостей с нуля.
    • Обновить тесты, которые раньше зависели от Get.testMode.

Похожие специфичные механизмы, которые фреймворк распространяет на весь проект, есть и в Riverpod.

Riverpod

Даже прогрессивный Riverpod всё чаще навязывает свою архитектуру.

Повсеместное использование ConsumerWidget

ConsumerWidget — основной способ связать Riverpod-провайдеры с UI приложения. Чтобы получить доступ к провайдерам, мы должны отнаследоваться от ConsumerWidget. Тогда в методе build будет доступ к WidgetRef — а он, в свою очередь, предоставляет доступ ко всем зависимостям, которые зарегистрированы в Riverpod-контейнере с зависимостями.

1// Riverpod-специфика: WidgetRef, StateProvider
2final userProvider = StateProvider<User>((ref) => User());
3
4// Обязательно наследование от ConsumerWidget,
5// что противоречит идее Flutter о композиции виджетов.
6class ProfileScreen extends ConsumerWidget {
7  @override
8  Widget build(context, ref) {
9    final user = ref.watch(userProvider);
10    return Text(user.name);
11  }
12}
  • WidgetRef и провайдеры становятся обязательными для любого UI-компонента.

Глубокое проникновение family-провайдеров

FamilyProvider — класс из Riverpod, который позволяет выполнять какую-то логику или инстанцировать вашу сущность в зависимости от внешнего параметра. Давайте рассмотрим на примере:

1// Провайдер с параметром:
2final userPostsProvider = FutureProvider.family<List<Post>, String>(
3  (ref, userId) => ref.watch(apiClientProvider).fetchPosts(userId),
4);
5
6// Использование в виджете:
7class UserPostsWidget extends ConsumerWidget {
8  final String userId;
9
10  @override
11  Widget build(BuildContext context, WidgetRef ref) {
12    final posts = ref.watch(userPostsProvider(userId));
13    return posts.when(
14      data: (posts) => ListView.builder(/* ... */),
15      error: (_, __) => Text('Ошибка загрузки'),
16      loading: () => CircularProgressIndicator(),
17    );
18  }
19}
20
21// Использование в другом месте:
22void _onRefresh(String userId) {
23  ref.invalidate(userPostsProvider(userId)); // Инвалидация по family-параметру
24}
  • family-провайдеры пронизывают все слои приложения.
  • Виджеты (UserPostsWidget) зависят от передачи userId в провайдер.
  • Логика обновления данных (_onRefresh) использует ref.invalidate с family-параметром.

Кодогенерация бизнес-логики через @riverpod

1// Кодогенерируемый провайдер
2@riverpod
3class UserProfile extends _$UserProfile {
4  // Логика внутри кодгена — зависимость от Riverpod-специфичных классов
5  @override
6  FutureOr<User> build(String userId) async {
7    return ref.watch(apiClientProvider).fetchUser(userId);
8  }
9
10  // Методы — часть кодгена
11  Future<void> updateName(String name) async {
12    state = const AsyncValue.loading();
13    try {
14      await ref.read(apiClientProvider).updateUserName(userId, name);
15      state = AsyncValue.data(await build(userId));
16    } catch (e) {
17      state = AsyncValue.error(e, StackTrace.current);
18    }
19  }
20}
21
22// Использование:
23ref.read(userProfileProvider('123').notifier).updateName('New Name');
  • Бизнес-логика описывается внутри кодгенерируемого класса UserProfile, который наследуется от Riverpod-специфичных базовых сущностей.
  • Без глубокого понимания работы кодогенерации библиотеки невозможно однозначно сказать, как именно будет себя вести данная бизнес-логика при вызове из других мест приложения.

То есть чем больше проект использует уникальные возможности GetX (глобальные контроллеры или статические обращения к Get) или Riverpod (кодогенерация, family, WidgetRef), тем сильнее он становится заложником фреймворка:

  1. Архитектурные решения диктуются фреймворком, а не требованиями проекта.
  2. Миграция на другие инструменты требует переписывания большой части кодовой базы.
  3. Обновления фреймворка могут превратиться в длительный рефакторинг.

Ниже поговорим, как ослабить зависимость от архитектурных фреймворков.

Альтернативы

В качестве альтернативы архитектурным фреймворкам приведём несколько примеров того, как может быть организован менее инвазивный подход, когда библиотека не формирует образ всего проекта, а сфокусированно влияет только на отдельные изолированные части кода.

Cubit или BLoC

Давайте рассмотрим простейший пример со счётчиком. Из предыдущего параграфа про Bloc вы уже знакомы с Cubit. Здесь мы используем Cubit в качестве инструмента управления состоянием и прокидываем его в UI. Cubit, как и Bloc, выступает альтернативой для GetxController из GetX или для связки Provider + StateNotifier из Riverpod.

1class CounterCubit extends Cubit<int> {
2  CounterCubit() : super(0);
3
4  void increment() => emit(state + 1);
5}

Использование в виджете:

1class CounterWidget extends StatelessWidget {
2  @override
3  Widget build(BuildContext context) {
4    return BlocProvider(
5      create: (_) => CounterCubit(),
6      child: Builder(
7        builder: (context) {
8          final cubit = context.read<CounterCubit>();
9          
10          return Scaffold(
11            body: Center(
12              child: BlocBuilder<CounterCubit, int>(
13                builder: (_, count) => Text('Count: $count'),
14              ),
15            ),
16            floatingActionButton: FloatingActionButton(
17              onPressed: cubit.increment,
18              child: const Icon(Icons.add),
19            ),
20          );
21        },
22      ),
23    );
24  }
25}
  • Несмотря на наследование от Cubit, вся бизнес-логика описывается и используется с помощью стандартных средств языка, а специфика Cubit сконцентрирована в единственном методе emit(), отвечающем за мутацию внутреннего состояния класса.

  • BlocBuilder можно заменить на StreamBuilder или ValueListenableBuilder, если убрать пакет flutter_bloc, UI-слой сохраняет подход композиции виджетов и может использовать любой способ реактивного донесения изменений до виджетов.

Однако класс Bloc уже чуть более «фреймворк», чем Cubit, — он уже навязывает некоторый собственный контракт использования — отправка Event и их обработка через on.

ChangeNotifier

Ещё один пример — ChangeNotifier. Это стандартный подход во Flutter для управления состоянием. Мы описываем необходимые нам переменные в ChangeNotifier, а при их изменении вызываем метод notifyListeners, который уведомляет подписчиков о том, что состояние изменилось. Это один из самых простых и прямолинейных инструментов для управления состоянием, который часто может быть гибкой альтернативой более комплексным инструментам, таким как Riverpod, GetX или даже BLoC.

1// Бизнес-логика: зависит только от Flutter Core
2class CounterNotifier extends ChangeNotifier {
3  int _count = 0;
4  int get count => _count;
5
6  void increment() {
7    _count++;
8    notifyListeners();
9  }
10}

Использование в UI:

1class CounterWidget extends StatefulWidget {
2  const CounterWidget({super.key});
3
4  @override
5  State<CounterWidget> createState() => _CounterWidgetState();
6}
7
8class _CounterWidgetState extends State<CounterWidget> {
9  final CounterNotifier _notifier = CounterNotifier();
10
11  @override
12  void dispose() {
13    _notifier.dispose();
14    super.dispose();
15  }
16
17  @override
18  Widget build(BuildContext context) {
19    return Scaffold(
20      body: Center(
21        // ListenableBuilder подписывается на изменения ChangeNotifier
22        child: ListenableBuilder(
23          listenable: _notifier,
24          builder: (context, _) {
25            return Text('Count: ${_notifier.count}');
26          },
27        ),
28      ),
29      floatingActionButton: FloatingActionButton(
30        onPressed: _notifier.increment,
31        child: const Icon(Icons.add),
32      ),
33    );
34  }
35}
  • ChangeNotifier и ListenableBuilder — встроенный класс Flutter, а не часть внешней библиотеки.

Как видите, BLoC и ChangeNotifier позволяют сохранить контроль над архитектурой.

Они:

  • Не навязывают свой «диалект» (вроде .obs в GetX или ref.watch в Riverpod).
  • Делают код понятным даже тем, кто не знаком с конкретной библиотекой.

Пример из практики: как fish_redux стал техническим долгом

На старте разработки команда Яндекс Про выбрала fish_redux для структурирования проекта. Фреймворк обеспечивал понятную структуру на любом уровне проекта: как формировать слои бизнес-логики, навигацию, фичи и UI. Кроме того, тогда во Flutter ещё не было сформировано устойчивых архитектурных практик, поэтому фреймворк, описывающий всю систему как единое целое, обеспечивал преимущество в плане скорости и понятности разработки.

Но с ростом проекта стали появляться сложности:

  1. Фреймворк оказался достаточно многословным и требовал написания большого количества сервисного кода.
  2. Фреймворк слабо поддавался модуляризации и тестированию.
  3. Всплывали всё новые и новые баги фреймворка.
  4. Со временем фреймворк перестал поддерживаться мейнтейнерами и стал стремительно устаревать, полностью потеряв актуальность с выходом версии Dart 3.0.

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

Давайте разберемся, как минимизировать связанность и потенциальные проблемы, к которым может привести повсеместное использование архитектурных фреймворков.

Советы по работе с фреймворками

Абстрагируйте ключевые слои

  • Например, навигация может быть описана через абстрактный интерфейс, не привязанный к реализации:

    1abstract class AppRouter {
    2  void openProfile(User user);
    3}
    4
    5// Реализация для GetX:
    6class GetXRouter implements AppRouter {
    7  void openProfile(User user) => Get.to(ProfileScreen(user));
    8}
    

Используйте стандартные механизмы Flutter

  • ChangeNotifier + ListenableBuilder — вместо использования Provider для состояния в простых случаях.
  • Streams + StreamBuilder — для реактивности без Riverpod/GetX.
  • Если используется библиотека, то сохраняющая парадигму композиции виджетов — Provider, BlocBuilder.

Анализируйте библиотеку перед выбором

  • Избегайте «монолитов» (GetX), которые заменяют сразу несколько инструментов (навигацию, DI, управление состоянием).
  • Отдавайте предпочтение узконаправленным (flutter_bloc, provider) — их проще комбинировать друг с другом и при необходимости даже заменить.

Избегайте уникальных возможностей фреймворка

  • Не используйте кодогенерацию, если есть сопоставимые по простоте способы решить задачу без неё. Например, класс для управления состоянием можно описать на чистом Dart (Bloc, Cubit, ChangeNotifier или даже StateNotifier) без дополнительного бойлерплейта, при этом кодогенерация для такого класса добавит дополнительную зависимость на конкретный механизм библиотеки.
  • Не привязывайтесь к специфичным сущностям, например FamilyProvider в Riverpod. Такой провайдер не имеет прямых альтернатив в других подходах, поэтому его использование заставляет вас подстраиваться под механизм работы этих провайдеров, а при возникновении необходимости миграции будет сложно найти подходящую альтернативу.

Фреймворки вроде GetX и Riverpod предоставляют мощные инструменты для управления состоянием и навигацией, но при этом создают зависимость от архитектуры, которую они навязывают. Это может быть полезно для ускорения разработки и упрощения управления состоянием, но также может привести к ограниченной гибкости и сложности поддержки. Выбор между этими подходами зависит от целей проекта и долгосрочных планов команды.

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

А в следующем параграфе мы поговорим о том, как реализовать во Flutter сложные сценарии навигации: например, с восстановлением состояния экрана, и какие архитектурные приёмы для этого требуются.

Чтобы добавить в заметки выделенный текст, нажмите Ctrl + E
Предыдущий параграф5.13. Redux: продвинутые концепции
Следующий параграф5.15. Сложная навигация с помощью Navigation 2.0