ATL-2026-008package_reviewexperimental

Пользователь вышел. Что осталось жить в get_it?

Что закрытие области get_it делает с сохранёнными ссылками, асинхронной подготовкой и ошибками освобождения. Четыре примера с кодом показывают границы завершения пользовательской сессии.

Опубликовано
18 сентября 2026 г.
Проверено
14 сентября 2026 г.

Пользователь вышел. Что осталось жить в get_it?

Кнопка «Выйти» редко выглядит сложной частью приложения. Убрать пользовательские данные, закрыть подключения, показать экран входа. Если зависимости пользователя собраны в отдельную область, значительная часть работы вообще сводится к одному вызову: await getIt.popScope().

Удобное решение. Особенно пока все объекты успели создаться, а их закрытие проходит без ошибок.

Теперь пользователь нажимает «Выйти» чуть раньше — пока один из сервисов ещё готовится к работе. Область закрывается, но создание сервиса продолжается. Через некоторое время готовый объект возвращается туда, откуда его запросили. Только пользователь к этому моменту уже вышел.

Кто должен закрыть этот объект? И почему он вообще появился после закрытия области?

Чтобы разобраться, достаточно нескольких небольших примеров с get_it 9.2.1. Они показывают не абстрактный «жизненный цикл зависимостей», а вполне конкретные вещи: какой объект приложение получает, когда он готов и что происходит при выходе. Начать стоит с обычного сценария — иначе необычный просто не с чем сравнивать.

Гость, пользователь и одна зависимость

Предположим, приложению нужен сервис для работы с данными. До входа он работает в гостевом режиме, после — от имени пользователя. Тип сервиса один, объекты разные.

Для примеров вместо настоящего подключения используется небольшой Resource. У него есть имя и признак закрытия. Имя поможет отличить гостевой объект от пользовательского, а closed — увидеть, вызывался ли код освобождения. Никаких скрытых методов за этим классом нет:

import 'dart:async';
import 'package:get_it/get_it.dart';

class Resource {
  Resource(this.name);

  final String name;
  bool closed = false;
}

Дальше — отдельные примеры с этим классом; каждый выполняется внутри своей async-функции. Первый создаёт контейнер и регистрирует гостевой объект:

final services = GetIt.asNewInstance();
services.registerSingleton<Resource>(Resource('guest'));

print(services<Resource>().name); // guest

services.pushNewScope(scopeName: 'session');
services.registerSingleton<Resource>(
  Resource('user'),
  dispose: (resource) { resource.closed = true; },
);

final retained = services<Resource>();
print(retained.name); // user

await services.popScope();

print(services<Resource>().name); // guest
print(retained.name);            // user
print(retained.closed);          // true

registerSingleton сохраняет один объект, который контейнер будет возвращать при обращениях по типу Resource. pushNewScope открывает поверх существующих регистраций новую область — scope. В ней можно зарегистрировать другой объект того же типа: пока область открыта, поиск найдёт именно его. Гостевой объект при этом не заменяется и не уничтожается.

При закрытии области контейнер вызывает переданную функцию dispose и убирает пользовательскую регистрацию. Следующий поиск снова возвращает guest. Для переключения зависимостей всё сработало.

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

Это ещё не утечка памяти. Возможно, ссылка нужна, чтобы дочитать результат уже начатой операции. Однако пользоваться закрытым объектом так, будто ничего не произошло, — отдельное решение, которое get_it за приложение не принимает. В настоящем сервисе нужно определить, какие обращения после закрытия допустимы, а какие должны отклоняться. В примере флаг лишь показывает границу; никакой защиты он сам по себе не даёт.

А если закрытие занимает время?

Установить closed = true легко. Настоящему сервису перед закрытием может потребоваться закончить запись или дождаться остановки своей работы. Именно поэтому popScope() возвращает Future: функция освобождения может быть асинхронной.

Для проверки не нужна настоящая медленная запись. Достаточно остановить dispose в известном месте и разрешить ему продолжить позже. В Dart это удобно сделать через Completer: у него есть future, которого можно ждать, и complete(), которым это ожидание завершается.

