BLoC паттерн во Flutter: Управление состоянием

Курс посвящен изучению архитектурного паттерна BLoC (Business Logic Component) для создания масштабируемых приложений на Flutter. Вы освоите работу с потоками, библиотекой flutter_bloc, Cubit и научитесь разделять бизнес-логику и пользовательский интерфейс.

Введение в реактивное программирование и концепцию BLoC: Streams и Sinks

Введение в реактивное программирование и концепцию BLoC: Streams и Sinks

Добро пожаловать в курс «BLoC паттерн во Flutter: Управление состоянием». Это первая статья, в которой мы заложим фундамент для понимания одного из самых популярных и мощных архитектурных паттернов во Flutter — BLoC (Business Logic Component).

Прежде чем мы начнем писать код и создавать сложные приложения, нам необходимо разобраться с базовыми концепциями, на которых строится этот паттерн. Речь пойдет о реактивном программировании, потоках данных (Streams) и точках входа (Sinks).

Что такое реактивное программирование?

В традиционном императивном программировании мы привыкли явно приказывать компьютеру, что делать. Например: «возьми значение А, прибавь к нему Б и запиши результат в Ц». Если значение А изменится позже, значение Ц останется прежним, пока мы снова явно не выполним команду обновления.

Реактивное программирование — это парадигма, ориентированная на потоки данных и распространение изменений. В этой модели, если меняется А, то Ц обновляется автоматически, потому что между ними установлена постоянная связь.

Представьте себе таблицу в Excel. Если вы зададите ячейке формулу =A1 + B1, то при любом изменении чисел в ячейках A1 или B1, результат обновится мгновенно. Вам не нужно нажимать кнопку «Пересчитать». Это и есть простейший пример реактивного поведения.

Во Flutter, который является декларативным фреймворком, реактивный подход работает идеально. Интерфейс (UI) — это просто отражение текущего состояния приложения. Когда состояние меняется, интерфейс перерисовывается.

Анатомия потоков: Streams и Sinks

В языке Dart, на котором написан Flutter, основой реактивности является класс Stream. Чтобы понять BLoC, вы должны чувствовать себя уверенно при работе с потоками.

Давайте используем аналогию с водопроводом.

1. Stream (Поток)

Stream — это источник асинхронных событий данных. Представьте его как конвейерную ленту, по которой к вам едут коробки (данные). Вы не знаете точно, когда приедет следующая коробка, но вы стоите у ленты и ждете. Как только коробка появляется, вы на нее реагируете.

В Dart потоки могут передавать:

  • Данные (значения, объекты).
  • Ошибки (если что-то пошло не так).
  • Сигнал о завершении («Done», когда поток закрывается).

2. Sink (Воронка/Вход)

Если Stream — это то, откуда данные выходят, то Sink — это то, куда мы эти данные кладем. Это входное отверстие нашей трубы. В контексте BLoC мы будем добавлять события (нажатия кнопок, запросы) именно в Sink.

3. StreamController (Контроллер потока)

Чтобы управлять этой системой, в Dart существует класс StreamController. Это «пульт управления», который объединяет в себе и Stream, и Sink.

Рассмотрим простой пример на Dart:

import 'dart:async';

void main() {
  // Создаем контроллер
  final controller = StreamController<String>();

  // Подписываемся на поток (слушаем данные)
  // Это наш "выход" из трубы
  final subscription = controller.stream.listen((data) {
    print('Получено сообщение: $data');
  });

  // Добавляем данные в поток через Sink
  // Это наш "вход" в трубу
  controller.sink.add('Привет, BLoC!');
  controller.sink.add('Потоки это просто.');

  // Не забываем закрывать контроллер, когда он не нужен
  // controller.close();
}

Когда вы запустите этот код, вы увидите в консоли:

Получено сообщение: Привет, BLoC!
Получено сообщение: Потоки это просто.

Типы потоков

Важно знать, что в Dart существует два основных типа потоков:

  1. Single-subscription Stream (Поток с одной подпиской). Это самый частый тип. Такой поток позволяет подключить только одного слушателя (подписчика). Если вы попытаетесь подписаться второй раз, возникнет ошибка. Это похоже на обычный телефонный звонок: вы можете говорить только с одним человеком.

  2. Broadcast Stream (Широковещательный поток). Этот поток позволяет иметь множество слушателей одновременно. Это похоже на радиостанцию: передача идет одна, но слушать её могут тысячи приемников. Чтобы создать такой поток, используется StreamController.broadcast().

Концепция BLoC

Теперь, когда мы понимаем, как работают потоки, мы можем перейти к самому паттерну BLoC. Аббревиатура расшифровывается как Business Logic Component.

Этот паттерн был разработан Google для того, чтобы отделить бизнес-логику от пользовательского интерфейса (UI). Это критически важно для создания поддерживаемых, тестируемых и масштабируемых приложений.

Как это работает?

BLoC — это «черный ящик», который преобразует поток Событий (Events) в поток Состояний (States).

  1. Events (События): Пользователь нажимает кнопку, вводит текст или приложение получает уведомление от сети. Это входные данные для BLoC. Они попадают в BLoC через Sink.
  2. Business Logic (Логика): Внутри BLoC происходит магия. Мы анализируем событие, возможно, делаем запрос к базе данных или серверу, производим вычисления.
  3. States (Состояния): Результат работы логики. Это может быть «Загрузка», «Данные получены» или «Ошибка». Состояния выходят из BLoC через Stream.
  4. UI (Интерфейс): Экран подписывается на поток состояний и перерисовывается каждый раз, когда приходит новое состояние.

