Для больших проектов не всегда хватает одного модуля: иногда надо пошарить какие‑нибудь классы или UI‑элементы. Так со временем проект дробится на много мелких модулей, которые так или иначе связаны разного рода зависимостями. И если за этим не следить, то рано или поздно структура проекта может стать запутанной.

Наша команда разрабатывает приложение Яндекс Про, которым пользуются водители и курьеры. Из‑за многомодульности разные команды использовали разные подходы к связыванию зависимостей — в основном это getIt, injectable и riverpod. Но у всех решений были свои недостатки. К тому же то, что в одном проекте используется несколько подходов, сильно усложняло взаимодействие команд.

Сложности подтолкнули нас к поиску собственного решения, которое удовлетворяло бы всем нашим требованиям. И теперь мы готовы поделиться им с комьюнити: наше решение — это группа библиотек yx_scope.

Расскажу, зачем нужны скоупы и как они работают, в чём преимущества нашего фреймворка и как его использовать.

Что такое yx_scope

Прежде всего я уточню, что именно мы называем скоупом.

Скоуп — это контейнер с набором зависимостей, который «живёт» только определённое время. Создание и удаление отдельного скоупа происходит по заранее описанным условиям в процессе работы приложения. Причём в приложении может существовать несколько скоупов, и у всех может быть разный жизненный цикл.

Скоупы — явление не новое. По сути, они в том или ином виде представлены во многих других библиотеках для работы с зависимостями: в get_it есть возможность пушить скоупы, как и в injectable, в riverpod похожую идею отражает ProviderContainer, а в provider скоупы органично появляются сами собой при использовании его в разных частях приложения.

Но ни одно из этих решений не подходило нам по разным причинам. get_it — не compile‑safe, в injectable мешает кодогенерация, riverpod сильно привязан к инструментам библиотеки, а provider предназначен только для Flutter.

Если сформулировать все требования, которые мы предъявляли к желаемому решению, то получим следующий список:

  • чистый Dart;

  • DI‑like — не статика и не ServiceLocator;

  • отсутствие кодогенерации;

  • возможность встраивать DI‑контейнер в виджеты;

  • нереактивное дерево зависимостей;

  • декларативное описание дерева зависимостей;

  • однозначные поведение и жизненный цикл зависимостей в контейнерах;

  • возможность делать скоупы любой вложенности;

  • поддержка асинхронных зависимостей и их инициализации;

  • compile‑safe‑доступ к зависимостям;

  • compile‑safe‑проверка на существование активных скоупов;

  • compile‑safe‑защита от циклических зависимостей.

В итоге у нас появился yx_scope — DI‑фреймворк, который помогает упорядочить работу со скоупами. Он состоит из трёх библиотек:

  • yx_scope — ядро реализации фреймворка, его «движок»;

  • yx_scope_flutter — библиотека‑адаптер, позволяющая встраивать контейнеры yx_scope в дерево виджетов;

  • yx_scope_linter — набор кастомных lint‑правил, дополнительно защищающих от ошибок при работе с yx_scope.

В чём особенности нашего решения:

  • Compile‑safety. Если что‑то может быть написано и скомпилировано — это будет корректно работать.

  • Простота. В базовых ситуациях yx_scope практически не отличается от других известных решений для управления зависимостями. К тому же у него понятный синтаксис и предсказуемое поведение.

  • Масштабируемость. При необходимости скоупы легко можно создавать, связывать друг с другом и изменять, пряча всю реализацию DI за абстракциями.

  • Чистый Dart, но Flutter‑friendly. DI‑контейнер не зависит от UI, но при этом легко интегрируется в него, используя привычные для Flutter паттерны.

Далее я покажу, как именно работает yx_scope. Начнём с простого примера и постепенно перейдём к тому, чем именно полезен наш фреймворк.

Ключевые понятия

Пока мы не сильно углубились в детали библиотеки, пройдёмся по основным сущностям, которые встретим дальше.

Dep (зависимость) — контейнер для одного конкретного инстанса любой вашей сущности.

ScopeContainer (контейнер скоупа) — это изолированный непересекающийся набор зависимостей, объединённых по смыслу областью видимости и имеющих общий жизненный цикл.

Скоуп объединяет зависимости (а точнее, их Dep), которые должны быть доступны в рамках какого‑то смыслового этапа работы приложения, ограниченного во времени. Например, AppScope — это скоуп, существующий на протяжении жизни всего приложения. А вот AccountScope — это скоуп, который появляется только при авторизации пользователя и исчезает, когда происходит выход из аккаунта.

ScopeContainer может быть закрыт некоторым публичным интерфейсом, и тогда оказываются скрыты детали реализации и доступ к Dep. В этом случае у интерфейса будет суффикс Scope (например, AccountScope). Далее я буду использовать слово «скоуп» для обоих этих вариантов реализации: SomeScopeContainer либо SomeScope — как интерфейс к SomeScopeContainer.

