Привет, Хабр! Меня зовут Юрий Петров, я 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<int> {
  CounterCubit() : super(0);

  void increment() => emit(state + 1);
}

listen_cubit.dart
class ListenCubit extends Cubit<String> {
  ListenCubit(this.counterCubit) : super('Start') {
    subscription = counterCubit.stream.listen((event) {
      if (event.isEven) {
      emit("Число четное $event");
      }
    });
  }

  final CounterCubit counterCubit;
  late final StreamSubscription subscription;

  @override
  Future<void> 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<CounterCubit>()),
        ),
      ],
    child: _CounterScreenView(),
    );
  }
}


class _CounterScreenView extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      floatingActionButton: FloatingActionButton(
        onPressed: () => context.read<CounterCubit>().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<ListenCubit, String>(
            builder: (context, state) {
              return Text(
                  state.toString(),
                  style: Theme.of(context).textTheme.headlineMedium,
              );
            },
        ),
      Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
              const SizedBox(height: 30),
              BlocBuilder<CounterCubit, int>(
                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<T> on<T>() =>
    _streamController.stream.where((event) => event is T).cast<T>();

  /// Добавление события в шину
  void addEvent(Event event) {
    _streamController.add(event);
  }

  /// Закрытие контроллера. В основном, данный метод не нужен.
  /// Так как поток шины событий, должен работать пока работает
  /// приложение. Так же, перед закрытием лучше проверить наличие 
  /// подписчиков.
  void dispose() {
    _streamController.close();
  }
}

Далее создаем класс, где будем описывать все события, какие будут в приложении. Также есть базовый интерфейс Even c дженериком, чтобы можно было передавать данные через события Event<T>.

event.dart
import 'package:equatable/equatable.dart';


/// Родительский класс событий
abstract class Event<T> extends Equatable {
  const Event(this.data);

  final T data;


  @override
  List<Object?> get props => [data];
}


/// Событие - если счетчик изменился
class CounterIsEven<int> extends Event {
  const CounterIsEven(data) : super('Число четное $data');
}

Теперь немного исправим 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<String> {
  ListenCubit(this.eventBus) : super('Start') {
    subscription = eventBus.on<CounterIsEven>().listen((event) {
    emit(event.data);
  });
}

  final EventBus eventBus;
  late final StreamSubscription subscription;
  
  @override
  Future<void> 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<CounterCubit>().increment();
        /// После каждой смены состояния счетчика, отправляем событие в шину
        /// если счетчик четный
        final counter = context.read<CounterCubit>().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<ListenCubit, String>(
            builder: (context, state) {
              return Text(
                state.toString(),
                style: Theme.of(context).textTheme.headlineMedium,
              );
            },
          ),
          Center(
            child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: <Widget>[
              const SizedBox(height: 30),
              BlocBuilder<CounterCubit, int>(
                builder: (context, state) {
                  return Text(
                  state.toString(),
                  style: Theme.of(context).textTheme.headlineMedium,
                  );
                },
            )]))])));
  }
}

В строке 49 при каждом увеличении счетчика мы проверяем значение. Если оно четное, то передаем текущее значение в шину события CounterEvent.

Преимущество использования шины событий в проекте

  • У вас только один Publisher (создатель событий) в проекте. Это убережет вас от головной боли, когда приложение станет очень большим и нужно будет четко понимать, что и от чего зависит и кто на что подписан.

  • Шина событий никак не завязана на библиотеку flutter_bloc или на любую другую. На самом деле это просто обычный стрим. Его можно использовать всегда и везде.

  • Объявление всех событий в одном месте: легко понять, какие события создаются и кто на них подписан.

  • Шину событий можно легко расширить, добавить новый функционал, такие как проверка событий, проверка на наличие подписчиков и так далее.

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

Комментарии (4)


  1. ReinRaus
    17.10.2023 16:50

    Есть ли у данного паттерна преимущества/недостатки в сравнении с Provider ?(https://pub.dev/packages/provider)

    Вопрос задаю по причине того, что указанные Вами преимущества паттерна не относятся к Provider (кроме преимущества, что не надо использовать сторонние пакеты - сам Provider является сторонней библиотекой).


    1. mrDevGo Автор
      17.10.2023 16:50

      Я не готов рассуждать про сравнение пакетов, так как это страшно холиварная тема. В статье рассказывается именно про паттерн.


  1. sla000
    17.10.2023 16:50
    -1

    А можно в ListenCubit передавать не весь CounterCubit а только Stream<int> и тогда жесткой зависимости нет, проще тестировать и переиспользовать.
    Имхо в реактивном языке, где что угодно можно сделать стримом, EventBus не нужен.


    1. mrDevGo Автор
      17.10.2023 16:50

      Да, можно и так, так же есть и много других способов. Тут же идёт речь именно про паттерн.