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

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

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

Для начала разберёмся с тем, что такое стейт-менеджмент.

Для начала разберёмся с тем, что такое стейт-менеджмент

Занимаясь мобильной разработкой, необходимо вбить в себе в голову тот факт, что всё, что мы видим на экране - это набор состояний.

  • Сраница, которую видит пользователь - это состояние.

  • Цвет страницы - это состояние.

  • Размер шрифта - это состояние.

  • Циферка, показывающая количество денег на счёте - тоже состояние.

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

И наша цель - научиться эффективно управлять тем, какое состояние и в какой момент видит пользователь.

Пример

Создадим на экране обычный Container(). Даже не будем ничего туда класть

Код
void main() {
  runApp(const App());
}

class App extends StatelessWidget {
  const App({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: Center(
          child: Container(),
        ),
      ),
    );
  }
}

Посмотрим, что на экране:

Что же мы увидели на экране? Правильно, ничего. Почему? Потому что мы добавили виджет, но не добавили к нему ни одного состояния. Добавим нашему контейнеру его первое состояние путём добавления новых полей. Вместо пустого Container() покажем

Container(
color: Colors.green,
width: 100,
height: 100,
)

Код
void main() {
  runApp(const App());
}

class App extends StatelessWidget {
  const App({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: Center(
          child: Container(
            color: Colors.green,
            width: 100,
            height: 100,
          ),
        ),
      ),
    );
  }
}

Посмотрим, что теперь на экране:

Вау! Зелёный квадрат! И это, как ты уже понял, наше первое состояние.

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

Получается, что нам нужно:

  1. Добавить ещё одно состояние

  2. Показывать нужный виджет в зависимости от того, какой стейт сейчас активный.

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

Теперь посмотри на код и попробуй самостоятельно понять, как это работает

Код
sealed class ContainerState {}
class AuthorizedState extends ContainerState {}
class NotAuthorizedState extends ContainerState {}

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

  @override
  State<App> createState() => _AppState();
}

class _AppState extends State<App> {
  ContainerState state = NotAuthorizedState();

  // Метод изменяет текущее состояние на противоположное
  void changeState() {
    setState(() {
      if (state is AuthorizedState) {
        state = NotAuthorizedState();
      } else {
        state = AuthorizedState();
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        floatingActionButton: FloatingActionButton(
          child: state is AuthorizedState
              ? const Text('log out')
              : const Text('log in'),
          onPressed: () {
            changeState();
          },
        ),
        body: Center(
          child: SizedBox(
            height: 100,
            width: 100,
            child: switch (state) {
              AuthorizedState() => const ColoredBox(color: Colors.green),
              NotAuthorizedState() => const ColoredBox(color: Colors.red),
            },
          ),
        ),
      ),
    );
  }
}

Пояснение к коду

1) Создаем базовый sealed класс СontainerState(). Наследуем от него два наших состояния - AuthorizedState и NotAuthorizedState. Зачем нам нужен sealed класс и два наследника? В целом, просто для красоты и удобства. Это даёт нам возможность использовать в коде красивую switch-конструкцию и уменьшает количество кода в самом виджете.

2) Преобразуем Stateless виджет в Statefull. Нам ведь нужно где-то хранить состояние авторизации пользователя.

3) Создаём переменную state в стейтфул виджете. Если она является объектом AuthorizedState(), то показываем зелёный квадрат, если NotAuthorizedState - красный. За изменение этой переменной отвечает метод changeState(), вызываемый нажатием на FloatingActionButton().

Едем дальше

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

И как же отделить слой логики от презентационного слоя?

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

Внешний вид остаётся тем же, но этот код будет намного легче поддерживать.

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

Код классов состояний
sealed class ContainerState {}
class AuthorizedState extends ContainerState {}
class NotAuthorizedState extends ContainerState {}

Код стейт-менеджера + пояснение
class StateManager extends ChangeNotifier {
  ContainerState state = NotAuthorizedState();
  void changeState() {
    if (state is AuthorizedState) {
      state = NotAuthorizedState();
    } else {
      state = AuthorizedState();
    }
    notifyListeners();
  }
}