ScopeHolder (хранилище состояния скоупа) — инстанс, хранящий актуальное состояние скоупа и отвечающий за его создание и удаление с помощью методов create и drop.

Изначально ScopeHolder содержит состояние null. После вызова метода create появляется скоуп — контейнер с зависимостями, доступ к которому можно получить через ScopeHolder. После метода drop все зависимости скоупа удаляются — ScopeHolder снова начинает содержать null.

За счёт null‑safety ScopeHolder обеспечивает compile‑safe‑проверку существования скоупа непосредственно в момент написания кода, а не в рантайме.

Когда скоуп не нужен

Кстати, то, что логика yx_scope строится вокруг понятия скоупа, не означает, что скоупы нужно создавать на всё подряд. Грамотная организация структуры и иерархии скоупов — ключевой залог удобства работы с фреймворком.

Отдельный скоуп следует выделять только в случае, если у жизненного цикла какой‑то группы зависимостей есть понятные условия начала и завершения их существования, а ещё если они отличаются от жизненных циклов других скоупов. То есть начало может не совпадать ни с одним другим скоупом, и конец также может не совпадать с другим скоупом.

Если вы не понимаете, какое конкретное условие должно произойти в приложении, чтобы контейнер с нужным набором зависимостей появился (и потом по конкретному условию завершился), — выделять отдельный скоуп не нужно.

Если и начало, и конец жизненного цикла группы зависимостей совпадает с началом и концом другого скоупа — выделять отдельный скоуп опять же не нужно. Такую группу зависимостей можно расположить внутри уже существующего скоупа, объединив их с помощью ScopeModule — о нём будет упомянуто ниже.

Начало работы: собираем зависимости

Итак, соберём простейший контейнер зависимостей. Для начала добавим yx_scope в pubspec.yaml.

yx_scope: ^1.0.0

Создадим файл app_scope.dart и добавим туда описание нашего контейнера и зависимостей.

class AppScopeContainer extends ScopeContainer {

  late final routerDelegateDep = dep(() => AppRouterDelegate());

  late final appStateObserverDep = dep(
    () => AppStateObserver(
      routerDelegateDep.get,
    ),
  );
}

В том же файле app_scope.dart (или в отдельном) добавляем описание ещё одного класса — ScopeHolder.

class AppScopeHolder extends ScopeHolder<AppScopeContainer> {
  @override
  AppScopeContainer createContainer() => AppScopeContainer();

}

Теперь создадим AppScopeHolder, потом создадим контейнер и получим доступ к зависимостям.

void main() {
  final appScopeHolder = AppScopeHolder();

  await appScopeHolder.create(); // В этот момент у холдера вызывается createContainer, и у контейнера инициализируются асинхронные зависимости (подробнее о них ниже).

  final AppStateObserver? appStateObserver = appScopeHolder.scope?.appStateObserverDep.get;
}

Если добавить проверку на существование скоупа, то мы сможем использовать зависимости без необходимости проверять их на null.

void main() {
  final appScopeHolder = AppScopeHolder();

  await appScopeHolder.create();

  final appScope = appScopeHolder.scope;

  if(appScope != null) {
    // Обратите внимание: теперь AppStateObserver уже non-nullable тип.
    final AppStateObserver appStateObserver = appScope.appStateObserverDep.get;
  }
}

Важная особенность библиотеки в том, что мы работаем с DI‑контейнером без привязки к UI. DI‑контейнер первичен, и уже в дополнение его можно прикрепить к UI.

DI‑контейнер создаётся как реакция на событие в логике работы приложения. Да, вы всё ещё можете создавать контейнер в результате появления какого‑то элемента интерфейса на экране, но я бы всё же рекомендовал всегда пытаться понять первопричину появления скоупа. Например, если в приложении открылась главная страница с лентой пользователя, то первопричина — это то, что пользователь успешно авторизовался. Именно это породило создание скоупа, а открытие главного экрана — это уже следствие.

Не UI порождает скоупы, а скоупы порождают UI. Это важный принцип, на котором строится механика работы yx_scope.

Дерево скоупов

Есть два варианта, как скоупы могут относиться друг к другу:

  • Сиблинги. Скоупы, которые находятся на одном уровне вложенности и в одной области видимости.

  • Родитель — ребёнок. Один скоуп выступает родительским, а второй — дочерним. Такие отношения характеризуются двумя ключевыми особенностями:

    • ScopeHolder дочернего скоупа находится внутри ScopeContainer родительского скоупа.

    • Дочерний скоуп не может существовать, если не существует родительский скоуп. Из этого следует, что при закрытии родительского скоупа автоматически закрываются все его дочерние скоупы. Однако при открытии родительского скоупа дочерние скоупы могут открываться позже, при достижении определённых условий.

