InquirerEco concerns raised over land conversion in Tarlac Aeta communitiesESPN'Remarkable' return: Daniels seals Falcons win with INT week after scary injuryESPN DeportesJazz toma ventaja temprano frente a LakersThe Jerusalem PostIDF chief pushed back on Netanyahu order for ‘action’ against Iran, fearing political motivesPunchFULL LIST: NDC names Peter Obi, Henry Dickson, others as campaign council membersDaily MaverickANALYSIS: Inside the big shiny plan to change how local government worksZDF heuteAktuelle Pressemitteilungen des ZDFThe South AfricanCats to burps: The Kiffness trolls President Trump [video]SRF NewsGrosser Preis der USA – Antonelli siegt in Austin – doch die WM-Entscheidung ist vertagtNU.nlSven Kockelmann wint Sonja Barend Award voor interview met Dick SchoofNational PostTrump says decision nearing on Iran: deal or ‘finish it off’Rai NewsSerie A, Serie A, Udinese-Milan0-2: i rossoneri agganciano la terza piazza e la Roma
The Daily Newsstand · Free, Always
Sunday, October 25, 2026

Как один UI-баг заставил меня разбираться с графом зависимостей целого приложения

Translate

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

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

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

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

Но обо всём по порядку.

Ну тут явно что-то с обновлением состояния

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

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

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

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

Но довольно быстро выяснилось, что событие приходит, обработчик вызывается и состояние действительно обновляется. Более того, если посмотреть на данные непосредственно в том месте, где они изменяются, всё выглядит правильно.

А интерфейс всё равно не реагирует так, как должен.

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

Как я оказался в глубинах DI

В небольшом приложении подобная история, скорее всего, закончилась бы довольно быстро. Открыл ViewController, посмотрел Presenter или ViewModel, дошёл до сервиса, проверил репозиторий и нашёл место, где теряется актуальное значение.

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

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

ViewController
      |
      v
  Presenter
      |
      v
   Manager A
      |
      v
   Service A
      |
      v
 Repository A
      |
      v
   Manager B
      |
      v
   Service B

Разумеется, это сильно упрощённая схема, потому что в реальности у каждого второго компонента были дополнительные зависимости, некоторые объекты создавались фабриками, другие приходили из контейнеров, а третьи вообще доставались через какие-нибудь provider’ы.

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

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

Самое неприятное заключалось в том, что код нигде не выглядел подозрительно. Никаких очевидных антипаттернов, странных синглтонов или методов, которые забыли вызвать. Обычный dependency injection, вполне понятные интерфейсы и компоненты, каждый из которых выполняет свою задачу.

Например, представим такой менеджер:

final class SomeManager {
    private let repository: RepositoryProtocol

    init(repository: RepositoryProtocol) {
        self.repository = repository
    }

    func update() {
        repository.refresh()
    }
}

Что здесь может быть не так? Менеджер получает зависимость через инициализатор, хранит её в приватном свойстве и использует, когда необходимо. Всё достаточно аккуратно, и если бы я увидел такой код на ревью, вряд ли стал бы задавать какие-то вопросы.

Но, как выяснилось, именно в этой совершенно обычной конструкции и скрывалась проблема.

Точнее, не в самой конструкции, а в предположении, которое мы сделали, когда её использовали.

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

А вот это оказалось неправдой.

Два репозитория, и оба вроде правильные

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

final class StateManager {
    private let repository: RepositoryProtocol

    init(repository: RepositoryProtocol) {
        self.repository = repository
    }

    func performAction() {
        repository.execute()
    }
}

И есть контейнер, который хранит текущий репозиторий и умеет заменять его при изменении состояния приложения:

final class DependencyContainer {
    private(set) var repository: RepositoryProtocol

    init(repository: RepositoryProtocol) {
        self.repository = repository
    }

    func replaceRepository(
        with repository: RepositoryProtocol
    ) {
        self.repository = repository
    }
}

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

При первоначальной сборке графа зависимостей мы создаём контейнер, передаём ему репозиторий, а затем создаём менеджер:

let container = DependencyContainer(
    repository: Repository()
)

let manager = StateManager(
    repository: container.repository
)

Пока всё хорошо. Контейнер хранит репозиторий, менеджер получает ссылку на тот же самый экземпляр, и оба работают с одним объектом.

Но затем пользователь авторизуется, глобальное состояние приложения меняется, и контейнер заменяет репозиторий:

container.replaceRepository(
    with: Repository()
)

И вот здесь начинается самое интересное.

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

Причём с точки зрения Swift здесь вообще ничего неправильного не происходит. Мы создали объект, передали ссылку другому объекту, а потом заменили значение свойства в контейнере. Разумеется, все остальные ссылки на предыдущий экземпляр от этого никуда не исчезли.

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

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

И если посмотреть на методы, типы или результаты отдельных вызовов, это расхождение может быть совершенно незаметным.

Почему я не заметил этого раньше

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

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

Представим, что зависимости распределены примерно так:

                  DI Container
                       |
            +----------+----------+
            |                     |
            v                     v
        Manager A             Factory A
            |                     |
            v                     v
        Service A             Manager B
            |                     |
            v                     v
       Repository A           Service B
            |                     |
            +----------+----------+
                       |
                       v
                 Shared State

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

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

Например, после смены состояния граф может выглядеть уже так:

                Application
                     |
          +----------+----------+
          |                     |
          v                     v
     Manager A              Container
          |                     |
          v                     v
   Repository #1          Repository #2
       (старый)              (новый)

