Combine представляет собой реактивный фреймворк Apple (iOS 13+): Publisher выдаёт значения во времени и затем одно завершение, операторы преобразуют этот поток, а Subscriber в конце цепочки запрашивает и получает значения.
Реактивное программирование: вы описываете, как значения текут и меняются, а не вручную связываете колбэки и изменяемые флаги. Combine предлагает типизированную версию идеи RxSwift от Apple.
**Publisher<Output, Failure>**: оба типа входят в сигнатуру. Failure == Never значит, что поток не может упасть, а компилятор проверяет, что у соединяемых паблишеров совпадают оба типа.
Операторы: методы вроде map, filter и debounce, которые возвращают новый паблишер. Они ленивые: ничего не выполняется, пока кто-то не подпишется.
**Subscriber**: конец цепочки. Вручную его пишут редко: sink и assign покрывают почти все случаи.
**Subscription**: связь между ними. Она передаёт спрос (demand) и отменяется через AnyCancellable, который вы храните.
В 2026 году Combine стабилен, но почти не развивается: новый код опирается на async/await, AsyncSequence и @Observable, а Combine остаётся в существующих кодовых базах и там, где нужно комбинировать потоки во времени.
iOSSenior
Combine
Publisher, Subscriber, операторы, Subjects, мост с async/await
Закрыто вопросов
0 / 24
- 0Повторить
- 1Часто спрашивают
Вопросы
PassthroughSubject передаёт каждое значение только тем, кто подписан в этот момент, и ничего не хранит; CurrentValueSubject хранит последнее значение, отдаёт его через .value и сразу передаёт каждому новому подписчику.
Оба являются субъектами: паблишерами, в которые вы сами отправляете значения через send(_:) и завершаете через send(completion:). Это мост из императивного кода (делегат, обработчик кнопки) в пайплайн.
PassthroughSubject нужен для событий: нажатие, «элемент сохранён»; CurrentValueSubject нужен для состояния: флаг загрузки, текущий пользователь.
Классический баг: отправить значение в PassthroughSubject до того, как кто-то подписался. Значение просто теряется.
После send(completion:) субъект игнорирует новые значения, а новые подписчики получают только завершение.
Держите субъект private и наружу отдавайте subject.eraseToAnyPublisher(): тогда вызывающий код может слушать, но не может отправлять.
debounce ждёт, пока апстрим замолчит на заданный интервал, и затем выдаёт последнее значение; throttle выдаёт не больше одного значения за интервал, пока значения продолжают приходить.
**debounce(for:scheduler:): поле поиска: отправить запрос, когда пользователь перестал печатать. Если паузы не наступает, он не выдаёт ничего.
throttle(for:scheduler:latest:)**: позиция скролла, прогресс, повторные нажатия. latest: true выдаёт самое новое значение каждого окна, а false выдаёт первое.
Оба принимают планировщик (scheduler): DispatchQueue.main для работы с UI и внедрённый планировщик в тестах.
Ставьте removeDuplicates() после debounce, чтобы ввод «swift», удаление буквы и повторный ввод не отправили один и тот же запрос дважды.
AnyCancellable служит токеном, который возвращает подписка: он отменяет её при вызове cancel() или при деаллокации, поэтому пайплайн живёт ровно столько, сколько кто-то держит этот токен.
Токен не сохранён: он сразу освобождается, и подписка отменяется. Синхронный паблишер вроде [1, 2, 3].publisher всё равно успевает доставить значения во время вызова sink, поэтому баг прячется в плейграунде и проявляется на сетевом запросе, ответ на который так и не приходит.
**store(in: &bag)**: складывайте токены в Set<AnyCancellable>, которым владеет view model или view controller. Когда владелец освобождается, вместе с ним уходит и набор, и все подписки отменяются.
Досрочная отмена одной подписки: храните её в отдельном свойстве AnyCancellable?. Присваивание нового значения освобождает старый токен, а это отменяет предыдущий запрос.
Циклы удержания: владелец держит набор, набор держит подписку, подписка держит замыкание. Замыкание, которое сильно захватывает self, замыкает круг, поэтому в sink используйте [weak self].
Завершение освобождает память: завершившийся паблишер отпускает замыкания; циклы опасны для бесконечных потоков: @Published, субъектов, таймеров.
assign(to: &$property) вообще не возвращает токен: время жизни подписки привязано к @Published-свойству.
assign(to:on:) сильно держит объект, в который пишет, а возвращённый cancellable хранится в том же объекте, поэтому self → bag → subscription → self образует цикл удержания, который живёт, пока апстрим не завершится. assign(to: &$title) вместо этого привязывает время жизни к свойству и цикла не создаёт.
**sink**: вызывает замыкание на каждое значение и, при желании, на завершение. Что захватывать, решаете вы, поэтому [weak self] убирает цикл.
**assign(to:on:): пишет каждое значение по key path и удерживает цель. Безопасен, только если цель не владеет этим cancellable.
assign(to: &$title)** (iOS 14+) пишет в @Published-свойство и привязывает время жизни подписки к этому свойству. Он не возвращает cancellable и не создаёт цикла; это и есть исправление здесь.
Цикл рвётся только когда апстрим завершается. @Published, субъекты, нотификации и таймеры сами не завершаются, поэтому такая утечка проходит код-ревью.
Оба варианта assign требуют Failure == Never, поэтому сначала обработайте ошибки через replaceError(with:) или catch.
Подписка создаётся в viewWillAppear, который вызывается при каждом возврате на экран, и каждая новая добавляется в bag, а старые продолжают жить. После трёх возвратов работают три sink, и каждое обновление рендерится и логирует feed_shown трижды.
Почему они копятся: viewWillAppear срабатывает при каждом возврате назад, переключении таба и закрытии полноэкранного модального экрана, а сам view controller продолжает жить, поэтому bag только растёт: старые AnyCancellable никто не отменяет до deinit.
Это дороже лишней строки в логе: $posts при подписке сразу отдаёт текущее значение, поэтому каждый возврат ещё и заново рендерит экран; если в sink есть reloadData или запрос, работа умножается с каждым визитом.
Исправление: подписываться один раз в viewDidLoad (или связывать в init владельца). Если подписка нужна только пока экран виден, вызывайте bag.removeAll() в viewDidDisappear.
Тот же баг в другом месте: ячейка, которая подписывается в configure(with:) и не отменяет подписку в prepareForReuse, получает ещё один sink при каждом переиспользовании.
Как поймать в следующий раз: тест, который дважды вызывает viewWillAppear, сбрасывает мок аналитики, отправляет одно обновление и проверяет, что мок вызван ровно один раз ($posts отдаёт значение при подписке, поэтому до сброса счётчик не равен 1); на ревью store(in:) внутри колбэка появления экрана считается тревожным сигналом.
eraseToAnyPublisher() оборачивает паблишер в AnyPublisher<Output, Failure> и скрывает вложенные типы операторов, так что API показывает только то, что он выдаёт и как падает, а не то, как устроена цепочка.
Без него возвращаемый тип описывает всю цепочку, например Publishers.Decode<Publishers.Map<URLSession.DataTaskPublisher, Data>, [Post], JSONDecoder>, и любое изменение цепочки меняет публичную сигнатуру.
AnyPublisher позволяет веткам возвращать разные цепочки (Just для закешированного значения, сетевой запрос в остальных случаях) и упрощает моки на протоколах.
Стирание типа у субъекта заодно прячет send: вызывающий код может подписаться, но не может отправлять значения.
Начиная со Swift 5.7 у Publisher есть первичные ассоциированные типы, поэтому some Publisher<[Post], Error> скрывает тип без обёртки. Но все ветки должны возвращать один конкретный тип, а требование протокола не может использовать some, поэтому на границах модулей и протоколов нормой остаётся AnyPublisher.
Стирание добавляет небольшую обёртку и косвенный вызов. Стирайте тип один раз на границе API, а не после каждого оператора.
merge перемешивает значения одного типа из нескольких паблишеров; combineLatest при изменении любого входа выдаёт кортеж из последних значений каждого; zip склеивает значения по порядковому номеру и выдаёт, только когда у каждого входа есть новое значение.
**merge**: у входов должны совпадать Output и Failure; ничего не склеивается в пары. Подходит для нескольких источников одного события: пуш, поллинг, pull-to-refresh. Publishers.MergeMany принимает массив.
**combineLatest**: для состояния: email и пароль, чтобы включить кнопку, фильтры и данные. Он ничего не выдаёт, пока каждый вход не выдал хотя бы одно значение, поэтому необязательному входу задайте стартовое значение через prepend.
**Всплески combineLatest**: два входа, изменившиеся в одном такте, дают два выхода; removeDuplicates() или короткий debounce схлопывают их.
**zip**: для парных событий, например чтобы дождаться трёх одноразовых запросов вместе (Publishers.Zip3). Если одна сторона быстрее, zip буферизует её лишние значения без ограничений: память растёт, а пары становятся всё более устаревшими.
Всем трём нужны совпадающие типы Failure (mapError, setFailureType(to:)), и ошибка любого входа роняет объединённый паблишер.
flatMap держит живыми все внутренние паблишеры и сливает их результаты, поэтому медленный старый запрос может затереть более новый; map с последующим switchToLatest отменяет предыдущий внутренний паблишер при новом значении, и именно это нужно полю поиска.
flatMap запускает все внутренние паблишеры одновременно, если его не ограничить. Это правильно для независимой работы, например загрузки пяти фотографий.
flatMap(maxPublishers: .max(1)) выполняет их последовательно: следующий внутренний паблишер стартует только после завершения предыдущего, и это подходит для очереди, где важен порядок. С горячим апстримом вроде субъекта значения, пришедшие пока работает внутренний паблишер, теряются, поэтому применяйте его с холодной последовательностью или сначала буферизуйте.
switchToLatest даёт семантику «отменить предыдущий»: старый запрос именно отменяется, а не просто игнорируется.
Ставьте перед ним debounce и removeDuplicates, чтобы не слать запрос на каждое нажатие, а receive(on:) ставьте в конце, перед работой с UI.
Внутренний паблишер, который может упасть, роняет всю цепочку: после первой ошибки внешний поток тоже завершается. С апстримом Failure == Never, как $query, это компилируется без жалоб, поэтому ловите ошибки внутри внутреннего паблишера (catch, replaceError).
В async/await тот же паттерн выглядит как сохранённый Task, который вы отменяете перед запуском следующего.
Он выведет 10 и -1: брошенная ошибка означает завершение, поэтому replaceError выдаёт последнее значение и поток заканчивается, а 3 не приходит. Чтобы пережить ошибку, ловите её внутри flatMap.
Операторы ошибок: tryMap превращает throw в ошибку потока, mapError меняет тип ошибки, catch заменяет упавший паблишер запасным, replaceError(with:) работает как catch с одним значением, а retry(n) заново подписывается на апстрим.
Они реагируют на ошибку, которая уже завершила апстрим: catch и replaceError заканчивают поток запасным вариантом; retry подписывается заново, что помогает сетевому запросу, но теряет упавшее значение субъекта.
Чтобы пережить ошибку, перенесите падающий шаг внутрь flatMap (или map плюс switchToLatest). Падает и заменяется внутренний паблишер, а внешний поток сохраняет Failure == Never и продолжает жить.
Для UI превращайте ошибку в значение (Result или enum состояния с кейсом .failed), чтобы экран показал ошибку и продолжил работать.
Для соединения паблишеров нужны совпадающие типы ошибок: mapError к общему типу, setFailureType(to:) для паблишера, который не может упасть.
Логируйте до replaceError, иначе ошибка исчезнет бесследно.
Холодный паблишер запускает работу отдельно для каждого подписчика в момент подписки; горячий выдаёт значения по своему расписанию независимо от того, слушает ли кто-то, поэтому опоздавший подписчик видит только то, что пришло после подписки.
Холодные: Just, [1, 2, 3].publisher, URLSession.dataTaskPublisher, Deferred и цепочки операторов поверх них. Два вызова sink означают, что работа выполнится дважды: два сетевых запроса.
Горячие: PassthroughSubject, паблишеры NotificationCenter, Timer.publish после подключения. Значения, отправленные до подписки, потеряны.
Горячие с текущим значением: CurrentValueSubject и @Published повторяют новому подписчику только последнее значение, ничего раньше.
**Future**: особый случай: он стартует при создании и кеширует один результат для всех. Обёртка в Deferred делает его холодным.
Из холодного в горячий: share(), multicast и makeConnectable() выполняют апстрим один раз для многих подписчиков; так убирают дублирующиеся запросы.
Практическое правило: подписывайтесь на горячий паблишер до того, как запускаете действие, которое в него отправляет.
CurrentValueSubject хранит последнее значение и отдаёт его каждому новому подписчику, поэтому каждый экран, подписавшийся в viewDidLoad, сразу получает старое сообщение и снова показывает тост. Для одноразового события нужен PassthroughSubject, который ничего не хранит, и показывать его должен только один экран.
Кто показывает: subject доставляет значение каждому живому подписчику, а каждый экран в навигационном стеке подписался в viewDidLoad и остаётся подписанным, пока скрыт. Даже без replay три экрана в стеке показали бы тост три раза. Фильтр по viewIfLoaded?.window пропускает только видимый экран; подошёл бы и единый презентер на уровне окна.
Признак проблемы: стартовое значение nil и compactMap { $0 } нужны только для того, чтобы пропустить «сообщения пока нет». Если субъекту нужно фиктивное начальное значение, это обычно поток событий.
Исправление: PassthroughSubject<String, Never>() доставляет каждое сообщение только тем, кто подписан в этот момент; сообщение, отправленное, когда никто не слушает, теряется, и для тоста это как раз то, что нужно. Отправлять nil после показа тоста, чтобы «сбросить» его, значит городить костыль, который ломается, как только подписчиков становится два.
Та же ловушка: @Published и $property тоже отдают текущее значение при подписке, поэтому @Published var error: Error?, по которому показываются алерты, показывает старый алерт при каждой новой подписке.
Как поймать в следующий раз: тест, который отправляет сообщение, подписывается после этого и проверяет, что ничего не пришло.
Future является паблишером, который выдаёт ровно одно значение или одну ошибку через колбэк promise; он выполняет своё замыкание сразу при создании и кеширует результат, поэтому нужен Deferred, чтобы сделать его ленивым и повторяемым.
Большинство паблишеров описывают работу, которая начинается при подписке. Future реализован как класс, который стартует сразу, поэтому само его создание уже выполняет побочный эффект: в map, в хранимом свойстве, в фабрике, результат которой выбросили.
Каждый подписчик получает один и тот же закешированный результат. Из-за этого retry бесполезен: повторная подписка воспроизводит закешированную ошибку, а не вызывает замыкание снова.
Deferred { Future { ... } } создаёт новый Future на каждую подписку, и это возвращает ленивое поведение для каждого подписчика и заставляет retry работать.
Отмена подписки только прекращает доставку; работа внутри замыкания продолжается. Если работу нужно отменять, храните на неё хендл и отменяйте его из handleEvents(receiveCancel:).
Повторный вызов promise игнорируется: побеждает первый.
Используйте Future для одноразовых API на колбэках. Если значение уже есть, используйте Just.
receive(on:) задаёт планировщик, на котором значения и завершение доставляются всему, что ниже него; subscribe(on:) задаёт, где апстрим получает подписку, запросы и отмену, то есть где начинается работа, и не управляет доставкой (синхронный апстрим всё равно выдаёт значения на этой очереди).
Они действуют в противоположных направлениях: subscribe(on:) влияет на всё, что выше него, где бы вы его ни написали, а receive(on:) влияет на всё, что ниже.
Ставьте receive(on: DispatchQueue.main) как можно позже, прямо перед sink или assign, которые трогают UI. Если поставить его раньше, он утянет на главный поток дорогие шаги вроде декодирования JSON.
subscribe(on:) полезен для синхронного апстрима (блокирующего чтения файла внутри Deferred, выборки из Core Data). Перед URLSession.dataTaskPublisher он бесполезен: тот и так работает и доставляет значения на своей очереди.
Каждый receive(on:) стоит переключения очереди на каждое значение, поэтому два таких оператора в одной цепочке удваивают работу впустую.
Для UI предпочитайте DispatchQueue.main, а не RunLoop.main: планировщик run loop перестаёт доставлять значения, пока пользователь тащит scroll view.
Он выведет 0 0, а затем 2 0: $items выдаёт текущее значение при подписке, а @Published публикует из willSet, поэтому sink выполняется до записи свойства и self.items ещё содержит старый массив.
ObservableObject устроен так же, и его паблишер назван честно: objectWillChange. Для SwiftUI это нормально: он только помечает view как «грязную» и читает свойства при следующей отрисовке.
Проблема возникает в написанных вручную sink, которые читают данные обратно из объекта: новые items приходят рядом с другими свойствами, которые ещё не обновились.
Исправление 1: используйте значение, которое вам передали, а остальное состояние подтягивайте через combineLatest, а не читая self.
Исправление 2: receive(on: DispatchQueue.main) доставляет значение в следующем такте, уже после записи свойства. Это работает, но опирается на тайминг, а не на гарантию.
Исправление 3: публикуйте после факта сами: CurrentValueSubject, который наполняется из didSet.
Добавьте dropFirst(), если нужны только изменения, а не значение в момент подписки.
Apple даёт короткий список (NotificationCenter, KVO, Timer, URLSession и ваши собственные @Published-свойства), а встроенного паблишера для UIControl нет, поэтому для кнопки вы сами оборачиваете UIAction в субъект.
NotificationCenter.default.publisher(for:) покрывает системные события; нотификации о фрейме клавиатуры остаются классическим случаем в UIKit.
object.publisher(for: \.keyPath) подключает KVO на объектах Apple, например WKWebView.estimatedProgress или AVPlayerItem.status.
Timer.publish(every:on:in:) возвращает подключаемый (connectable) паблишер: он ничего не выдаёт до autoconnect() или connect().
URLSession.dataTaskPublisher(for:) уместен на экране, который уже построен на Combine; новый код вызывает async-метод.
Для текстового поля используйте UITextField.textDidChangeNotification или оберните событие .editingChanged так же, как для кнопки.
Храните все cancellable в Set<AnyCancellable>, которым владеет view controller, чтобы весь граф разбирался вместе с экраном.
Таймер запланирован в RunLoop.Mode.default, а пока палец тащит scroll view, главный run loop работает в режиме tracking, и таймеры режима default не срабатывают; запланируйте его с in: .common.
.common включает и режим default, и режим tracking, поэтому таймер продолжает срабатывать во время перетаскивания. Весь фикс сводится к этому одному аргументу.
Timer.publish(every:tolerance:on:in:) возвращает подключаемый паблишер. Без .autoconnect() или connect() он не срабатывает вообще, и это второй частый баг «мой таймер мёртв».
Та же ловушка есть и на стороне доставки: receive(on: RunLoop.main) тоже встаёт на паузу во время tracking, а DispatchQueue.main не является run loop, и на него это не влияет.
Задайте видимому таймеру tolerance, чтобы система могла группировать пробуждения и экономить батарею.
Не считайте тики, чтобы измерять время. Между ними приложение может быть приостановлено; храните дедлайн как Date и пересчитывайте оставшееся время на каждом тике.
Паблишер становится AsyncSequence через .values, а асинхронная работа становится паблишером через Deferred { Future { ... } } или субъект, который наполняется из Task; Combine по-прежнему лучше там, где нужно комбинировать несколько потоков во времени.
Из паблишера в async: publisher.values возвращает AsyncPublisher (или AsyncThrowingPublisher, если паблишер может упасть); читайте его через for await. Отмена задачи отменяет подписку.
Из async в паблишер: оберните вызов в Deferred { Future { promise in Task { ... } } }. Отмена подписки не отменяет этот Task, а режим Swift 6 без обходного пути не даёт захватить не-Sendable promise в Task.
Смешивание: пусть Combine отвечает за тайминг (debounce, removeDuplicates), а асинхронный вызов запускайте из sink, сохраняя Task, чтобы отменить предыдущий.
Где Combine всё ещё выигрывает: debounce, throttle, combineLatest и share над несколькими живыми потоками со встроенной отменой; существующий код на ObservableObject; API SDK, которые отдают только паблишер.
Где выигрывает async: одноразовые запросы, последовательные шаги и код под строгой проверкой конкурентности Swift 6, которая появилась позже Combine. Swift Async Algorithms добавляет debounce, merge и combineLatest для AsyncSequence.
Пометьте каждую стадию через print("name") или handleEvents и прочитайте лог жизненного цикла: первая стадия, которая перестала логировать значения, указывает на место, где цепочка умерла, а её последнее событие говорит, как именно.
print(_:) логирует протокол подписчика: receive subscription, request unlimited, receive value, receive finished или receive failure, receive cancel. Дайте каждой стадии свою метку.
**Нет receive subscription**: никто не подписался: нет sink или у connectable-паблишера не вызван connect().
**receive subscription и сразу receive cancel**: AnyCancellable выбросили (не сохранили) или его владелец освободился.
**receive finished или receive failure**: апстрим завершился. Завершение окончательно, поэтому всё, что отправлено позже, уходит в никуда.
Внутри flatMap или switchToLatest помечайте и внутренний паблишер: ошибка внутреннего завершает внешнюю цепочку, и это выглядит точно так же, как замолчавший источник.
print пишет в stdout, поэтому в релизной сборке используйте handleEvents(receiveSubscription:receiveOutput:receiveCompletion:receiveCancel:receiveRequest:) и отправляйте события в Logger.
breakpointOnError() останавливает отладчик в том фрейме, где возникла ошибка.
Три раза, потому что каждая подписка заново запускает всю цепочку; share(), multicast или makeConnectable() заставляют подписчиков использовать одно выполнение апстрима.
Паблишер представляет собой рецепт, а не запущенный поток. Подписка запускает работу, и каждый подписчик получает свою подписку и свой апстрим.
share() сводится к multicast через PassthroughSubject плюс autoconnect(). Подписчики, подключившиеся до первого значения, делят одно выполнение; тот, кто подключился после отправки значения, его пропустит.
share() работает по счётчику ссылок: когда последний подписчик отменяется, апстрим отменяется, и следующий подписчик запускает новое выполнение. Он разделяет работу, но не кеширует.
makeConnectable() с явным connect() убирает гонку: сначала подключите всех подписчиков, потом запустите работу. Храните возвращённый Cancellable: если его выбросить, апстрим остановится. Доступен только при Failure == Never, поэтому сначала обработайте ошибки.
share с повтором последних значений (replay) в Combine нет. Для опоздавших подписчиков храните результат в собственном CurrentValueSubject или кешируйте его в слое данных.
В async/await та же задача «один запрос в полёте на много вызывающих» решается актором, который хранит запущенный Task и даёт каждому вызывающему дождаться его через await.
Backpressure означает, что подписчик сообщает паблишеру через Subscribers.Demand, сколько значений готов принять; sink и assign сразу запрашивают .unlimited, так что протокол фактически выключен и каждое значение проталкивается насквозь.
Как работает спрос
Подписчик получает Subscription и вызывает request(_:). Каждый receive(_:) возвращает дополнительный спрос. Спрос только накапливается, поэтому .unlimited уже не забрать назад.
Спрос замедляет только паблишеры, которые производят значения по запросу: последовательность или ваш собственный pull-источник.
Горячие источники его игнорируют
Сокет, таймер или нотификация производят значения по своим часам. PassthroughSubject отбрасывает значения для подписчика без спроса, а CurrentValueSubject хранит только последнее.
Рост памяти обычно идёт от очереди, о которой никто не подумал: receive(on: DispatchQueue.main) ставит по блоку на каждое значение в главную очередь, которая и так не успевает.
Что делать вместо этого
Снижайте частоту до дорогой стадии: throttle(latest:), debounce или collect(.byTime(_:_:)), чтобы обновлять пачками.
flatMap(maxPublishers: .max(n)) ограничивает число одновременных внутренних задач.
buffer(size:prefetch:whenFull:) ограничивает очередь, только если ниже по цепочке кто-то запрашивает ограниченный спрос; за sink он никогда не заполнится.
Пишите свой Subscriber, только если нижняя часть цепочки действительно работает по запросу: запрашивайте .max(1) и просите следующее значение, когда работа закончена.
Собственный оператор почти всегда оказывается расширением Publisher, которое комбинирует существующие операторы и возвращает AnyPublisher или some Publisher; свой Publisher с собственной Subscription оправдан только для нового источника, который сам должен соблюдать спрос и отмену.
Сначала композиция: extension Publisher where Output == String с методом, который связывает debounce и removeDuplicates, уже является полноценным собственным оператором. Корректную работу со спросом, отменой и потоками он наследует от операторов внутри.
Источник без паблишера: в Combine нет Observable.create, как в RxSwift. Прежде чем писать свои типы, попробуйте PassthroughSubject с очисткой в handleEvents(receiveCancel:) или Deferred { Future { ... } } для одного значения.
Контракт написанного вручную: receive(subscriber:) передаёт Subscription; подписка выдаёт значения, только пока остался спрос, прибавляет спрос, который возвращает каждый receive(_:), отправляет не больше одного завершения и ничего после него, а на cancel() отпускает подписчика, чтобы он освободился.
Где это ломается: request(_:) и cancel() могут прийти из разных потоков и реентрантно, изнутри receive(_:). В продакшен-коде состояние защищают блокировкой и никогда не вызывают подписчика, держа её.
Если вы пришли из RxSwift: в Combine типизированные ошибки и поддержка от Apple, но меньше операторов: нет withLatestFrom и нет share с повтором (replay), поэтому команды пишут их сами или подключают CombineExt.
Внедряйте планировщик вместо жёстко прописанного DispatchQueue.main и передавайте в тесте планировщик с виртуальными часами, которые тест двигает вперёд вручную.
Каждый оператор, завязанный на время, принимает Scheduler. Сделайте его зависимостью: продакшен передаёт главную очередь, а тест передаёт управляемый планировщик.
В Combine есть ImmediateScheduler, его хватает для receive(on:), но не для debounce или throttle: им нужно, чтобы прошло время. Команды пишут небольшой тестовый планировщик или берут пакет Point-Free combine-schedulers, в котором есть DispatchQueue.test и стёртый тип AnySchedulerOf<DispatchQueue>.
Без пакета сделайте тип дженериком по S: Scheduler; стёртый тип только упрощает сигнатуры.
Ожидание expectation с реальной задержкой делает набор тестов медленным и нестабильным (flaky), а нестабильные тесты в итоге отключают.
Проверяйте поведение, а не операторы: три быстрых нажатия дают один запрос, повторный запрос не даёт ни одного, новый запрос отменяет тот, что уже в полёте.
Держите cancellable живым весь тест, иначе пайплайн разберётся раньше, чем что-то будет записано.
Combine появился раньше Sendable и изоляции акторов, поэтому Swift 6 отвергает значения и замыкания, которые через него пересекают границы изоляции, и добавляет проверки во время выполнения, роняющие main-actor sink, который кормится из фоновой очереди.
Ошибки компиляции
Future { promise in Task { ... } }: promise не Sendable, поэтому захват его в Task отвергается.
static let с не-Sendable субъектом или паблишером помечается как небезопасное для конкурентности.
Не-Sendable значения, отправляемые из sink в Task { @MainActor in ... } или в актор.
Падения во время выполнения
Замыкание, написанное внутри @MainActor-типа, наследует изоляцию главного актора. Swift 6 вставляет проверку в замыкания, переданные в API без поддержки конкурентности, поэтому sink, получивший значение на фоновой очереди, теперь падает с «Incorrect actor executor assumption», вместо молчаливой гонки данных.
receive(on: DispatchQueue.main) перед таким sink удовлетворяет проверку, но компилятор не может это доказать, поэтому баг проявляется, только когда этот путь выполняется.
Исправления в порядке предпочтения
Доставляйте на главный поток прямо перед кодом главного актора или привязывайте через assign(to: &$property).
Делайте Sendable значения, которые пересекают границы: обычно это struct вместо класса.
На краю выходите из Combine: читайте publisher.values внутри Task на главном акторе.
@preconcurrency import Combine глушит диагностику на время миграции, но не является исправлением. Мигрируйте модуль за модулем.