Каждый Flutter-разработчик рано или поздно сталкивается с проблемой управления состоянием. Кнопки нажимаются в неподходящий момент, данные теряются между экранами, а код превращается в лабиринт из setState
. Паттерн BLoC (Business Logic Component)
поможет нам превратить этот хаос в предсказуемый поток данных.
В этом параграфе вы шаг за шагом познакомитесь с паттерном BLoC:
-
Узнаете, как и зачем он появился, разберётесь в его эволюции и ключевых принципах.
-
На практике вы напишете свой первый блок без использования сторонних библиотек, чтобы прочувствовать основы подхода.
-
А затем сравните классическую концепцию BLoC с её современным воплощением — увидите, как изменились интерфейсы, структура и подходы к управлению состоянием.
Важно
Что такое BLoC
BLoC (Business Logic Component)
— это архитектурный шаблон, который используется в разработке программного обеспечения. Его основная задача — упростить разделение бизнес-логики и пользовательского интерфейса. Он помогает организовать код, делая его более понятным, тестируемым и масштабируемым.
Этот паттерн был впервые представлен инженером Паоло Соаресом (англ. Paolo Soares) на конференции Google I/O 2018 в докладе о создании реактивных приложений на Flutter. Основная задача заключалась в том, чтобы создать структуру, которая позволила бы отделить бизнес-логику от UI. Это позволило бы сделать интерфейс более реактивным и легче поддерживаемым.
Ключевую роль в популяризации и практической реализации паттерна BLoC
сыграл разработчик Феликс Анжелов (англ. Felix Angelov), создавший готовые библиотеки bloc
и flutter_bloc
. Это позволило упростить интеграцию паттерна в проекты.
Его вклад также включает детальную документацию, примеры приложений и активную поддержку сообщества, благодаря чему BLoC
стал одним из самых популярных решений для управления состоянием во Flutter, сочетая гибкость, тестируемость и прозрачность данных.
Эволюция паттерна
Изначальная суть паттерна BLoC
заключалась в концепции «чёрного ящика» с двумя типами интерфейсов: входы (sinks
) и выходы (streams
).
Входы принимали данные из разных источников — клики пользователя, ответы API, настройки приложения, а выходы транслировали результаты наружу — обновлённое состояние UI, ошибки или данные для других сервисов.
Внутренняя реализация не регламентировалась: разработчик сам решал, как преобразовывать входы в выходы, будь то асинхронные запросы, комбинация данных или даже игнорирование некоторых событий. Например, BLoC
мог агрегировать данные из нескольких входов (поисковый запрос + геолокация) и выдавать через выходы отфильтрованные результаты и статус загрузки.
Ключевое отличие оригинального BLoC
от современных реализаций — отсутствие жёстких правил. Компонент мог иметь множество входов и выходов, а связь между ними не обязана была быть прямой. Входные данные не всегда являлись «событиями» в классическом понимании — это могли быть сырые значения (текст поля ввода, координаты). Паттерн не требовал, чтобы каждый вход генерировал выход, что позволяло создавать сложные сценарии: кеширование, отложенную обработку, объединение потоков. Например, BLoC
для чата мог включать sinks
для сообщений, статусов «печатает…» и уведомлений, а streams
— для отображения истории переписки, индикаторов активности и ошибок подключения.
Со временем паттерн адаптировали под нужды разработчиков. Феликс Анжелов (англ. Felix Angelov), создатель пакетов bloc
и flutter_bloc
, предложил упрощённую версию BLoC
, где компонент обрабатывал события (events) и выдавал состояния (states) по принципу «один вход — один выход». Это повысило предсказуемость и упростило тестирование, но сузило изначальную концепцию: исчезла поддержка множественных входов/выходов, а бизнес-логика стала сводиться к преобразованию событий в состояния. Позже появился Cubit
— ещё более минималистичный вариант, где вместо событий использовались методы. Однако такие изменения отошли от идеи «чёрного ящика», сделав акцент на строгой структуре, а не на гибкости.
Идея компонентов со входами и выходами существовала и до BLoC
— её аналоги можно найти в реактивном программировании (RxJS), MVC или даже в классических конечных автоматах. Уникальность BLoC
заключалась в адаптации этих принципов под Flutter
и Dart Streams
, а также в стандартизации подхода. Паттерн стал мостом между реактивными практиками и разработчиками, которые ранее не работали с потоками данных. Однако его популярность связана не столько с новизной, сколько с удобством: BLoC
предложил чёткие правила разделения кода, что критично для больших проектов.
Давайте рассмотрим как паттерн BLoC
выглядит в изначальной его версии и как он изменился с появлением библиотеки bloc
.
Изначальный и современный BLoC
Для ясности будем использовать BLoC
для обозначения изначальной концепции и Bloc
— для современной реализации.
Рассмотрим, как выглядят интерфейсы обеих реализаций на примере поисковой системы:
1// Изначальный BLoC: множественные входы и выходы
2abstract class SearchBLoC {
3 // Входы
4 StreamSink<String> get searchSink;
5 StreamSink<String> get filterSink;
6
7 // Выходы
8 Stream<List<String>> get resultsStream;
9 Stream<bool> get isLoadingStream;
10
11 // Геттеры для получения текущих данных
12 List<String> get results;
13 bool get isLoading;
14}
Теперь напишем интерфейс Bloc
, но сначала определим события и состояния.
События — это действия или инициирующие факторы, которые обычно создаются либо пользователем, либо системой. В нашем случае это изменение текста поиска и фильтра.
Состояния — это текущее состояние Bloc
. Ожидание действий, загрузка данных, ошибка, успешное получение данных и так далее.
1// События
2sealed class SearchEvent {
3 const SearchEvent();
4}
5
6final class SearchChangedEvent extends SearchEvent {
7 final String query;
8 final String filter;
9
10 const SearchChangedEvent({
11 required this.query,
12 required this.filter,
13 });
14}
15
16// Состояния
17sealed class SearchState {
18 final List<String> results;
19
20 bool get isLoading => switch (this) {
21 SearchLoadingState() => true,
22 _ => false,
23 };
24
25 const SearchState(this.results);
26}
27
28final class SearchIdleState extends SearchState {
29 const SearchIdleState(super.results);
30}
31
32final class SearchLoadingState extends SearchState {
33 const SearchLoadingState(super.results);
34}
35
36final class SearchSuccessState extends SearchState {
37 const SearchSuccessState(super.results);
38}
39
40final class SearchErrorState extends SearchState {
41 const SearchErrorState(super.results);
42}
43
44// Современный Bloc: единый вход и выход
45abstract class SearchBloc {
46 StreamSink<SearchEvent> get eventSink;
47
48 Stream<SearchState> get stateStream;
49
50 SearchState get state;
51}
Мы написали наглядный пример интерфейсов обеих реализаций, можно перейти к практической части и создать реализацию на основе этих интерфейсов.
Написание собственного BLoC
Разобравшись с разницей между изначальным BLoC и современной версией, давайте создадим собственные реализации. Это поможет глубже понять, как работает паттерн изнутри.
Начнём с изначальной концепции — множественных входов и выходов.
Изначальный BLoC: множественные входы и выходы
Для реализации изначальной концепции нам понадобится библиотека rxdart
— расширение стандартных Dart Streams, которое предоставляет расширенные возможности для работы с потоками данных. Она даёт разработчику инструменты для комбинирования, фильтрации и трансформации потоков, что особенно полезно при создании сложных реактивных архитектур.
Создадим реализацию нашего BLoC:
1import 'dart:async';
2import 'package:rxdart/rxdart.dart';
3
4final class SearchBLoC {
5 // Контроллеры для входных потоков
6 final StreamController<String> _searchController =
7 StreamController<String>.broadcast();
8 final StreamController<String> _filterController =
9 StreamController<String>.broadcast();
10
11 // Контроллеры для выходных потоков
12 final StreamController<List<String>> _resultsController =
13 StreamController<List<String>>.broadcast();
14 final StreamController<bool> _isLoadingController =
15 StreamController<bool>.broadcast();
16
17 // Внутреннее состояние
18 List<String> _results = <String>[];
19 bool _isLoading = false;
20
21 // Подписка для управления жизненным циклом
22 late final StreamSubscription<void> _combinedSubscription;
23
24 SearchBLoC() {
25 _initialize();
26 }
Мы используем broadcast()
-контроллеры, чтобы несколько слушателей могли подписаться на один и тот же поток. Без broadcast()
каждый поток может иметь только одного слушателя, а нам необходимо, чтобы несколько слушателей могли подписаться на один и тот же поток.
Теперь добавим геттеры для доступа к данным:
1 // Геттеры для текущих значений
2 List<String> get results => _results;
3 bool get isLoading => _isLoading;
4
5 // Геттеры для потоков
6 Stream<List<String>> get resultsStream => _resultsController.stream;
7 Stream<bool> get isLoadingStream => _isLoadingController.stream;
8
9 // Геттеры для входных потоков
10 StreamSink<String> get filterSink => _filterController.sink;
11 StreamSink<String> get searchSink => _searchController.sink;
Здесь мы предоставляем два типа доступа: синхронный (через геттеры) и асинхронный (через потоки). Это даёт гибкость — виджеты могут либо получить текущее значение мгновенно, либо подписаться на изменения.
Теперь самое интересное — инициализация логики:
1 void _initialize() {
2 // Комбинируем поиск и фильтр
3 _combinedSubscription = CombineLatestStream.combine2(
4 _searchController.stream,
5 _filterController.stream,
6 (query, filter) => (query: query, filter: filter),
7 )
8 .asyncMap((params) => _onSearch(params.query, params.filter))
9 .listen(_resultsController.add);
10 }
CombineLatestStream.combine2
из библиотеки rxdart
берёт два потока и создаёт новый поток, который эмитит значение, только когда оба входных потока имеют данные. Это означает, что поиск сработает, только когда у нас есть и текст поиска, и фильтр.
Метод asyncMap
преобразует каждое событие в асинхронную операцию. В данном случае asyncMap
используется для простоты, игнорируя многие корнер-кейсы, например когда нужно прекратить выполнение кода из-за изменения входных данных. Для более сложных сценариев можно использовать switchMap
или другие расширения из библиотеки rxdart
, которые позволяют создавать более сложные цепочки обработки данных.
Теперь добавим логику обработки поиска:
1 Future<List<String>> _onSearch(String query, String filter) async {
2 // Устанавливаем состояние загрузки
3 _isLoading = true;
4 _isLoadingController.add(_isLoading);
5
6 // Выполняем поиск
7 final data = await _fetchData(query, filter);
8 _results = data;
9
10 // Сбрасываем состояние загрузки
11 _isLoading = false;
12 _isLoadingController.add(_isLoading);
13
14 return data;
15 }
16
17 Future<List<String>> _fetchData(String query, String filter) async {
18 final List<String> data = ...; // Получаем данные
19 return data;
20 }
Обратите внимание, что мы обновляем состояние загрузки до и после операции. Это позволяет UI показать индикатор загрузки пользователю.
И, наконец, добавим метод для очистки ресурсов:
1 Future<void> close() async {
2 await [
3 _combinedSubscription.cancel(),
4 _searchController.close(),
5 _filterController.close(),
6 _resultsController.close(),
7 _isLoadingController.close(),
8 ].wait;
9 }
10}
Этот метод важен для предотвращения утечек памяти. Мы отменяем все подписки и закрываем все контроллеры.
Перейдём к реализации Bloc
с одним входом и выходом.
Современный Bloc: один вход и выход
Создадим версию, которая следует современному подходу. Здесь у нас будет один поток событий и один поток состояний.
Сначала определим события:
1import 'dart:async';
2
3// События
4sealed class SearchEvent {
5 const SearchEvent();
6}
7
8final class SearchChangedEvent extends SearchEvent {
9 final String query;
10 final String filter;
11
12 const SearchChangedEvent({
13 required this.query,
14 required this.filter,
15 });
16}
Теперь определим состояния:
1// Состояния
2sealed class SearchState {
3 final List<String> results;
4
5 bool get isLoading => switch (this) {
6 SearchLoadingState() => true,
7 _ => false,
8 };
9
10 const SearchState(this.results);
11}
12
13final class SearchIdleState extends SearchState {
14 const SearchIdleState(super.results);
15}
16
17final class SearchLoadingState extends SearchState {
18 const SearchLoadingState(super.results);
19}
20
21final class SearchSuccessState extends SearchState {
22 const SearchSuccessState(super.results);
23}
24
25final class SearchErrorState extends SearchState {
26 const SearchErrorState(super.results);
27}
Мы используем sealed class
, потому что это позволяет компилятору проверить, что мы обработали все возможные состояния в switch-выражениях. Это предотвращает ошибки и делает код более надёжным.
Теперь создадим сам Bloc:
1class SearchBloc {
2 // Текущее состояние
3 late SearchState _state;
4
5 // Подписка на события
6 late final StreamSubscription<void> _onEventSubscription;
7
8 // Контроллеры для событий и состояний
9 final StreamController<SearchState> _stateController =
10 StreamController<SearchState>.broadcast();
11 final StreamController<SearchEvent> _eventController =
12 StreamController<SearchEvent>.broadcast();
13
14 SearchBloc() : _state = const SearchIdleState([]) {
15 _initialize();
16 }
17
18 // Геттеры для доступа к данным
19 SearchState get state => _state;
20 Stream<SearchState> get stateStream => _stateController.stream;
21 StreamSink<SearchEvent> get eventSink => _eventController.sink;
Конструктор инициализирует состояние как idle
и запускает инициализацию. Геттеры предоставляют доступ к текущему состоянию, потоку состояний и входу для событий.
Добавим метод для изменения состояния:
1 void _onEmitState(SearchState state) {
2 _state = state;
3 _stateController.add(state);
4 }
Этот метод обновляет внутреннее состояние и уведомляет всех слушателей об изменении.
Теперь инициализация:
1 void _initialize() {
2 _onEventSubscription = _eventController.stream
3 .asyncExpand(_mapEventToStates)
4 .listen(_onEmitState);
5 }
Здесь мы подписываемся на поток событий, преобразуем каждое событие в поток состояний через asyncExpand
и слушаем результаты через listen
.
Метод _mapEventToStates
:
1 Stream<SearchState> _mapEventToStates(SearchEvent event) async* {
2 switch(event) {
3 case SearchChangedEvent(): yield* _onSearchChanged(event);
4 }
5 }
Этот метод использует async*
(генератор) для создания потока состояний. В нашем простом случае у нас только один тип события, но в реальном приложении здесь был бы switch
со множественными случаями.
Логика обработки поиска:
1 Stream<SearchState> _onSearchChanged(SearchChangedEvent event) async* {
2 // Начинаем загрузку
3 yield SearchLoadingState(results: state.results);
4
5 try {
6 // Выполняем поиск
7 final results = await _fetchData(event.query, event.filter);
8 yield SearchSuccessState(results: results);
9 } on Object {
10 // Обрабатываем ошибку
11 yield SearchErrorState(results: state.results);
12 } finally {
13 yield SearchIdleState(results: state.results);
14 }
15 }
16
17 Future<List<String>> _fetchData(String query, String filter) async {
18 final List<String> data = ...; // Получаем данные
19 return data;
20 }
Обратите внимание на использование yield*
— это делегирует генерацию состояний другому генератору. Это позволяет создавать сложные цепочки обработки событий.
И, наконец, метод очистки:
1 Future<void> close() async {
2 await [
3 _onEventSubscription.cancel(),
4 _stateController.close(),
5 _eventController.close(),
6 ].wait;
7 }
8}
Обе реализации,SearchBLoC
и SearchBloc
, используют паттерн BLoC
для управления бизнес-логикой поискового процесса, но с некоторыми различиями в подходах и организации кода.
Теперь, когда мы разобрали основы создания собственных BLoC
-реализаций, давайте рассмотрим важные аспекты, которые часто упускаются из виду: трансформацию потоков и сравнение состояний. Эти темы критически важны при взаимодействии с потоками данных и для обеспечения лучшей производительности приложения.
Трансформация потоков и сравнение состояний
Трансформация потоков — это процесс преобразования одного потока данных в другой с помощью специальных трансформеров. Поскольку все входные данные в BLoC
являются потоками (события пользователя, ответы API, таймеры), мы можем гибко настраивать их обработку: фильтровать лишние события, группировать похожие операции или изменять частоту их обработки.
Сейчас SearchBloc
принимает ввод данных от пользователя и сразу же обрабатывает. Представьте поисковую строку — каждый символ генерирует событие SearchChangedEvent
. Без контроля это приведёт к множественным запросам к серверу и плохой производительности.
Обновим наш SearchBloc
:
1import 'package:rxdart/rxdart.dart';
2
3class SearchBloc {
4 // ... остальной код ...
5
6 void _initialize() {
7 _onEventSubscription = _eventController.stream
8 .debounceTime(const Duration(milliseconds: 300)) // Ждём паузу в вводе
9 .switchMap(_mapEventToStates) // «Отменяем» предыдущие запросы
10 .listen(_onEmitState);
11 }
12}
При такой реализации, если пользователь быстро печатает "flutter", запрос сработает только через 300 ms после последнего символа, при этом предыдущая обработка события будет отменена.
Ещё одним шагом к оптимизации работы нашего блока может стать использование оператора distinct
из библиотеки rxdart
. Этот оператор позволяет пропускать повторяющиеся значения в потоке состояний, гарантируя, что однотипные состояния не будут передаваться подписчикам повторно. Таким образом, если новое состояние идентично предыдущему, оно не вызовет лишних обновлений интерфейса или других побочных эффектов.
Библиотека bloc
включает часть этой логики на уровне своей инфраструктуры, автоматически обрабатывая проверку обновлений. Тем не менее понимание работы этой концепции и ручная реализация соответствующих проверок (особенно в сложных сценариях) могут привести к более оптимизированному и ясному коду.
В этом параграфе вы познакомились с основами паттерна BLoC, его происхождением и эволюцией, а также написали свои первые блоки без сторонних библиотек — это заложило прочную базу для дальнейшего понимания принципов работы паттерна.
А в следующем параграфе мы подробно разберём библиотеку bloc
: узнаем, как она облегчает внедрение BLoC в реальных Flutter-проектах, рассмотрим её ключевые компоненты и многое другое!