June 19

Почему мы решили отказаться от Bloc в пользу Riverpod

Всем привет! Я Алина, CEO LighTech. Наша команда мобильной разработки поверила во Flutter ещё в далеком 2017 году. Уже много лет мы остаемся евангелистами технологии и знаем так называемые pros and cons.

В этой статье расскажем о нашем опыте использования Bloc, а также почему мы решили от него отказаться в пользу Riverpod.

Отправляю ❤️ Денису Грошеву за огромную работу по подготовке данной статьи.

Итак слушаем, что дальше расскажет нам Денис…

Откуда взялась идея?

TodoProvider(  this._todoRepository, {  @visibleForTesting TodoState? initialState,}) : super(initialState ?? const TodoState());

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

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

  • Вариативность подходов к реализации логики. Bloc включает в себя несколько компонентов, которые тяготеют к разным архитектурным подходам — легковесный Cubit и полноценный Bloc. Эта вариативность может быть одновременно как плюсом, так и минусом, поскольку возможна разрозненность программистов и смешивание во время разработки.
  • Бойлерплейт. В случае использования полноценного Bloc'а приходится писать много однотипного бойлерплейта — описание ивентов для страницы и регистрация нужных обработчиков в самом Bloc'е. Если при разработке небольших приложений эта особенность не так заметна, то в крупных проектах она становится настоящей пыткой.
  • Навязывание библиотек для использования. Этот пункт напрямую связан с разработчиком Bloc'а. Для тестирования логики используется bloc_test, который включает в себя mocktail. Сам по себе bloc_test — отличная штука, позволяющая досконально протестировать поведение, но при добавлении этой зависимости в pubspec, мы автоматически подтягиваем mocktail. Таким образом, разработчик библиотеки не предоставляет альтернатив и навязывает свои библиотеки для использования, с которыми нам не хотелось бы работать.
  • Mocktail. Он обязывает нас вручную создавать моки, в то время как наша команда привыкла автоматизировать такие процессы. Поэтому здесь мы отдаем предпочтение Mockito.


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

Варианты стейт-менеджеров


При изучении существующих библиотек мы оценивали их по важным для нас критериям:

  • Документация
  • Количество бойлерплейта
  • Тестируемость
  • Объем комьюнити и возможные риски

Для рассмотрения взяли: Bloc, Riverpod, Triple, GetX, Provider, Redux, а также несколько его оберток — Fish Redux и Async Redux. Мы оценили достоинства и недостатки каждого варианта, и часть библиотек сразу же отпала, поскольку имела существенные недостатки и не удовлетворяла нашим требованиям. В результате сравнительного анализа остались только две новые для нас библиотеки — Riverpod и Triple.

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

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

Мы также сравнили код на Riverpod'е с кодом на Bloc'е. И это сравнение превзошло все ожидания. Оказалось, что с Riverpod всё выглядит, пишется и читается гораздо проще, чем то же самое, реализованное на Bloc'е. Этим мы убедились, что могли бы достичь желаемого результата, применив новый менеджер. Сейчас мы предлагаем сосредоточиться на основных отличиях этих двух библиотек.

Ключевые отличия Riverpod от bloc


Взглянем на код, для работы с todo-списком на Riverpod.

class TodoProvider extends StateNotifier<TodoState> {
  TodoProvider(
    this._todoRepository, {
    @visibleForTesting TodoState? initialState,
  }) : super(initialState ?? const TodoState());

  final TodoRepository _todoRepository;

  Future<void> initializeTodos() async {
    final savedTodos = await _todoRepository.fetchAll();
    
    // To-do list fetches from Hive, so it doesn't take much time.
    // In this case we use one second delay to avoid blink of progress bar
    await Future.delayed(const Duration(seconds: 1), () {
      state = state.copyWith(
        isLoading: false,
        todos: savedTodos.entries.map(TodoUi.fromMapEntry).toList(),
      );
    });
  }
Future<void> createTodo(String todo) async {
    await _todoRepository.save(TodoHive(todo));
    final savedTodos = await _todoRepository.fetchAll();
    state = state.copyWith(
      todos: savedTodos.entries.map(TodoUi.fromMapEntry).toList(),
    );
  }

  Future<void> removeTodo(int key) async {
    await _todoRepository.removeByKey(key);
    state = state.copyWith(
      todos: List.of(state.todos)..removeWhere((element) => element.key == key),
    );
  }
}

Теперь взглянем на ту же логику, которая реализована при помощи Bloc’а.