Здесь disposalStarted сообщает, что закрытие началось, а allowDisposal удерживает его до нужного момента:

final services = GetIt.asNewInstance();
final disposalStarted = Completer<void>();
final allowDisposal = Completer<void>();

services.registerSingleton<Resource>(Resource('guest'));
services.pushNewScope(scopeName: 'session');
services.registerSingleton<Resource>(
  Resource('user'),
  dispose: (resource) async {
    disposalStarted.complete();
    await allowDisposal.future;
    resource.closed = true;
  },
);

var logoutCompleted = false;
final logout = services.popScope().then((_) {
  logoutCompleted = true;
});

await disposalStarted.future;
print(logoutCompleted);           // false
print(services<Resource>().name); // user

allowDisposal.complete();
await logout;
print(services<Resource>().name); // guest

Пока функция освобождения ждёт, выход из области не завершён. Более того, поиск всё ещё возвращает пользовательский объект. Только после окончания dispose контейнер убирает область и снова выдаёт гостевой сервис.

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

Впрочем, наличие await тоже не означает, что завершилось абсолютно всё, относящееся к сессии. Он ждёт конкретную операцию. Следующий пример как раз показывает, что осталось за её пределами.

Сервис готов. Только он уже не нужен

Иногда объект нельзя создать сразу: сначала нужно получить настройки или подготовить подключение. Для этого в get_it есть registerSingletonAsync. Вместо готового объекта ему передаётся асинхронная функция создания — фабрика.

Получать такую зависимость обычным get до её готовности рано. getAsync позволяет дождаться объекта, а allReady — готовности ожидаемых регистраций. Но ни один из этих методов по своему назначению не отвечает на вопрос, нужен ли результат всё ещё текущему пользователю.

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

final services = GetIt.asNewInstance();
final creationStarted = Completer<void>();
final allowCreation = Completer<void>();
var disposalCalls = 0;

services.pushNewScope(scopeName: 'session');
services.registerSingletonAsync<Resource>(
  () async {
    creationStarted.complete();
    await allowCreation.future;
    return Resource('late-user');
  },
  dispose: (resource) {
    disposalCalls++;
    resource.closed = true;
  },
);

final pendingResource = services.getAsync<Resource>();
final pendingReadiness = services.allReady();
await creationStarted.future;

await services.popScope();
print(services.isRegistered<Resource>()); // false

allowCreation.complete();
final lateResource = await pendingResource;
await pendingReadiness;

print(lateResource.name);   // late-user
print(lateResource.closed); // false
print(disposalCalls);       // 0

Порядок важен. Сначала приложение запрашивает объект, затем закрывает область и лишь после этого получает результат. Весь этот порядок виден в коде; за выводом нет предположения о скорости сети или случайно подобранной задержки.

popScope() завершился, регистрации больше нет. Но уже запущенная фабрика продолжила работу и вернула объект. Функция dispose для него не была вызвана. Ожидание, полученное через allReady до закрытия области, также завершилось после возврата фабрики — оно не стало подтверждением действующей сессии.

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

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

Эти варианты не становятся проверенными решениями от перечисления в статье. В примере показана сама проблема, а не универсальный механизм её устранения. Но теперь понятно, где его искать: в коде, который запускает подготовку сессии и принимает результат, а не в надежде, что удаление записи остановит всё связанное с ней выполнение.

Закрытие сломалось. Повторить?

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

Проверить это можно без асинхронной фабрики. Достаточно двух объектов в области. Один из них закрывается обычным образом, второй вместо закрытия выбрасывает исключение:

final services = GetIt.asNewInstance();
var firstDisposed = false;

services.registerSingleton<Resource>(Resource('guest'));
services.pushNewScope(scopeName: 'session');
services.registerSingleton<Resource>(
  Resource('first'),
  instanceName: 'first',
  dispose: (_) { firstDisposed = true; },
);
services.registerSingleton<Resource>(
  Resource('user'),
  dispose: (_) { throw StateError('Disposal failed'); },
);

