Управление состоянием в клиентском приложении — всегда сложная (и вызывающая множество мнений) тема.
Веб-разработчики знакомы с такими инструментами, как Redux или MobX. Эти инструменты следуют очень специфическим паттернам, которые не всегда легко изучить или понять новым разработчикам.
Такие же инструменты существуют и в мире Flutter, но недавно я изучал альтернативу, которая может показаться неизменно более простой. Мы можем управлять состоянием приложения с помощью rxdart и get_it.
Немного предыстории
В любой момент времени у приложения есть состояние. Если мы подумаем о таком приложении, как Instagram, то состояние может быть следующим
список постов и историй, которые приложение отображает. Состояние также может быть вашими предпочтениями в приложении, содержанием
истории, которую вы собираетесь опубликовать, список уведомлений и количество непрочитанных сообщений.
В общем, состояние — это данные.
Давайте вспомним об уведомлениях. Когда пользователь отправляет вам сообщение, многие компоненты приложения должны отразить это изменение состояния: Маленькая красная точка, сообщающая о наличии непрочитанных сообщений, должна быть отображена, список сообщений должен быть обновлен и так далее.
На более низком уровне состояние — это набор переменных. Во Flutter мы расширяем класс StatefulWidget
, когда хотим создать виджет, который будет содержать состояние:
import 'package:flutter/material.dart';
class Update {
Update({required this.description});
String description;
}
class MyStatefulWidget extends StatefulWidget {
@override
State<StatefulWidget> createState() => MyStatefulWidgetState();
}
class MyStatefulWidgetState extends State<MyStatefulWidget> {
List<Update> updates = [];
@override
Widget build(BuildContext context) {
print("Updates: $updates");
return Column(
children: [
ElevatedButton(
onPressed: () {
fetchUpdates().then((newUpdates) {
setState(() {
updates = newUpdates;
});
});
},
child: const Text("Fetch notifications"),
),
Flexible(
child: ListView.builder(
itemCount: updates.length,
itemBuilder: (BuildContext context, int index) {
return ListTile(
title: Text(updates[index].description),
);
},
),
)
],
);
}
}
Виджет MyStatefulWidgetState
отображает следующий вид:
В этом примере, когда пользователь нажимает кнопку Fetch notifications
, вызывается функция fetchUpdates()
. Эта функция возвращает объект Future, и после успешного завершения запроса мы устанавливаем список обновлений в переменную updates
.
Переменная updates
является состоянием в этом виджете.
Вызов setState
при установке нового значения переменной updates
приведет к повторному рендерингу только этого виджета. Если этот виджет содержит другие виджеты (например, ElevatedButton
и ListView
), то эти виджеты также будут перерисованы.
Теперь мы хотим добавить иконку в верхнюю панель, чтобы указать пользователю, что были получены новые уведомления. Следующий виджет отобразит значок уведомления:
import 'package:flutter/material.dart';
class NotificationIcon extends StatelessWidget {
@override
Widget build(BuildContext context) {
var notificationsCount = 0;
return Stack(
children: <Widget>[
const Icon(Icons.notifications),
Positioned(
right: 0,
child: Container(
padding: EdgeInsets.all(1),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(6),
),
constraints: const BoxConstraints(
minWidth: 12,
minHeight: 12,
),
child: Text(
'$notificationsCount',
style: const TextStyle(
color: Colors.white,
fontSize: 8,
),
textAlign: TextAlign.center,
),
),
)
],
);
}
}
Как мы можем уведомить виджет NotificationIcon
о том, что MyStatefulWidget
получил новый список уведомлений?
Некоторые варианты…
Мы можем перерисовать наш компонент, чтобы включить NotificationIcon
в MyStatefulWidget
. Проблема с этим подходом заключается в том, что нам может понадобиться включить и другие, не связанные между собой виджеты.
Другой подход заключается в использовании унаследованных виджетов. Однако этот подход может потребовать от нас модификации других виджетов, кроме этих двух, что не всегда возможно.
Как мы можем уведомить независимые виджеты об изменении состояния?
Ввести управление состоянием.
Управление состоянием с помощью потоков
Потоки данных — это аналог очередей: Одна часть приложения публикует данные в поток, а другие части приложения могут подписаться на этот поток и потреблять данные, которые он производит.
Мы можем полагаться на потоки для передачи изменений состояния между независимыми компонентами.
Популярной реализацией потоков для приложений Flutter является rxdart.
Мы можем добавить их в наш проект с помощью следующей команды:
flutter pub add rxdart
Создание потока
Затем мы можем создать сервис для создания потока:
import 'package:rxdart/rxdart.dart';
import 'MyStatefulWidget.dart';
class NotificationsService {
final BehaviorSubject<List<Update>> notificationSubject = BehaviorSubject.seeded([]);
ValueStream get stream$ => notificationSubject.stream;
updateNotifications(List<Update> updates) {
notificationSubject.add(updates);
}
}
Прежде чем мы сможем использовать этот сервис, нам нужен способ доступа виджетов к общему экземпляру потока. Для этого мы будем использовать get_it:
flutter pub add get_it
Регистрация сервиса
Этот инструмент является локатором сервиса: Он позволяет нам создавать экземпляры объектов и передавать их по всему приложению без необходимости передавать их в качестве параматеров.
Мы можем создать экземпляр NotificationService
при запуске приложения следующим образом:
void main() {
GetIt getIt = GetIt.I;
getIt.registerSingleton<NotificationsService>(NotificationsService());
runApp(const MyApp());
}
Теперь в любом месте нашего приложения мы можем получить этот экземпляр синглтона с помощью следующего кода:
GetIt getIt = GetIt.I;
var service = getIt.get<NotificationsService>();
Мы обновляем MyStatefulWidgetState
, чтобы получить сервис, и отправляем новые обновления в поток:
fetchUpdates().then((newUpdates) {
GetIt getIt = GetIt.I;
var service = getIt.get<NotificationsService>();
service.updateNotifications(newUpdates);
...
Подписка на поток
Теперь мы обновим NotificationIcon
, чтобы подписаться на поток и слушать новые обновления.
Для этого мы будем использовать StreamBuilder
, который принимает поток и перерисовывает его содержимое, когда поток выдает новые результаты.
Следующий код показывает обновленную версию NotificationIcon
:
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'MyStatefulWidget.dart';
import 'NotificationsService.dart';
class NotificationIcon extends StatelessWidget {
late NotificationsService service;
NotificationIcon() {
GetIt getIt = GetIt.I;
service = getIt.get<NotificationsService>();
}
@override
Widget build(BuildContext context) {
return StreamBuilder(
stream: service.stream$,
builder: (BuildContext context, AsyncSnapshot<dynamic> snapshot) {
if (!snapshot.hasData || snapshot.data == null) {
return const CircularProgressIndicator();
}
List<Update> updates = snapshot.data;
if (updates.isEmpty) {
return Container();
}
var notificationsCount = updates.length;
return Stack(
children: <Widget>[
const Icon(Icons.notifications),
Positioned(
right: 0,
child: Container(
padding: EdgeInsets.all(1),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(6),
),
constraints: const BoxConstraints(
minWidth: 12,
minHeight: 12,
),
child: Text(
'$notificationsCount',
style: const TextStyle(
color: Colors.white,
fontSize: 8,
),
textAlign: TextAlign.center,
),
),
)
],
);
},
);
}
}
Сфокусируйтесь на важной части:
return StreamBuilder(
stream: service.stream$,
builder: (BuildContext context, AsyncSnapshot<dynamic> snapshot) {
if (!snapshot.hasData || snapshot.data == null) {
return const CircularProgressIndicator();
}
List<Update> updates = snapshot.data;
if (updates.isEmpty) {
return Container();
}
var notificationsCount = updates.length;
// ... render the notification icon
Поскольку поток может быть пустым, нам нужно обработать случай, когда виджет не получил никаких обновлений. Класс AsyncSnapshot
имеет несколько методов, которые позволяют нам проверять состояние потока и действовать соответствующим образом.
Теперь, когда пользователь нажмет на кнопку, новые обновления будут отправлены в поток, и все виджеты, прослушивающие его, переоткроются:
Мы можем пойти еще дальше и сделать MyStatefulWidgetState
виджетом без состояния, получая свое состояние непосредственно из того же потока. Не стесняйтесь сделать этот дополнительный шаг и попрактиковаться в этом подходе к управлению состоянием.
Плюсы и минусы
Некоторые из преимуществ этого подхода к управлению состоянием следующие:
- Простота. Несколько независимых виджетов могут получать уведомления об изменении состояния без изменения их родительских виджетов и без передачи дополнительных реквизитов.
- Это позволяет нам использовать больше виджетов без состояния. Виджеты без состояния имеют более предсказуемое поведение и требуют меньше кода.
- Это уменьшает количество ненужных повторных рендерингов. Если бы мы передавали состояние приложения в виде свойств через дерево виджетов, все промежуточные виджеты были бы перерисованы при изменении состояния. При таком подходе только те виджеты, которые нуждаются в повторном отображении, делают это.
- Меньше условностей. Нет определенного API, которому нужно следовать. Нет редукторов и мапперов. Хотя условности полезны для больших проектов, небольшие приложения могут воспользоваться гибкостью, которую обеспечивает этот подход.
С другой стороны, есть и недостатки:
- Он может быть слишком гибким. Этот подход открывает дверь для отсутствия структуры в проекте. Больше структуры лучше для больших команд и больших кодовых баз.
- Если не использовать
get_it
ответственно, это может привести к куче глобальных состояний. Разработчики могут злоупотреблять магией этого сервисного локатора и добавлять состояние, которое не нужно хранить.
Заключение
Управление состоянием — это сложно. Из-за этого разработчики создали множество инструментов для хранения состояния и передачи информации о его изменениях. Некоторые из этих инструментов лучше подойдут для нужд вашего конкретного проекта.
Использование потоков вместе с сервисным локатором — это простой, но эффективный способ работы с глобальным состоянием.
(первоначально опубликовано в моем личном блоге)