Всем привет! Наша команда мобильной разработки поверила во Flutter ещё в далеком 2017 году. Уже много лет мы остаемся евангелистами технологии и знаем так называемые pros and cons. В этой статье расскажем о нашем опыте использования Bloc, а также почему мы решили от него отказаться в пользу Riverpod.
Откуда взялась идея?
Не секрет, что немалую роль в разработке проекта играет выбор правильного менеджера состояний, архитектуры и структуры. В текущей статье мы рассмотрим именно первый пункт — менеджер состояний.
На всех проектах мы долгое время использовали 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 шла достаточно бодро. В целом код получился достаточно красивым и лаконичным. Проект уже полностью реализован и запущен в продакшен. Поэтому, основываясь на этом практическом опыте, мы можем отметить как положительные, так и отрицательные моменты.