Flutter — управление состоянием с RxDart Streams

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 {


константа 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 с чистой архитектурой, это означает, что у нас есть следующий поток данных:


  1. Wiget/Экран открыт

  1. Когда он инициализируется, его ViewModel также инициализируется вместе с ним.

  1. ViewModel вызывает вариант использования для инициализации данных.

  1. Вариант использования вызывает репозиторий для получения данных

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

например:


  1. Открывается экран «Университеты».

  1. UniversitiesScreen инициализирует ViewModel

  1. UniversitiesScreen подписывается на поток данных университетов из ViewModel

  1. UniversitiesViewModel вызывает GetUniversitiesByCountryUseCase, чтобы получить университеты.

  1. Вариант использования вызывает «UniversitiesRepository», чтобы получить данные университетов.

  1. Репозиторий вызывает «UniversityRemoteDataSource», чтобы получить данные университетов.

  1. Источник данных, использующий UniversityEndpoint, отправляет API-запрос к API для получения данных.

Когда есть результат от вызова API, данные возвращаются обратно через потоки.


  1. UniversityEndpoint получает данные (ошибка, данные, загрузка)

  1. он отправляет его обратно в UniversityRemoteDataSource

  1. отправляет его обратно в UniversitiesRepository

  1. отправляет его обратно в GetUniversitiesByCountryUseCase

  1. отправляет его обратно в UniversitiesViewModel

  1. затем он, наконец, попадает на «UniversitiesScreen», где он обрабатывается по своему состоянию и отображается

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


Перейдем к коду!


Здесь у нас есть UniversitiesScreen:



``` дротик


класс UniversitiesScreen расширяет StatefulWidget {


const UniversitiesScreen({Key? key}): super(key: key);


@переопределить


State createState() => _UniversitiesScreenState();


класс _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 _screenState = const AppResult.loading();


@переопределить


недействительным 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 _searchByCountry = PublishSubject();


поздние университеты 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. Что делает эта функция, так это то, что она только отправляет полученную строку во внутреннюю тему, а затем все происходит автоматически (строка отправляется в flatMapflatMap вызывает _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> invoke() {


вернуть 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:





Оригинал
PREVIOUS ARTICLE
NEXT ARTICLE