Привет! Меня зовут Антон, я 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 и получает:

  1. Subscription (для управления подпиской и запросом данных);

  2. Значения (Output);

  3. Завершение потока (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

Пошаговое объяснение

  1. publisher.subscribe(subscriber)
    – Паблишер получает подписчика.
    – Создаёт MySubscription и вызывает subscriber.receive(subscription:).

  2. MySubscriber.receive(subscription:)
    – Сохраняет ссылку на подписку.
    – Запрашивает .unlimited (все возможные значения).

  3. MySubscription.request(_:)
    – Отправляет значения 1…5 в subscriber.receive(_:).
    – После этого вызывает receive(completion: .finished).

  4. Поток завершается.

Как это выглядит на схеме:

В этом примере мы последовательно 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 делает следующее:

  1. Создаёт внутренний Sink (подписку), который подписывается на publisher.

  2. Создаёт объект Subscription, связывающий паблишер и подписчика.

  3. Возвращает тебе объект 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 останется полезным – они учат смотреть на работу с данными реактивно и структурно.

Комментарии (1)


  1. Azon
    23.10.2025 12:25

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