Хочу поделиться подходом, который родился у меня в процессе разработки нескольких проектов. Весь код доступен в репозитории (ссылка в конце статьи), а также опубликован в виде pub-пакета.

Объяснение проблемы

На сегодняшний день во флаттер разработке сформировались несколько подходов к State Management, такие, как MobX, Redux, Bloc и прочие. Они по-своему хороши, но описывают лишь часть архитектуры, нежели полноценный подход к построению приложения. В то же время у нас есть Clean Architecture, который можно применять в разработке практически любого проекта на любом языке и фреймворке.

В процессе поиска идеальной архитектуры для себя я наткнулся на вариант использования  стандартного Clean подхода для всего приложения, в котором UI-стейт менеджмент часть создается на чем угодно (BLoC, Mobx, Redux и так далее) Так как мне больше импонирует BLoC (а точнее, зачастую, его облегченная реализация - Cubit), то я выбрал такой подход Clean Architecture + Cubit для логики UI. В итоге у нас получается что-то вроде такого: 

Flutter Clean Architecture (картинка не моя)
Flutter Clean Architecture (картинка не моя)

Дисклеймер: предлагаю пока опустить UseCases, просто чтобы не описывать много лишнего кода в статье, но в GitHub репозитории есть example с юзкейсами. Пока делаем вид, что кубиты общаются напрямую с репозиториями (естественно через абстракции от репозитория)

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

Пример экрана
Пример экрана

Опишем стандартную задачу:

Возьмем классический экран: список данных и кнопка.

По кнопке открывается экран с добавлением и редактированием, после чего делается запрос с добавлением нового / редактированием старого элемента. 

Экран закрывается, список должен обновиться.

Если один кубит занимается обеими задачами, то в состоянии нужно учесть следующие параметры (минимально):

  1. Данные (Список моделей)

  2. Bool с состоянием загрузки этих данных, чтобы отобразить лоадер при открытии экрана, пока кубит обращается к репозиторию за данными

  3. Bool с состоянием, говорящим нам о том, что происходит запрос на добавление в список нового элемента

  4. Переменная с ошибкой при загрузке данных (nullable)

  5. Переменная с ошибкой при добавлении данных (nullable)

  6. Переменная bool с результатом добавления

А затем мы на UI слое должны проверить параметры этого состояния и отобразить нужные значения виджетов. Если добавить в этот пример еще несколько типичных операций, таких как обновление, удаление и так далее, то состояние разрастается до необыкновенных масштабов. А также немалозначимой проблемой перформанса является то, что кубит билдером придется обрачивать весь экран.

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

Кубит загрузки данных. Здесь нам нужно состояние загрузки, состояние с данными и состояние с ошибкой. Сами состояния очень простые классы, данные содержат только соответствующие своему типу.

@immutable
abstract class ItemsCubitState {
  final List<SampleItem>? items;
  const ItemsCubitState(this.items);
}

//STATES
class ItemsCubitShimmerState extends ItemsCubitState {
  const ItemsCubitShimmerState(List<SampleItem>? items) : super(items);
}

class ItemsCubitHasDataState extends ItemsCubitState {
  const ItemsCubitHasDataState(List<SampleItem> items) : super(items);
}

class ItemsCubitErrorState extends ItemsCubitState {
  final LoadError? error;
  const ItemsCubitErrorState(List<SampleItem>? items, {required this.error}) : super(items);

  String getErrorString(BuildContext context) {
    //get localization from context here
    return 'Sorry, something went wrong, please, try again';
  }
}

//CUBIT

class ItemsCubit extends Cubit<ItemsCubitState> {
  ItemsCubit(this._repository) : super(const ItemsCubitShimmerState(null)) {
    loadItems();
  }

  final ItemsRepository _repository;

  void loadItems() async {
    final result = await _repository.loadData();
    if (result.$1 != null) {
      emit(ItemsCubitHasDataState(result.$1!));
    } else {
      emit(ItemsCubitErrorState(null, error: LoadError(result.$2 ?? 'Unknown error')));
    }
  }
}

