Cтейт-менеджмент на Flutter. Введение в Bloc

от автора

Салют! Меня зовут Ваня Берсенев и в этой статье я постараюсь спасти от выгорания твой джуновский энтузиазм, впервые столкнувшийся с одним из главных боссов 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


ссылка на оригинал статьи https://habr.com/ru/articles/833054/


Комментарии

Добавить комментарий

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