Почему именно потоки?

Использование Streams для BLoC не случайно. Потоки обеспечивают:

  • Асинхронность: Интерфейс не зависает, пока выполняется тяжелая логика.
  • Независимость: BLoC не знает, кто его слушает. Он просто «вещает» состояния. Это позволяет легко тестировать логику отдельно от верстки.

Простой пример счетчика на чистых потоках

Чтобы закрепить материал, давайте представим, как выглядел бы простейший BLoC для счетчика без использования сторонних библиотек.

import 'dart:async';

class CounterBloc {
  // Текущее состояние (счетчик)
  int _counter = 0;

  // Контроллер для входных событий (например, команды "увеличить")
  final _eventController = StreamController<void>();

  // Контроллер для выходных состояний (чисел)
  final _stateController = StreamController<int>();

  // Вход (Sink) для событий
  Sink<void> get incrementEventSink => _eventController.sink;

  // Выход (Stream) для состояний
  Stream<int> get counterStream => _stateController.stream;

  CounterBloc() {
    // Слушаем события
    _eventController.stream.listen(_mapEventToState);
  }

  void _mapEventToState(void event) {
    // Логика: увеличиваем счетчик
    _counter++;
    // Отправляем новое состояние в выходной поток
    _stateController.sink.add(_counter);
  }

  void dispose() {
    _eventController.close();
    _stateController.close();
  }
}

В этом примере:

  • UI будет вызывать bloc.incrementEventSink.add(null), когда пользователь нажмет кнопку.
  • UI будет подписан на bloc.counterStream, чтобы обновлять цифру на экране.

Преимущества и недостатки подхода

Преимущества:

  1. Разделение ответственности: UI занимается только рисованием, BLoC — только логикой.
  2. Тестируемость: Легко написать Unit-тесты для BLoC, так как он не зависит от виджетов Flutter.
  3. Переиспользование: Один и тот же BLoC можно использовать на разных экранах.

Недостатки:

  1. Порог входа: Новичкам сложно сразу понять концепцию потоков и асинхронности.
  2. Многословность (Boilerplate): Даже для простых задач приходится писать много кода (создавать контроллеры, закрывать их, определять события).

К счастью, сообщество Flutter создало отличную библиотеку flutter_bloc, которая скрывает большую часть рутинной работы с StreamController и делает код чище. Именно её мы будем изучать в следующих уроках.

Заключение

Сегодня мы разобрали фундамент паттерна BLoC. Мы узнали, что:

  • Reactive Programming — это про автоматическое распространение изменений.
  • Stream — это конвейер, доставляющий данные асинхронно.
  • Sink — это вход для данных.
  • BLoC принимает события через Sink и отдает состояния через Stream.

В следующей статье мы перейдем от теории к практике и разберем, как устроена библиотека flutter_bloc, которая является стандартом индустрии для управления состоянием во Flutter.

Готовы проверить свои знания? Попробуйте ответить на вопросы ниже.

Основы библиотеки flutter_bloc: Использование Cubit для простого управления состоянием

Основы библиотеки flutter_bloc: Использование Cubit для простого управления состоянием

В предыдущей статье мы погрузились в мир реактивного программирования, изучив, как работают Stream и Sink в Dart. Мы создали простейший BLoC своими руками и увидели, что, несмотря на мощь этого подхода, он требует написания большого количества шаблонного кода (boilerplate). Нам приходилось вручную создавать контроллеры, следить за их закрытием и связывать потоки.

Сегодня мы переходим на новый уровень абстракции. Мы познакомимся с библиотекой flutter_bloc — инструментом, который стал стандартом де-факто в мире Flutter. И начнем мы с самого простого и доступного компонента этой библиотеки — Cubit.

Зачем нам flutter_bloc?

Библиотека flutter_bloc, разработанная Феликсом Ангеловым (Felix Angelov), решает несколько критических задач, с которыми мы столкнулись при работе с «чистыми» потоками:

  1. Управление ресурсами: Она автоматически закрывает потоки, когда они больше не нужны, предотвращая утечки памяти.
  2. Упрощение API: Вместо сложной работы с StreamController, мы получаем понятные классы и методы.
  3. Интеграция с UI: Библиотека предоставляет готовые виджеты (например, BlocBuilder), которые эффективно перерисовывают интерфейс только тогда, когда это действительно нужно.

Что такое Cubit?

Cubit — это упрощенная версия паттерна BLoC. Если классический BLoC работает по схеме «Событие (Event) -> Состояние (State)», то Cubit исключает понятие событий. Вместо отправки событий в Sink, мы просто вызываем функции (методы) класса Cubit.

Представьте, что BLoC — это сложный пульт управления атомной станцией с множеством кнопок и рычагов, а Cubit — это выключатель света. Он проще, понятнее и идеально подходит для ситуаций, где сложная логика обработки событий не требуется.

Архитектура Cubit

Давайте визуализируем, как работает Cubit по сравнению с тем, что мы изучили ранее.

Как видите, входной точкой теперь является не Sink, а обычный метод класса. Это делает код намного более читаемым и привычным для разработчиков, пришедших из императивного программирования.

Подключение библиотеки

Прежде чем начать писать код, необходимо добавить зависимость в ваш файл pubspec.yaml:

dependencies:
  flutter:
    sdk: flutter
  flutter_bloc: ^8.1.3 # Используйте актуальную версию

Создание первого Cubit

Давайте перепишем наш пример со счетчиком из прошлой статьи, используя Cubit. Теперь нам не нужно создавать StreamController вручную. Класс Cubit уже содержит в себе всю необходимую логику для управления состоянием.