Кубит добавления и обновления данных. Здесь нам нужно initial состояние, состояние загрузки, состояние с ошибкой и success состояние. 

// STATES
@immutable
abstract class ItemUpdateCubitState {}

class ItemUpdateCubitInitState extends ItemUpdateCubitState {}

class ItemUpdateCubitShimmerState extends ItemUpdateCubitState {
  final String? updateId;

  ItemUpdateCubitShimmerState({this.updateId});
}

class ItemUpdateCubitSuccessState extends ItemUpdateCubitState {
  final SampleItem? item;

  ItemUpdateCubitSuccessState(this.item);
}

class ItemUpdateCubitErrorState extends ItemUpdateCubitState {
  final UpdateError? error;

  ItemUpdateCubitErrorState({
    this.error,
  });

  String getErrorString(BuildContext context) {
    //get localization from context here
    return 'Sorry, something went wrong, please, try again';
  }
}

// CUBIT

class ItemUpdateCubit extends Cubit<ItemUpdateCubitState> {
  ItemUpdateCubit(this._repository) : super(ItemUpdateCubitInitState());

  final ItemsRepository _repository;

  void addItem(String name) async {
    final item = SampleItem.create(name: name);
    final result = await _repository.addNewItem(item);
    if (result.$1) {
      emit(ItemUpdateCubitSuccessState(item));
    } else {
      emit(ItemUpdateCubitErrorState(error: UpdateError(result.$2 ?? 'Unknown error')));
    }
  }

  void deleteFlatItem(SampleItem item) async {
    final result = await _repository.deleteItem(item.id);
    if (result.$1) {
      emit(ItemUpdateCubitSuccessState(item));
    } else {
      emit(ItemUpdateCubitErrorState(error: UpdateError(result.$2 ?? 'Unknown error')));
    }
  }

  void updateFlatItem(SampleItem item) async {
    final result = await _repository.updateItem(item.id, item.name);
    if (result.$1) {
      emit(ItemUpdateCubitSuccessState(item));
    } else {
      emit(ItemUpdateCubitErrorState(error: UpdateError(result.$2 ?? 'Unknown error')));
    }
  }
}

В такой реализации load кубит может создаваться локально для виджета, запрашивать актуальные данные из репозитория и отображать их пользователю. Каждый виджет получит свой билдер: список с данными билдер кубита загрузки, виджет кнопки – билдер добавления. 

Сами кубиты максимально однозадачные (соблюдаем Single Responsibility принцип), имеют понятный срок жизни, не требуют, чтобы их передавали вниз по дереву. Состояния также очень понятные и не содержат в себе лишних данных.

И вроде бы все счастливы, но есть один нюанс… :-)

Проблема

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

Самый большой вопрос - как кубит загрузки данных узнает о том, что кубит добавления отправил в репозиторий новые данные?

Ему как минимум должен прийти эвент обновиться и получить новые данные от репозитория. Частенько видел, когда такие вещи решают на UI слое при помощи BlocListeners (в листенере кубита добавления ждем success состояния, триггерим кубит загрузки данных на обновление). Но тогда получается, что наш UI управляет логикой, что точно не хорошо, так как мы бы хотели максимально изолировать эти две сущности - UI не должен знать о Бизнес логике приложения. 

И тут получается логичный вывод...

Решение (концепция)

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

В итоге у нас рождается сущность Реактора. И получается модель такая:

Схема взаимодействия сущностей через реактор.
Схема взаимодействия сущностей через реактор.

В чем смысл реактора - это объект, который имеет связь с репозиторием, а также провайдит на выход подписку для кубитов. Кубиты подписываются на эвенты от репозитория (причём получают только свои эвенты, своего типа) и дальше эмитят состояния. Такой реактор можно было бы реализовать на стримах, но раз уж я больше предпочитаю уход от блоков в сторону кубитов, где возможно, здесь я выбрал вариант реализации через event listeners.

Как выглядит реактор изнутри:

