Flutter — управление состоянием с RxDart Streams
31 мая 2022 г.Что такое управление состоянием и зачем оно нам нужно?
Во Flutter у нас есть виджеты, которые определяют наши экраны. Виджет — это компонент Flutter, который может быть построен из 0, 1 или нескольких виджетов, которые все вместе могут стать экраном. Эти экраны отображают для нас некоторую информацию, и большую часть времени нам нужно, чтобы эта информация менялась. Например, если у нас есть приложение, которое отображает список университетов, нам нужен экран со списком виджетов, где каждый виджет отображает некоторую информацию для определенного университета. Этот экран также должен что-то отображать, когда он загружает данные (например, счетчик загрузки), и он также должен показывать что-то, когда ему не удалось загрузить данные. Нам нужно найти способ управлять всеми тремя фазами (загрузка, успех, ошибка) в жизненном цикле приложения простым в тестировании и использовании способом.
Эти 3 фазы, момент, когда экран загружает данные, момент, когда данные успешно загружаются, и момент, когда что-то не так и отображается ошибка, формируют состояние экрана.
Чего мы достигнем здесь, так это управления состоянием экрана с помощью потоков Dart и библиотеки RxDart.
кодовая база для примеров в этой статье
Что такое RxDart?
Rx означает ReactiveX и происходит от реактивного программирования. В Dart RxDart поставляется с набором расширений для Streams и StreamControllers, а также вводит множество других специфических Rx-компонентов, таких как BehaviourSubject, ReplaySubjects, MergeStreams, CombineStreams и т. д. Библиотека Rx основана на стиле функционального программирования, что означает, что мы можем применить к потоку цепочку функций преобразования:
``` дротик
вернуть _universityEndpoint
.getUniversitiesByCountry(страна)
.safeApiConvert((p0) => p0.map((e) => e.toDomain()).toList())
.map((событие) => transformToSomethingElse(событие))
.flatMap((данные) => transformIntoAStream(данные))
.mergeWith([поток2, поток3]);
- «Функциональное программирование заключается в создании программного обеспечения путем составления чистых функций, избегая при этом общего состояния и изменяемых данных».
Реактивное программирование — это парадигма асинхронного программирования, связанная с потоками данных и распространением изменений.
Вместе функциональное программирование и реактивное программирование образуют комбинацию функциональных и реактивных методов, которые могут представлять элегантный подход к программированию, управляемому событиями, со значениями, которые меняются со временем, и где потребитель реагирует на данные по мере их поступления. Эта технология приносит объединив различные реализации его основных принципов, некоторые авторы создали документ, определяющий общий словарь для описания приложений нового типа». - https://www.baeldung.com/rx-java
Поскольку RxDart использует потоки, он следует шаблону Observer. Что вы всегда должны знать при работе с потоками, так это то, что они никогда не будут работать, если вы не подпишетесь на них.
``` дротик
// Stream.fromIterable создает поток, который выдает значения из заданного списка
Stream.fromIterable([1, 2, 3, 4, 5, 6, 7])
// где функция вызывает свое тело каждый раз, когда значение выдается потоком
// и проверяет, четное ли число. Эта функция действует как фильтр.
.где((элемент) {
// если элемент четный, эта функция возвращает true
// и элемент проходит фильтр
// в противном случае возвращается false и элемент отфильтровывается.
возвращаемый элемент % 2 == 0;
:::предупреждение
Вы всегда должны подписаться на поток, чтобы получать из него какую-либо информацию!
Приведенный выше код ничего не делает, потому что нет другого кода, который подписывается/слушает его. Итак, что мы можем сделать, чтобы это заработало, так это вызвать listen для этого потока.
``` дротик
var evenNumbers = Stream.fromIterable([1, 2, 3, 4, 5, 6, 7])
.где((элемент) {
возвращаемый элемент % 2 == 0;
evenNumbers.listen((элемент){
печать (элемент);
Теперь функция .listen
создает подписку на поток evenNumbers
и прослушивает его события. В тот момент, когда .listen
вызывается и регистрируется как подписка на поток evenNumbers
, код потока начинает работать. Сначала он берет первый элемент из списка, затем проверяет его в функции .where
, а затем, если он проходит проверку (в нашем случае это четное число), он отправляется в обратном вызове listen
, а затем печатается. Таким образом, приведенный выше код печатает 2 4 6
Когда мы вручную прослушиваем потоки, мы должны быть очень осторожны, потому что мы можем легко добавить утечки памяти в наш код. Когда мы слушаем поток, создается подписка. Эта подписка хранится в памяти, и поэтому, если она не удалена/удалена, она останется там, вызывая утечку памяти. Что мы можем сделать, чтобы предотвратить это, так это сохранить подписку где-нибудь, а затем, когда она нам больше не нужна, мы избавимся от нее.
``` дротик
Подписки CompositeSubscription = CompositeSubscription();
вар четные числа =
Stream.fromIterable([1, 2, 3, 4, 5, 6, 7]).где((элемент) {
возвращаемый элемент % 2 == 0;
subscribes.add(evenNumbers.listen((элемент) {
печать (элемент);
// другой код....
// Когда мы закончили с нашими потоками
подписки.dispose();
Dart предоставляет нам простой способ управления нашими подписками. Существует CompositeSubscription, который похож на список подписок. Каждый раз, когда мы хотим прослушать поток, мы добавляем прослушивание внутрь нашего списка подписок, а в конце, когда они нам больше не нужны, мы вызываем функцию dispose, которая закрывает все подписки из памяти.
Дополнительную информацию о RxDart можно найти на их странице GitHub https://github.com/ReactiveX/rxdart.
:::предупреждение
Неудаленная подписка Stream приводит к утечке памяти
Что такое состояние приложения и как его создать?
Прежде чем управлять состоянием, нам нужно создать класс, который инкапсулирует состояния нашего приложения («Загрузка», «Данные», «Ошибка»). Чтобы избежать большого количества шаблонного кода для создания различных методов в моделях («copyWith», «equals», «toString», «when» и т. д.), я использую [freezed library] (https://pub.dev/packages/freezed). ).
``` дротик
@замороженный
класс AppResult
константа AppResult._();
const factory AppResult.data (значение T) = данные;
const factory AppResult.loading() = Загрузка;
const factory AppResult.appError([String? message]) = AppError;
Теперь, когда у нас есть класс AppResult, мы можем использовать его для обработки наших состояний. Итак, что мы собираемся сделать, так это всякий раз, когда мы хотим обработать эти 3 состояния где-то в приложении, мы создадим функции для возврата объекта типа AppResult<OurData>
. Этот объект может иметь одно из 3 состояний (Data
, Загрузка
, Ошибка
).
После создания состояния мы должны его обработать. Поскольку мы используем freezed
, он уже создает для нас метод when
, который позволяет нам выполнять действие для каждого возможного состояния.
``` дротик
приложениеРезультат.когда(
данные: (данные) {
// код запускается, когда appResult имеет статус данных
загрузка: () {
// код запускается, когда appResult находится в состоянии загрузки
ошибка приложения: (Ошибка приложения) {
// код запускается, когда appResult имеет состояние ошибки
Если мы не используем freezed
, тогда нам нужно вручную написать переключатель, чтобы проверить, в каком состоянии мы сейчас находимся.
``` дротик
переключатель (результат приложения) {
случай AppResult.data:
// код запускается, когда appResult находится в состоянии загрузки
ломать;
случай AppResult.loading:
// код запускается, когда appResult находится в состоянии загрузки
ломать;
случай AppResult.error:
// код запускается, когда appResult имеет состояние ошибки
ломать;
Управление состоянием с помощью RxDart
Прежде чем перейти к коду, я хочу немного описать приложение, созданное с использованием RxDart для управления состоянием. Будет приложение, которое предоставляет список университетов, которые могут быть запрошены по их стране.
Там мы используем:
RxDart
для потоков данных и управления состоянием
- MVVM с Чистой архитектурой в качестве шаблона проектирования
Модернизация
сDio
для обработки вызовов API
Freezed
, чтобы избежать шаблонного кода моделей
- Json Serializer за помощь в сериализации и десериализации данных JSON.
- Бесплатный API
[http://universities.hipolabs.com](http://universities.hipolabs.com)
для получения данных университетов
Эта статья посвящена только управлению состоянием RxDart.
Поскольку мы следуем MVVM с чистой архитектурой, это означает, что у нас есть следующий поток данных:
Wiget
/Экран
открыт
- Когда он инициализируется, его ViewModel также инициализируется вместе с ним.
- ViewModel вызывает вариант использования для инициализации данных.
- Вариант использования вызывает репозиторий для получения данных
- В зависимости от того, где находятся данные и логика, стоящая за ними, репозиторий вызывает конечную точку к API, запрашивает локальную базу данных или получает данные из общих настроек. Данные возвращаются точно через те же классы в обратном порядке с использованием потока.
например:
- Открывается экран «Университеты».
UniversitiesScreen
инициализируетViewModel
UniversitiesScreen
подписывается на поток данных университетов изViewModel
UniversitiesViewModel
вызываетGetUniversitiesByCountryUseCase
, чтобы получить университеты.
- Вариант использования вызывает «UniversitiesRepository», чтобы получить данные университетов.
- Репозиторий вызывает «UniversityRemoteDataSource», чтобы получить данные университетов.
- Источник данных, использующий
UniversityEndpoint
, отправляет API-запрос к API для получения данных.
Когда есть результат от вызова API, данные возвращаются обратно через потоки.
UniversityEndpoint
получает данные (ошибка, данные, загрузка)
- он отправляет его обратно в
UniversityRemoteDataSource
- отправляет его обратно в
UniversitiesRepository
- отправляет его обратно в
GetUniversitiesByCountryUseCase
- отправляет его обратно в
UniversitiesViewModel
- затем он, наконец, попадает на «UniversitiesScreen», где он обрабатывается по своему состоянию и отображается
Поскольку мы используем потоки, мы можем легко отправить любое количество значений в одном потоке.
Перейдем к коду!
Здесь у нас есть UniversitiesScreen
:
``` дротик
класс UniversitiesScreen расширяет StatefulWidget {
const UniversitiesScreen({Key? key}): super(key: key);
@переопределить
State
класс _UniversitiesScreenState расширяет State
final UniversitiesViewModel _viewModel = UniversitiesViewModel();
@переопределить
Сборка виджета (контекст BuildContext) {
вернуть эшафот(
панель приложения: панель приложения (
title: const Text("Состояние RxDart"),
тело: столбец(
дети: [
Контейнер(
поле: const EdgeInsets.all(10),
ребенок: TextField(
onChanged: _viewModel.searchByCountry,
украшение: const InputDecoration(
labelText: 'Поиск', suffixIcon: Icon(Icons.search)),
Расширенный(
ребенок: StreamBuilder(
поток: _viewModel.universities,
строитель: (контекст BuildContext,
AsyncSnapshot
вернуть snapshot.data?.когда(
данные: (e) => _buildUniversities(e.universities),
загрузка: () => _buildLoading(),
appError: (e) => _buildError(e.toString()),
apiError: (e) => _buildError(e.toString())) ??
_buildLoading();
Виджет _buildUniversities(List
вернуть ListView.builder (
itemCount: университеты.длина,
itemBuilder: (контекст BuildContext, индекс int) {
возврат карты(
высота: 5,
поле: const EdgeInsets.all(10),
ребенок: Контейнер(
заполнение: const EdgeInsets.all(25),
ребенок: столбец (
дети: [
Text("Имя: ${universities[index].name}"),
Text("Страна: ${universities[index].country}"),
Text("Веб-сайт: ${universities[index].website}"),
Виджет _buildLoading() {
вернуть постоянный центр (
дочерний элемент: CircularProgressIndicator(),
Виджет _buildError (ошибка строки) {
Центр возврата(
ребенок: Текст(
ошибка,
стиль:
Theme.of(context).textTheme.headline3?.copyWith(color: Colors.red),
Наш экран состоит из «Scaffold» с «AppBar» и столбца с вводом «TextField» для поиска университетов по стране, а затем списка университетов.
Что касается списка университетов, мы видим, что у нас есть StreamBuilder:
``` дротик
StreamBuilder(
поток: _viewModel.universities,
строитель: (контекст BuildContext,
AsyncSnapshot
вернуть snapshot.data?.когда(
данные: (e) => _buildUniversities(e.universities),
загрузка: () => _buildLoading(),
appError: (e) => _buildError(e.toString()),
apiError: (e) => _buildError(e.toString())) ??
_buildLoading();
Мы используем здесь StreamBuilder, потому что все наши данные управляются Streams, помните? StreamBuilder автоматически управляет подпиской на университетский поток (помните, что для того, чтобы поток работал, мы должны подписаться/прослушать его, а затем позаботиться о подписке, чтобы предотвратить утечку памяти) и он предоставляет нам функцию обратного вызова builder
, где мы можем создать фактический виджет, который мы хотим отобразить, на основе предоставленного результата.
Мы также можем вручную подписаться на поток и обрабатывать изменение состояния с помощью обратного вызова setState
. Ниже приведен пример того, как прослушивать поток и как избавиться от подписок.
``` дротик
final UniversitiesViewModel _viewModel = UniversitiesViewModel();
final CompositeSubscription _subscriptions = CompositeSubscription();
AppResult
@переопределить
недействительным initState () {
супер.initState();
_subscriptions.add(_viewModel.universities.listen((событие) {
setState (() {
_screenState = событие;
@переопределить
недействительным распоряжаться () {
_subscriptions.dispose();
супер.распоряжаться();
@переопределить
Сборка виджета (контекст BuildContext) {
вернуть эшафот(
панель приложения: панель приложения (
title: const Text("Состояние RxDart"),
тело: столбец(
дети: [
Контейнер(
поле: const EdgeInsets.all(10),
ребенок: TextField(
onChanged: _viewModel.searchByCountry,
украшение: const InputDecoration(
labelText: 'Поиск', suffixIcon: Icon(Icons.search)),
Расширенный(
ребенок: _screenState.when(
данные: (e) => _buildUniversities(e.universities),
загрузка: () => _buildLoading(),
appError: (e) => _buildError(e.toString()),
apiError: (e) => _buildError(e.toString())),
ViewModel
инициализируется точно так же, как и раньше, но на этот раз мы используем переменную для сохранения состояния приложения. Поскольку у нас нет данных, хранящихся локально, и нам всегда приходится получать их с сервера, мы хотим видеть индикатор загрузки при инициализации экрана, поэтому мы также инициализируем переменную _screenState с состоянием загрузки.
Затем, перед построением экрана, в обратном вызове initState
мы подписываемся на поток данных университетов и регистрируем подписку в нашем списке подписок _subscriptions
. В обратном вызове подписки вы можете видеть, что мы вызываем setState
, а затем устанавливаем новое значение в нашей переменной _screenState
. Здесь происходит волшебство. Когда вызывается setState
, Flutter проверяет, что было изменено, переходит в дерево виджетов и перестраивает виджеты в зависимости от измененных переменных. Итак, он переходит в метод build
и перестраивает оттуда виджеты, используя новый _screenState
Чтобы предотвратить утечку памяти, мы удаляем/закрываем все подписки из списка внутри обратного вызова dispose.
Это все, что нам нужно сделать для управления состоянием экрана в пользовательском интерфейсе.
В UniversitiesViewModel
всего несколько строк кода.
``` дротик
класс UniversitiesViewModel {
final GetUniversitiesByCountryUseCase _getUniversitiesByCountryUseCase;
final Subject
поздние университеты Stream
УниверситетыВиевМодел(
{GetUniversitiesByCountryUseCase? getUniversitiesByCountryUseCase})
: _getUniversitiesByCountryUseCase = getUniversitiesByCountryUseCase ??
GetUniversitiesByCountryUseCase() {
университеты = _searchByCountry
.startWith (нулевой)
.flatMap((значение) => _getUniversitiesByCountryUseCase.invoke(значение));
void searchByCountry (строка страны) {
_searchByCountry.add(страна);
Мы можем заметить, что здесь у нас есть что-то новое, пришедшее из RxDart. Вот Subject
, который создается с помощью PublishSubject
. Эти Subject
ы являются некоторыми расширениями StreamController
, который является просто потоком, который позволяет нам отправлять через него значения. Этот Subject
с именем _searchByCountry
используется для отправки поискового запроса в useCase
для предоставления нам университетов из этой страны. Поскольку мы не хотим, чтобы какой-либо другой класс мог отправлять информацию через этот субъект, мы делаем его приватным.
Теперь, когда у нас есть способ отправки наших запросов, нам также нужен способ, чтобы представление могло получить необходимую информацию. Для этого мы создаем переменную Stream, которая будет отправлять через нее данные о состоянии приложения. Эта переменная universities
должна быть инициализирована в конструкторе viewModel
. Там мы также устанавливаем для него некоторые правила/преобразования:
``` дротик
// Мы присваиваем нашу _searchByCountry Subject переменной университетов
университеты = _searchByCountry
// Когда приложение открыто, мы также хотим загрузить некоторые университеты, и потому
// здесь мы не хотим выбирать страну по умолчанию, мы просто говорим потоку
// соединение для отправки null при первом подключении
.startWith (нулевой)
// Теперь мы хотим вызвать наш useCase, чтобы получить университеты для указанной страны
// Это делается с помощью flatMap, который отображает/преобразует поток в другой поток
.flatMap((значение) => _getUniversitiesByCountryUseCase.invoke(значение));
Теперь, когда мы инициализировали наш поток данных, нам больше нечего делать. Представление должно подписаться на потоки «университетов», и оно получит необходимые данные.
Если вы заметили на экране, там было «Поиск» «Текстовое поле», используемое для получения пользовательского ввода для определенной страны.
``` дротик
Текстовое поле(
onChanged: _viewModel.searchByCountry,
украшение: const InputDecoration(
labelText: 'Поиск', suffixIcon: Icon(Icons.search)),
Там мы должны установить функцию обратного вызова для действия onChanged
, чтобы в момент изменения текста он вызывал указанную нами функцию обратного вызова. В нашем случае мы будем использовать функцию searchByCountry
из нашей viewModel
. Что делает эта функция, так это то, что она только отправляет полученную строку во внутреннюю тему, а затем все происходит автоматически (строка отправляется в flatMap
→ flatMap
вызывает _getUniversitiesByCountryUseCase
→ и затем отправляет данные обратно в слушатель)
``` дротик
void searchByCountry (строка страны) {
_searchByCountry.add(страна);
Теперь мы просто рассмотрим все, что связано с фактическим управлением состоянием наших виджетов Flutter.
В этой статье я не рассматривал детали архитектурного подхода или того, как извлекаются данные. В нашем случае, если вы не хотите использовать MVVM с чистой архитектурой, вы можете напрямую выполнить вызов API в viewModel
или даже в виджете, а затем преобразовать его в поток AppResult<UniversityScreenState>
и все такое. заработает. Пока вы используете Streams для получения данных, вы можете применить описанные выше шаги для любой выбранной вами архитектуры.
Почему Streams, а не Async и Await?
Основное различие между streams и async-await заключается в том, что Stream
используется для асинхронной отправки нескольких данных, тогда как async-await используется для асинхронной отправки только одного элемента.
Для многих проектов, которые взаимодействуют только с Rest API, не будет необходимости в потоках, простое ожидание асинхронной функции может выполнить эту работу. Но в этом случае мы должны обрабатывать состояние загрузки вручную, возможно, в viewmodel
или в виджете.
Более того, если приложению потребуется подключиться к WebSocket, то асинхронное ожидание нам не поможет, и мы должны использовать поток, который подключен к WebSocket, а затем постоянно отправляет нам данные.
Другой вариант использования, который намного проще обрабатывать с помощью Streams, — это когда нам нужно получить данные из локального хранилища, отобразить их на экране, а затем обновить их с сервера, а после обновления отобразить обновленную версию.
Преимущества потоков
Большую часть времени в реальных приложениях есть экраны, которым нужны данные из нескольких источников. Примером может быть экран с информацией о пользователе и списком книг пользователей. В этом случае мы должны объединить данные из двух источников (в данном случае из двух потоков) и объединить их данные в объект ScreenState
. например
``` дротик
класс GetMainScreenUseCase {
окончательный BooksRepository _booksRepository;
окончательный UserRepository _userRepository;
GetMainScreenUseCase({
Книгохранилище? книгиРепозиторий,
Пользовательский репозиторий? пользовательский репозиторий,
}) : _booksRepository = booksRepository ?? Книгохранилище(),
_userRepository = userRepository ?? ПользовательскийРепозиторий();
Stream
вернуть CombineLatestStream.combine2(
_userRepository.getUserData(),
_booksRepository.getBooks(),
(userDataResponse, booksResponse) {
// Здесь нам нужно проверить, получили ли мы успех на обоих потоках
если (userDataResponse — это AppResult.data &&
booksResponse — это AppResult.data) {
// Так как мы получили данные по обоим потокам, теперь мы создаем наш ScreenState
// с полученными данными.
вернуть AppResult.data(
ScreenState.от(
пользовательский ответ,
книгиответ,
}иначе если(....){
еще{
Могут быть и другие случаи, когда вам нужно отобразить некоторые данные из разных источников, для этого случая вы можете использовать MergeStream
.
Другими полезными потоками являются BehaviourSubjects
, которые представляют собой потоки, которые сохраняют последнее переданное значение (сохраняет состояние) и напрямую отправляют его, когда кто-то подписывается на него. Это поведение можно добавить и к простым потокам, используя расширение .reply
. Эти два полезны, когда мы хотим сохранить состояние экрана, например. Пользователь находится на главной странице, видит там все данные, затем переходит на страницу сведений, и когда он возвращается на главный экран, нам нужно отобразить те же данные, что и раньше, без запуска какого-либо вызова DB/API/SharedPreferences.
Вы можете узнать больше о том, что предоставляют эти потоки, прочитав документацию RxDart и документацию от RxJava.
https://github.com/ReactiveX/rxdart
https://reactivex.io/documentation/observable.html
Другие статьи о RxDart и его интеграции с Flutter:
Оригинал