1. Определение состояния

Для простого счетчика состоянием является просто число (int). В более сложных случаях это будет класс (например, UserProfileState).

2. Создание класса Cubit

Наш класс должен наследоваться от Cubit<T>, где T — это тип нашего состояния.

import 'package:flutter_bloc/flutter_bloc.dart';

// Мы наследуемся от Cubit и указываем тип состояния (int)
class CounterCubit extends Cubit<int> {
  // В конструкторе мы обязаны передать начальное состояние
  // super(0) означает, что при старте счетчик равен 0
  CounterCubit() : super(0);

  // Метод для увеличения счетчика
  void increment() {
    // state - это текущее состояние (доступно внутри Cubit)
    // emit - это метод для отправки нового состояния подписчикам
    emit(state + 1);
  }

  // Метод для уменьшения счетчика
  void decrement() {
    emit(state - 1);
  }
}

Обратите внимание, насколько меньше кода стало по сравнению с версией на чистых стримах! Нет никаких контроллеров, синк-ов и подписок внутри класса.

Магия метода emit

Ключевым элементом здесь является защищенный метод emit(T state).

  • Только внутри: Вы можете вызывать emit только внутри класса Cubit. Снаружи (из UI) вы не можете просто сказать кубиту «стань равным 5». Вы должны вызвать метод (например, increment), который внутри себя решит, нужно ли менять состояние.
  • Проверка изменений: emit умный. Он проверяет, отличается ли новое состояние от текущего. Если вы вызовете emit(5), когда state уже равно 5, ничего не произойдет. UI не будет перерисован лишний раз. Это огромная оптимизация производительности «из коробки».

Связывание Cubit с Flutter UI

Теперь, когда у нас есть логика, нам нужно отобразить её на экране. Библиотека flutter_bloc предоставляет для этого три основных виджета, но сегодня мы сосредоточимся на двух самых важных: BlocProvider и BlocBuilder.

BlocProvider: Внедрение зависимости

BlocProvider — это виджет, который создает экземпляр вашего Cubit и делает его доступным для всех виджетов ниже по дереву (в child). Это реализация паттерна Dependency Injection (DI) через InheritedWidget.

Важная особенность BlocProvider: он автоматически вызывает метод close() у вашего Cubit, когда виджет удаляется из дерева. Вам больше не нужно беспокоиться об утечках памяти!

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    // Оборачиваем наше приложение (или часть дерева) в BlocProvider
    return BlocProvider(
      create: (context) => CounterCubit(),
      child: const MaterialApp(
        home: CounterPage(),
      ),
    );
  }
}

BlocBuilder: Реактивная перерисовка

BlocBuilder — это виджет, который слушает изменения состояния Cubit и перестраивает свой интерфейс в ответ на них. Он требует два типа: сам Cubit и тип состояния.

class CounterPage extends StatelessWidget {
  const CounterPage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Cubit Counter')),
      body: Center(
        // BlocBuilder слушает CounterCubit и ожидает состояние int
        child: BlocBuilder<CounterCubit, int>(
          builder: (context, count) {
            // Эта функция вызывается каждый раз при изменении state
            // count - это и есть наше актуальное число
            return Text(
              '$count',
              style: Theme.of(context).textTheme.headlineMedium,
            );
          },
        ),
      ),
      floatingActionButton: Column(
        mainAxisAlignment: MainAxisAlignment.end,
        children: [
          FloatingActionButton(
            // Получаем доступ к кубиту через context и вызываем метод
            onPressed: () => context.read<CounterCubit>().increment(),
            child: const Icon(Icons.add),
          ),
          const SizedBox(height: 8),
          FloatingActionButton(
            onPressed: () => context.read<CounterCubit>().decrement(),
            child: const Icon(Icons.remove),
          ),
        ],
      ),
    );
  }
}

Доступ к Cubit: context.read vs context.watch

В примере выше вы могли заметить использование context.read<CounterCubit>(). Это расширение (extension method) из библиотеки flutter_bloc.

  • context.read<T>(): Находит ближайший экземпляр T (нашего кубита) вверх по дереву виджетов. Используется только для вызова методов (событий), например, при нажатии кнопки. Этот метод не заставляет виджет перерисовываться при изменении состояния.
  • context.watch<T>(): Также находит экземпляр, но подписывается на изменения. Если состояние изменится, виджет, вызвавший watch, перестроится. Обычно вместо прямого использования watch рекомендуется использовать BlocBuilder, так как он позволяет более точно контролировать область перерисовки.

Cubit vs BLoC: Когда и что использовать?

Вы можете задаться вопросом: если Cubit такой простой и удобный, зачем вообще нужен полноценный BLoC с его событиями?

Используйте Cubit, когда:

  • Логика простая и понятная.
  • Состояния меняются синхронно или в результате простых асинхронных вызовов (например, один API запрос).
  • Вам не нужно отслеживать как именно и почему произошло изменение состояния, важен только результат.

Используйте BLoC, когда:

  • Вам нужна сложная трансформация событий (например, debounce — ожидание, пока пользователь закончит печатать, прежде чем отправить запрос).
  • События зависят друг от друга.
  • Вам нужно детальное логирование всех входящих событий для аналитики или отладки (Event Sourcing).

В 90% случаев для простых экранов и фич Cubit является идеальным выбором. Он убирает лишний шум, оставляя суть.

Обработка ошибок и сложные состояния

В реальном приложении состояние редко бывает просто числом (int). Обычно мы имеем дело с загрузкой данных. Для этого принято создавать базовый класс состояния и наследоваться от него.

