Делаем кастомный трансформер для BLoC
Hola, Amigos! На связи Павел Гершевич, Mobile Team Lead агентства продуктовой разработки Amiga и соавтор телеграм-канала Flutter.Много. Как известно, BLoC — один из самых популярных способов для управления состоянием. Его преимущество в том, что мы можем управлять не только самим состоянием, но и теми данными, которые в него попадают.
В этой статье мы разберемся с такими вопросами:
- Что такое Event Transformers? Для чего они нужны?
- Как их применять?
И попробуем сделать 2 кастомных трансформера различной сложности.
Немного теории
Сначала посмотрим на то, как устроен BLoC:
Кратко это можно описать так — BLoC состоит из двух потоков данных, которые объединяются при помощи обработки событий из одного и добавления данных в другой. Эти потоки нужны для событий BLoC и его состояния.
Чтобы контролировать поток состояний, нам нужно контролировать поток событий и их обработку. Если с обработкой нет ничего сложного, так как это обычные функции, то вот с потоком событий могут возникнуть сложности.
В нем мы можем управлять порядком, в котором будут вызываться функции для обработки, и тем самым знать, когда изменится состояние. Чтобы это делать, существуют специальные функции для преобразования — трансформеры.
Как устроен трансформер?
Если говорить коротко, то функция, которую мы применяем, возвращает еще одну, которая как раз и обрабатывает поток.
(events, mapper) => events.map((e) => mapper(e));
На вход она получает поток событий и функцию для обработки данных. Уже внутри нее идет преобразование.
Как добавить трансформер к событиям?
Event Transformer можно применить как ко всем событиям в BLoC, так и только к определенным, передав функцию в метод on из BLoC:
on(
(event, emit) {
…
},
transformer: sequential(),
);
А есть ли готовые трансформеры?
Разработчики библиотеки bloc предоставляют дополнительную библиотеку bloc_concurrency, в которую входят 4 уже готовых трансформера. Давайте посмотрим на них по порядку.
Первый — concurrent
. Он нужен для обработки событий в тот момент, когда они поступили.
Второй — sequential
. Он позволяет выполнять обработку событий последовательно.
Например, при бесконечном скролле, пока мы загружаем одну страницу, вторая страница не будет обрабатываться.
Третий — droppable
. С его помощью можно отменить выполнение других событий, пока одно обрабатывается.
Например, кнопка для сохранения формы. Если пользователь нажал ее несколько раз, то обработано будет только первое нажатие.
Четвертый — restartable
. Он позволяет отменить обработку текущего события, если пришло новое.
Например, для тяжеловесных операций, которые могут быть перезапущены для экономии ресурсов, или для поиска. Пользователь вводит 10 символов один за другим, а мы отбрасываем, пока он не прекратит ввод.
Пишем кастомные трансформеры
Иногда таких трансформеров может не хватать, особенно, если нам нужно сделать какие-то более сложные вещи. Например, добавить debounce
к какому-либо событию.
Создаем трансформер в виде функции
Давайте попробуем написать такой трансформер сами. Для этого уже есть подготовленный тип данных — EventTransformer
, но не нужно забывать, что он принимает в себя какой-либо класс события, поэтому лучше добавлять в него дженерик. И для простоты работы мы дополнительно будем передавать длительность уже внутрь трансформера.
EventTransformer debounced({required Duration duration}) {
return (events, mapper) => events
.debounce((_) => TimerStream(true, duration))
.map(mapper);
}
Итого, используя метод debounce
из библиотеки rxdart, мы создали свой первый простой EventTransformer
, который можем применить в нашем BLoC.
on(
(event, emit) {
…
},
transformer: debounced(
duration: const Duration(milliseconds: 500),
),
);
Создаем более сложный трансформер
Таких простых трансформеров может быть все равно недостаточно. Например, если нам нужно сделать буферизацию событий с изначальной задержкой. Давайте рассмотрим именно такой случай.
Допустим, у нас есть вот такое событие:
class AddToCart extends CartEvent {
final Item item;
…
}
Сначала нужно вернуть то же событие, которое поступает на вход. Для этого преобразуем его для работы со списком.
class AddToCart extends CartEvent {
final List- items;
…
}
И создадим метод, который будет преобразовывать несколько событий в одно:
AddToCart fromBuffer(List events) {
return AddToCart(
items: events
.map((e) => e.items)
.expand()
.toList(),
);
}
Такие методы могут быть более сложными, но они обязательно должны принимать список событий и возвращать всего одно, причем того же типа.
Можно добавить тип данных под это:
typedef EventFlatMapper = T Function(List);
Теперь у нас все готово, и мы продолжаем с создания дополнительного класса, в котором и будем обрабатывать наш поток событий. Для этого в Dart уже есть специальный абстрактный класс — StreamTransformerBase
.
class _BufferedEventTransformer extends StreamTransformerBase {
final EventMapper mapper;
final EventFlatMapper flatMapper;
final Duration duration;
_BufferedEventTransformer({
required this.mapper,
required this.flatMapper,
required this.duration,
});
…
}
В наш класс мы передаем mapper
, который обрабатывает наше событие из BLoC, функцию для раскладывания flatMapper
и длительность минимальной задержки duration
.
А как же нам все-таки обрабатывать поток данных? Тут все просто — есть метод bind, от которого мы и оттолкнемся.
@override
Stream bind(Stream stream) {}
По идее, тут мы можем делать абсолютно то же самое, что позволяют простые трансформеры. Но мы пишем что-то более сложное.
Оглянемся на те трансформеры, которые есть в bloc_concurrency, а именно на droppable
. Давайте посмотрим, как там организован метод bind
:
@override
Stream bind(Stream stream) {
late StreamSubscription subscription;
StreamSubscription? mappedSubscription;
final controller = StreamController(
onCancel: () async {
await mappedSubscription?.cancel();
return subscription.cancel();
},
sync: true,
);
subscription = stream.listen(
(data) {
if (mappedSubscription != null) return;
final Stream mappedStream;
mappedStream = mapper(data);
mappedSubscription = mappedStream.listen(
controller.add,
onError: controller.addError,
onDone: () => mappedSubscription = null,
);
},
onError: controller.addError,
onDone: () => mappedSubscription ?? controller.close(),
);
return controller.stream;
}
Можно увидеть, создается подписка на основной поток данных, а внутри нее сначала проверяется, завершилась ли обработка события — если нет, то следующее отбрасывается. Это очень похоже на то, что необходимо при буферизации. Поэтому работать будем с этим кодом, а изменим только подписку на основной Stream
.
Первое, что нужно сделать — добавить задержку перед обработкой первого события. Она же будет и максимальной задержкой между обработкой событий. Сделаем это при помощи rxdart: буферизуем по времени, обрабатываем нашим flatMapper
и отбрасываем пустые события.
subscription = stream
.bufferTime(duration)
.map((e) {
if (e.isEmpty) return null;
return flatMapper(e);
})
.whereType()
.listen(
(data) {
…
},
onError: controller.addError,
onDone: () => mappedSubscription ?? controller.close(),
);
Теперь все события, которые добавляются за время задержки, будут обработаны вместе. Но у нас до сих пор отбрасываются события, а не улетают в буфер. Для этого мы перенесем проверку выше в bufferTest
, обработаем flatMapper
и отбросим пустые события.
subscription = stream
.bufferTime(duration)
.map((e) {
if (e.isEmpty) return null;
return flatMapper(e);
})
.whereType()
.bufferTest((_) => mappedSubscription != null)
.map((e) {
if (e.isEmpty) return null;
return flatMapper(e);
})
.whereType()
.listen(
(data) {
…
},
onError: controller.addError,
onDone: () => mappedSubscription ?? controller.close(),
);
Единственное, что осталось — убрать проверку на то, что mappedSubscription
не закончил свою работу из прослушивания. И все, наш трансформер готов к тому, чтобы по нему сделать метод для добавления в BLoC:
EventTransformer buffered(
EventFlatMapper flatMapper,
Duration duration,
) {
return (events, mapper) => events.transform(
_BufferedEventTransformer(
mapper: mapper,
flatMapper: flatMapper,
duration: duration,
),
);
}
Теперь мы можем добавлять буферизацию к нашим событиям, чтобы ускорить выполнение некоторых ресурсоемких операций.
Если есть вопросы — буду ждать вас в комментариях. А еще подписывайтесь на телеграм-канал про Flutter-разработку — я часто там пишу и делюсь полезными инсайтами и новостями из мира мобильной разработки.