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,
),
),
),
);
}
}
Посмотрим, что теперь на экране:
Вау! Зелёный квадрат! И это, как ты уже понял, наше первое состояние.
А теперь предположим, что нам необходимо показывать пользователю различные виджеты в зависимости от того авторизован он или нет.
Например, если пользователь авторизован, мы будем показывать ему зеленый квадрат, а если не авторизован — красный.
Получается, что нам нужно:
Добавить ещё одно состояние
Показывать нужный виджет в зависимости от того, какой стейт сейчас активный.
Код будет ниже. Сначала посмотри, что получилось, а потом подумай как это реализовано.
Теперь посмотри на код и попробуй самостоятельно понять, как это работает
Код
sealed class ContainerState {}
class AuthorizedState extends ContainerState {}
class NotAuthorizedState extends ContainerState {}
class App extends StatefulWidget {
const App({super.key});
@override
State createState() => _AppState();
}
class _AppState extends State {
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 {
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()!
.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 {
AuthBloc() : super(FirstState()) {
on((event, emit) {
emit(FirstState());
});
on((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();
return BlocBuilder(
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 {
AuthBloc() : super(FirstState()) {
Разберём весь наш путь при нажатии на кнопку.
Нажимаем на кнопку
go to SecondState
Вызывается метод
bloc.add(GoToSecondStateEvent)
Эвент
GoToSecondStateEvent
попадает в bloc и обрабатывается в теле конструктора.В блоке вызывается метод
emit(SecondState())
. Этот метод изменяет текущий стейт наSecondState()
и уведомляет об этом подписчиков.Виджет
BlocBuilder
, находящийся в презентационной логике, получает от блока уведомление о том, чтоstate
изменился и перерисовывает всё, что находится ниже по дереву.При перерисовке
switch
видит, что полученSecondState()
и поэтому рисуетStateScreen
с заголовком »{SecondState}»Мы видим новый экран с новым стейтом.
Этот пример, на самом деле, притянут за уши и не показывает всех возможностей, но, думаю, хорошо демонстрирует базовые основы работы с блоком. Дальше дело за тобой
Что делать дальше?
У блока есть большое количество виджетов, методов и фишек, которые необходимо научиться правильно использовать. Советую для начала почитать документацию — https://bloclibrary.dev/getting-started. Там, кстати, есть примеры, демонстрирующие эталонную работу с блоком.
Документация, само собой, на английском, но у себя в телеграмм-канале я разберу основные виджеты и расскажу как правильно их использовать, а также оставлю несколько ссылок на неплохие туториалы. Удачи!
тг: t.me/vanyakodit