Event Bus: пишем шину событий во Flutter-приложении
Привет, Хабр! Меня зовут Юрий Петров, я Flutter Team Lead в Friflex. Как и многие коллеги, я пришел во Flutter из мира Android. Конечно, есть практики, которые мы использовали при разработке нативных приложений для Android и которые мы тянем за собой в кроссплатформенную разработку. В статье хочу вам рассказать про чудесный инструмент Event Bus. При переводе на русский этот термин дословно означает «шина событий».
При написании больших и сложных приложений обычно постоянно нужно отслеживать много состояний: авторизацию, местонахождение пользователя, выбранный магазин при покупке и другие состояния. Разные кейсы обрабатываются по-разному. Например, если пользователь поменял магазин, то заново делаем запрос в сеть, чтобы получить список магазинов согласно выбранному городу. Или, например, если пользователь вышел из аккаунта, то очищаем данные и передаем запрос на бэкенд о выходе пользователя из аккаунта. Соответственно, для таких задач нам нужен механизм, когда мы можем подписывать объекты друг на друга. Как раз для решения этой задачи программисты придумали такой паттерн, как шина событий.
Паттерн проектирования Event Bus
На самом деле паттерн довольно простой. Идея заключается в том, что есть один публикатор (Publisher), который отправляет некое событие в поток данных. И есть подписчики (Subscribers) — они ждут событие, на которое они подписаны из этого потока. Таким образом мы обеспечиваем взаимодействие «слабо связанных» компонентов. Схематично шина событий выглядит так:
Реализуем простую задачу без шины событий
Чтобы лучше понять суть шины событий, можно попробовать рассмотреть следующую задачу, которая должна быть реализована в библиотеке flutter_bloc.
Мы имеем стандартный счетчик (проект слетон во Flutter), который при нажатии на кнопку увеличения инкрементируется. Теперь давайте попробуем сделать так, чтобы при четном значении счетчика выводилось сообщение.
Для наглядности можно создать два простых кубита. Первый кубит отвечает за управление счетчика, а задача второго — просто отслеживать счетчик. Чтобы отследить значение счетчика, передадим как параметр CounterCubit в ListenCubit. А в ListenCubit создадим подписку в виде StreamSubscription на CounterCubit. На самом деле это самый простой способ подписываться на изменения bloc/cubit. Называется данный тип подписки bloc to bloc.
counter_cubit.dart
import 'package:flutter_bloc/flutter_bloc.dart';
class CounterCubit extends Cubit {
CounterCubit() : super(0);
void increment() => emit(state + 1);
}
listen_cubit.dart
class ListenCubit extends Cubit {
ListenCubit(this.counterCubit) : super('Start') {
subscription = counterCubit.stream.listen((event) {
if (event.isEven) {
emit("Число четное $event");
}
});
}
final CounterCubit counterCubit;
late final StreamSubscription subscription;
@override
Future close() {
subscription.cancel();
return super.close();
}
}
main.dart
void main() {
runApp(MaterialApp(
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const App()));
}
class App extends StatelessWidget {
const App({super.key});
@override
Widget build(BuildContext context) {
return MultiBlocProvider(
providers: [
BlocProvider(
create: (context) => CounterCubit(),
),
BlocProvider(
create: (context) => ListenCubit(context.read()),
),
],
child: _CounterScreenView(),
);
}
}
class _CounterScreenView extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
floatingActionButton: FloatingActionButton(
onPressed: () => context.read().increment(),
child: const Icon(Icons.add),
),
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
),
body: Padding(
padding: const EdgeInsets.all(16),
child: Stack(
children: [
const SizedBox(height: 30),
BlocBuilder(
builder: (context, state) {
return Text(
state.toString(),
style: Theme.of(context).textTheme.headlineMedium,
);
},
),
Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 30),
BlocBuilder(
builder: (context, state) {
return Text(
state.toString(),
style: Theme.of(context).textTheme.headlineMedium,
);})]))])));
}
}
И в итоге получаем рабочую схему подписки bloc to bloc:
Результат
Пишем эту же простую задачу с помощью шины событий
Теперь давайте попробуем реализовать то же самое, но уже с помощью шины событий. Смотря на общую схему, мы понимаем, что нам необходимо создать саму шину, которая может принимать события и отправлять подписчикам. Для такой реализации мы будем использовать стандартные потоки (Stream), которые входят в SDK Dart.
app_event.dart
import 'dart:async';
import 'event.dart';
final class EventBus {
final StreamController _streamController = StreamController.broadcast();
/// Метод, возвращает стрим при изменении события
Stream on() =>
_streamController.stream.where((event) => event is T).cast();
/// Добавление события в шину
void addEvent(Event event) {
_streamController.add(event);
}
/// Закрытие контроллера. В основном, данный метод не нужен.
/// Так как поток шины событий, должен работать пока работает
/// приложение. Так же, перед закрытием лучше проверить наличие
/// подписчиков.
void dispose() {
_streamController.close();
}
}
Далее создаем класс, где будем описывать все события, какие будут в приложении. Также есть базовый интерфейс Even c дженериком, чтобы можно было передавать данные через события Event
event.dart
import 'package:equatable/equatable.dart';
/// Родительский класс событий
abstract class Event extends Equatable {
const Event(this.data);
final T data;
@override
List
Теперь немного исправим ListenCubit:
listen_cubit.dart
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:mitap_event_bus/event_bus/event.dart';
import 'package:mitap_event_bus/event_bus/event_bus.dart';
class ListenCubit extends Cubit {
ListenCubit(this.eventBus) : super('Start') {
subscription = eventBus.on().listen((event) {
emit(event.data);
});
}
final EventBus eventBus;
late final StreamSubscription subscription;
@override
Future close() {
subscription.cancel();
return super.close();
}
}
Что поменялось? Мы видим, что теперь нам не надо передавать ссылку на CounterCubit — мы принимаем ссылку на EventBus. Важно отметить, что шина событий должна быть синглтоном. Вам нужно внедрить зависимость как Singleton в DI или создать экземпляр вручную.
Далее в инициализации подписки ждем только одно событие CounterEvent (событие — когда значение счетчика четное). Остальные события, которые поступают по шине, нас не интересуют.
Ну и осталось поправить main.dart:
main.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:mitap_event_bus/cubits/counter_cubit/counter_cubit.dart';
import 'package:mitap_event_bus/cubits/listen_cubit/listen_cubit.dart';
import 'package:mitap_event_bus/event_bus/event_bus.dart';
import 'event_bus/event.dart';
/// Создание единственного экземпляра шины событий
EventBus eventBus = EventBus();
void main() {
runApp(MaterialApp(
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const App()));
}
class App extends StatelessWidget {
const App({super.key});
@override
Widget build(BuildContext context) {
return MultiBlocProvider(
providers: [
BlocProvider(
create: (context) => CounterCubit()),
BlocProvider(
create: (context) => ListenCubit(eventBus)),
],
child: _CounterScreenView());
}
}
class _CounterScreenView extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
floatingActionButton: FloatingActionButton(
onPressed: () {
context.read().increment();
/// После каждой смены состояния счетчика, отправляем событие в шину
/// если счетчик четный
final counter = context.read().state;
if (counter.isEven) {
eventBus.addEvent(CounterIsEven(counter));
}},
child: const Icon(Icons.add)),
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
),
body: Padding(
padding: const EdgeInsets.all(16),
child: Stack(
children: [
const SizedBox(height: 30),
BlocBuilder(
builder: (context, state) {
return Text(
state.toString(),
style: Theme.of(context).textTheme.headlineMedium,
);
},
),
Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 30),
BlocBuilder(
builder: (context, state) {
return Text(
state.toString(),
style: Theme.of(context).textTheme.headlineMedium,
);
},
)]))])));
}
}
В строке 49 мы видим, при каждом увеличении счетчика, мы проверяем значение, и если значение четное, то отправляем в шину события CounterEvent и передаем текущее значение счетчика.
Преимущество использования шины событий в проекте
У вас только один Publicher (создатель событий) в проекте. Это убережет вас от головной боли, когда приложение станет очень большим и нужно четко понимать, что и от чего зависит и кто на что подписан.
Шина событий никак не завязана на библиотеку flutter_bloc или на любую другую. На самом деле это просто обычный стрим. Его можно использовать всегда и везде.
Объявление всех событий в одном месте: легко понять, какие события создаются и кто на них подписан.
Шину событий можно легко расширить, добавить новый функционал, такие как проверка событий, проверка на наличие подписчиков и так далее.
Рекомендую использовать данный паттерн, так как шина событий зарекомендовала себя очень хорошо на больших и сложных проектах.