abstract class TodoEvent extends Equatable {
  const TodoEvent();

  @override
  List<Object?> get props => [];
}

class TodoInitialed extends TodoEvent {
  const TodoInitialed();
}

class TodoCreated extends TodoEvent {
  const TodoCreated(this.todo);

  final String todo;

  @override
  List<Object?> get props => [todo];
}

class TodoRemoved extends TodoEvent {
  const TodoRemoved(this.key);

  final int key;

  @override
  List<Object?> get props => [key];
}

class TodoBloc extends Bloc<TodoEvent, TodoState> {
  TodoBloc(TodoRepository todoRepository)
      : _todoRepository = todoRepository,
        super(const TodoState.initial()) {
    on<TodoInitialed>(_onTodoInitialed);
    on<TodoCreated>(_onTodoCreated);
    on<TodoRemoved>(_onTodoRemoved);
  }
  final TodoRepository _todoRepository;

  Future<void> _onTodoInitialed(TodoInitialed event, Emitter emit) async {
    final savedTodos = await _todoRepository.fetchAll();
    await Future.delayed(const Duration(seconds: 1), () {
      emit(TodoState.update(
        savedTodos.entries
            .map<TodoUi>((e) => TodoUi.fromHiveModel(e.key as int, e.value))
            .toList(),
      ));
    });
  }

  Future<void> _onTodoCreated(TodoCreated event, Emitter emit) async {
    await _todoRepository.save(TodoHive(event.todo));
    final savedTodos = await _todoRepository.fetchAll();
    emit(TodoState.update(
      savedTodos.entries
          .map<TodoUi>((e) => TodoUi.fromHiveModel(e.key as int, e.value))
          .toList(),
    ));
  }

  Future<void> _onTodoRemoved(TodoRemoved event, Emitter emit) async {
    await _todoRepository.removeByKey(event.key);
    emit(TodoState.update(List.of(state.todos)
      ..removeWhere((element) => element.key == event.key)));
  }
}

Как видно из примера, реализация одной и той же логики требует большего количества строк кода, если дело касается блока (Bloc). Немного поясним, как это работает.

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

Также существует возможность использовать вместо полноценного блока (Bloc) его упрощенную часть – Cubit. Это позволит визуально сделать код точь-в-точь, как на Riverpod, но существует разница при работе с пробросом нового состояния на UI.

В случае использования Cubit при работе с получением большого объема данных есть вероятность, что пользователь покинет экран раньше, чем данные будут получены. В этом случае в логику нужно добавлять проверки, не закрыт ли Cubit, чтобы не столкнуться с ненужными ошибками. При работе с Riverpod такая проблема ни разу не была замечена, а значит логика не обрастает дополнительными специфическими проверками.

Мы больше привыкли работать с полноценным блоком (Bloc), поэтому в сравнении с ним Riverpod не имеет событий как таковых. Обращение к конкретным обработчикам идет напрямую через провайдер, в котором они реализованы. То есть, определенная кнопка является ответственной за вызов определенного метода в обработчике, который изменит состояние UI после выполнения необходимых бизнес-операций. Теперь давайте поглубже копнем и найдем еще немного отличий.

Углубленное исследование


Одно дело применять библиотеку, другое — взглянуть на ее внутреннее устройство, чтобы понять качество кода и принципы внутреннего устройства.

Наиболее часто используемый объект в проекте — это StateNotifier, который позволяет оперировать более сложными сущностями, состоящими из наборов нужных нам примитивов — классов. По своей сути, он работает так же, как блок, только вызов обработчика происходит напрямую.

В случае с bloc’ом, когда приходит ивент, он сопоставляется с зарегистрированным для этого типа обработчиком, который, после выполнения нужных действий, помещает новый стейт в StreamController. И именно этот стейт мы слушаем в UI. Использование Stream гарантирует регулярное обновление стейта при поступлении новых ивентов.

Искать отличия в логике работы Riverpod в этом смысле не особо уместно, поскольку принцип работы здесь практически такой же. Однако есть отличие в другом аспекте, и сейчас мы о нем поговорим.

Bloc использует собственное расширение Provider, которое позволяет вызывать ребилд экрана при изменении стейта. Оно основывается на работе с контекстом приложения, поэтому нам необходимо оборачивать нужные виджеты в BlocProvider, чтобы предоставить доступ к блоку нижележащему поддереву. Для работы с самим стейтом нужна обертка BlocBuilder или его продвинутый аналог — BlocConsumer.