На схеме скоупы‑сиблинги — это скоупы одного цвета. Синий скоуп — родитель для голубых Account и Register. AccountScope — родитель для зелёных Online, Order и Map. Зелёный MapScope — родитель для фиолетового MapNavigationScope. При этом он не является сиблингом для дочернего скоупа, например, от OrderScope, хоть они и находятся на одном уровне.

Как это работает на практике:

  • AppScope существует всё время жизни приложения.

  • AccountScope существует в рамках AppScope. Он появляется, только когда пользователь залогинился, и пропадает, когда он вышел из аккаунта.

  • RegisterScope существует в рамках AppScope. Он появляется, когда пользователь переходит на регистрацию. Жизненные циклы AccountScope и RegisterScope никак не пересекаются между собой. Более того, в будущем мы можем решить, что пользователь может попасть на сценарий новой регистрации, будучи уже авторизованным, — тогда жизненные циклы этих скоупов будут пересекаться, но они всё ещё не зависят друг от друга, а значит, это скоупы‑сиблинги.

  • OnlineScope, OrderScope и MapScope могут существовать, только если пользователь авторизован и находится в AccountScope. При этом жизненные циклы этих трёх скоупов полностью самостоятельны и не связаны между собой. OnlineScope появляется, когда пользователь готов выполнять заказы, OrderScope появляется, когда пользователь получил заказ, а MapScope появляется, когда пользователь перешёл на экран карты. И все эти скоупы должны быть закрыты, если пользователь вышел из аккаунта — то есть AccountScope закрылся.

  • MapNavigationScope — навигация на карте может выполняться, только когда открыта карта и есть MapScope. При этом карта может быть открыта и без навигации, то есть начало жизненного цикла MapNavigationScope отличается от начала MapScope. Поэтому мы отделяем этот скоуп.

В коде дерево скоупов будет выглядеть так (покажу на примере AppScope):

class AppScopeContainer extends ScopeContainer {

  // Дочерний скоуп AppScope — это просто вложенный ScopeHolder другого скоупа.
  late final accountScopeHolderDep = dep(() => AccountScopeHolder(this));

  late final registerScopeHolderDep = dep(() => RegisterScopeHolder(this));

  late final routerDelegateDep = dep(() => AppRouterDelegate());

  late final appStateObserverDep = dep(
    () => AppStateObserver(
      routerDelegateDep.get,
      accountScopeHolderDep.get,
      registerScopeHolderDep.get,
    ),
  );

}

Но как понять, в каких отношениях должны быть два скоупа? Проверьте их по следующим пунктам:

  1. Если у двух групп зависимостей разные жизненные циклы, которые могут пересекаться во времени в любом порядке (или не пересекаться вовсе), — нужно два скоупа‑сиблинга.

  2. Если у двух групп зависимостей разные жизненные циклы, но один из них может существовать только строго в рамках жизненного цикла другого — первый скоуп в данном случае будет ребёнком, а второй — родителем.

Типы скоупов

Тип скоупа зависит от двух критериев:

  • есть ли у скоупа данные, без которых он не может существовать;

  • существует ли скоуп, без которого новый скоуп не может существовать.

Комбинация ответов на эти вопросы приведёт нас к одному из четырёх типов:

  • «Нет» + «Да» (зелёный) → ChildScopeContainer + ChildScopeHolder

  • «Да» + «Да» (зелёный + синий) → ChildDataScopeContainer + ChildDataScopeHolder

  • «Нет» + «Нет» (прозрачный) → ScopeContainer + ScopeHolder

  • «Да» + «Нет» (синий) → DataScopeContainer + DataScopeHolder

Чтобы было понятнее, рассмотрим несколько примеров из предыдущей схемы с деревом скоупов:

  • AppScope — это ScopeContainer без родителя и входных данных. В нашем примере это так, но вы вполне можете обогатить данными даже корневой скоуп.

  • AccountScope — это ChildDataScopeContainer. У него есть родитель и обязательные данные — аккаунт пользователя.

  • OnlineScope — ChildScopeContainer, потому что есть родитель, но нет обязательных данных для его существования.

Для чего нужна такая классификация скоупов? Для compile‑safety. Работая внутри конкретного скоупа, вы получаете гарантию существования его родителя и наличия необходимых данных. Например, все зависимости внутри OrderScope гарантированно работают с non‑null‑данными о конкретном заказе без всяких дополнительных проверок: если скоуп и зависимости существуют и они выполняют какие‑то операции, значит, этот конкретный заказ точно существует. Также вы можете обращаться к родительскому скоупу и использовать его зависимости внутри дочернего скоупа.

ScopeModule

Если жизненные циклы двух групп зависимостей полностью совпадают, но при этом зависимости отличаются по смыслу и хочется их отделить друг от друга, то правильным решением будет использовать ScopeModule.