И что особенно неприятно, оба репозитория могут совершенно нормально выполнять свои методы. Старый не обязан падать, возвращать ошибку или каким-то другим образом сообщать, что пользоваться им больше не стоит.

Он просто продолжает работать с тем состоянием, которое получил при создании.

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

Пришлось сравнивать не значения, а сами объекты

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

В Swift для этого есть ObjectIdentifier, который позволяет получить идентификатор конкретного экземпляра ссылочного типа.

print(ObjectIdentifier(repository as AnyObject))

Если протокол ограничен AnyObject, дополнительное приведение не потребуется.

А если хочется посмотреть непосредственно адрес объекта в памяти, можно воспользоваться таким выражением:

po Unmanaged.passUnretained(
    repository as AnyObject
).toOpaque()

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

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

И вот тут наконец всё сложилось.

Контейнер уже возвращал новый репозиторий, а менеджер продолжал использовать старый.

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

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

Ну хорошо, давайте просто пересоздадим менеджер

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

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

Условно это выглядело так:

                  Manager A
                 /         \
                v           v
           Service A     Service B
                |           |
                v           v
           Manager B     Manager C
                \           /
                 v         v
                  Repository

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

В общем, решение технически вполне рабочее, но ради одной зависимости хотелось обойтись без пересборки половины приложения.

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

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

Значит, возможно, мы изначально передали ему не совсем то, что требовалось.

Вместо репозитория передаём способ его получить

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

То есть вместо такого варианта:

final class StateManager {
    private let repository: RepositoryProtocol

    init(repository: RepositoryProtocol) {
        self.repository = repository
    }
}

Получился такой:

final class StateManager {
    private let repositoryProvider: () -> RepositoryProtocol

    init(
        repositoryProvider: @escaping () -> RepositoryProtocol
    ) {
        self.repositoryProvider = repositoryProvider
    }

    func performAction() {
        let repository = repositoryProvider()
        repository.execute()
    }
}

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

При создании это выглядит следующим образом:

let manager = StateManager(
    repositoryProvider: {
        container.repository
    }
)

И если контейнер заменит репозиторий после авторизации, при следующем вызове performAction() менеджер получит уже новый экземпляр.

При этом сам менеджер продолжает жить, его потребителей пересоздавать не нужно, да и вообще остальная часть графа может ничего не знать о том, что внутри контейнера что-то поменялось.

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

Но в нашем случае этого оказалось достаточно.

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

Замыкание ещё не означает, что зависимость будет актуальной

Посмотрим на два практически одинаковых варианта.

Первый:

let repository = container.repository

let manager = StateManager(
    repositoryProvider: {
        repository
    }
)

Вроде бы всё сделали правильно: передали замыкание вместо объекта, менеджер теперь вызывает provider и получает репозиторий.

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

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

А теперь второй вариант:

let manager = StateManager(
    repositoryProvider: {
        container.repository
    }
)

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

Поэтому после замены зависимости менеджер действительно сможет получить новый экземпляр.

Разница минимальная, но с точки зрения жизненного цикла объектов она довольно существенная.

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

Именно второе поведение нам и требовалось.

Правда, теперь нужно следить за другими вещами

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

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

Можно случайно получить retain cycle

Допустим, контейнер хранит менеджер, а менеджер хранит замыкание, которое сильно захватывает контейнер.

Получится вполне классический цикл сильных ссылок:

Container
    |
    v
 Manager
    |
    v
 Closure
    |
    +------> Container

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

Поэтому при передаче provider’ов важно понимать, кто владеет замыканием и какие объекты оно захватывает. Иногда можно использовать слабую ссылку, но тогда придётся явно определить, что делать, если контейнер уже уничтожен. В других случаях удобнее передать отдельный объект-провайдер, жизненный цикл которого не создаёт подобных проблем.

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

Можно получить два разных репозитория внутри одной операции

Есть ещё один момент, который легко упустить.

Представим, что мы сделали вычисляемое свойство:

private var repository: RepositoryProtocol {
    repositoryProvider()
}

И теперь используем его следующим образом:

func performAction() {
    repository.prepare()
    repository.execute()
}

Выглядит нормально, но на самом деле provider здесь вызывается дважды. Если между этими обращениями зависимость успеет измениться, prepare() выполнится на одном экземпляре, а execute() — уже на другом.

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

Поэтому лучше один раз получить актуальную зависимость и дальше работать именно с ней:

func performAction() {
    let repository = repositoryProvider()

    repository.prepare()
    repository.execute()
}

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

Provider не решает проблемы с потоками

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

Замыкание здесь ничем не поможет, потому что само по себе оно не делает чтение и запись общего состояния потокобезопасными.

В зависимости от архитектуры это может быть actor, lock, изоляция на MainActor или другой механизм, который гарантирует согласованность операций.

Главное — не путать возможность получить актуальную зависимость с гарантией того, что её получение безопасно в любой момент времени.

И нет, теперь не нужно везде писать provider

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

Но я бы так делать точно не стал.

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

private let repository: RepositoryProtocol

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

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

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

И что в итоге?

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

Но времени на понимание причины ушло несравнимо больше, чем на саму правку.

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

Только в реальном приложении этот граф не обязательно остаётся неизменным после первоначальной сборки.

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

Поэтому теперь, когда я вижу в каком-нибудь долгоживущем сервисе очередное свойство:

private let repository: RepositoryProtocol

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

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

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

Потому что самое неприятное в таких багах — код действительно может работать совершенно правильно.

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

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.