Привет! Меня зовут Антон, я iOS-разработчик в Банки.ру. Когда я только начинал изучать Combine, он казался для меня магией. Пара команд и вот у тебя уже есть какие-то данные. Чтобы Combine перестал оставаться черным ящиком давайте заглянем внутрь. Эта статья – мое виденье этого фреймворка.
Небольшая сводка: Combine – фреймворк для работы с асинхронными событиями в декларативном стиле. Он помогает разработчикам управлять потоками данных, избавляя от множества колбэков, ручного управления очередями и других сложностей, связанных с асинхронностью.
Большинство статей описывают 3 сущности: Publisher (издатель), Subscriber (подписчик) и Operator'ы. Но они умалчивают еще об одном игроке – Subscription. Именно подписка управляет всей "жизнью" цепочки: кто кому и когда передаёт данные, и когда всё заканчивается.
В центре внимания Combine:
Publisher (издатель) — посылает сигналы
Subscriber (подписчик) — подписывается на Publisher и реагирует на поступающие значения
Operator'ы — модифицируют, фильтруют или комбинируют значения между Publisher и Subscriber
Publisher – протокол, который является источником данных
Если проводить аналогии, то это “Человек с микрофоном”, который готов сообщать новости (значения).
Выглядит он вот так:
public protocol Publisher { associatedtype Output associatedtype Failure: Error func receive<S: Subscriber>(subscriber: S) where S.Input == Output, S.Failure == Failure }
За что отвечает:
Генерирует значения или ошибки;
Метод receive() принимает Subscriber и присоединяет указанного подписчика к данному Publisher, после чего данный Subscriber сможет получать значения от Publisher;
Создаёт Subscription и связывает ее с Subscriber, c помощью метода sink, который возвращает подписку.
public func sink( receiveCompletion: @escaping (Subscribers.Completion<Self.Failure>) -> Void, receiveValue: @escaping (Self.Output) -> Void ) -> AnyCancellable
Параметры этого метода это два замыкания:
receiveValue — вызывается при каждом новом значении от паблишера.
receiveCompletion — вызывается, когда паблишер завершает работу (.finished или .failure).
Если не сохранить подписку, то значение которое отправил Publisher будет утеряно.
У Publisher есть еще много методов, реализованных через расширения. Их выделяют в отдельную сущность Operator'ы – промежуточные обработчики данных.
Операторов можно рассматривать как “Фильтр в цепи” – например, усилитель или эквалайзер между микрофоном и динамиком. Они помогают управлять потоком данных, маппить данные, обрабатывать ошибки и тд.
Subscription – контракт между Subscriber и Publisher
Используем аналогию: “Шнур между микрофоном и наушниками” — передаёт звук, но может быть отключён или ограничен.
public protocol Subscription: Cancellable { func request(_ demand: Subscribers.Demand) }
Главные задачи Subscription:
Создаётся когда Subscriber подписывается на паблишер Publisher, отвечает за жизненный цикл этой связи. Передача данных от Publisher к Subscriber прервется, если Subscription уйдет из памяти;
Управляет передачей значений;
Контролирует объём запрошенных данных, и может быть отменена (через cancel()).
Subscription наследуется от Cancellable:
public protocol Cancellable { func cancel() }
Любая подписка должна уметь отменять получение данных. Когда вызываешь cancel(), паблишер должен прекратить посылку значений подписчику и освободить ресурсы.
Метод request(_:)
func request(_ demand: Subscribers.Demand)
Это ключевой метод Combine для контроля потока данных. Он говорит паблишеру, сколько значений подписчик готов получить. Это указывается во входном параметре.
Subscribers.Demand – это структура, описывающая сколько значений подписчик может принять. В качестве значений передаются:
public static let unlimited: Subscribers.Demand // подписчик готов принять сколько угодно значений @inlinable public static func max(_ value: Int) -> Subscribers.Demand // готов принять максимум value значений public static let none: Subscribers.Demand // Это эквивалентно max(0)
Это делает Combine "pull-based" системой – подписчик запрашивает значения, а не просто "получает по факту".
Subscriber – получатель данных от Publisher
Его можно представить как “слушателя в зале”. Он может сказать: Прекрати, Продолжай, или Я готов к N сообщениям..
public protocol Subscriber: CustomCombineIdentifierConvertible { associatedtype Input associatedtype Failure: Error func receive(subscription: Subscription) func receive(_ input: Input) -> Subscribers.Demand func receive(completion: Subscribers.Completion<Failure>) }
Subscriber подписывается на Publisher и получает:
Subscription (для управления подпиской и запросом данных);
Значения (Output);
Завершение потока (Completion).
То есть Subscriber описывает как именно обрабатываются события от паблишера.
Связанные типы подписчика:
associatedtype Input // тип значений, которые получает подписчик. (должен совпадать с Publisher.Output)
associatedtype Failure: Error // тип ошибки, которую может выдать паблишер. (должен совпадать с Publisher.Failure)
Описание методов подписчика:

