GetX for Flutter. Dependency Injection для частных случаев
GetX удобен. Действительно удобен, лаконичен, функционален, выразителен. Но порою его функционала не хватает. В частности, ниже рассматриваются ситуации, когда стандартного инжектирования контроллеров средствами Get недостаточно.
Описание кейсов
Пример 1. Управлять контроллерами в страницах PageView
Архитектура PageView
такова, что субстраницы строятся одномоментно в процессе build
и при переходах не пересоздаются. Но предположим, что по бизнес-логике необходимо рассматривать эти субстраницы, как отдельные элементы, например инициализировать и отключать таймеры, обновлять поля, открывать и закрывать какие-то ресурсы при переходе между ними. Явно просится прикрутить по контроллеру к каждой из них.
body: PageView(
controller: controller.pageController,
children: [
HomePage(), // + HomePageController
BusinessPage(), // + BusinessPageController
],
),
Вообще, следует начать с того, что непонятно, куда прикручивать инжектирование. Биндить к странице-владельцу PageView
? Но теряется контекстность применения, ведь контроллеры управляют данными конкретных субстраниц, а onInit/onReady/onClose
контроллеров никак не будут соответствовать моментам переходов. Размещать в build
вообще не хочется, неправильно это. В конструкторы субстраниц - тоже не решает проблемы, они не пересоздаются (см. выше). Вообще ни один стандартный подход не решает вопроса.
Из лога видно, что никакой привязки контроллеров не получается.
А вот как это должно быть:
Очевидно, что теперь жизненные циклы контроллеров страниц отслеживаются. Это позволяет управлять ресурсами в контексте бизнес-логики.
Пример 2. Управлять контроллерами в Get.bottomSheet
История похожая. Имеется View
, которая переиспользуется в Get.bottomSheet
с разными параметрами при разных вызовах....
/// Туда
Get.bottomSheet(SubRouterDialog(
'Go to Sub',
okCallback: () {
Get.back();
Get.to(() => HomeSubPage());
},
cancelCallback: () {
Get.back();
},
));
/// ... и обратно
Get.bottomSheet(SubRouterDialog(
'Go back to Home',
okCallback: () {
Get.back();
Get.back();
},
cancelCallback: () {
Get.back();
},
color: Colors.green,
));
Get.bottomSheet
какой он есть, не в состоянии переинжектировать контроллер, и логика рушится.
А вот как он должен функционировать:
Почему так происходит
Если в двух словах. то виджеты и их контроллеры, участвующие в GetPageRoute
, синхронизируют свои жизненные циклы, и все работает из коробки - при смене роута в навигаторе нужные контроллеры заново инициализируются, ненужные удаляются. Для операций вне роутинга это не предусмотрено. А наши кейсы - как раз вне роутинга.
Как с этим бороться
Для решения этих задач я использую небольшую надстройку в виде двух классов на стороне виджета и одного на стороне контроллера.
Statex* - автоинжектирование без ограничений
На стороне виджетов введены 2 класса:
1. StatexWidget
- базовый абстрактный класс
Его задача в том, чтобы позади основного дерева виджетов внедрить служебный микровиджет _StatexWidgetInjector
(по сути, пустой контейнер). Вся работа производится в этом виджете.
abstract class StatexWidget<T extends StatexController>
extends StatelessWidget {
/// Базовый конструктор для передачи билдера и свойств.
/// Под капотом вызывает Get.put(...), передавая туда билдер, тег
/// и флаг permanent.
/// Удобен для передачи параметров прямо по месту вызова, но не поддерживает
/// принцип Dependency Inversion, так как точный тип контроллера нужно знать
/// в точке вызова.
/// Если требуется что-то типа Get.lazyPut<Base>(()=>Inherited()),
/// то следует воспользоваться конструктором [StatexWidget.find]
///
/// ```
/// class BusinessPage extends StatexWidget<_BusinessPageControllerImpl> {
/// BusinessPage() : super(() => _BusinessPageControllerImpl(),
/// tag: 'TAG', permanent: true, args: {'key': value} );
///
/// ```
const StatexWidget(
this.builder, {
this.tag,
this.permanent = false,
this.args = const <String, dynamic>{},
Key? key,
}) : super(key: key);
/// Конструктор для работы в паре с [Get.lazyPut<Some>(()=>SomeImpl())].
/// Другими словами, для поддержания концепции Dependency Inversion.
///
/// [markAsPermanent] используется в случаях,
//// когда конструктор `StatexWidget.find` будет вызываться
/// в паре с ранее зарегистрированной ленивой фабрикой Get.lazyPut.
/// Для Get.lazyPut нельзя сделать контроллер перманентным,
/// только возобновляемым при помощи свойства `fenix`.
/// Но fenix заново создает контроллер, убивая его состояние.
/// В случае создания контроллера [markAsPermanent] передаст
/// свое значение в инжектор Get.put(..., permanent = markAsPermanent),
/// тем самым создав перманентный контроллер.
/// И соответственно, при [dispose] не будет удаления Get.delete<T>
/// для этого типа.
///
/// ```
/// // Где-то в инжекторе определяем фабрику и параметры,
/// // подставляя имплементацию
/// Get.lazyPut<HomePageController>(
/// () => HomePageControllerImpl(),
/// fenix: true,
/// );
///
/// // Используем [StatexWidget.find], передавая дополнительные параметры.
/// // Если инстанс не существует, он будет создан с нужными параметрами.
/// // Иначе будет найден и выдан текущий инстанс.
/// class HomePage extends StatexWidget<HomePageController> {
/// HomePage({Key? key}) : super.find(
/// markAsPermanent: true,
/// key: key,
/// );
///
/// ```
const StatexWidget.find({
String? tag,
bool markAsPermanent = false,
Map<String, dynamic> args = const <String, dynamic>{},
Key? key,
}) : this(null, tag: tag, permanent: markAsPermanent, args: args, key: key);
/// [builder] обязателен для базового конструктора, но не используется
/// для [StatexWidget.find]
final InstanceBuilderCallback<T>? builder;
///
final String? tag;
final bool permanent;
final Map<String, dynamic> args;
T get controller => GetInstance().find<T>(tag: tag);
Widget buildWidget(BuildContext context);
/// Идея в том, чтобы внедрить виджет-менеджер времени
/// жизни контроллера в стек позади основного дерева клиента.
@override
Widget build(BuildContext context) {
// Необходимый стек для внедрения [_StatexWidget]
return Stack(
fit: StackFit.passthrough,
children: [
// Wrapping уменьшает геометрию виджета до минимально возможной
Wrap(
children: [
_StatexWidgetInjector<T>(
builder,
tag: tag,
permanent: permanent,
args: args,
),
],
),
buildWidget(context),
],
);
}
}
2. _StatexWidget + State
: Виджет управления состояниями контроллера.
/// Контрольный [StatefulWidget]
class _StatexWidgetInjector<T extends StatexController> extends StatefulWidget {
_StatexWidgetInjector(
InstanceBuilderCallback<T>? builder, {
this.tag,
this.permanent = false,
Map<String, dynamic> args = const <String, dynamic>{},
Key? key,
}) : super(key: key) {
// Инжектирование контроллера прямо в конструкторе виджета.
final inst = GetInstance();
if (builder != null && !inst.isRegistered<T>(tag: tag)) {
inst.put(builder(), tag: tag, permanent: permanent);
}
final c = inst.find<T>(tag: tag);
c.args = args;
}
final String? tag;
final bool permanent;
@override
_StatexWidgetInjectorState<T> createState() =>
_StatexWidgetInjectorState<T>();
}
/// Состояние для [_StatexWidgetInjector]
/// Управляет вызовами [onWidgetInitState], [onWidgetDisposed]
/// и в случае необходимости, удаляет инстанс контроллера
/// из памяти
class _StatexWidgetInjectorState<T extends StatexController>
extends State<_StatexWidgetInjector<T>> {
@override
initState() {
super.initState();
final wc = GetInstance().find<T>(tag: widget.tag);
wc.onWidgetInitState();
}
@override
void dispose() {
final inst = GetInstance();
if (inst.isRegistered<T>(tag: widget.tag)) {
final wc = inst.find<T>(tag: widget.tag);
wc.onWidgetDisposed();
if (!widget.permanent) {
Get.delete<T>(tag: widget.tag);
}
}
super.dispose();
}
@override
Widget build(BuildContext context) => Container();
}
Это StatefulWidget
и поэтому он поддерживает полный жизненный цикл при перестройке дерева, в котором находится, включая initState
и dispose
. Этим и воспользуемся.
Вот как это работает:
Переданный в конструкторе билдер (если есть), создаст нам инстанс контроллера с требуемыми параметрами, или вернет существующий
В ином случае мы попытаемся найти инстанс через
GetInstance.find
Напоследок передадим в инстанс аргументы
Далее, в
_StatexWidgetState.initState
, вызываетсяonInitWidgetState
давая возможность произвести нужные действия в момент инициализации дереваА в
_StatexWidgetState.dispose
мы производим вызовonWidgetDisposed
, и при необходимости удаляем контроллер
StatexView.find - теперь и с инверсией зависимостей
Отдельного разговора заслуживает именованный конструктор StatexView.find.
Передача билдера конкретного типа в конструктор не вписывается в Dependency Inversion. Если необходимо управлять имплементациями, подойдет связка Get.lazyPut<Some>(()=>SomeImpl) + StatexView.find()
.
Это работает так
// [1.]
// Где-то в инжекторе определяется имплементация интерфейса,
// например, вот так
Get.lazyPut<HomePageController>(
() => HomePageControllerImpl(),
fenix: true,
);
// или так
Get.lazyPut<HomeSubPageController>(
() => HomeSubPageControllerImpl(),
tag: HomeSubPageController.someTagForFindStrategy,
fenix: true,
);
// [2.]
// В конструкторе виджета идет обращение к StatexView.find
// вот так
HomePage({Key? key})
: super.find(
markAsPermanent: true,
key: key,
);
// или так
HomeSubPage({Key? key})
: super.find(
key: key,
tag: HomeSubPageController.someTagForFindStrategy,
);
Этого достаточно, чтобы произошел поиск инстанса, создание нужной имплементации в случае неоходимости, и все заверте всего остального цикла работы.
Подготовка контроллера
Чтобы все окончательно заработало, на стороне контролллера введен тип StatexController
abstract class StatexController extends GetxController {
final _args = <String, dynamic>{};
Map<String, dynamic> get args => _args;
set args(Map<String, dynamic> value) => _args.assignAll(value);
/// Вызывается в момент [_StatexWidgetState.initState].
/// Таким образом можно отлавливать момент перехода на страницы
/// в [PageView], например
void onWidgetInitState() {}
/// Вызывается в момент [_StatexWidgetState.dispose].
void onWidgetDisposed() {}
}
Конкретно инжектирования там касается только два метода-события, которые вызываются из StatexWidget
в нужное время.
Как собрать все это воедино
Унаследовать контроллер от
StatexController
. Если есть необходимость, переопределить нужные методыУнаследовать
Widget
отStatexWidget
В конструкторе вызвать конструктор суперкласса
либо основной - для простого инжектирования
либо
super.find
для связки с ленивой инициализацией. Имеет смысл только для инверсии зависимостей
Вместо
build
реализоватьbuildWidget
Профит
Полный набор классов доступен в gist
Пример использования находится здесь (ветка без Statex*, ветка с использованием Statex*)
PS (от 26 июля)
Сегодня закрыли мой issue насчет Get.bottomSheet
по поводу отсутствия dispose
, так что возможно, что для этого кейса вопрос уже решен (будем проверять). Однако описываемое в статье решение позволяет использовать его универсально для любых виджетов, имеющих детерминированный жизненный цикл.