Оглавление
Прежде наша команда в своих проектах использовала преимущественно паттерны MVVM и VIPER. Приступив к изучению архитектурных практик во Flutter, мы обнаружили, что здесь пишут несколько иначе. Тем, кто тоже интересуется этой темой, рекомендую изучить репозиторий, где представлены различные варианты реализации одного и того же небольшого приложения «
Первый взгляд на BLoC
Казалось бы, все звучит довольно просто. Вот UI, вот Bloc, который в этой архитектуре выступает в качестве презентера. Берем потоки, подписываемся на них и посылаем данные
Главный вопрос, который нас занимал — кто в этой архитектуре исполняет обязанности
Дело в том, что во Flutter вам жизненно необходима такая сущность, как BuildContext. Хотите перерисовать виджет? Нужен BuildContext. Хотите показать другой экран или диалоговое окно? Показывайте с помощью BuildContext. Хотите локализовать строку с помощью библиотеки (хотя она не единственная, но очень популярная)? Вы знаете, куда идти. И этот BuildContext доступен только на уровне UI в момент обращения к виджету.
То есть выполняете вы
Мы придумали два способа для выхода из этой ситуации. Способ первый — просто отдавать в Bloc контекст из виджета. В теле метода виджета initState () мы обращались к нашему
Почему нельзя помещать BuildContext в Bloc
Сохранив BuildContext внутри
Ответ прост — все та же изоляция слоев по практикам Clean Architecture. Поместив context в Bloc, вы тем самым жестко завязываете вашу
Отделяем навигацию от UI и бизнес-логики
Вспомним, что BLoC – прежде всего реактивный паттерн. Вы можете организовать отдельный поток для событий. На вход этого потока поступают данные из Bloc-а (это могут быть строковые ключи, числовые константы или значения enum-ов). На выходе есть некто, кто слушает эти сообщения и в зависимости от запрошенного события вызывает ваш Router, передавая ему свой контекст. Назовем эту сущность BlocContext. Ее основное назначение – помогать Bloc-у в тех операциях, которые он не может самостоятельно выполнить без контекста. К таким операциям относятся навигация между экранами и показ диалогов.
Проиллюстрируем взаимоотношения классов следующей диаграммой:
Child (он же виджет) использует Bloc для подписки на потоки с данными и отправки сообщений
Код BlocContext может выглядеть следующим образом:
abstract class BlocContextBase <T extends BlocBase> {
// обязательный к реализации метод
void subscribe(T bloc, BuildContext context);
// общие обработчики диалогов, которые могут пригодиться каждому bloc
void showInfoAlert(BuildContext context, String message) {
// ...
}
void showError(BuildContext context, String message) {
// ...
}
void showProgressDialog(BuildContext context) {
// ...
}
}
Метод subscribe нам нужен для того, чтобы наш BlocContext мог подписаться на события, которые посылает связанный с ним Bloc. Зная, какое событие произошло, и имея при этом ссылку на BuildContext, мы всегда можем показать новый диалог или перейти на другой экран.
Чтобы получить доступ к контексту, немного дополним реализацию BlocProvider из предыдущей статьи:
class BlocProvider<T extends BlocBase> extends StatefulWidget {
BlocProvider(
{@required this.child,
@required this.bloc,
@required this.blocContext,
Key key})
: super(key: key);
final T bloc;
final Widget child;
final BlocContextBase<T> blocContext;
@override
_BlocProviderState<T> createState() => _BlocProviderState<T>();
static T of<T extends BlocBase>(BuildContext context) {
final Type type = _typeOf<BlocProvider<T> > ();
final BlocProvider<T> provider = context.ancestorWidgetOfExactType(type);
return provider.bloc;
}
static Type _typeOf<T>() => T;
}
class _BlocProviderState<T> extends State<BlocProvider<BlocBase>> {
@override
void initState() {
super.initState();
widget.blocContext.subscribe(widget.bloc, context);
}
@override
void dispose() {
widget.bloc.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return widget.child;
}
}
Рассмотрим внесенные изменения. В BlocProvider появился третий параметр — собственно BlocContext. Также появилась реализация метода initState. Поскольку BlocProvider является виджетом, у него есть BuildContext. Благодаря этому мы можем в методе initState вызвать subscribe
Реализуя конкретных наследников BlocContext, вы можете сами определить, как и на какие события вы будете подписываться, какие именно диалоги вы будете показывать и каким
Пример навигации между двумя экранами
Для иллюстрации вышесказанного возьмем код проекта из предыдущей статьи и дополним его. Сначала расширим базовый функционалclass BlocEvent<T> {
T type;
Map<String, dynamic> parameters;
BlocEvent({@required this.type, this.parameters});
}
// Общий интерфейс для всех bloc-ов
abstract class BlocBase<T> {
StreamController<BlocEvent<T>> _eventsController =
StreamController<BlocEvent<T>>();
@protected
Sink<BlocEvent<T>> get inEvents => _eventsController.sink;
Stream<BlocEvent<T>> get outEvents => _eventsController.stream;
void dispose() {
_eventsController.close();
}
}
Сюда также добавляется новый параметризованный класс BlocEvent, хранящий тип события (для этого мы будем использовать перечисления) и словарь параметров. Объекты этого класса мы будем пробрасывать от Перейдем к главному экрану. Немного изменим верстку: добавим туда текст, по нажатию на который будет открываться новый экран. Найдем в коде верстки виджет Column, где располагается основной контент, и поместим туда следующий элемент:
InkWell(
child: Text("Checkout next screen",
style: TextStyle(color: Colors.blue)),
onTap: () => bloc.onGoToNextScreenButton(),
)
Также нам потребуется реализовать соответствующий метод. Обратите внимание: в отличие от предыдущей, в этой статье не используется реактивность для передачи событий на сторону
import 'dart:async';
import 'package:simple_bloc_app/blocs/common/bloc_base.dart';
enum MainBlocEvent {
// события навигации для bloc-а
goToNextScreen
}
class MainBloc extends BlocBase< MainBlocEvent> {
int _counter = 0;
final StreamController<int> _counterController = StreamController<int>();
Sink<int> get _inCounter => _counterController.sink;
Stream<int> get outCounter => _counterController.stream;
MainBloc();
void onIncrementButton() {
_handleIncrementCounterEvent();
}
void onGoToNextScreenButton() {
inEvents.add(BlocEvent(type: MainBlocEvent.goToNextScreen));
}
@override
void dispose() {
// закрываем контроллер
_counterController.close();
// не забываем вызвать метод суперкласса
super.dispose();
}
void _handleIncrementCounterEvent() {
_inCounter.add(++_counter);
}
}
Здесь мы изменили перечисление MainBlocEvent и добавили обработчик для перехода на следующий экран.
Для простоты восприятия реализуем второй экран без
class SecondScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text("Second screen")),
body: Center(
child: Text("Some content will be there"),
),
);
}
}
Для перехода нам понадобится соответствующий Router:
class SecondScreenRouter {
static void openModule(BuildContext context) {
Navigator.push(context,
MaterialPageRoute(builder: (BuildContext context) => SecondScreen()));
}
}
Теперь реализуем MainBlocContext:
class MainBlocContext extends BlocContextBase {
@override
void subscribe(MainBloc bloc, BuildContext context) {
bloc.outEvents.listen((BlocEvent<MainBlocEvent> event) {
switch (event.type) {
case MainBlocEvent.goToNextScreen:
SecondScreenRouter.openModule(context);
break;
default:
assert(false, "Should never reach there");
break;
}
});
}
}
Итак, все готово. Осталось только внести завершающие штрихи в main.dart:
class _MyHomePageState extends State<MyHomePage> {
// кешируем ссылки на Bloc и его контекст
// чтобы избежать их повторного создания и проблем с подпиской на потоки Bloc-а
final MainBloc bloc = MainBloc();
final MainBlocContext blocContext = MainBlocContext();
@override
Widget build(BuildContext context) {
return BlocProvider(
child: MainScreen(),
bloc: bloc,
blocContext: blocContext,
);
}
}
Готово! Наш переход между экранами работает:
Summary: полученный результат
Итак, чего мы сегодня добились?
- Изолировали
бизнес-логику от UI. Теперь нашиBloc-и ничего не знают про BuildContext и не нуждаются в нем для показа экрана/диалога. - Вынесли общий функционал, связанный с показом сообщений об ошибках, информационных диалогов и других вещей в отдельный класс. Вам не придется реализовывать эти методы отдельно для каждого
Bloc-а . - Вам не нужно думать о том, чтобы не забыть подписать ваш BlocContext на Bloc. Сама подписка выполняется внутри BlocProvider. Ваша задача — реализовать соответствующие классы и передать их
BlocProvider-у .
Таким образом, мы адаптировали идеи Clean Architecture к BLoC во Flutter и получили то разделение ответственностей, к которому стремились. А в следующей статье мы расскажем, как работать с платформенными сервисами вроде камеры, галереи или телефонной книги.