Пример структуры состояний:

// Базовый абстрактный класс
abstract class UserState {}

// Состояние "начальное" (ничего не происходит)
class UserInitial extends UserState {}

// Состояние "загрузка" (показываем спиннер)
class UserLoading extends UserState {}

// Состояние "успех" (показываем данные)
class UserLoaded extends UserState {
  final String userName;
  UserLoaded(this.userName);
}

// Состояние "ошибка" (показываем SnackBar)
class UserError extends UserState {
  final String message;
  UserError(this.message);
}

Тогда наш Cubit будет выглядеть так:

class UserCubit extends Cubit<UserState> {
  UserCubit() : super(UserInitial());

  Future<void> getUser() async {
    try {
      // Сначала сообщаем UI, что началась загрузка
      emit(UserLoading());

      // Имитация запроса к серверу
      await Future.delayed(const Duration(seconds: 2));

      // Если все хорошо, отправляем данные
      emit(UserLoaded('Иван Иванов'));
    } catch (e) {
      // Если ошибка, отправляем сообщение об ошибке
      emit(UserError('Не удалось загрузить пользователя'));
    }
  }
}

А в UI мы будем использовать BlocBuilder для проверки типа состояния:

BlocBuilder<UserCubit, UserState>(
  builder: (context, state) {
    if (state is UserLoading) {
      return const CircularProgressIndicator();
    } else if (state is UserLoaded) {
      return Text('Привет, ${state.userName}!');
    } else if (state is UserError) {
      return Text('Ошибка: ${state.message}');
    }
    return const Text('Нажмите кнопку для загрузки');
  },
)

Заключение

Сегодня мы сделали огромный шаг вперед. Мы ушли от ручного управления потоками и начали использовать профессиональные инструменты.

Мы узнали, что:

  • Cubit — это упрощенный BLoC, где состояния меняются через вызов функций.
  • BlocProvider отвечает за создание и закрытие Cubit.
  • BlocBuilder отвечает за построение UI на основе текущего состояния.
  • Метод emit используется для обновления состояния и работает только внутри Cubit.

Cubit идеально подходит для большинства задач управления состоянием благодаря своей простоте и лаконичности. Однако в следующей статье мы рассмотрим «старшего брата» — полноценный BLoC, чтобы понять, как обрабатывать сложные потоки событий и реализовывать продвинутую бизнес-логику.

Полноценный BLoC: Работа с событиями, состояниями и асинхронными операциями

Полноценный BLoC: Работа с событиями, состояниями и асинхронными операциями

Добро пожаловать на третий урок курса «BLoC паттерн во Flutter: Управление состоянием». В предыдущей статье мы познакомились с Cubit — упрощенной версией BLoC, которая позволяет управлять состоянием через вызов функций. Cubit отлично справляется с простыми задачами, где действие пользователя напрямую вызывает изменение состояния.

Однако, реальные приложения часто требуют более сложной логики. Что если нам нужно отслеживать не только результат действия, но и причину его возникновения? Что если пользователь слишком часто нажимает на кнопку «Обновить», и нам нужно игнорировать лишние нажатия? Что если одно событие должно запускать цепочку асинхронных операций?

Здесь на сцену выходит «старший брат» — полноценный класс Bloc.

Фундаментальное отличие: События вместо Функций

Главное архитектурное отличие между Cubit и Bloc заключается в способе взаимодействия с UI.

  • Cubit: UI вызывает функции (методы) класса (например, cubit.increment()).
  • Bloc: UI отправляет события (events) в поток (например, bloc.add(IncrementEvent())).

Это изменение кажется небольшим, но оно меняет парадигму. BLoC превращается в «машину», которая принимает поток входящих событий, обрабатывает их и выдает поток исходящих состояний.

Анатомия BLoC

Чтобы создать BLoC, нам нужно определить три компонента: События, Состояния и сам Класс BLoC.

1. События (Events)

События — это входные данные. Это всё, что происходит в приложении: нажатия кнопок, ввод текста, таймеры, ответы от API. В коде события обычно представляются абстрактным классом и набором его наследников.

Рассмотрим пример приложения погоды:

// Базовый класс события
abstract class WeatherEvent {}

// Событие: Пользователь запросил погоду для конкретного города
class WeatherRequested extends WeatherEvent {
  final String city;
  WeatherRequested(this.city);
}

// Событие: Пользователь нажал кнопку "Обновить"
class WeatherRefreshed extends WeatherEvent {}

2. Состояния (States)

Состояния описывают, как выглядит интерфейс в данный момент времени. Здесь всё точно так же, как и в Cubit.

abstract class WeatherState {}

class WeatherInitial extends WeatherState {}
class WeatherLoading extends WeatherState {}

class WeatherLoaded extends WeatherState {
  final double temperature;
  WeatherLoaded(this.temperature);
}

class WeatherError extends WeatherState {}

3. Класс Bloc и обработчик on<Event>

Теперь самое интересное. Мы создаем класс, наследуемый от Bloc<Event, State>. В отличие от Cubit, здесь мы не пишем публичные методы для изменения состояния. Вместо этого мы регистрируем обработчики событий в конструкторе, используя метод on<Event>.

import 'package:flutter_bloc/flutter_bloc.dart';

