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