Организация

В моем недавнем проекте Flutter за управление состоянием отвечает пакет Riverpod. Раньше, не имея опыта работы с этим пакетом, я использовал в основном Provider и GetX. Однако мне стало интересно, почему пользователи Flutter в последнее время с таким энтузиазмом отзываются о Riverpod. Поэтому при разработке нового проекта решил попробовать именно его  —  и был очарован возможностями этого инструмента.

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

Не хочу сказать, что провайдер, объявленный как глобальная переменная, всегда является злом (более того, состояние провайдера управляется внутри ProviderContainer, поэтому вряд ли будет глобальным).

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

Проблемы, возникающие в связи с объявлением глобальной переменной Riverpod

Такой недостаток влечет за собой различные проблемы.

Например, представьте, что вы недавно присоединились к проекту Flutter, использующему Riverpod, и руководитель ставит перед вами следующую задачу:

“Напишите код для модульных тестов на основе провайдеров, используемых на главной странице”.

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

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

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

Миф о “глобальности” Rivepod

Чтобы предупредить вышеупомянутые проблемы, необходимо структурировать область применения провайдеров. Другими словами, должно быть легко определить, какие провайдеры используются в конкретном разделе. Размышляя над тем, как структурировать область применения провайдеров, я наткнулся на YouTube на видеоролик под названием “Миф о “глобальности” Riverpod” от Рэндала Л. Шварца.

Настоятельно рекомендую посмотреть это видео

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

Рассмотрим два метода, упомянутых в этом видео:

  • локальные переменные (обозначенные подчеркиванием);
  • статические переменные внутри класса.

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

Примеры, рассмотренные в этой статье, основаны на Todo App из официальной документации Riverpod.

Локальные переменные

Сначала рассмотрим метод локализации провайдеров путем объявления их приватными для определенного раздела страницы.

final _uncompletedTodosCount = Provider<int>((ref) {  
return ref.watch(todoListProvider).where((todo) => !todo.completed).length;
});

class Toolbar extends HookConsumerWidget {
const Toolbar({
Key? key,
}) : super(key: key);

@override
Widget build(BuildContext context, WidgetRef ref) {
return Material(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${ref.watch(_uncompletedTodosCount)}', // <- Обращение к локализованному провайдеру
),
...

}
}

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

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

final _uncompletedTodosCount = Provider<int>((ref) {  
return ref.watch(todoListProvider).where((todo) => !todo.completed).length;
});

part 'tool_bar3.dart'; // <- Разделение на part-файл

