Flutter — динамично развивающийся фреймворк для написания кроссплатформенных приложений. Несмотря на свою молодость (первая стабильная версия Flutter 1.0 была представлена всего лишь 4 декабря 2018 года, то есть чуть больше года назад на момент публикации статьи), он приобрел немало сторонников, и его популярность только растет. Сегодня мы хотели бы рассказать об одной из самых популярных среди Flutter-разработчиков архитектур, которая была разработана в Google, и называется — BLoC.
Что такое BLoC
Давайте разберемся с основной терминологией. BLoC — это акроним от «Business Logic Component» (компонент бизнес-логики). Как следует из названия, это класс, отделяющий бизнес-логику приложения от пользовательского интерфейса. Такой компонент содержит код, который можно повторно использовать где угодно: в другом модуле, на другой платформе, в другом приложении. Если вы до этого работали с архитектурой MVVM, то можете провести аналогию и сравнить Bloc с ViewModel — они похожи по своему назначению.
Обратите внимание на разницу в написании терминов. Здесь и далее употребляется BLoC, когда речь идет об архитектуре, и Bloc — когда о классе в программе.
Отделение UI от бизнес-логики для Flutter жизненно необходимо. Согласитесь, что карабкаться вверх-вниз по дереву виджетов (UI-компонентов) в поисках нужной вам логики — не самая приятная вещь. Особенно, если верстка и так содержит очень много кода и разбросана по разным файлам. Кроме того, если мы следуем заветам Clean Architecture (а мы им следуем, не так ли?), то в нашем UI вообще не должно быть ничего лишнего. Равно как и бизнес-логика ничего не должна знать про UI, который к ней обращается. Благодаря такому тщательному разделению ответственностей мы получаем полностью изолированный компонент, который можно легко тестировать независимо от UI и использовать в другом окружении. Этот компонент и есть наш Bloc.
Одной из отличительных особенностей паттерна является то, что он полностью базируется на реактивности. Что это значит? Реактивное программирование — это программирование с асинхронными потоками данных. В традиционном императивном стиле мы обычно пишем код следующего содержания: вот текстовое поле, назначаем ему обработчик, реагирующий на ввод нового символа, в обработчике добавляем объект, у которого вызываем метод обновления текстового поля в модели. Мы четко описываем каждый шаг. В реактивном подходе все выглядит немного иначе. Текстовое поле связывается с конкретной переменной в модели. И как только пользователь начинает что-то печатать, эта переменная сразу принимает то значение, которое сейчас содержится в текстовом поле.
Для реализации реактивных сценариев язык Dart по умолчанию предлагает класс StreamController. Он позволяет нам моделировать поток данных с помощью двух составляющих — sink (входной поток, куда пользователь добавляет события) и stream (выходной поток, который слушают один или несколько объектов и реагируют на изменения). Пример описания входа-выхода для экрана в коде Bloc-а выглядит следующим образом:
final StreamController<String>_nameController = StreamController<String>();
// описываем геттер для входного потока (sink)
Sink<String> get inName =>_nameController.sink;
// описываем геттер для выходного потока (stream)
Stream<String> get outName =>_nameController.stream;
Отдельные геттеры удобно делать по следующим причинам. Во-первых, так гораздо проще, чем каждый раз писать nameController.sink, во-вторых, вы даете входу/выходу осмысленное имя, что позволяет легче понять его назначение и тем самым улучшает читабельность.
Код метода build для вашего виджета может выглядеть так:
@override
Widget build(BuildContext context) {
return StreamBuilder<String> (
stream: someBloc.outName,
builder: (BuildContext context, AsyncSnapshot<String>snapshot) => Text(snapshot.data)
);
}
Здесь мы создаем специальный виджет StreamBuilder. Первым аргументом указываем поток, который поставляет данные нашему виджету, вторым — функцию, «собирающую» виджет на основе отрисовочного контекста и данных, асинхронно приходящих из потока.
Для реализации BLoC часто используются уже готовые библиотеки, например, эта. Нашей же команде она показалась слишком тяжеловесной, поэтому мы воспользовались кастомной, упрощенной реализацией, описанной в этой статье. Рассмотрим ее основные положения.
Знакомство с BlocProvider
Каждый Bloc поддерживает общий интерфейс BlocBase, содержащий в себе только один обязательный к реализации метод dispose. Он нужен, чтобы не забыть реализовать освобождение ресурсов, занятых Bloc-ом, например, закрыть активные потоки. Выглядит он так:
abstract class BlocBase {
void dispose();
}
Для того чтобы вы могли получить доступ к Bloc-у из любого узла дерева виджетов, корнем этого дерева становится такая сущность, как BlocProvider. Рассмотрим ее код:
class BlocProvider<T extends BlocBase>extends StatefulWidget {
BlocProvider({
Key key,
@required this.child,
@required this.bloc,
}): super(key: key);
final T bloc;
final Widget child;
@override
_BlocProviderState<T>createState() => _BlocProviderState<T>();
static T of<T extends BlocBase>(BuildContext context){
final type = _typeOf<BlocProvider<T>>();
BlocProvider<T> provider = context.ancestorWidgetOfExactType(type);
return provider.bloc;
}
static Type _typeOf<T>() => T;
}
class _BlocProviderState<T> extends State<BlocProvider<BlocBase>>{
@override
void dispose(){
widget.bloc.dispose();
super.dispose();
}
@override
Widget build(BuildContext context){
return widget.child;
}
}
Идея заключается в том, что наш провайдер, точно такой же виджет, который состоит из двух слагаемых: Bloc, к которому будут обращаться виджеты, и дерево виджетов, которое мы видим на экране. Для доступа к Bloc-у в методе виджета build вам нужно написать так:
@override
Widget build(BuildContext context) {
MyBloc bloc = BlocProvider.of(context);
return Scaffold(body: /*ваша реализация с использованием StreamBuilder*/);
}
Доступ к Bloc-у под капотом реализован очень просто. Мы движемся вверх по иерархии виджетов в рамках BuildContext, пока не встретим BlocProvider. Как только это произошло, мы можем вернуть Bloc этого провайдера.
Небольшое уточнение по поводу оптимизации. Поиск по иерархии виджетов имеет вычислительную сложность O (n). Если ваш провайдер расположен относительно близко к виджету, который запросил Bloc, такая операция не займет много времени. Однако в случае, когда ваш виджет имеет большую глубину вложенности, а доступ к Bloc-у ему требуется часто, это может сказаться на производительности. Тогда есть смысл подумать о том, чтобы кэшировать ссылку на Bloc, например, передавая его как аргумент в конструктор виджета.
Пример реализации
Рассмотрим, как работа с BLoC-архитектурой может выглядеть на практике. Перепишем пример, который по умолчанию генерируется при создании Flutter-проекта. Так как здесь всего один экран, у нас будет один виджет MainScreen с прилагающимся к нему MainBloc.
У MainBloc есть входной поток с UI событиями (в нашем случае нажатия на кнопку «+») и выходной поток со значением счетчика нажатий. Само значение счетчика counter из State должно переехать сюда, поскольку оно — часть бизнес-логики. В результате получаем следующий код для MainBloc:
import 'dart:async';
import 'package:simple_bloc_app/bloc/common/bloc_base.dart';
enum MainBlocEvent {
incrementCounter
// ...другие события, которые будет обрабатывать Bloc
}
class MainBloc extends BlocBase {
// данные Bloc-а
int _counter = 0;
// stream controllers
final StreamController<int> _counterController = StreamController<int>();
final StreamController<MainBlocEvent> _eventController =
StreamController<MainBlocEvent>();
Sink<int> get _inCounter => _counterController.sink;
Stream<int> get outCounter => _counterController.stream;
Sink<MainBlocEvent> get inEvent => _eventController.sink;
Stream<MainBlocEvent> get _outEvent => _eventController.stream;
MainBloc() {
// подписываемся на поток
// здесь обрабатываются события, пришедшие со стороны UI
_outEvent.listen(_handleEvent);
}
// альтернатива потоку с UI-событиями
void onIncrementButton() {
_handleIncrementCounterEvent();
}
@override
void dispose() {
// здесь мы закрываем открытые контроллеры
_eventController.close();
_counterController.close();
}
void _handleEvent(MainBlocEvent event) {
switch (event) {
case MainBlocEvent.incrementCounter:
_handleIncrementCounterEvent();
break;
default:
// чтобы гарантировать, что мы не пропустим ни один кейс enum-а
assert(false, "Should never reach there");
break;
}
}
void _handleIncrementCounterEvent() {
_inCounter.add(++_counter);
}
}
Если создание отдельного потока для UI-событий вам кажется излишним, то вы можете написать отдельный метод-обработчик нажатия на кнопку «+» (onIncrementButton) и вызывать его напрямую.
Теперь вынесем код виджета из main.dart в MainScreen и интегрируем в него наш MainBloc:
import 'package:flutter/material.dart';
import 'package:simple_bloc_app/bloc/common/bloc_provider.dart';
import 'package:simple_bloc_app/bloc/main_bloc.dart';
class MainScreen extends StatefulWidget {
@override
State<StatefulWidget>createState() => _MainScreenState();
}
class _MainScreenState extends State<MainScreen> {
@override
Widget build(BuildContext context) {
final MainBloc bloc = BlocProvider.of(context);
return Scaffold(
appBar: AppBar(
title: Text("My first BLoC app"),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
'You have pushed the button this many times:',
),
StreamBuilder(
stream: bloc.outCounter,
builder: (BuildContext context, AsyncSnapshot<int> snapshot) {
return Text(
"${snapshot.data ?? 0}",
style: Theme.of(context).textTheme.display1,
);
},
)
],
),
),
floatingActionButton: FloatingActionButton(
// используйте либо поток, либо вызов метода Bloc-а
onPressed: () => bloc.inEvent.add(MainBlocEvent.incrementCounter),
//onPressed: () => bloc.onIncrementButton(),
tooltip: 'Increment',
child: Icon(Icons.add),
),
);
}
}
Обратите внимание, текстовый виджет со значением счетчика теперь находится в StreamBuilder, который подписан на поток из Bloc-а. При нажатии на кнопку в Bloc прокидывается соответствующее событие (на ваше усмотрение, через поток или обработчик). Bloc обрабатывает событие, увеличивая хранящийся в нем счетчик нажатий на 1. После чего значение этого счетчика передается в другой поток, на который подписан виджет на стороне UI. StreamBuilder получает новое состояние из потока (AsyncSnapshot) и перерисовывает свое содержимое с новыми данными.
Наконец, создадим BlocProvider для нашего экрана. Вынесем этот код в main.dart:
import 'package:flutter/material.dart';
import 'package:simple_bloc_app/bloc/common/bloc_provider.dart';
import 'package:simple_bloc_app/bloc/main_bloc.dart';
import 'package:simple_bloc_app/screen/main_screen.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: MyHomePage(),
);
}
}
class MyHomePage extends StatefulWidget {
MyHomePage({Key key}) : super(key: key);
@override
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
@override
Widget build(BuildContext context) {
// создаем Bloc и экран для него
final MainBloc bloc = MainBloc();
final MainScreen screen = MainScreen();
// на их основе создаем BlocProvider
return BlocProvider(
child: screen,
bloc: bloc,
);
}
}
Запускаем приложение и проверяем его работоспособность.
Код проекта вы можете посмотреть здесь.
Чек-лист по работе с BLoC-архитектурой
Итак, алгоритм работы при создании новых экранов с использованием BLoC-архитектуры выглядит следующим образом:
- Создать Bloc для конкретного экрана.
- Описать его входы и выходы в виде потоков.
- Реализовать требуемую бизнес-логику.
- Создать новый экран.
- Поместить в корень дерева виджетов BlocProvider, передать ему экземпляр реализованного Bloc-а и, собственно, виджеты, которые будут отображаться на экране.
- Реализовать экран, используя по необходимости StreamBuilder-ы, подписанные на выходы из Bloc-а.
Разумеется, никто не заставляет вас ограничивать себя исключительно StreamBuilder-ами. Более того, в случае, когда для отрисовки виджета вам нужно просто один раз синхронно получить данные из Bloc-а, «плодить» stream-ы даже нерационально. А для разовой подгрузки данных по сети можно использовать, например, FutureBuilder. Поэтому изучайте, экспериментируйте, создавайте. В следующей статье мы расскажем, как наша команда переходила от привычного паттерна VIPER к BLoC и с какими сложностями мы при этом столкнулись.