惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Forbes - Security
Forbes - Security
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
F
Fortinet All Blogs
B
Blog
T
The Blog of Author Tim Ferriss
Engineering at Meta
Engineering at Meta
GbyAI
GbyAI
Y
Y Combinator Blog
Microsoft Azure Blog
Microsoft Azure Blog
L
LangChain Blog
Recent Announcements
Recent Announcements
U
Unit 42
Martin Fowler
Martin Fowler
M
MIT News - Artificial intelligence
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
The Register - Security
The Register - Security
Recorded Future
Recorded Future
C
Check Point Blog
V
V2EX
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
Hugging Face - Blog
Hugging Face - Blog
WordPress大学
WordPress大学
Google DeepMind News
Google DeepMind News
酷 壳 – CoolShell
酷 壳 – CoolShell
F
Full Disclosure
小众软件
小众软件
A
About on SuperTechFans
云风的 BLOG
云风的 BLOG
宝玉的分享
宝玉的分享
Last Week in AI
Last Week in AI
有赞技术团队
有赞技术团队
MongoDB | Blog
MongoDB | Blog
爱范儿
爱范儿
P
Proofpoint News Feed
罗磊的独立博客
量子位
D
Docker
博客园_首页
D
DataBreaches.Net
Project Zero
Project Zero
博客园 - 司徒正美
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
博客园 - Franky
Security Latest
Security Latest
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
N
Netflix TechBlog - Medium
K
KPMG report finds enterprise disconnect between AI and its ROI | CIO
博客园 - 三生石上(FineUI控件)
H
Hackread – Cybersecurity News, Data Breaches, AI and More
大猫的无限游戏
大猫的无限游戏

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Потоко-защищённая шина событий в Swift
Станислав Шияновский · 2026-06-01 · via Все публикации подряд на Хабре

В крупных приложениях для iOS взаимодействие между компонентами зачастую оказывается сложнее, чем сам компонент. Сервис завершает операцию, координатор должен отреагировать, возможно, потребуется обновить несколько экранов, и передача каждой зависимости по всему дереву навигации быстро начинает казаться излишней «рутинной» работой. Внедрение зависимостей и управление состоянием по-прежнему имеют своё место. Шина (данных) событий предоставляет нам ещё один инструмент для слабосвязанных уведомлений, где прямое управление добавило бы ненужную сложность. Цель этого компонента проста: позволить одной части приложения публиковать событие, а другим частям приложения — подписываться на события определенного типа. Реализация сосредоточена на типовой безопасности, потокобезопасном хранении, автоматической очистке при деаллокации владельца, явной отмене отдельных подписок, доставке MainActor для кода пользовательского интерфейса и поддержке AsyncStream для потребителей async/await.

Определение события

Каждое событие, передаваемое по шине, соответствует протоколу с использованием небольших маркеров:

public protocol EventBusEvent: Sendable {}

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

Например:

struct UserDidLoginEvent: EventBusEvent {
    let userID: String
}

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

Тип EventBus

Корневым элементом является класс:

public final class EventBus: @unchecked Sendable {
    private let lock = NSLock()
    private var subscriptionsByEvent: [ObjectIdentifier: [SubscriptionEntry]] = [:]

    public static let `default` = EventBus()

    public init() {}
}

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

Концептуально хранилище выглядит так:

UserDidLoginEvent.self -> [subscription, subscription]
CartDidUpdateEvent.self -> [subscription]

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

Класс помечен как @unchecked Sendable, поскольку безопасность для многопоточного выполнения обеспечивается вручную с помощью NSLock. Компилятор не может полностью проверить безопасность изменяемого словаря, поэтому ответственность за синхронизацию доступа берет на себя реализация.

Компонент также предоставляет общий экземпляр (по-сути, является синглтоном):

public static let `default` = EventBus()

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

Оповещение о событии

API оповещения лаконичен:

public func publish<Event: EventBusEvent>(_ event: Event)

Оповещение начинается с создания ключа на основе типа события:

let eventKey = ObjectIdentifier(Event.self)

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

let receivers: [(Any) -> Void] = lock.withLock {
    guard var bucket = subscriptionsByEvent[eventKey] else {
        return []
    }

    bucket = cleanDeadSubscriptions(in: bucket)
    subscriptionsByEvent[eventKey] = bucket.isEmpty ? nil : bucket
    return bucket.map(\.subscription.receive)
}