class WeatherBloc extends Bloc<WeatherEvent, WeatherState> {
  // Передаем начальное состояние в super
  WeatherBloc() : super(WeatherInitial()) {

    // Регистрируем обработчик для события WeatherRequested
    on<WeatherRequested>((event, emit) {
      // Логика обработки события
      // event - это экземпляр WeatherRequested, из которого мы можем взять city
      // emit - функция для отправки нового состояния
    });

    // Регистрируем обработчик для события WeatherRefreshed
    on<WeatherRefreshed>((event, emit) {
      // Логика обновления
    });
  }
}

Асинхронные операции

Одной из главных задач BLoC является работа с асинхронностью (запросы к серверу, работа с БД). Внутри обработчика on<Event> мы можем использовать ключевые слова async и await.

Давайте реализуем логику загрузки погоды:

on<WeatherRequested>((event, emit) async {
  // 1. Сразу сообщаем UI, что началась загрузка
  emit(WeatherLoading());

  try {
    // 2. Выполняем асинхронную операцию (имитация запроса)
    // Обратите внимание: мы берем название города из event.city
    final double temperature = await _fetchWeatherFromApi(event.city);

    // 3. Если успешно — отправляем данные
    emit(WeatherLoaded(temperature));
  } catch (e) {
    // 4. Если ошибка — отправляем состояние ошибки
    emit(WeatherError());
  }
});

Важные правила работы с emit:

  1. Последовательность: Вы можете вызывать emit несколько раз внутри одного обработчика. Это позволяет создавать цепочки состояний: Загрузка -> Данные.
  2. Завершенность: Обработчик on должен завершить свою работу. Если вы запустите бесконечный цикл внутри on без пауз, вы заблокируете обработку других событий этого типа (по умолчанию события обрабатываются последовательно).

Продвинутые возможности: Трансформация событий

Вы можете спросить: «Зачем мне писать классы событий и регистрировать обработчики, если в Cubit я мог просто написать функцию getWeather(city)? Кода же меньше!»

Сила BLoC раскрывается, когда нам нужно управлять потоком событий. Представьте поиск с автодополнением (Search Autocomplete). Пользователь быстро печатает слово «Flutter».

  • В Cubit: При каждом нажатии клавиши вы вызываете метод search(). Если пользователь печатает быстро, вы отправите 7 запросов на сервер. Это неэффективно и может привести к багам (гонка состояний).
  • В Bloc: Вы отправляете события SearchQueryChanged. BLoC позволяет вам использовать Event Transformers (трансформаторы событий).

С помощью трансформаторов мы можем сказать BLoC: «Подожди, пока пользователь перестанет печатать на 300 миллисекунд, и только потом обрабатывай последнее событие. Все предыдущие — игнорируй».

Это называется Debounce (устранение дребезга).

Пример (с использованием пакета bloc_concurrency):

import 'package:bloc_concurrency/bloc_concurrency.dart';

// ... внутри конструктора Bloc

on<SearchQueryChanged>(
  (event, emit) async {
    emit(SearchLoading());
    final results = await api.search(event.query);
    emit(SearchLoaded(results));
  },
  // Добавляем трансформатор: droppable, restartable, sequential и т.д.
  transformer: restartable(),
);
  • restartable(): Если приходит новое событие, пока старое еще обрабатывается, старое отменяется, и начинается обработка нового. Идеально для поиска.
  • droppable(): Если приходит новое событие во время обработки старого, новое игнорируется. Полезно для кнопок отправки формы, чтобы избежать двойной отправки.

В Cubit реализовать такую логику отмены предыдущих запросов гораздо сложнее и требует ручного управления StreamSubscription или CancelToken.

Сравнение Cubit и Bloc: Итоговая таблица

Характеристика Cubit Bloc
Входные данные Вызов методов Отправка событий (Events)
Сложность кода Низкая (меньше boilerplate) Средняя (нужны классы событий)
Отслеживание Сложно понять, почему изменилось состояние Полная история (Event -> State)
Трансформация Ограничена Мощная (Debounce, Throttle, SwitchMap)
Использование Простые экраны, формы, настройки Сложные фичи, поиск, корзина, чаты

Пример использования в UI

Интеграция с Flutter UI практически не отличается от Cubit. Мы используем те же виджеты BlocProvider и BlocBuilder.

Единственное отличие — способ взаимодействия:

// В Cubit мы делали так:
// context.read<WeatherCubit>().getWeather('London');

// В Bloc мы делаем так:
context.read<WeatherBloc>().add(WeatherRequested('London'));

Метод add() — это способ положить событие в Sink нашего BLoC.

Заключение

Полноценный Bloc — это мощный инструмент для управления сложным состоянием. Он требует написания чуть большего количества кода, чем Cubit, но взамен дает полный контроль над потоком данных, возможность отмены устаревших операций и четкую структуру «Событие — Реакция».

В большинстве проектов вы будете использовать комбинацию обоих подходов: Cubit для простых виджетов и Bloc для ключевых бизнес-процессов.

В следующей статье мы разберем тему, которая часто вызывает вопросы даже у опытных разработчиков: «Архитектура приложения с BLoC: Внедрение зависимостей и связь блоков».

Интеграция с UI: Виджеты BlocProvider, BlocBuilder, BlocListener и BlocConsumer

Интеграция с UI: Виджеты BlocProvider, BlocBuilder, BlocListener и BlocConsumer

Добро пожаловать на четвертый урок курса «BLoC паттерн во Flutter: Управление состоянием». В предыдущих статьях мы проделали большой путь: разобрались с теорией реактивного программирования, научились создавать простые Cubit и мощные Bloc, а также обрабатывать асинхронные события.

Однако, до сих пор наша бизнес-логика жила в вакууме. Мы писали классы, тесты, но не видели результата на экране смартфона. Пришло время соединить «мозги» нашего приложения (BLoC) с его «лицом» (UI).