Давайте подытожим:


Теперь – как все это работает на примере кастомной цепочки
// MARK: - Custom Publisher struct MyPublisher: Publisher { typealias Output = Int typealias Failure = Never func receive<S>(subscriber: S) where S : Subscriber, MyPublisher.Failure == S.Failure, MyPublisher.Output == S.Input { // Создаём подписку и передаём её подписчику let subscription = MySubscription(subscriber: subscriber) subscriber.receive(subscription: subscription) } } // MARK: - Custom Subscription final class MySubscription<S: Subscriber>: Subscription where S.Input == Int { private var subscriber: S? private var current = 1 private let max = 5 init(subscriber: S) { self.subscriber = subscriber } func request(_ demand: Subscribers.Demand) { // Если подписчик запросил данные guard demand > .none else { return } // Отправляем несколько значений while current <= max { _ = subscriber?.receive(current) current += 1 } // Завершаем поток subscriber?.receive(completion: .finished) } func cancel() { print("Подписка отменена") subscriber = nil } } // MARK: - Custom Subscriber final class MySubscriber: Subscriber { typealias Input = Int typealias Failure = Never func receive(subscription: Subscription) { print("Подписка получена") // Запрашиваем все значения subscription.request(.unlimited) } func receive(_ input: Int) -> Subscribers.Demand { print("Получено значение:", input) // Можно вернуть .none (не запрашивать дополнительно) return .none } func receive(completion: Subscribers.Completion<Never>) { print("Завершено:", completion) } } // MARK: - Пример использования do { let publisher = MyPublisher() let subscriber = MySubscriber() publisher.subscribe(subscriber) }
Вывод в консоль:
Подписка получена
Получено значение: 1
Получено значение: 2
Получено значение: 3
Получено значение: 4
Получено значение: 5
Завершено: finished
Пошаговое объяснение
publisher.subscribe(subscriber)
– Паблишер получает подписчика.
– Создаёт MySubscription и вызывает subscriber.receive(subscription:).MySubscriber.receive(subscription:)
– Сохраняет ссылку на подписку.
– Запрашивает .unlimited (все возможные значения).MySubscription.request(_:)
– Отправляет значения 1…5 в subscriber.receive(_:).
– После этого вызывает receive(completion: .finished).Поток завершается.
Как это выглядит на схеме:

В этом примере мы последовательно while сurrent <= max запросили 5 значений, при этом MySubscriber никак не ограничивает поток значений, ведь subscription.request(.unlimited), давайте это исправим.
Для MySubscriber
Метод:
func receive(subscription: Subscription) { print("Подписка получена") // Запрашиваем все значения subscription.request(.unlimited) }
Поменяем на:
func receive(subscription: Subscription) { print("Подписка получена") // Запрашиваем все значения subscription.request(.max(3)) }
Для MySubscription
Метод:
func request(_ demand: Subscribers.Demand) { // Если подписчик запросил данные guard demand > .none else { return } // Отправляем несколько значений while current <= max { _ = subscriber?.receive(current) current += 1 } // Завершаем поток subscriber?.receive(completion: .finished) }
Поменяем на:
func request(_ demand: Subscribers.Demand) { // Если подписчик запросил данные guard demand > .none else { return } // Отправляем несколько значений while current <= demand.max ?? max { _ = subscriber?.receive(current) current += 1 } // Завершаем поток subscriber?.receive(completion: .finished) }
Вывод в консоль:
Подписка получена
Получено значение: 1
Получено значение: 2
Получено значение: 3
Завершено: finished
Теперь мы ограничили вызванные значения до 3.
Что будет если мы дважды вызовем publisher.subscribe(subscriber)?
publisher.subscribe(subscriber) publisher.subscribe(subscriber)
У нас дважды выводится:
Подписка получена
Получено значение: 1
Получено значение: 2
Получено значение: 3
Завершено: finished
Подписка получена
Получено значение: 1
Получено значение: 2
Получено значение: 3
Завершено: finished
Каждый вызов subscribe создаёт новый экземпляр MySubscription и новую независимую цепочку. Это можно проверить, если добавить в MySubscription:
deinit { print("🔴 MySubscription освобождена из памяти") }
Тогда в конце каждой цепочки мы увидим этот вывод.
Получается что каждая подписка:
имеет свой current = 1;
свой вызов request(.max(3));
и потому каждая отдаёт значения 1, 2, 3.
В нашей реализации MySubscription держит subscriber ( private var subscriber: S?), поскольку MySubscription живёт только пока выполняется метод request, то утечки нет.
Общая схема кто кого держит:
MySubscriber → MySubscription → MySubscriber.
Именно поэтому этот код работает без сохранения подписки.
Важно помнить о сохранении подписки!
В начале я говорил, что подписку надо сохранять, иначе мы не получим данные.
Давайте изменим вывод и вместо:
publisher.subscribe(subscriber) publisher.subscribe(subscriber)
Будем использовать:
let сancellable: AnyCancellable = publisher.sink { value in print(value) }
.sink - это метод Паблишера, который возвращает AnyCancellable (обертку над подпиской).
Вывод будет:
1
2
3
4
5
Что произошло?
Когда ты вызываешь .sink, под капотом Combine делает следующее:
Создаёт внутренний Sink (подписку), который подписывается на publisher.
Создаёт объект Subscription, связывающий паблишер и подписчика.
Возвращает тебе объект AnyCancellable, который:
держит ссылку на Subscription;
при deinit вызывает .cancel() у неё.
Пока этот AnyCancellable жив – подписка активна.
Когда AnyCancellable уходит из памяти – поток завершается
Мы точно не знаем как он выглядит, но можно предположить, что так:
public final class Sink<Input, Failure: Error>: Subscriber, Cancellable { private var receiveValue: ((Input) -> Void)? private var receiveCompletion: ((Subscribers.Completion<Failure>) -> Void)? private var subscription: Subscription? public func receive(subscription: Subscription) { self.subscription = subscription subscription.request(.unlimited) } public func receive(_ input: Input) -> Subscribers.Demand { receiveValue?(input) return .none } public func receive(completion: Subscribers.Completion<Failure>) { receiveCompletion?(completion) cancel() } public func cancel() { subscription?.cancel() subscription = nil receiveValue = nil receiveCompletion = nil } }
Кстати, то что метод receive(subscription: Subscription) вызывается именно в subscription.request(.unlimited) можно проверить:
func request(_ demand: Subscribers.Demand) { // ВОТ ТУТ МОЖНО СДЕЛАТЬ ПРИНТ ИЛИ ПОСТАВИТЬ БРЯКУ guard demand > .none else { return } while current <= demand.max ?? max { _ = subscriber?.receive(current) current += 1 } subscriber?.receive(completion: .finished) }
Теперь, когда мы знаем о всей Combine-цепочке, давайте посмотрим как она интегрирована в SwiftUI
В SwiftUI Combine применяется в нескольких ключевых точках:
@Published – для автоматического создания Publisher’а из свойства;
@ObservedObject и @StateObject – для подписки на объект, который использует Combine;
.onReceive(_:) – для подписки на Publisher внутри View;
@EnvironmentObject – для совместного использования ObservableObject между вьюшками.
Published и ObservableObject
Published превращает свойство в Publisher. В связке с ObservableObject это позволяет SwiftUI автоматически обновлять вьюшку при изменении.


SwiftUI следит за viewModel
При изменении @Published count, View перерисовывается!
ObservableObject – это протокол, у которого есть ассоциированный паблишер objectWillChange, который по умолчанию – ObservableObjectPublisher:
protocol ObservableObject { associatedtype ObjectWillChangePublisher: Publisher where ObjectWillChangePublisher.Output == Void, ObjectWillChangePublisher.Failure == Never var objectWillChange: ObjectWillChangePublisher { get } }
@Published – обёртка
Ключевое: когда обёртка используется внутри ObservableObject, компилятор "прошивает" вызов objectWillChange.send() в willSet этого свойства. Поэтому SwiftUI узнаёт о грядущем изменении ещё до смены значения, а ваши подписчики $property получат новое значение затем. Это поведение задокументировано: «синтезирует objectWillChange, который испускает событие до изменения любого @Published свойства»
@propertyWrapper public struct Published<Value> { // Хранилище значения public var wrappedValue: Value // Проецированное значение — типизированный паблишер для этого свойства public var projectedValue: Published<Value>.Publisher public struct Publisher: Combine.Publisher { public typealias Output = Value public typealias Failure = Never // ... } }
На Swift Forums это описывают так: сгенерированный objectWillChange «устанавливается» во все @Publishedсвойства и дергается при их изменении. Это не официальная инфа, но дает верное понимание происходящег��.
Нюансы и грабли
1. Равные значения тоже триггерят событие. @Published не делает сравнение – сигналит на каждую запись. Для исключения повторяющихся событий лучше использовать операторы .removeDuplicates() или .dropFirst() или использовать логику в сеттере (это следует из модели работы willSet и отсутствия сравнения в Published.)
https://developer.apple.com/documentation/combine/published?utm_source=chatgpt.com
2. Поток исполнения. Всё, что приводит к обновлению UI, делайте на главной очереди (receive(on: DispatchQueue.main)), иначе словите предупреждения/артефакты. (UI – main-thread-only; общая рекомендация Combine/SwiftUI.)
https://developer.apple.com/documentation/combine?utm_source=chatgpt.com
3. Вычисляемые свойства не «паблишатся». @Published нужен хранимому свойству. Для зависимых значений – либо @Published private(set), либо рассчитывайте в пайплайнах.
4. Не «ловите» objectWillChange для данных. Это Void-событие – только триггер, данные берите из свойств или из $property.
Вот упрощенная зарисовка Published и ObservableObject:
final class ObservableObjectPublisher: Publisher { typealias Output = Void typealias Failure = Never // хранит подписчиков, при send() раздаёт Void }
Выше я уже писал о том, что @Published это не просто свойста, а полноценный паблишер, но думаю еще раз стоит проговорить это. Таким образом можно подписаться на обновления этого свойства (что и делает SwiftUI):
@Published var value: Int = 0 $viewModel.value // это Publisher viewModel.$value .sink { print($0) } .store(in: &cancellables)
Еще немного о Combine в SwiftUI
onReceive(_:) – подписка на любой Publisher
SwiftUI View может подписаться на любой Publisher через .onReceive.
Так же есть .sink – это подписка вне View
Короткий вывод
Combine – это не просто набор классов и операторов. Это другой способ думать о данных: как о потоке, который можно наблюдать, преобразовывать и управлять им.
Разобравшись в базовых сущностях – Publisher, Subscriber и Subscription – проще понять, что происходит “под капотом”, и писать код осознанно, а не по шаблону из документации.
Даже если вы позже перейдёте на Swift Concurrency, понимание принципов Combine останется полезным – они учат смотреть на работу с данными реактивно и структурно.
