Pull to refresh

GetX for Flutter. Dependency Injection для частных случаев

Reading time 7 min
Views 13K

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. Этим и воспользуемся.

Вот как это работает:

  1. Переданный в конструкторе билдер (если есть), создаст нам инстанс контроллера с требуемыми параметрами, или вернет существующий

  2. В ином случае мы попытаемся найти инстанс через GetInstance.find

  3. Напоследок передадим в инстанс аргументы

  4. Далее, в _StatexWidgetState.initState, вызывается onInitWidgetState давая возможность произвести нужные действия в момент инициализации дерева

  5. А в _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 в нужное время.

Как собрать все это воедино

  1. Унаследовать контроллер от StatexController. Если есть необходимость, переопределить нужные методы

  2. Унаследовать Widget от StatexWidget

  3. В конструкторе вызвать конструктор суперкласса

    1. либо основной - для простого инжектирования

    2. либо super.find для связки с ленивой инициализацией. Имеет смысл только для инверсии зависимостей

  4. Вместо build реализовать buildWidget

  5. Профит


PS (от 26 июля)

Сегодня закрыли мой issue насчет Get.bottomSheet по поводу отсутствия dispose, так что возможно, что для этого кейса вопрос уже решен (будем проверять). Однако описываемое в статье решение позволяет использовать его универсально для любых виджетов, имеющих детерминированный жизненный цикл.

Tags:
Hubs:
-2
Comments 11
Comments Comments 11

Articles