Оригинальное название – Demystifying State Management in Flutter: An In-Depth Exploration


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

Flutter State Management
Flutter State Management

Зачем нам нужно правильно управлять состоянием?

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

  • Экран списка продуктов: если пользователь добавляет товар в корзину, интерфейс должен отражать это (например, показывать на товаре значок "В корзине").

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

  • Экран сведений о продукте: если товар уже находится в корзине, это должно быть отражено на экране сведений о продукте (например, путем замены кнопки "Добавить в корзину" на кнопку "Удалить из корзины").

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

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

Скриншот примера нашей корзины
Скриншот примера нашей корзины

Provider

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

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

Provider – визуализация
Provider – визуализация

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

class CartModel extends ChangeNotifier {
  // Для простоты наша корзина хранит только количество находящихся в ней товаров.
  int _items = 0;

  int get items => _items;

  void addItem() {
    _items++;
    notifyListeners();  // Уведомить все виджеты, прослушивающие данную модель.
  }

  void removeItem() {
    _items--;
    notifyListeners();  // Уведомить все виджеты, прослушивающие данную модель.
  }
}

Теперь мы предоставим эту модель нашим виджетам с помощью ChangeNotifierProvider:

void main() {
  runApp(
    ChangeNotifierProvider(
      create: (context) => CartModel(),
      child: MyApp(),
    ),
  );
}

В Flutter ChangeNotifier – это класс, входящий в состав Flutter SDK, который предоставляет слушателям уведомления об изменениях.

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

class MyCartIcon extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    var cart = Provider.of<CartModel>(context);

    return IconButton(
      icon: Badge(
        // Значок будет перестраиваться с обновленным количеством при каждом изменении корзины.
        label: Text(cart.items.toString(), textAlign: TextAlign.center, style: TextStyle(fontSize: 16)),
        child: Icon(Icons.shopping_cart, size: 62),
      ),
      onPressed: () {},
    );
  }
}

class MyAddButton extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // В этом случае нет необходимости прослушивать изменения, поэтому мы устанавливаем значение false.
    var cart = Provider.of<CartModel>(context, listen: false);

    return IconButton(
      icon: Icon(Icons.add, size: 62),
      onPressed: () {
        cart.addItem();
      },
    );
  }
}

В этом упрощенном примере мы имеем корзину, в которой хранится только количество товаров. Модель CartModel предоставляется дереву виджетов с помощью ChangeNotifierProvider, и все виджеты, зависящие от корзины (например, MyCartIcon), могут прослушивать корзину и перестраиваться при ее изменении. Виджеты, которые изменяют корзину, но не нуждаются в ее прослушивании (например, MyAddButton), могут обращаться к ней с дополнительным параметром listen: false при вызове of.

Прим. пер.: давным-давно, начиная с версии 4.1.0, были добавлены прекрасные расширения для BuildContextcontext.watch<T> и context.read<T>(). Они изысканней и короче, и синтаксически яснее выражают намерения разработчика (возможно, только для тех, у кого Riverpod прогерского мозга).

Riverpod

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

Давайте интегрируем Riverpod в наше приложение.

Riverpod – визуализация. Прим. пер.: эта схема верна, если внутри stateless будет находиться Consumer-виджет :)
Riverpod – визуализация. Прим. пер.: эта схема верна, если внутри stateless будет находиться Consumer-виджет :)

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

final cartProvider = StateProvider<int>((ref) => 0);

В этом примере cartProvider – это провайдер, который содержит целочисленное состояние, представляющее количество товаров в корзине. Начальное состояние устанавливается равным 0.

Теперь давайте получим доступ и изменим состояние. В Riverpod виджеты могут получать доступ к состоянию и изменять его, используя ref.watch() для наблюдения за провайдером и ref.read() для чтения провайдера:

class MyCartIcon extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final cart = ref.watch(cartProvider);

    return IconButton(
      icon: Badge(
        // Значок будет перестраиваться с обновленным счетчиком при каждом изменении корзины.
        label: Text(cart.toString(), textAlign: TextAlign.center, style: TextStyle(fontSize: 16)),
        child: Icon(Icons.shopping_cart, size: 62),
      ),
      onPressed: () {},
    );
  }
}

class MyAddButton extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return IconButton(
      icon: Icon(Icons.add, size: 62),
      onPressed: () {
        // Это вызовет изменение состояния, в результате чего прослушивающие виджеты будут перестроены.
        ref.read(cartProvider.notifier).state++;
      },
    );
  }
}

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

void main() {
  runApp(
    // Добавление ProviderScope позволяет использовать Riverpod для всего проекта
    const ProviderScope(child: MyApp()),
  );
}

В этом простом примере cartProvider хранит состояние корзины, а виджеты MyCartIcon и MyAddButton могут как читать, так и изменять это состояние. При изменении состояния cart.state все виджеты, наблюдающие за cartProvider, перестраиваются.