class StateManagerProvider extends InheritedNotifier<StateManager> {
  const StateManagerProvider({
    required this.stateManager,
    super.key,
    required this.child,
  }) : super(
          child: child,
          notifier: stateManager,
        );

  final StateManager stateManager;
  final Widget child;

  static StateManager of(BuildContext context) {
    return context
        .dependOnInheritedWidgetOfExactType<StateManagerProvider>()!
        .stateManager;
  }
}

По сути только что мы создали примитивный стейт-менеджер.

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

А класс StateManagerProvider это просто инхерит, который мы пробросим в дерево. Зачем?

1) Это дарит нам возможность далее получать наш StateManager в любой точке дерева.

2) За счёт использования InheritedNotifier мы сможем в презентационном слое подписаться на изменения StateManager'а. Это значит, что при изменении стейта, слой логики будет говорить презентационному слою - "Дружище, перерисуй экран!". И презентационный слой будет перерисовывать экран, учитывая новые данные.

Чтобы лучше понять, как работает InheritedNotifier, можешь посмотреть этот ролик - https://youtu.be/n_HLJUBkc48?feature=shared Да и вообще, все ролики на этом канале нужно обязательно посмотреть

Код презентационного слоя + пояснение
class App extends StatelessWidget {
  const App({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: StateManagerProvider(
        stateManager: StateManager(),
        child: const _View(),
      ),
    );
  }
}

class _View extends StatelessWidget {
  const _View({
    super.key,
  });

  @override
  Widget build(BuildContext context) {
    final stateManager = StateManagerProvider.of(context);
    final state = stateManager.state;
    return Scaffold(
      floatingActionButton: FloatingActionButton(
        child: state is AuthorizedState
            ? const Text('log out')
            : const Text('log in'),
        onPressed: () {
          stateManager.changeState();
        },
      ),
      body: Center(
        child: SizedBox(
          height: 100,
          width: 100,
          child: switch (state) {
            AuthorizedState() => const ColoredBox(color: Colors.green),
            NotAuthorizedState() => const ColoredBox(color: Colors.red),
          },
        ),
      ),
    );
  }
}

Чтобы обращаться к стейт-менеджеру, мы получаем его через context. Также мы получаем state и отрисовываем нужный виджет.

Сразу обрати внимание, что в дереве теперь нет ни одного стейтфул виджета! На производительность, в целом, это не влияет, но смотрится чище. Это достигается за счёт того, что наш state теперь хранится в StateManager'е, а не в стейтфул виджете.

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

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


Bloc

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

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

Как ты уже знаешь, всё, что пользователь видит на экране - это состояния (т.е. states).
И возникает естественный вопрос - как оперировать этими состояниями? Каким образом они сменяют друг друга?

На вопрос частично отвечает само название State Manager - менеджер состояний.
То есть у нас есть какой-то менеджер, которому мы даём команду, менеджер ее обрабатывает и выдаёт нам новый state. Командами, которые мы даём стейт-менеджеру, в библиотеке Bloc называются события (т.е. events). Мы используем event'ы в случаях, когда, например, пользователь нажал на кнопку и ожидает увидеть новый state.

Итого. Как выглядит в общих чертах вся схема работы со стейт менеджментом на Bloc?

добавляем event → bloc обрабатывает event → bloc возвращает state.

Смоделируем небольшой пример

Допустим, у нас есть два стейта - FirstState и SesondState. На экране мы показываем активный стейт и даем возможность поменять стейт на противоположный. Ниже схема, как будет работать наш Bloc. Ещё ниже будет демонстрация гифкой

Попробуем реализовать пример со схемы.
Мы ожидаем увидеть что-то подобное:

Код эвентов и стейтов + пояснение
sealed class Event {}
class GoToFirstState extends Event {}
class GoToSecondState extends Event {}

sealed class State {}
class FirstState extends State {}
class SecondState extends State {}

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