Библиотека flutter_bloc предоставляет набор специализированных виджетов, которые делают эту интеграцию бесшовной, эффективной и чистой. Сегодня мы разберем «большую четверку» этих виджетов: BlocProvider, BlocBuilder, BlocListener и BlocConsumer.

BlocProvider: Фундамент и внедрение зависимостей

Прежде чем использовать BLoC в интерфейсе, его нужно создать и передать в дерево виджетов. Для этого используется BlocProvider.

BlocProvider — это виджет, который внедряет экземпляр BLoC (или Cubit) в дерево виджетов, делая его доступным для всех дочерних элементов. Технически он работает на базе InheritedWidget.

Создание нового BLoC

Самый частый сценарий — создание нового экземпляра BLoC. В этом случае BlocProvider берет на себя ответственность за управление жизненным циклом.

BlocProvider(
  create: (context) => WeatherBloc(),
  child: const WeatherPage(),
);

Важный момент: Когда BlocProvider, создавший BLoC, удаляется из дерева виджетов (например, пользователь закрыл экран), он автоматически вызывает метод close() у вашего BLoC. Это предотвращает утечки памяти. Вам не нужно закрывать потоки вручную.

Передача существующего BLoC

Иногда вам нужно передать уже существующий BLoC на другой экран (например, при навигации или в модальное окно). В этом случае нельзя использовать create, так как это создаст новый экземпляр. Используйте конструктор value:

BlocProvider.value(
  value: _existingWeatherBloc,
  child: const DetailPage(),
);

При использовании BlocProvider.value провайдер не закрывает BLoC автоматически, так как он его не создавал.

Доступ к BLoC из дерева

Чтобы получить доступ к BLoC внутри дочернего виджета, используется расширение контекста:

// Чтение (для вызова событий/методов)
final bloc = context.read<WeatherBloc>();
bloc.add(WeatherRequested('Moscow'));

// Наблюдение (редко используется напрямую, лучше через Builder)
final state = context.watch<WeatherBloc>().state;

BlocBuilder: Реактивная перерисовка

BlocBuilder — это «рабочая лошадка» библиотеки. Этот виджет слушает изменения состояний BLoC и перестраивает свой дочерний интерфейс в ответ на них.

Он требует два типа данных: класс вашего BLoC и класс состояния.

BlocBuilder<WeatherBloc, WeatherState>(
  builder: (context, state) {
    // Функция builder вызывается при каждом изменении состояния
    if (state is WeatherLoading) {
      return const CircularProgressIndicator();
    } else if (state is WeatherLoaded) {
      return Text('Температура: ${state.temperature}');
    } else {
      return const Text('Ожидание действий');
    }
  },
)

Оптимизация с buildWhen

Представьте, что ваш BLoC отправляет состояния очень часто (например, прогресс загрузки: 1%, 2%, 3%...). Но вы хотите перерисовывать экран только тогда, когда загрузка завершена. Для этого существует параметр buildWhen.

buildWhen — это функция, которая принимает предыдущее и текущее состояние и возвращает bool. Если вернуть true, перерисовка произойдет. Если false — builder вызван не будет.

BlocBuilder<WeatherBloc, WeatherState>(
  buildWhen: (previous, current) {
    // Перерисовываем только если изменился тип состояния
    // или если температура изменилась более чем на 1 градус
    return previous.runtimeType != current.runtimeType;
  },
  builder: (context, state) { ... },
)

BlocListener: Одноразовые действия (Side Effects)

Это одна из самых частых ошибок новичков: попытка показать SnackBar, диалоговое окно или выполнить навигацию внутри BlocBuilder.

Запомните правило: Метод build (и builder) должен быть «чистым». Он должен только возвращать виджеты. Он может вызываться фреймворком многократно. Если вы покажете диалог в builder, он может открыться 10 раз подряд.

Для одноразовых действий («побочных эффектов») существует BlocListener.

Он не рисует интерфейс (у него есть параметр child, который он просто возвращает без изменений), но он слушает поток состояний.

BlocListener<WeatherBloc, WeatherState>(
  listener: (context, state) {
    if (state is WeatherError) {
      // Показываем ошибку
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('Ошибка загрузки!')),
      );
    } else if (state is WeatherLoaded) {
      // Или переходим на другой экран
      Navigator.of(context).pushNamed('/details');
    }
  },
  child: const WeatherView(), // Статический интерфейс
)

Управление с listenWhen

Аналогично buildWhen, у BlocListener есть параметр listenWhen. Он позволяет игнорировать определенные изменения состояний, чтобы не вызывать слушатель лишний раз.

BlocConsumer: Два в одном

Часто возникает ситуация, когда нам нужно и перерисовать экран, и выполнить действие. Например, при ошибке мы хотим показать текст ошибки на экране (Builder) И показать всплывающее уведомление (Listener).

Вместо того чтобы вкладывать BlocListener в BlocBuilder (или наоборот), создавая «лесенку» из кода, мы используем BlocConsumer.

BlocConsumer объединяет в себе параметры обоих виджетов:

BlocConsumer<WeatherBloc, WeatherState>(
  listenWhen: (previous, current) {
    // Условие для слушателя
    return current is WeatherError;
  },
  listener: (context, state) {
    // Логика слушателя (SnackBar, Navigation)
    if (state is WeatherError) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text(state.message)),
      );
    }
  },
  buildWhen: (previous, current) {
    // Условие для перерисовки
    // Например, не перерисовываем экран при ошибке, оставляем старые данные
    return current is! WeatherError;
  },
  builder: (context, state) {
    // Логика построения UI
    if (state is WeatherLoading) {
      return const CircularProgressIndicator();
    }
    return const Text('Данные загружены');
  },
)