Прим. пер.: На данный момент существует три способа получения состояния провайдера – ref.watch для прослушивания состояния и перестройки виджета (запрещается использовать в обратных вызовах и методах, подобных initState() ; можно использовать в методе Widget build(...) и в других провайдерах), ref.read для одноразового чтения значения (не рекомендуется использовать в методе сборки и внутри других провайдеров, т.к. при перестройке/обновлении провайдера, читающие его будут использовать старый экземпляр, что чревато ошибками) и ref.listen – можно использовать где угодно: слушайте новые значения и делайте какие-либо действия на основе этого (удобно показывать toast|snack, использовать декларативную навигацию или логгировать какие-либо действия).

Автор не указал (код верный), что теперь мы должны использовать ConsumerWidget и ConsumerStatefulWidget вместо StatelessWidget и StatefulWidget соответственно, которые содержат в себе некоторую логику по работе с riverpod + добавляют аргумент WidgetRef ref в метод build() (или поле в случае c ConsumerStatefulWidget). На самом деле, если заглянуть под капот, то оба из них наследуются от одного и того же класса – StatefulWidget.

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

Bloc/Cubit

Bloc (Business Logic Component) и Cubit – решения для управления состоянием, входящие в состав пакета flutter_bloc.

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

Bloc/Cubit – визуализация. Прим. пер.: автор неверно указал голубой квадрат – там должен находиться BlocProvider -> CartCubit
Bloc/Cubit – визуализация. Прим. пер.: автор неверно указал голубой квадрат – там должен находиться BlocProvider -> CartCubit

Снова интегрируем пакет в наше приложение корзины.

Во-первых, необходимо дать определение кубита. Вы создаёте класс Cubit, который будет хранить и управлять вашим состоянием:

class CartCubit extends Cubit<int> {
  CartCubit() : super(0);
 
  void addItem() => emit(state + 1);  // Увеличение количества элементов корзины
 
  void removeItem() {
    if (state > 0) {
      emit(state - 1);  // Уменьшение количества элементов корзины
    }
  }
}

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

Теперь мы можем использовать Cubit. Виджеты могут обращаться к Cubit и реагировать на изменения состояния:

class MyCartIcon extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final cartCubit = context.watch<CartCubit>();

    return IconButton(
      icon: Badge(
        // Значок будет перестраиваться с обновленным количеством при каждом изменении корзины.
        label: Text(cartCubit.state.toString(),
            textAlign: TextAlign.center, style: TextStyle(fontSize: 16)),
        child: Icon(Icons.shopping_cart, size: 62),
      ),
      onPressed: () {},
    );
  }
}

class MyAddButton extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final cartCubit = context.read<CartCubit>();

    return IconButton(
      icon: Icon(Icons.add, size: 62),
      // Это вызовет изменение состояния, что приведет к перестройке прослушивающих виджетов.
      onPressed: () => cartCubit.addItem(),
    );
  }
}

И, наконец, нам необходимо предоставить Cubit. Чтобы сделать Cubit доступным для виджетов, обычно используется BlocProvider:

void main() {
  runApp(
    BlocProvider(
      create: (context) => CartCubit(),
      child: MyApp(),
    ),
  );
}

В этом простом примере CartCubit хранит и управляет состоянием корзины, а виджеты MyCartIcon и MyAddButton могут прослушивать и изменять это состояние. При изменении состояния cartCubit.state все виджеты, слушающие CartCubit, перестраиваются.

Redux

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

Давайте заиспользуем его в нашем приложении.

Redux – визуализация
Redux – визуализация

Сначала определим State и Reducer. Вы определяете модель состояния и функцию, называемую редуктором, которая определяет, как изменяется состояние в ответ на определенные действия:

class AppState {
  final int itemCount;
 
  AppState({this.itemCount = 0});
}
 
enum CartActions { AddItem, RemoveItem }
 
AppState cartReducer(AppState state, dynamic action) {
  if (action == CartActions.AddItem) {
    return AppState(itemCount: state.itemCount + 1);
  } else if (action == CartActions.RemoveItem) {
    return state.itemCount > 0 ? AppState(itemCount: state.itemCount - 1) : state;
  }
 
  return state;
}

Давайте создадим Store. Store объединяет состояние и редуктор. Он создается в корне вашего приложения и передается всем дочерним элементам:

void main() {
  final store = Store<AppState>(cartReducer, initialState: AppState());

  runApp(
    StoreProvider<AppState>(
      store: store,
      child: MyApp(),
    ),
  );
}

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

class MyCartIcon extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return StoreConnector<AppState, int>(
      converter: (Store<AppState> store) => store.state.itemCount,
      builder: (context, itemCount) => IconButton(
        icon: Badge(
          label: Text(
            itemCount.toString(),
            style: TextStyle(fontSize: 16),
          ),
          child: Icon(Icons.shopping_cart, size: 62),
        ),
        onPressed: () {},
      ),
    );
  }
}


class MyAddButton extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return IconButton(
      icon: Icon(Icons.add, size: 62,),
      onPressed: () {
        StoreProvider.of<AppState>(context).dispatch(CartActions.AddItem);
      },
    );
  }
}

