Привет! Меня зовут Антон, я 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 останется полезным – они учат смотреть на работу с данными реактивно и структурно.
Azon
Полностью согласен с выводом! Есть только один минус - когда привыкаешь думать о решении проблем через потоки данных, сложно вернуться обратно в мир без возможности решить задачу одной строкой