MultiBlocProvider и MultiBlocListener

В реальных приложениях часто нужно предоставить доступ к нескольким BLoC одновременно или слушать несколько источников.

Вместо глубокой вложенности:

// Плохо: Ад вложенности
BlocProvider(
  create: (_) => BlocA(),
  child: BlocProvider(
    create: (_) => BlocB(),
    child: BlocProvider(
      create: (_) => BlocC(),
      child: App(),
    ),
  ),
)

Используйте MultiBlocProvider:

// Хорошо: Чистый код
MultiBlocProvider(
  providers: [
    BlocProvider(create: (_) => BlocA()),
    BlocProvider(create: (_) => BlocB()),
    BlocProvider(create: (_) => BlocC()),
  ],
  child: App(),
)

Аналогично работает MultiBlocListener для объединения нескольких слушателей.

Резюме: Какой виджет выбрать?

Чтобы вам было проще ориентироваться, я составил небольшую таблицу-шпаргалку:

Задача Виджет
Создать BLoC и передать его вниз BlocProvider
Перерисовать часть экрана при изменении состояния BlocBuilder
Показать SnackBar, Dialog или сделать навигацию BlocListener
И то, и другое одновременно BlocConsumer
Избежать вложенности провайдеров MultiBlocProvider

Заключение

Теперь у вас есть полный набор инструментов для создания полноценных приложений на Flutter с использованием BLoC. Вы знаете, как создавать логику, как обрабатывать события и как правильно отображать результаты пользователю.

В этой статье мы научились:

  1. Использовать BlocProvider для внедрения зависимостей.
  2. Строить реактивный UI с BlocBuilder.
  3. Обрабатывать навигацию и ошибки с BlocListener.
  4. Комбинировать подходы с BlocConsumer.

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

Тестирование BLoC, обработка ошибок и лучшие архитектурные практики

Тестирование BLoC, обработка ошибок и лучшие архитектурные практики

Добро пожаловать на пятый и заключительный урок курса «BLoC паттерн во Flutter: Управление состоянием». Мы прошли долгий путь: от понимания потоков и создания первых Cubit до реализации сложных BLoC и интеграции их с пользовательским интерфейсом.

Однако, написать код, который работает — это только половина дела. В профессиональной разработке важно написать код, который:

  1. Надежен: Он не падает при ошибках сети или некорректных данных.
  2. Тестируем: Мы можем автоматически проверить, что логика работает верно.
  3. Масштабируем: Архитектура позволяет легко добавлять новые функции.

В этой статье мы сосредоточимся на «инженерной» части работы с BLoC: архитектуре слоев, написании Unit-тестов и глобальной обработке ошибок.

Архитектура приложения: Слои ответственности

Одна из главных ошибок новичков — писать запросы к базе данных или API прямо внутри BLoC. BLoC — это компонент бизнес-логики, он должен отвечать на вопрос «Что делать с данными?», а не «Откуда их взять?».

Чтобы приложение было чистым, мы используем слоистую архитектуру.

1. Data Layer (Слой данных)

Это самый низкий уровень. Здесь находятся классы, которые умеют делать «грязную» работу: отправлять HTTP-запросы, читать JSON, писать в локальную базу данных.

  • Data Provider: Делает прямой запрос (например, http.get(...)).
  • Data Model: Простые классы (DTO), отражающие структуру JSON.

2. Domain Layer / Repository (Слой репозитория)

Это «прослойка» между данными и BLoC. Репозиторий абстрагирует источник данных. BLoC не должен знать, откуда пришли данные о погоде — из интернета или из кэша.

class WeatherRepository {
  final WeatherApiClient apiClient;

  WeatherRepository({required this.apiClient});

  Future<Weather> getWeather(String city) async {
    // Репозиторий решает: если нет интернета, вернуть из кэша,
    // иначе запросить API.
    final rawData = await apiClient.fetchWeather(city);
    return Weather.fromJson(rawData);
  }
}

3. Presentation Layer (Слой представления)

Здесь живут наши Виджеты и BLoC. BLoC общается только с Репозиторием.

class WeatherBloc extends Bloc<WeatherEvent, WeatherState> {
  final WeatherRepository repository;

  // Мы внедряем репозиторий через конструктор
  WeatherBloc({required this.repository}) : super(WeatherInitial()) {
    on<WeatherRequested>((event, emit) async {
      try {
        final weather = await repository.getWeather(event.city);
        emit(WeatherLoaded(weather));
      } catch (e) {
        emit(WeatherError());
      }
    });
  }
}

Такое разделение позволяет нам легко менять источники данных (например, сменить REST API на GraphQL), не переписывая ни строчки в BLoC или UI.

Тестирование BLoC

BLoC идеально подходит для Unit-тестирования, потому что он отделен от UI. Нам не нужен эмулятор или запущенное приложение, чтобы проверить логику. Мы просто подаем на вход события и проверяем выходные состояния.

Для тестирования стандартом является библиотека bloc_test. Добавьте её в dev_dependencies:

dev_dependencies:
  bloc_test: ^9.1.0
  test: ^1.24.0
  mocktail: ^1.0.0 # Для создания моков (имитации) репозиториев

Анатомия теста

Тест с bloc_test состоит из трех основных фаз:

  1. build: Создание экземпляра BLoC.
  2. act: Добавление события в BLoC.
  3. expect: Ожидание списка состояний, которые BLoC должен выдать.