После снятия блокировки шина передает событие:

receivers.forEach { $0(event) }

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

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

В случае обычных подписок при выполнении команды publish создается снэпшот, а событие передается синхронно после снятия блокировки.

Подписка на события

Основной API подписки принимает в качестве аргументов владельца и обработчик:

@discardableResult
public func subscribe<Owner: AnyObject, Event: EventBusEvent>(
    owner: Owner,
    to eventType: Event.Type = Event.self,
    handler: @escaping (Owner, Event) -> Void
) -> SubscriptionToken

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

eventBus.subscribe(owner: self, to: UserDidLoginEvent.self) { owner, event in
    owner.handleUserLogin(event)
}

Интерес представляет параметр owner. Подписка связана с объектом, и шина хранит этот объект в режиме слабой ссылки. Когда память объекта owner освобождается, подписка становится неактивной и впоследствии очищается в ходе операций оповещений или подписки.

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

let token = eventBus.subscribe(owner: self) { owner, event in
    owner.handle(event)
}

token.cancel()

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

Явная поддержка владельца

Формат обработчика с учетом владельца — это небольшое, но важное решение в отношении API:

{ owner, event in
    owner.handle(event)
}

Шина слабо захватывает объект владельца и передает в обработчик сильную ссылку только в том случае, если объект-владелец все еще существует.

Это позволяет избежать типичной ситуации, когда для каждой подписки требуется собственный блок [weak self]:

{ [weak self] event in
    self?.handle(event)
}

API также предоставляет перегрузку, работающую только с событиями:

@discardableResult
public func subscribe<Owner: AnyObject, Event: EventBusEvent>(
    owner: Owner,
    to eventType: Event.Type = Event.self,
    handler: @escaping (Event) -> Void
) -> SubscriptionToken

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

Представление внутренней подписки

Внутри системы каждая подписка хранится в виде объекта SubscriptionEntry:

struct SubscriptionEntry {
    let id: UUID
    let subscription: Subscription
}

UUID идентифицирует одну конкретную подписку. Это позволяет возвращаемому SubscriptionToken отменить именно ту подписку, на основании которой он был создан.

Сама подписка хранит слабый указатель на владельца, состояние отмены и замыкание с аргументом, у которого очищен тип (type-erased):

final class Subscription {
    private weak var owner: AnyObject?
    let cancellationState: CancellationState
    let receive: (Any) -> Void
}

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

self.receive = { [weak owner] rawEvent in
    guard !cancellationState.cancelled(),
          let owner,
          let event = rawEvent as? Event else {
        return
    }

    handler(owner, event)
}

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

Отмена (cancellation)

Возвращаемый токен представляет одну конкретную подписку:

public final class SubscriptionToken: @unchecked Sendable {
    private let lock = NSLock()
    private let cancellationState: CancellationState
    private let cancelClosure: @Sendable () -> Void
    private var isCancelled = false

    public func cancel() {
        lock.lock()
        guard !isCancelled else {
            lock.unlock()
            return
        }
        isCancelled = true
        lock.unlock()

        cancellationState.cancel()
        cancelClosure()
    }
}

Отмена состоит из двух этапов.

Сначала общий параметр CancellationState помечается как «отмененный». Это предотвращает доставку, даже если подписка уже была скопирована в снэпшот публикации.

Второй: подписка физически удаляется из внутреннего словаря.

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

Отмена подписки по владельцу (Owner)

Шина предоставляет также отмену подписки, основанную на владельце:

public func unsubscribe<Owner: AnyObject, Event: EventBusEvent>(
    owner: Owner,
    from eventType: Event.Type = Event.self
)

Это удаляет все подписки, созданные этим владельцем для одного типа события.

Существует также более общий вариант:

public func unsubscribeAll(for owner: AnyObject)

Это удаляет все подписки, созданные владельцем для всех типов событий.

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

Ленивая очистка

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

func cleanDeadSubscriptions(in bucket: [SubscriptionEntry]) -> [SubscriptionEntry] {
    bucket.filter {
        $0.subscription.isAlive && !$0.subscription.cancellationState.cancelled()
    }
}

Эта очистка выполняется во время подписки и публикации.

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

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

Одноразовая подписка