В данном примере у нас есть AppState, представляющий состояние нашего приложения, и cartReducer, определяющий, как это состояние изменяется в зависимости от действий. При диспетчеризации (отправке) действия (например, CartActions.AddItem) редуктор генерирует новое состояние, что приводит к перестройке прослушивающих виджетов.

MobX

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

Давайте сделаем тот же функционал в последний раз в нашем приложении корзины.

MobX – визуализация
MobX – визуализация

Во-первых, определим Store – класс, в котором хранится ваше состояние и действие, изменяющее это состояние:

import 'package:mobx/mobx.dart' as mobx;

class CartStore {
  // Создание наблюдаемой переменной и действия
  final itemCount = Observable<int>(0);
  mobx.Action? incrementItemCount;

  CartStore() {
    // Определяем тело действия внутри конструктора
    incrementItemCount = mobx.Action(() {
      itemCount.value++;
    });
  }
}

В данном примере CartStore – это хранилище MobX, которое содержит наблюдаемое состояние itemCount, представляющее количество товаров в корзине, и действие incrementItemCount для изменения этого состояния.

Теперь мы понаблюдаем за состоянием. Виджеты могут наблюдать за состоянием и автоматически перестраиваться при его изменении:


class MobxScreen extends StatefulWidget {
  const MobxScreen({super.key});

  @override
  State<MobxScreen> createState() => _MobxScreenState();
}

class _MobxScreen extends State<MobxScreen> {
  final cartStore = CartStore(); // Создание экземпляра CartStore
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Column(
          children: [
            SizedBox(height: 100),
            Text("Click to Add to Cart:"),
            MyAddButton(cartStore: cartStore),
            SizedBox(height: 100),
            MyCartIcon(cartStore: cartStore)
          ],
        ),
      ),
    );
  }
}

class MyCartIcon extends StatelessWidget {
  final CartStore cartStore;

  MyCartIcon({required this.cartStore});

  @override
  Widget build(BuildContext context) {
    return Observer(
      builder: (_) => IconButton(
        icon: Badge(
          // Значок будет перестраиваться с обновленным счётчиком при каждом изменении корзины.
          label: Text('${cartStore.itemCount.value}', textAlign: TextAlign.center, style: TextStyle(fontSize: 16)),
          child: Icon(Icons.shopping_cart, size: 62),
        ),
        onPressed: () {},
      ),
    );
  }
}

class MyAddButton extends StatelessWidget {
  final CartStore cartStore;

  MyAddButton({required this.cartStore});

  @override
  Widget build(BuildContext context) {
    return IconButton(
      icon: Icon(Icons.add, size: 64),
      // Это вызовет изменение состояния, что приведет к перестройке прослушивающих виджетов.
      onPressed: () => cartStore.incrementItemCount!(),
    );
  }
}

В этом простом примере CartStore хранит и управляет состоянием корзины, а виджеты MyCartIcon и MyAddButton могут прослушивать и изменять это состояние. При изменении cartStore.itemCount все виджеты, наблюдающие за состоянием, перестраиваются.

Какой из них использовать?

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

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

  • Riverpod: Это шаг вперёд по сравнению с Provider. Он сохраняет простоту Provider, но стремится преодолеть некоторые его недостатки, такие как сложность работы с несколькими провайдерами и неловкость при чтении провайдера извне дерева виджетов. Riverpod хорошо подходит, когда вы начинаете чувствовать себя ограниченным возможностями Provider. Например, если вы работаете над более крупным проектом с более сложными потребностями в управлении состоянием или вам требуется бо́льший контроль над жизненным циклом ваших провайдеров.

  • Bloc/Cubit: Эти варианты хороши для крупных приложений или приложений, требующих сложного управления состоянием. Они обеспечивают четкое разделение между бизнес-логикой и представлением информации, что делает их хорошим выбором для крупных проектов или проектов, в которых бизнес-логика может быть сложной или подверженной изменениям. Например, для приложений электронной коммерции со сложными бизнес-правилами, такими как система скидок, аутентификация пользователей и отслеживание заказов, может подойти Bloc/Cubit.

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

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

Заключение

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

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

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

Кроме того, вы можете связаться со мной в Twitter (jordan_goulet) или (UbermenschDev), чтобы узнать больше о моей работе.

Еще раз спасибо, что читаете, и удачного дня!


Материал переведён Ruble.

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


  1. Alpatraum
    13.07.2023 05:43

    Неплохая статья. Но разве во flutter_bloc не входит provider ?


    1. PackRuble Автор
      13.07.2023 05:43

      Да, входит. Но мы не можем из пакета flutter_bloc вызывать сущности пакета provider. Разве что, кроме этих

      Если говорить коротко - provider нужен flutter_bloc'у для внедрения зависимостей в дерево виджетов. Удобного и признанного. И можно делать аналогии а-ля BlocProvider это обёртка над Provider, что несомненно, так и есть, но внутри каждый из них выглядит по-своему. И нет такого, что BlocProvider extends Provider