mixin Reactor<LST, ResponseType extends ReactorResponse<LST>> {
  final List<CubitListener> listeners = [];
  final List<void Function(ResponseType)> dataListeners = [];

  final List<ResponseType> _sessionHistory = [];

  void addDataListener(void Function(ResponseType) listener) {
    dataListeners.add(listener);
  }

  void removeDataListener(void Function(ResponseType) listener) {
    dataListeners.remove(listener);
  }

  void addListener(CubitListener listener) {
    listeners.add(listener);
  }

  void removeListener(CubitListener listener) {
    listeners.remove(listener);
  }

  void setLoading({required ResponseType currentData}) {
    for (var listener in List.from(listeners)) {
      if (listener.type == currentData.type || currentData.type == null) {
        listener.typedEmit(currentData, isLoading: true);
      }
    }
  }

  void provideDataToListeners(ResponseType data) {
    _sessionHistory.add(data);

    for (var listener in List.from(listeners)) {
      if (listener.type == data.type) {
        listener.typedEmit(data);
      }
    }

    for (var listener in List.from(dataListeners)) {
      listener.call(data);
    }
  }

  T? getLastData<T extends ResponseType>() {
    if (_sessionHistory.isEmpty) {
      return null;
    }

    if (T == ResponseType) {
      return _sessionHistory.last as T;
    }

    final result = _sessionHistory.whereType<T>().toList();

    if (result.isEmpty) {
      return null;
    }

    return result.toList().last;
  }
}

Кубиты, в с свою очередь, должны уметь слушать обновления, значит нам нужен небольшой mixin для кубита:

abstract class CubitListener<T, D extends ReactorResponse<T>, S> extends Cubit<S> {
  final Reactor _reactor;
  final T type;

  CubitListener(S state, this._reactor, this.type) : super(state) {
    _reactor.addListener(this);
  }

  @override
  Future<void> close() {
    _reactor.removeListener(this);
    return super.close();
  }

  void typedEmit(ReactorResponse<T> data, {bool isLoading = false}) {
    if (data is D) {
      if (isLoading) {
        setLoading(data: data);
      } else {
        emitOnResponse(data);
      }
    } else {
      log(data.runtimeType.toString());
    }
  }

  void emitOnResponse(D response);

  void setLoading({required D data});
}

Получается, что Reactor внутри себя вызывает функцию обновления данных, которая эмитит сначала состояния загрузки всем LoadingCubit-ам, что тут же по сути отражается на UI, затем другой Кубит делает запрос на обновление данных у репозитория, репозиторий обновляет данные и снова отдает новые данные всем LoadingCubit-ам, что тут же отражается на UI. 

Заключение

В итоге получается что наш AddCubit и LoadingCubit не знают о наличии друг друга, но при этом при обновлении данных в одном триггерится обновление в другом при помощи реактора. И UI получается максимально простой и кубиты однозначные. По сути, манипулирует процессом реактор.

Плюсы

  • Чистая ответственность — маленькие кубиты, простые состояния.

  • Автоматическая синхронизация — одно обновление → реакция в нескольких местах.

  • Гибкость — в example видно, как легко подключать новые типы операций (загрузка, обновление, удаление), просто создав новый CubitListener.

  • Кэширование на уровне Reactor — можно хранить актуальные данные в памяти и мгновенно их отдавать новому подписчику.

  • Не нужно мучиться с rxdart, behaviorSubject и тд.

Минусы

  • Кодовая база и clean подход чуть усложняется за счёт дополнительного слоя (Reactor), но можно реактором сделать сам репозиторий.

  • Без документации новому разработчику будет сложнее сходу понять, как работает цепочка Cubit ↔ Reactor ↔ Repository, но для своих проектов вроде и норм.

Репозиторий с кодом лежит тут. И там же есть example использования, где наглядно показано как оно работает.

Тут ссылка на pub.dev

Что думаете об этом? Изобретение велосипеда или решение проблемы? Как подобные проблемы вы решаете в вашей архитектуре? Может я что-то упустил и все можно сделать намного проще? Давайте обсудим :-) 

Комментарии (1)


  1. undersunich
    18.08.2025 13:04

    Сложно