class HomePage extends HookConsumerWidget with HomeEvent, HomeState {
const HomePage({Key? key}) : super(key: key);

@override
Widget build(BuildContext context, WidgetRef ref) {

return Scaffold(
body: ListView(
children: [
Toolbar1(ref.watch(_uncompletedTodosCount)),
Toolbar2(ref.watch(_uncompletedTodosCount)),
const _Toolbar3(),
...

}
}

Например, если у главной страницы (HomePage) есть дочерние виджеты с именами Toolbar1, Toolbar2 и Toolbar3 и все они должны обращаться к провайдеру _uncompletedTodosCount, это может привести к неудобствам  —  либо передаче значения состояния локализованного провайдера каждый раз в качестве аргумента, либо к разделению дочерних виджетов на part files (файлы типа part).

Статические переменные внутри класса

Другим методом решения ранее упомянутой проблемы является назначение провайдеров статическими переменными внутри класса.

abstract class HomeProviders {  
HomeProviders._();

static final todoListFilter = StateProvider((_) => TodoListFilter.all);

static final uncompletedTodosCount = Provider<int>((ref) {
return ref.watch(todoListProvider).where((todo) => !todo.completed).length;
});

...
}

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

class Toolbar extends ConsumwerWidget {  
const Toolbar({
Key? key,
}) : super(key: key);

@override
Widget build(BuildContext context, WidgetRef ref) {
return Material(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${ref.watch(HomeProviders.uncompletedTodosCount)}',
// Обращение к провайдеру через статическую переменную
),
...

}
}

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

Структурирование области применения провайдера с помощью миксинов

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

В этом подходе используются миксины состояний и событий.

Миксин состояний (state mixin class)

Миксин состояний включает методы, которые возвращают значения состояний всех провайдеров, используемых на конкретной странице.

mixin class HomeState {  
int uncompletedTodosCount(WidgetRef ref) => ref.watch(uncompletedTodosCountProvider);

List<Todo> filteredTodos(WidgetRef ref) => ref.watch(filteredTodosProvider);

...
}

Приведенный выше миксин HomeState управляет значениями состояний провайдеров, используемых в разделе HomePage. Каждый метод принимает WidgetRef в качестве аргумента и использует метод расширения watch WidgetRef для передачи состояния.

AsyncValue<Todo> todoAsync(WidgetRef ref) => ref.watch(todoProvider);

Если нужно вернуть асинхронные данные типа Future, можно обернуть значение в тип AsyncValue.

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

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

Миксин событий (event mixin class)

Миксин событий эффективно управляет всей логикой событий, используемой в определенном разделе. Как и миксин состояний, он принимает в качестве аргумента WidgetRef, что позволяет легко получить доступ к методам провайдера.

mixin class HomeEvent {  
void addTodo(
WidgetRef ref, {
required TextEditingController textEditingController,
required String value,
}) {
ref.read(todoListProvider.notifier).add(value);
textEditingController.clear();
}

void requestTextFieldsFocus(
{required FocusNode textFieldFocusNode,
required FocusNode itemFocusNode}) {
itemFocusNode.requestFocus();
textFieldFocusNode.requestFocus();
}
...

}

Как видите, приведенный выше метод addTodo через объект WidgetRef обращается к провайдеру Notifier с именем todoListProvider для выполнения метода, добавляющего новый элемент в текущий список.

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

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

Основная концепция

Несмотря на кажущуюся сложность, эта концепция довольно проста.

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

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

Преимущества разделения логики состояний и событий провайдеров

Итак, какие же преимущества дает управление значениями состояния и методами событий провайдера Riverpod в миксинах? Рассмотрим 5 основных плюсов.

1. Простота обслуживания

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

mixin class HomeState {  
List<Todo> todos(WidgetRef ref) => ref.watch(todoListFromRemoteProvider).value;
}

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

mixin class HomeState {  
List<Todo> todos(WidgetRef ref) => ref.watch(todoListFromLocal);
}

Чтобы перейти от вызова удаленных данных к загрузке локальных данных в существующем провайдере, заменив его на провайдер todoListFromLocal, можно просто заменить провайдер в классе HomeState.

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

2. Повышение уровня читаемости

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

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

3. Более удобное написание кода для модульных тестов

Использование миксинов состояний и событий делает написание кода для модульных тестов более удобным.

Определение области применения тестов

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

mixin class HomeEvent {  
void addTodo(
WidgetRef ref, {
required TextEditingController textEditingController,
required String value,
}) { ... }

void removeTodo(WidgetRef ref, {required Todo selectedTodo}) { ... }

void changeFilterCategory(WidgetRef ref, {required TodoListFilter filter}) { ... }

void toggleTodoState(WidgetRef ref, {required String todoId}) { ... }

void editTodoDesc(WidgetRef ref,
{required bool isFocused,
required TextEditingController textEditingController,
required Todo selectedTodo}) { ... }
}

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

Лаконичный код модульных тестов

Использование существующих модулей миксинов состояний и событий позволяет создавать более лаконичный код для модульных тестов.

mixin class HomeEventTest {  
void addTodo(
ProviderContainer container, {
required TextEditingController textEditingController,
required String value,
}) {
container.read(todoListProvider.notifier).add(value);
textEditingController.clear();
}

void removeTodo(ProviderContainer container, {required Todo selectedTodo}) {
container.read(todoListProvider.notifier).remove(selectedTodo);
}
...

}

mixin class HomeStateTest {
List<Todo> filteredTodos(ProviderContainer container) =>
container.read(filteredTodosProvider);

int uncompletedTodosCount(ProviderContainer container) =>
container.read(uncompletedTodosCountProvider);
...

}

Сначала скопируйте код существующих модулей миксинов состояний и событий, чтобы создать новый тестовый миксин (Test Mixin Class). В этом случае измените аргумент с WidgetRef на тип ProviderContainer и замените существующий метод .watch на .read, чтобы выполнить тестовый код.

void main() {  
final homeEvent = HomeEventTest();
final homeState = HomeStateTest();

test('Add todo', () {
final container = createContainer();
const String todoDescription = 'Write Riverpod Test Code';
homeEvent.onTodoSubmitted(container,
textEditingController: TextEditingController(), value: todoDescription);
expect(
homeState.filteredTodos(container).last.description, todoDescription);
});
}

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

Вот пошаговое объяснение.

  1. Инициализация: инициализируйте необходимые тестовые экземпляры миксинов состояний и событий.
  2. Манипулирование: используйте тестовый миксин состояний для выполнения логики событий, манипулирования и изменения состояния.
  3. Верификация: используйте тестовый миксин событий для проверки ожидаемых значений.

Поскольку миксин событий содержит тестируемые event methods (методы событий), а в миксине состояний определены ожидаемые тестовые result values (значения результатов тестирования), написание кода модульных тестов, включая сложные сценарии, становится простым делом.

4. Повышение эффективности рабочего процесса

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

5. Минимизация ошибок в процессе совместной работы

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

Заключение

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

Тем, кто заинтересовался примером проекта Todo App, рассмотренным в этой статье, предлагаю обратиться к GitHub-репозиторию. Я добавил логику применения миксинов состояний и событий и несколько простых тестов к существующему коду примера из официальной документации Riverpod.

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Ximya: Organize Your “Global” Providers in Flutter Riverpod with Mixin Class

Предыдущая статьяРазработка веб-дэшбордов с использованием React, Material UI, Tailwind CSS и Nivo. Часть 2
Следующая статьяiOS/Swift: подробное руководство по модульным и UI-тестам. Часть 1