Некоторые события используются только один раз. Для таких случаев шина предоставляет:

@discardableResult
public func subscribeOnce<Owner: AnyObject, Event: EventBusEvent>(
    owner: Owner,
    to eventType: Event.Type = Event.self,
    handler: @escaping (Owner, Event) -> Void
) -> SubscriptionToken

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

После первой доставки подписка автоматически отменяется:

let token = subscribeInternal(owner: owner, to: eventType) { owner, event in
    guard onceGate.consumeFirstDelivery() else {
        return
    }

    tokenBox.cancelAndClear()
    handler(owner, event)
}

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

В реализации используется небольшой вспомогательный объект под названием OnceGate:

final class OnceGate: @unchecked Sendable {
    private let lock = NSLock()
    private var hasDelivered = false

    func consumeFirstDelivery() -> Bool {
        lock.lock()
        defer { lock.unlock() }

        guard !hasDelivered else {
            return false
        }

        hasDelivered = true
        return true
    }
}

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

Подписки на главном потоке (MainActor)

Коду пользовательского интерфейса (User Interface) часто требуется обработка событий на главном потоке. Для этого шина предоставляет специальный API:

@MainActor
@discardableResult
public func subscribeOnMain<Owner: AnyObject, Event: EventBusEvent>(
    owner: Owner,
    to eventType: Event.Type = Event.self,
    handler: @escaping @MainActor (Owner, Event) -> Void
) -> SubscriptionToken

Внутренняя подписка организует доставку следующим образом:

self.receive = { rawEvent in
    guard !cancellationState.cancelled(),
          let event = rawEvent as? Event else {
        return
    }

    Task { @MainActor in
        guard !cancellationState.cancelled(),
              let owner = ownerRef.owner as? Owner else {
            return
        }

        handler(owner, event)
    }
}

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

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

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

Этот API полезен для вью контроллеров, вью моделей, координаторов и других объектов, связанных с пользовательским интерфейсом.

Поддержка AsyncStream

Шина также может предоставлять события в виде AsyncStream:

public func stream<Event: EventBusEvent>(
    _ eventType: Event.Type = Event.self,
    bufferingPolicy: AsyncStream<Event>.Continuation.BufferingPolicy = .bufferingNewest(100)
) -> AsyncStream<Event>

Это позволяет обрабатывать события с помощью async/await:

for await event in eventBus.stream(UserDidLoginEvent.self) {
    print(event)
}

Внутри потока создается частный объект-владелец:

final class StreamOwner: @unchecked Sendable {}

Этот владелец сохраняется до тех пор, пока поток активен. Когда поток завершается, подписка аннулируется:

streamContinuation.onTermination = { @Sendable _ in
    tokenBox.cancelAndClear()
}

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

.bufferingNewest(100)

Это обеспечивает потоку буфер с ограниченным размером и позволяет сохранять последние события в случае, если “читатель” работает медленнее, чем “писатель”.

Почему реализация использует NSLock

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

Такая структура позволяет сохранить синхронность и предсказуемость стандартного процесса публикации:

eventBus.publish(event)

Интеграции MainActor и AsyncStream добавляют асинхронное поведение только в тех случаях, когда API явно этого требует.

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

Пример реализации

Вот небольшой пример того, как EventBus можно использовать в приложении.

Сначала определите событие:

struct UserDidLoginEvent: EventBusEvent {
    let userID: String
}

Затем опубликуйте это из того места, где происходит действие:

final class AuthService {
    private let eventBus: EventBus

    init(eventBus: EventBus = .default) {
        self.eventBus = eventBus
    }

    func completeLogin(userID: String) {
        eventBus.publish(UserDidLoginEvent(userID: userID))
    }
}

Любой объект, которому необходимо реагировать на событие, может подписаться на него:

final class ProfileViewModel {
    private let eventBus: EventBus

    init(eventBus: EventBus = .default) {
        self.eventBus = eventBus

        eventBus.subscribe(owner: self) { owner, event: UserDidLoginEvent in
            owner.reloadProfile(for: event.userID)
        }
    }

    private func reloadProfile(for userID: String) {
        // Reload user-specific data
    }
}

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

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

eventBus.subscribeOnMain(owner: self) { owner, event: UserDidLoginEvent in
    owner.updateUI(for: event.userID)
}

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

Заключение

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

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

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