try {
  await services.popScope();
} on StateError {
  print('First attempt failed');
}

print(firstDisposed);                // false
print(services.hasScope('session')); // true

await services.popScope(); // Completes without an error.

print(services.hasScope('session')); // true
print(services<Resource>().name);    // user

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

В версии 9.2.1 объекты освобождаются в обратном порядке регистрации. Поэтому первым вызывается обработчик user. Он выбрасывает исключение, до обработчика first выполнение не доходит. Область остаётся открытой — первый вызов честно сообщил об ошибке.

Удивляет следующий вызов. Он заканчивается без ошибки, но область по-прежнему существует, а поиск возвращает user. Повтор не продолжил незавершённое освобождение.

В реализации popScope перед началом работы область отмечается как закрывающаяся. Если этот признак уже установлен, следующий вызов сразу возвращается. Само удаление области стоит после освобождения объектов. Исключение прервало первый вызов между этими действиями; второй увидел установленный признак и ничего не сделал.

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

Это поведение конкретной версии при показанном отказе. Из него не следует, что нужно отказаться от get_it, а любую ошибку освобождения лечить сбросом всего контейнера. Следует другое: отказный путь выхода нуждается в собственной проверке. Особенно если после него приложение разрешает вход следующему пользователю.

Что должно означать «пользователь вышел»

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

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

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

Эксперимент ArkTelos Lab нужен для продолжения этого разбора: изменить порядок действий, добавить своё условие отказа, повторить проверку на другой версии. Код ключевых ситуаций уже приведён выше; изучать устройство стенда ради понимания статьи не требуется. Именно такой способ проверки полезен и при применении ArkTelos: сначала установить, что инструмент действительно делает на границе сценария, а затем решать, чего не хватает приложению.

О проверке

Примеры адаптированы из эксперимента с get_it 9.2.1; сокращённые варианты проверены отдельно. Основной стенд содержит восемь случаев и сохранённые результаты четырёх запусков на Dart 3.12.2, macOS arm64: по два с assertions и без. Среди дополнительных проверок — идентичность объектов, порядок освобождения и ошибка асинхронного создания.

Здесь нет реальной авторизации, сети или замеров памяти. closed — признак тестового объекта, не доказательство закрытого соединения. Поздний объект после наблюдения закрывается вручную кодом стенда; после намеренной ошибки стенд очищает контейнер без повторного вызова обработчиков. Это уборка теста, не предложенное исправление. Все возможные порядки событий и асинхронный отказ dispose отдельно не проверялись.


ArkTelos Lab | Сайт ArkTelos

Каналы: Lab RU | Lab EN | ArkTelos RU | ArkTelos EN.

Границы результата

  • get_it 9.2.1, Dart 3.12.2, macOS arm64. Восемь случаев, четыре отдельных процесса: по два с assertions и без. Искусственные ресурсы, без сети, Flutter, реальной авторизации и замеров памяти. Не проверены все порядки событий и асинхронный отказ dispose. Уборка стенда не является исправлением.

CODE / DATA / AGENTS

Связанные эксперименты

experimentget-it-lifecycle-trial

Проверить сохранённые ссылки, ожидание освобождения, позднюю фабрику и повтор после отказа.

get_it 9.2.1, Dart 3.12.2, macOS arm64. Восемь случаев, четыре отдельных процесса: по два с assertions и без. Искусственные ресурсы, без сети, Flutter, реальной авторизации и замеров памяти. Не проверены все порядки событий и асинхронный отказ dispose. Уборка стенда не является исправлением.

Открыть паспорт эксперимента →

Версия в Telegram

Читать в ArkTelos Lab

Читать в ArkTelos Lab ↗

КАНАЛЫ ARKTELOS

Новости и инженерные материалы — без смешивания языков.

Основные каналы рассказывают о развитии ArkTelos. ArkTelos Lab публикует архитектурные разборы, эксперименты и воспроизводимые исследования.