Riverpod как раз и отличается тем, что не опирается на контекст. Он создает свой собственный контейнер для провайдеров, и за счет этого отпадает необходимость в использовании дополнительных оберток, чтобы получить наш стейт. Это позволяет избавиться от дополнительных вложенностей в дереве. Более того, Riverpod можно приспособить как Dependency injection для использования объектов в любом слое приложения. Достаточно иметь ref-ссылку, чтобы получить нужный объект инфраструктуры, данные или стейт. Это добавляет больше гибкости в использовании, хотя и возлагает на разработчика больше ответственности.

Обе библиотеки предоставляют одинаковые инструменты для работы в слое UI. Есть возможность подписываться на несколько провайдеров, ребилдить экран в зависимости от конкретных параметров, использовать удобные экстеншены и т.д.

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

Первый опыт использования


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

Riverpod отлично лег на нашу структуру проекта, поэтому переход на него оказался достаточно простым. Мы получше разобрались со способами управления стейтами и регистрацией нужных нам зависимостей, и вся разработка пошла гораздо быстрее и интереснее. Бойлерплейта стало значительно меньше, что для нас явилось буквально свежим глотком воздуха.

Вскоре начался новый коммерческий проект, направленный на развитие аграрной области некоторых стран Африки. Проект достаточно большой, включающий несколько ролей пользователей, и описывать все ивенты для Bloc’а под каждый экран было бы пыткой. Разработка вместе с Riverpod шла достаточно бодро. В целом код получился достаточно красивым и лаконичным. Проект уже полностью реализован и запущен в продакшен. Поэтому, основываясь на этом практическом опыте, мы можем отметить как положительные, так и отрицательные моменты.

Тестирование


Отдельно хочется коснуться темы тестирования кода.

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

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

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

blocTest<TodoBloc, TodoState>(
  'Check getting initial todos',
  build: () {
    when(todoRepository.fetchAll()).thenAnswer((_) async => savedTodos);
    return bloc;
  },
  act: (bloc) => bloc.add(const TodoInitialed()),
  wait: const Duration(seconds: 1),
  expect: () => [
    const TodoState.update(savedTodosUi),
  ],
);
test('Check get all todos', () async {
  when(todoRepository.fetchAll()).thenAnswer((_) async => savedTodos);
  
  await container.read(provider.notifier).initializeTodos();
  await Future<void>.delayed(const Duration(seconds: 1));
  expect(container.read(provider).todos, savedTodosUi);
});

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

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

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

В случае с Riverpod, мы ожидаем выполнения нашего метода и проверяем лишь конечный стейт. Но какие стейты приходят в процессе выполнения, так лаконично проверить не получится. В этих целях нам приходится использовать ref.listen, чтобы подписаться на все изменения, и именно здесь мы можем проверить все промежуточные стейты.

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

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

TodoProvider(
  this._todoRepository, {
  @visibleForTesting TodoState? initialState,
}) : super(initialState ?? const TodoState());

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

Заключение


В данный момент мы полностью удовлетворены использованием Riverpod. Он удобен и обеспечивает нас, как разработчиков, всем необходимым для быстрой и комфортной разработки приложений. Он также полностью соответствует всем нашим требованиям, предъявляемым к стейт-менеджеру.

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

Давайте подытожим, какие именно моменты нам удалось решить, применив новый менеджер для управления состоянием приложения.

  • Riverpod, в отличие от Bloc, не содержит дополнительных средств для реализации логики, таких как Cubit. Мы имеем лишь набор провайдеров, которые позволяют работать со стейтами или какими-то нужными объектами. Иными словами, мы исключили излишнюю вариативность и теперь можем быть уверены, что весь код будет написан в одном архитектурном подходе.
  • Минимизация бойлерплейта. Нам больше не нужно плодить фактически одинаковый код, описывающий все методы взаимодействия пользователя с экраном. Больше не нужен не только класс ивентов, но и все дополнительные обертки типа BlocProvider и BlocConsumer.
  • Стали проще тесты. Раньше вместе с Bloc использовались bloc_test, а они были достаточно громоздкими. Теперь в рамках простых тестов можно проверить провайдеры на все необходимые кейсы.
  • Мы вышли из-под влияния разработчика Bloc и можем самостоятельно выбирать тот стек технологий, который нам больше всего подходит.
    Стало удобнее работать со стейтом в UI. Можно без лишних сложностей подписать определенный виджет на обновление через ref.watch.
  • Также мы получили новый опыт и впечатления от работы с Riverpod, тем самым еще немного расширив нашу экспертность и наш кругозор.