ScopeModule — это сервисный класс, который позволяет объявить несколько зависимостей в одном месте, в рамках одного скоупа. Например:

class AppScopeContainer extends ScopeContainer {

  late final accountScopeHolderDep = dep(() => AccountScopeHolder(this));

  late final registerScopeHolderDep = dep(() => RegisterScopeHolder(this));

  // Собрали в модуль все зависимости, относящиеся к роутингу в приложении.
  late final routingModule = RoutingAppScopeModule(this);

}

class RoutingAppScopeModule extends ScopeModule<AppScopeContainer> {

  RoutingAppScopeModule(super.container);

  late final routerDelegateDep = dep(() => AppRouterDelegate());

  late final appStateObserverDep = dep(
    () => AppStateObserver(
      routerDelegateDep.get,
      // Можем обратиться к зависимостям из контейнера.
      container.accountScopeHolderDep.get,
      container.registerScopeHolderDep.get,
    ),
  );

}

Таким образом мы можем аккуратно распределить зависимости по смыслу, не создавая дополнительных дочерних скоупов и не увеличивая вложенность там, где этого делать не нужно.

Асинхронные зависимости

Существуют зависимости, которые, помимо инстанцирования, требуют дополнительной инициализации, часто асинхронной. Они особым образом объявляются в контейнере, но главное — их нужно обязательно указывать в очереди инициализации для данного скоупа.

class AppScopeContainer extends ScopeContainer {

  @override
  List<Set<AsyncDep>> get initializeQueue => [
        {
          appStateObserverDep,
        }
      ];

  late final routerDelegateDep = dep(() => AppRouterDelegate());

  late final appStateObserverDep = rawAsyncDep(
    () => AppStateObserver(
      routerDelegateDep.get,
    ),
    init: (dep) async => dep.init(), 
    dispose: (dep) async => dep.dispose(),
  );

}

initializeQueue — это очередь инициализации, состоящая из списка множеств зависимостей.

Все зависимости из одного множества инициализируются параллельно (через Future.wait([])). Зависимости, находящиеся в разных множествах, инициализируются последовательно: сначала инициализируются все зависимости внутри множества с меньшим индексом внутри списка, потом — с большим индексом.

Помещать в один сет можно зависимости, которые не зависят друг от друга и могут проинициализироваться параллельно без вреда друг для друга. Если же одна зависимость используется при инициализации другой, тогда их нужно расположить последовательно в два разных сета: сначала первую и затем вторую.

Инициализация всего скоупа заканчивается тогда, когда проинициализированы все асинхронные зависимости. Именно поэтому метод create() — асинхронный.

Есть другой вариант сделать зависимость асинхронной — реализовать интерфейс AsyncLifecycle с методами init/dispose. Для объявления такой зависимости можно использовать метод asyncDep.

При этом асинхронность методов и колбэков init/dispose — обязательная. Это сделано намеренно, даже для случаев, когда зависимость может быть проинициализирована синхронно. Обязательная асинхронность колбэков позволяет гарантировать (с помощью правила линтера unawaited_future), что асинхронные вызовы внутри них точно не будут выполнены без await.