Код стейт-менеджера + пояснение
class AuthBloc extends Bloc<Event, State> {
  AuthBloc() : super(FirstState()) {
    on<GoToFirstState>((event, emit) {
      emit(FirstState());
    });
    on<GoToSecondState>((event, emit) {
      emit(SecondState());
    });
  }
}

В конструктор с помощью super мы передаём самый первый state, который мы хотим показать. А в теле конструктора мы определяем функции для каждого нашего эвента. Теперь, когда в презентационной логике мы будем кидать event, bloc будет вызывать функцию, которую мы приготовили для этого эвента.

Код презентационного слоя
class App extends StatelessWidget {
  const App({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: BlocProvider(
        create: (BuildContext context) => AuthBloc(),
        child: const _View(),
      ),
    );
  }
}

class _View extends StatelessWidget {
  const _View({
    super.key,
  });

  @override
  Widget build(BuildContext context) {
    final bloc = context.read<AuthBloc>();
    return BlocBuilder<AuthBloc, State>(
      builder: (context, state) {
        return Scaffold(
          body: Center(
            child: switch (state) {
              FirstState() => StateScreen(
                  text: '{FirstState}',
                  textColor: Colors.green,
                  buttonText: 'go to SecondState',
                  buttonColor: Colors.red,
                  onTap: () {
                    bloc.add(GoToSecondState());
                  },
                ),
              SecondState() => StateScreen(
                  text: '{SecondState}',
                  textColor: Colors.red,
                  buttonText: 'go to FirstState',
                  buttonColor: Colors.green,
                  onTap: () {
                    bloc.add(GoToFirstState());
                  },
                ),
            },
          ),
        );
      },
    );
  }
}

class StateScreen extends StatelessWidget {
  const StateScreen({
    super.key,
    required this.text,
    required this.buttonText,
    required this.onTap,
    required this.textColor,
    required this.buttonColor,
  });

  final String text;
  final String buttonText;
  final Color textColor;
  final Color buttonColor;
  final VoidCallback onTap;

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Text(
          text,
          style: TextStyle(color: textColor, fontSize: 40),
        ),
        const SizedBox(
          height: 50,
        ),
        ElevatedButton(
          style: ButtonStyle(
            backgroundColor: WidgetStateProperty.all(buttonColor),
          ),
          onPressed: onTap,
          child: Text(
            buttonText,
            style: const TextStyle(
              color: Colors.white,
            ),
          ),
        ),
      ],
    );
  }
}

Рассмотрим подробнее сам bloc и процесс взаимодействия с ним.

Изначально мы находимся на экране FirstState, так как при создании блока мы передаём его в super на этом участке кода:

class AuthBloc extends Bloc<Event, State> {
  AuthBloc() : super(FirstState()) {

Разберём весь наш путь при нажатии на кнопку.

  1. Нажимаем на кнопку go to SecondState

  2. Вызывается метод bloc.add(GoToSecondStateEvent)

  3. Эвент GoToSecondStateEvent попадает в bloc и обрабатывается в теле конструктора.

  4. В блоке вызывается метод emit(SecondState()). Этот метод изменяет текущий стейт на SecondState() и уведомляет об этом подписчиков.

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

  6. При перерисовке switch видит, что получен SecondState() и поэтому рисует StateScreen с заголовком "{SecondState}"

  7. Мы видим новый экран с новым стейтом.

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

Что делать дальше?

У блока есть большое количество виджетов, методов и фишек, которые необходимо научиться правильно использовать. Советую для начала почитать документацию - https://bloclibrary.dev/getting-started. Там, кстати, есть примеры, демонстрирующие эталонную работу с блоком.

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

тг: t.me/vanyakodit

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


  1. gangsterpp
    01.08.2024 19:41

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


    1. fognature1
      01.08.2024 19:41

      Либо можно использовать что-то типа freezed и делать через map / when.
      Ну и плюсом под отдельные трансформеры отдельно обрабатывать.