Давайте протестируем наш CounterCubit из второго урока:

import 'package:bloc_test/bloc_test.dart';
import 'package:test/test.dart';

void main() {
  group('CounterCubit', () {
    // Тест 1: Проверка начального состояния
    test('начальное состояние равно 0', () {
      expect(CounterCubit().state, 0);
    });

    // Тест 2: Проверка логики инкремента
    bloc_test<CounterCubit, int>(
      'выдает [1], когда вызван increment',
      build: () => CounterCubit(),
      act: (cubit) => cubit.increment(),
      expect: () => [1],
    );

    // Тест 3: Проверка логики декремента
    bloc_test<CounterCubit, int>(
      'выдает [-1], когда вызван decrement',
      build: () => CounterCubit(),
      act: (cubit) => cubit.decrement(),
      expect: () => [-1],
    );
  });
}

Тестирование с моками (Mocking)

Когда BLoC зависит от репозитория, мы не хотим, чтобы тест делал реальные запросы в сеть. Мы используем «моки» — поддельные объекты, которые имитируют поведение реальных.

// Создаем класс-мок для репозитория
class MockWeatherRepository extends Mock implements WeatherRepository {}

void main() {
  late MockWeatherRepository mockRepo;

  setUp(() {
    mockRepo = MockWeatherRepository();
  });

  bloc_test<WeatherBloc, WeatherState>(
    'выдает [WeatherLoading, WeatherLoaded] при успешном запросе',
    build: () {
      // Настраиваем мок: когда вызовут getWeather, верни успешный результат
      when(() => mockRepo.getWeather(any()))
          .thenAnswer((_) async => Weather(temp: 20));

      return WeatherBloc(repository: mockRepo);
    },
    act: (bloc) => bloc.add(WeatherRequested('London')),
    expect: () => [
      WeatherLoading(),
      isA<WeatherLoaded>(), // Проверяем тип состояния
    ],
  );
}

Обработка ошибок и BlocObserver

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

Это идеальное место для подключения аналитики (Firebase, Sentry) или логирования.

Создание наблюдателя

class SimpleBlocObserver extends BlocObserver {
  // Вызывается при добавлении любого события
  @override
  void onEvent(Bloc bloc, Object? event) {
    super.onEvent(bloc, event);
    print('${bloc.runtimeType} $event');
  }

  // Вызывается при любом изменении состояния
  @override
  void onChange(BlocBase bloc, Change change) {
    super.onChange(bloc, change);
    print('${bloc.runtimeType} $change');
  }

  // Вызывается при ошибке внутри BLoC
  @override
  void onError(BlocBase bloc, Object error, StackTrace stackTrace) {
    print('${bloc.runtimeType} $error');
    super.onError(bloc, error, stackTrace);
  }
}

Подключение

Чтобы активировать наблюдатель, нужно назначить его при запуске приложения:

void main() {
  Bloc.observer = SimpleBlocObserver();
  runApp(const MyApp());
}

Теперь каждое действие в вашем приложении будет залогировано в консоли.

Важность Equatable

В предыдущих уроках мы упоминали, что BLoC не обновляет UI, если состояние не изменилось. Но как Dart понимает, что состояние изменилось?

По умолчанию в Dart сравнение объектов происходит по ссылке.

class UserState { final String name; UserState(this.name); }

final a = UserState('Ivan');
final b = UserState('Ivan');

print(a == b); // false! Это два разных объекта в памяти.

Если ваш BLoC выдаст emit(UserState('Ivan')), а текущее состояние уже 'Ivan', BlocBuilder все равно перерисует экран, потому что для Dart это разные объекты. Это вызывает лишние перерисовки и потерю производительности.

Чтобы исправить это, мы используем пакет equatable.

import 'package:equatable/equatable.dart';

class UserState extends Equatable {
  final String name;

  const UserState(this.name);

  // Мы указываем поля, которые участвуют в сравнении
  @override
  List<Object> get props => [name];
}

final a = UserState('Ivan');
final b = UserState('Ivan');

print(a == b); // true! Теперь сравниваются значения полей.

Лучшая практика: Всегда наследуйте свои классы Events и States от Equatable. Это спасет вас от множества трудноуловимых багов.

Структура папок

Как организовать файлы в проекте? Существует два основных подхода:

1. Layer-first (По слоям)

Мы группируем файлы по их технической роли.

  • /blocs (все блоки приложения)
  • /repositories (все репозитории)
  • /screens (все экраны)

Этот подход плох для больших проектов. Чтобы изменить одну фичу, приходится прыгать по разным папкам.

2. Feature-first (По фичам) — Рекомендуется

Мы группируем файлы по функциональности.

  • /features
    • /login
      • /bloc
      • /view
      • /repo
    • /weather
      • /bloc
      • /view
      • /models

Такая структура делает проект модульным и понятным.

Заключение курса

Поздравляю! Вы завершили курс по BLoC паттерну во Flutter.

Мы изучили:

  1. Реактивность: Как работают Streams и Sinks.
  2. Cubit: Как управлять простым состоянием.
  3. Bloc: Как обрабатывать сложные события и асинхронность.
  4. UI: Как связывать логику и верстку через BlocProvider и BlocBuilder.
  5. Качество: Как тестировать код, обрабатывать ошибки и строить архитектуру.

BLoC — это не просто библиотека, это философия разделения ответственности. Освоив её, вы сможете создавать сложные, надежные и поддерживаемые приложения, которые легко масштабировать.

Удачи в ваших проектах и чистого кода!