Если же вы уверены, что зависимость может быть проинициализирована уже после того, как скоуп появился и готов к работе, то можете явно не вызывать инициализацию через await (и игнорировать линтер //ignore: unawaited_future).

Интерфейс скоупа

До сих пор мы обращались непосредственно к реализации ScopeContainer. Это быстро и удобно, но повышает связанность кода. По факту, любой скоуп, на каком бы уровне вложенности он ни оказался, может по цепочке обратиться к любой другой части дерева скоупов. Это создаёт очень высокую, запутанную и опасную связанность между скоупами и их зависимостями.

Более гибким и расширяемым вариантом работы с контейнерами скоупов будет использование интерфейсов от базового интерфейса Scope — об этом как раз упоминалось в начале в разделе «Ключевые сущности».

Давайте рассмотрим пример для OnlineScope. Напомню, что этот скоуп появляется, когда пользователь выбирает в приложении, что он готов получать заказы:

abstract class OnlineScope implements Scope {

  AcceptOrderManager get acceptOrderManager; // менеджер, через который можно принимать входящие заказы

}

class OnlineScopeContainer extends ChildScopeContainer<AccountScopeContainer> implements OnlineScope {

  OnlineScopeContainer({required super.parent});

  late final _acceptOrderManagerDep = dep(() => AcceptOrderManager(
        parent.ordersStateHolderDep,
      ),
  );

  @override
  AcceptOrderManager get acceptOrderManager => _acceptOrderManagerDep.get;

}

Обратите внимание: теперь OnlineScopeContainer реализует интерфейс OnlineScope, который требует переопределить геттер для AcceptOrderManager. Мы реализуем его в контейнере, возвращая значение из зависимости _acceptOrderManagerDep. И её можно сделать приватной — за пределами файла скоупа, напрямую этим полем Dep не будет пользоваться никто.

Сделаем ещё одну доработку в OnlineScopeHolder:

// Теперь наследуемся от BaseChildScopeHolder.
class OnlineScopeHolder extends BaseChildScopeHolder<OnlineScope, OnlineScopeContainer, AccountScopeContainer> {

  OnlineScopeHolder(super.parent);

  @override
  OnlineScopeContainer createContainer(AccountScopeContainer parent) =>
      OnlineScopeContainer(parent: parent);

}

Теперь OnlineScopeHolder наследуется от BaseChildScopeHolder. Это позволяет нам в качестве первого дженерика указать интерфейс. А это, в свою очередь, полностью скрывает контейнер от потребителей: теперь при обращении к onlineScopeHolder.scope мы будем получать интерфейс OnlineScope и работать именно с ним, а не с самим контейнером. Это позволяет нам полностью скрыть происхождение контейнера от пользователей.

Такую же операцию мы можем проделать со ScopeHolder:

// Этот интерфейс расположим в domain-слое приложения.
abstract class OnlineOrderStateHolder {

  Future<void> toggle();

}

class OnlineScopeHolder extends BaseChildScopeHolder<OnlineScope, OnlineScopeContainer, AccountScopeContainer> implements OnlineOrderStateHolder {

  OnlineScopeHolder(super.parent);

  @override
  OnlineScopeContainer createContainer(OnlineScopeParent parent) =>
      OnlineScopeContainer(parent: parent);

  @override
  Future<void> toggle() async {
    if (scope == null) {
      await create();
    } else {
      await drop();
    }
  }
}

Таким образом, в domain‑слое приложения мы можем работать только с интерфейсом OnlineOrderStateHolder, не задумываясь о его реализации и о том, что это ScopeHolder.

Совет: по возможности старайтесь всегда работать со ScopeHolder и ScopeContainer через интерфейсы. Это значительно уменьшит связанность вашего domain‑слоя и управляющих сущностей DI.

Взаимодействие между скоупами через интерфейсы

Мы защитились от использования сырых реализаций скоупа извне, но при этом сами скоупы всё ещё остались связаны между собой: OnlineScopeContainer знает, что его родитель AccountScopeContainer. А это значит, что можно получить любую зависимость этого контейнера, в том числе и холдеры других скоупов, добраться до их зависимостей и создать неявную связь между скоупами из совершенно разных частей дерева.

Но такое взаимодействие между скоупами в любом случае необходимо. Например, внутри OrderScope мы можем захотеть запустить навигацию. Каким‑то образом нам нужно связать скоупы на разных уровнях и с разными родителями. Сделать это можно через Scope‑интерфейс родителя.

Давайте продолжим пример с OnlineScope.

abstract class OnlineScope implements Scope {

  AcceptOrderManager get acceptOrderManager;

}

/// Интерфейс, описывающий, какие сущности должен содержать родитель,
/// чтобы удовлетворять ожиданиям дочернего скоупа.
abstract class OnlineScopeParent implements Scope {

  OrderStateHolder get orderStateHolder;

}

/// Теперь ChildScopeContainer типизируется не AccountScopeContainer,
/// а интерфейсом, ожидаемым от родителя.
class OnlineScopeContainer extends ChildScopeContainer<OnlineScopeParent> implements OnlineScope {
  // Зависимости
}

/// ScopeHolder тоже типизируется OnlineScopeParent
class OnlineScopeHolder extends BaseChildScopeHolder<OnlineScope, OnlineScopeContainer, OnlineScopeParent> implements OnlineOrderStateHolder {
  // Реализация
}

Теперь OnlineScope становится по‑настоящему независимым. Его потребители используют интерфейс OnlineScope, а сам он знает о родителе только тот необходимый минимум, который требуется для работы.

Ну, и, чтобы родительский скоуп удовлетворял предъявленному контракту, мы должны его реализовать.

class AccountScopeContainer extends ChildDataScopeContainer<AccountScopeParent, Account> implements AccountScope, OnlineScopeParent {

  AccountScopeContainer({
    required super.parent,
    required super.data,
  });

  late final _onlineScopeHolderDep = dep(() => OnlineScopeHolder(this));

  late final _orderScopeHolderDep = dep(() => OrderScopeHolder(this));

  late final _mapScopeHolderDep = dep(() => MapScopeHolder());

  @override
  OrderStateHolder get orderStateHolder => _orderScopeHolderDep.get;

}

Здесь AccountScopeContainer уже реализует собственный интерфейс AccountScope, а также зависит от интерфейса родителя AccountScopeParent.

Совет: не возвращайте родительские скоупы транзитивно вниз по интерфейсам. OnlineScopeParent не должен содержать в себе геттер на AccountScopeParent. Это нарушит изоляцию, заставляя скоуп неявно знать о реализации родителя. Если вам нужны какие‑то сущности из родительского скоупа AccountScopeParent, объявляйте их явно в интерфейсе OnlineScopeParent.

Какие же преимущества даёт такой дополнительный бойлерплейт? Если все скоупы организованы строго по правилу описания через интерфейсы, то вы сможете практически безболезненно менять иерархию вашего дерева скоупов. В развивающемся продукте это почти неизбежно, потому что требования и жизненные циклы различных флоу приложения так или иначе могут меняться. При переносе дочернего скоупа от одного родителя к другому от вас потребуется внести изменения только в эти родительские скоупы. Сам дочерний скоуп останется без изменений.

В дереве виджетов

Ядро yx_scope — это чистый Dart. Но скоупы готовы к тому, чтобы встраиваться в виджеты Flutter. Для этого используется пакет yx_scope_flutter — набор виджетов‑адаптеров для ваших ScopeHolder.

Для использования, добавьте библиотеку в pubspec.yaml:

yx_scope_flutter: ^1.0.0

Вот простейший пример передачи корневого скоупа в дерево виджетов:

class App extends StatefulWidget {
  const App({super.key});

  @override
  State<App> createState() => _AppState();
}

class _AppState extends State<App> {

  final _appScopeHolder = AppScopeHolder();

  @override
  void initState() {
    super.initState();
    _appScopeHolder.create();
  }

  @override
  void dispose() {
    _appScopeHolder.drop();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return ScopeProvider(
      holder: _appScopeHolder,
      // Scope-виджеты поддерживают работу со Scope-интерфейсами.
      child: ScopeBuilder<AppScopeContainer>.withPlaceholder(
        builder: (context, appScope) {
          return MaterialApp.router(
            title: 'YxScopedFlutter Demo',
            routerDelegate: appScope.routerDelegateDep.get,
          );
        },
        // Этот виджет будет отображаться, пока [appScopeHolder] инициализируется
        placeholder: const Center(child: CircularProgressIndicator()),
      ),
    );
  }
}

Обратите внимание: AppScopeHolder — это поле внутри стейта вашего App. И это одно из важнейших преимуществ yx_scope: всё дерево скоупов (а по сути, весь ваш DI) состоит из нестатических инстансов ScopeHolder, которые локализуются в соответствии со своим местом в дереве. Инстанс корневого ScopeHolder будет существовать только там, где вы его проинстанцировали. Инстанс любого дочернего ScopeHolder будет существовать, только если существует скоуп его непосредственного родителя. В каком‑то смысле это можно назвать гиперлокальностью скоупов.

Статический анализатор

Ещё одна важная часть yx_scope — набор правил статического анализа с рекомендациями по работе с сущностями фреймворка. Для использования нужно добавить в файл pubspec.yaml в dev_dependencies:

dev_dependencies:
  yx_scope_linter: ^1.0.0
  custom_lint: ^0.5.3

Затем нужно выполнить pub get. После этого в файл analysis_options.yaml добавить:

analyzer:
 plugins:
   - custom_lint

Таким образом линтер поможет не забывать суффикс Dep и создавать поле final, а ещё подсветит непроинициализированные асинхронные зависимости, без какой‑либо кодогенерации найдёт циклические зависимости и даже покажет полный список участвующих в цикле сущностей.

Пока это далеко не все необходимые правила линтера — актуальный список можно посмотреть в yx_scope_linter в README.md. А правила, для которых ещё нет автоматики, можно найти в файле docs/manual_linter.md. Постепенно мы будем переводить указанные ручные правила в автоматику.


Мы рассмотрели основные принципы работы библиотеки yx_scope: от простого глобального контейнера до разделения скоупов через интерфейсы и их взаимодействия между собой.

Более подробный пример приложения с использованием скоупов вы можете найти в репозитории yx_scope_flutter. Помимо прочего, там есть пример создания динамического количества скоупов одного типа — OrderScope под несколько разных заказов.

Если вам интересно послушать про проблемы и причины, которые сподвигли нас на создание yx_scope, смотрите доклад с конференции Dev Day/Night. Но в докладе используется более ранняя версия yx_scope, в которой есть два важных отличия: она называется yx_scoped, а ScopeContainer называется ScopeNode. Пусть это вас не сбивает с толку.

Несмотря на то, что фундамент библиотеки полностью готов к работе, перед нами всё ещё стоит несколько задач:

  • Добавить все необходимые правила линтера в yx_scope_linter.

  • Расширить инструментарий для работы с yx_scope.

  • Добавить библиотеки‑адаптеры с другими популярными решениями: provider, riverpod.

  • Сделать визуализацию дерева скоупов.

  • Настроить мониторинг скоупов в рантайме.

  • Исследовать возможность уменьшить бойлерплейт с помощью макросов.

А сейчас предлагаю попробовать yx_scope и поделиться впечатлениями.

Комментарии (12)


  1. Zore_pinge
    23.10.2024 10:27

    ссылка на ваш репозиторий выдает 404


    1. kltsv Автор
      23.10.2024 10:27

      Спасибо, исправил!


  1. Tishka17
    23.10.2024 10:27

    В "классических" ioc-контейнерах обычно есть три вида зависимостей - singleton, scoped и transient. Когда я делал контейнер под python (https://github.com/reagento/dishka/) я делал линейную иреархию скоупов и меня часто спрашивают зачем это нужно. Подскажите, а зачем нужно дерево скоупов? Насколько это оправдано?


    1. kltsv Автор
      23.10.2024 10:27

      Дерево скоупов имеет смысл, когда в приложении есть группы зависимостей под разные крупные фичи, жизненный цикл которых не зависит друг от друга — может совсем не пересекаться или наоборот накладываться друг на друга. Если такое происходит — линейная иерархия уже не подойдёт, потому что это означает, что одна фича будет зависеть от другой, но с точки зрения бизнес-логики фичи могут совсем не пересекаться.

      Это может быть излишним усложнением для простых проектов, и поэтому я призываю аккуратно относиться к скоупам и не плодить их на всё подряд — действительно могут быть кейсы, где это излишне, и проще обойтись единственным контейнером с зависимостями.

      Но на больших проектах с крупными фичами, каждая из которых может разрабатываться отдельной командой и иметь свой изолированный ЖЦ и набор зависимостей — выделить такую фичу в скоуп, который будет жить на равных правах со своими скоупами-сиблингами из дерева — это достаточно гибкая стратегия.


      1. Tishka17
        23.10.2024 10:27

        Почему в таком случае не иметь просто несколько контейнеров?


        1. kltsv Автор
          23.10.2024 10:27

          Глобально причины две:

          1. Теряется null-safety. Если делать просто плоский набор контейнеров, тогда мы лишаем себя возможности описывать структуру, когда один контейнер должен существовать только в рамках другого. Это можно будет реализовать, но тогда придётся везде либо писать проверку на null для существования родительского скоупа, либо делать force-unwrap, но тогда null-safety полностью теряется.

          2. Теряется возможность автоматически закрывать всё поддерево скоупов. Это нужно будет делать вручную. С деревом скоупов же можно у родительского скоупа сделать drop, и все его дочерние скоупы автоматически закроются вместе с ним.


          1. Tishka17
            23.10.2024 10:27

            Я правильно понимаю, что вы не рассматриваете конкурентные инстансы одного скоупа? В серверной разработке мы можем одновременно обрабатывать много запросов и у каждого будет своя "копия" скоупа со своими коннекшенами к базе DAO и прочим (смотря что реально юзает). Возможна ли такая ситуация на мобилке?

            С закрытием поддерева тоже вопрос - если мы контролируем входы и выходы в скоуп, мы можем безопасно его закрыть сразу при выходе. Если же не контролируем, то потенциально объекты могут использоваться где-то когда мы попытаемся родительский закрыть. Или опять же это не применимо для вашего кейса?


            1. kltsv Автор
              23.10.2024 10:27

              Я правильно понимаю, что вы не рассматриваете конкурентные инстансы одного скоупа? … Возможна ли такая ситуация на мобилке?

              Через yx_scope можно создавать несколько скоупов одного типа, такой пример можно посмотреть в example.

              Да, похожая ситуация возможно, но в контексте скоупов в приложении создание разных скоупов одного и того же типа — это скорее исключительный кейс, чем стандартная ситуация.

              Если же не контролируем, то потенциально объекты могут использоваться где-то когда мы попытаемся родительский закрыть. 

              Контроля в этот момент нет, потому что решение о закрытии родительского скоупа может принимать сущность, которая даже не знает о наличии дочерних.

              И тут, по хорошему, объекты скоупа должны использоваться только когда скоуп доступен — либо внутри контекста этого скоупа (т.е. внутри зависимостей, принадлежащих этому скоупу), либо в UI, который подписан на изменение состояния скоупа.

              Т.е. при закрытии скоупа его зависимости тоже должны перестать использоваться.

              Но да, можно сохранить инстансы зависимостей куда-нибудь в переменные в другом контексте — тогда скоуп-то закроется, а работа с зависимостями может и не прекратиться. Но это пример неправильного использования зависимостей, про это есть правило в мануальном линтере.


  1. FreeDobby
    23.10.2024 10:27

    Такой вопрос: почему нет возможности объявить асинхронную зависимость которая бы инициализировалась асинхронно при создании? Я имею ввиду прямо в билдере вернуть Future. Вместо того чтобы отдельно на созданном объекте вызывать init. Просто есть например объекты сторонних библиотек, которые инициализируются именно так. Как пример могу привести метод Hive.openBox. Я вот например не знаю как переделать создание бокса так чтобы он создался, но не инициализировался.


    1. kltsv Автор
      23.10.2024 10:27

      Если я правильно понял вопрос, то такая возможность существует, достаточно типизировать зависимость нужным типом:

      late final futureTypeDep 
        = dep<Future<Type>>(() => /* зависимость, возвращающая Future */)

      И далее вы обращаетесь в futureTypeDep, при первом обращении фьюча начинает выполняться, и как только результат получен — он будет доступен всем пользователям этой зависимости.

      Другой вопрос, что если есть Future-зависимости, то это значит, что даже в проинициализированном контейнере некоторые зависимости всё ещё, условно, “не готовы”, и их нужно предварительно дождаться. 

      И чтобы решить эту ситуацию и дать возможность подготовить асинхронные зависимости до того, как контейнер станет доступен, мы сделали механизм initializeQueue — тогда инициализация асинхронной зависимости становится частью инициализации скоупа, и к моменту, когда скоуп создан, всего зависимости точно проинициализированы и готовы к работе.

      Но вы правильно заметили, что не все классы внешних библиотек можно инстанцировать, но не инициализировать. Для таких случаев самое простое решение — написать собственный класс-обёртку, у которого есть методы init/dispose, в которых он создаёт и сохраняет нужный инстанс. И этот класс обёртку уже можно безболезненно использовать в DI.

      Да, каждый раз писать такую обёртку — бойлерплейт, поэтому это одна из идей, которую мы уже рассматриваем как один из следующих шагов в yx_scope — добавить в библиотеку такой универсальный класс-обёртку, который можно будет использовать в initializeQueue.


      1. FreeDobby
        23.10.2024 10:27

        Да, я об этом. Что ж, буду следить за прогрессом. Пока вот такое написал:

        class AsyncInitializerDep<T> implements AsyncLifecycle {
          final Future<T> Function() initializer;
          final Future<void> Function(T)? disposer;
          late final T value;
        
          AsyncInitializerDep(this.initializer, {
            this.disposer,
          });
        
          @override
          Future<void> init() async {
            value = await initializer();
          }
        
          @override
          Future<void> dispose() async {
            await disposer?.call(value);
          }
        }

        Вроде можно использовать с asyncDep вместо того чтобы плодить бойлерплейт.

        Подскажите, а я вот не понял как вообще получить зависимость потом? Типа у вас в примере создается холдер, потом асинхронно вызывается на нем метод create, а без этого никак? Вот условно в одном месте приложения я создал этот холдер, а в другом мне надо получить зависимость. Мне что везде прокидывать этот холдер по цепочке? Это же неудобно. Для виджетов вы обертку сделали, а если это не в виджете? Например в каком-то кубите. С классическим подходом я делал просто GetIt.I<MyClass>() и все. Одним вызовом статичной функции я в любом месте проекта получал зависимость. Как с вашей библиотекой это делать я не понял.

        P.S. Выглядит как-то все уж очень запутанно и сложно. GetIt в 100 раз проще)


        1. kltsv Автор
          23.10.2024 10:27

          Пока вот такое написал

          Да, похожее решение я и имел в виду.

          Типа у вас в примере создается холдер, потом асинхронно вызывается на нем метод create, а без этого никак?

          Да, без этого никак, это одна из ключевых особенностей и отличий. Всегда нужно управлять ЖЦ скоупа через холдер. Это позволяет добиться того, что DI — это не какая-то глобальная статическая сущность без ЖЦ и существующая всегда. DI — это контейнер в конкретной области видимости, привязанный к конкретному ЖЦ фичи/процесса.

          Мне что везде прокидывать этот холдер по цепочке? Это же неудобно. Для виджетов вы обертку сделали, а если это не в виджете? Например в каком-то кубите.

          Предполагается, что все зависимости всегда поставляются через конструктор, в том числе и в кубит. Т.е. если кубит должен управлять каким-то скоупом через его холдер, то да, этот холдер должен быть передан в конструктор, причём желательно это должен быть интерфейс, который реализуется холдером. А в конструктор он передаётся внутри ScopeContainer. А ScopeContainer содержит другие зависимости этого же скоупа, и зависимости родительского скоупа — среди этих двух множеств и должен существовать тот холдер, который нужно прокинуть в кубит.

          С классическим подходом я делал просто GetIt.I<MyClass>() и все.

          Да, в GetIt можно просто сделать статический вызов GetIt.I<MyClass>(), но такой вызов чреват многими проблемами, как минимум это сложнее тестировать и контролировать, какие зависимости как друг с другом связываются.

          В принципе похожего поведения можно добиться и в yx_scope, если создать  top-level ScopeHolder, проинициализировать его в main и обращаться к нему через force-unwrap: scopeHolder.scope!.someDep. Тогда можно будет в любом месте делать так же как и GetIt.I<MyClass>(), но все преимущества yx_scope тогда теряют свою пользу.