Combine
Реактивный фреймворк Apple — publisher'ы, подписчики, операторы и связывание во MVVM.
13 вопросов
JuniorТеорияОчень частоЧто такое publisher и подписчик в Combine и как они связаны?
Что такое publisher и подписчик в Combine и как они связаны?
Publisher выдаёт поток значений во времени плюс завершение или ошибку. Подписчик запрашивает и получает их, а subscription управляет спросом, поэтому значения идут только по запросу.
Типичные ошибки
- ✗Думают, что publisher выдаёт значения сразу, не считаясь со спросом подписчика
- ✗Путают подписчика с объектом subscription, управляющим жизненным циклом
- ✗Считают, что любой publisher отдаёт ровно одно значение, а не поток
Уточняющие вопросы
- →Чем объект Subscription управляет между publisher и подписчиком?
- →Как publisher сигнализирует о завершении потока в отличие от ошибки?
JuniorТеорияОчень частоЧто делают @Published, CurrentValueSubject и AnyCancellable в Combine?
Что делают @Published, CurrentValueSubject и AnyCancellable в Combine?
@Published оборачивает свойство так, что присваивания публикуются в publisher через префикс $. CurrentValueSubject ещё и хранит и переотдаёт текущее значение. Возвращённый AnyCancellable удерживают, чтобы подписка жила.
Типичные ошибки
- ✗Забывают префикс
$для доступа к publisher свойства@Published - ✗Не удерживают
AnyCancellable, из-за чего подписка отменяется сразу - ✗Путают
CurrentValueSubject(отдаёт текущее значение) сPassthroughSubject(без хранимого значения)
Уточняющие вопросы
- →Когда вы выберете
CurrentValueSubjectвместоPassthroughSubject? - →Где обычно хранят cancellable-объекты во view model?
JuniorТеорияЧастоПочему каждый новый подписчик на dataTaskPublisher запускает свой запрос и что меняет share()?
Почему каждый новый подписчик на dataTaskPublisher запускает свой запрос и что меняет share()?
dataTaskPublisher — холодный publisher: каждая подписка, удерживаемая своим AnyCancellable, заново выполняет рецепт и шлёт свой запрос. share() делает его горячим, мультикастя подписку всем, поэтому поздний подписчик теряет ранние значения.
Типичные ошибки
- ✗Считают, что холодный publisher делит один запрос между подписчиками
- ✗Думают, что
share()переотдаёт или кэширует значения для поздних подписчиков - ✗Путают
share()(мультикаст) с хранимым, переотдаваемым значением
Уточняющие вопросы
- →Чем
share()отличается отmulticast(_:)сCurrentValueSubject? - →Что именно теряет поздний подписчик на
share()-publisher?
JuniorКодЧастоПостройте небольшой конвейер с map, filter, compactMap и scan и скажите, что выдаёт каждый
Постройте небольшой конвейер с map, filter, compactMap и scan и скажите, что выдаёт каждый
map преобразует каждый элемент один-к-одному и не отбрасывает. compactMap преобразует и отбрасывает nil, убирая нечисловые строки. filter пропускает лишь элементы по предикату. scan хранит аккумулятор и выдаёт текущую сумму после каждого элемента.
Типичные ошибки
- ✗Путают
compactMap(отбрасываетnil) сmap(преобразует один-к-одному) - ✗Думают, что
scanвыдаёт лишь раз при завершении, а не на каждый элемент - ✗Считают, что
filterпреобразует значения, а не пропускает их насквозь
Уточняющие вопросы
- →Чем
scanотличается отreduceпо тому, что он выдаёт? - →Когда вы возьмёте
compactMapвместоmapплюсfilter?
JuniorТеорияЧастоPassthroughSubject против CurrentValueSubject — какой выдаёт значение позднему подписчику и почему это важно для UI?
PassthroughSubject против CurrentValueSubject — какой выдаёт значение позднему подписчику и почему это важно для UI?
CurrentValueSubject хранит текущее значение и переотдаёт его каждому новому подписчику, поэтому поздний подписчик сразу получает актуальное состояние. PassthroughSubject ничего не хранит и передаёт только значения, отправленные после подписки.
Типичные ошибки
- ✗Считают, что
PassthroughSubjectпереотдаёт своё последнее значение как хранимую переменную - ✗Думают, что поздний подписчик на
CurrentValueSubjectобязан ждать следующей эмиссии - ✗Считают два subject взаимозаменяемыми при заполнении начального состояния UI
Уточняющие вопросы
- →Как задать начальное состояние UI, если есть только
PassthroughSubject? - →Какое значение выдаёт
CurrentValueSubjectв момент подписки?
MiddleДебаггингЧастоКонвейер замолкает после одной сетевой ошибки — почему и как catch/retry/replaceError сохраняют его живым?
Конвейер замолкает после одной сетевой ошибки — почему и как catch/retry/replaceError сохраняют его живым?
Завершение .failure терминально — оно рвёт подписку, и дальше ничего не течёт. Ставьте retry, catch или replaceError на внутренний publisher внутри flatMap, чтобы ошибка гасилась там, а внешний поток $query продолжал выдавать значения.
Типичные ошибки
- ✗Считают, что завершение
.failureвосстановимо без оператора - ✗Ставят
catch/retryна внешнюю цепочку вместо внутреннего publisher - ✗Думают, что пустое замыкание
receiveCompletionдержит поток живым
Уточняющие вопросы
- →Почему
catchна внутреннем publisher защищает внешний поток? - →Когда
retryсам по себе хуже, чемretryвместе сcatch?
MiddleКодЧастоПостройте поисковый конвейер — debounce → removeDuplicates → switchToLatest — привязанный к UI
Постройте поисковый конвейер — debounce → removeDuplicates → switchToLatest — привязанный к UI
Свяжите $query.debounce(...), removeDuplicates(), затем map каждого запроса в search(_:) и switchToLatest(), чтобы новый запрос отменял текущий. Завершите receive(on:) на главном потоке и assign(to: &$results).
Типичные ошибки
- ✗Берут
flatMapтам, где нуженswitchToLatest, и устаревшие результаты затирают свежие - ✗Меняют состояние UI не на главном потоке без
receive(on:) - ✗Убирают
debounce, запуская запрос на каждое нажатие
Уточняющие вопросы
- →Почему
switchToLatestотменяет предыдущий запрос, аflatMapнет? - →Где разместить
receive(on:)относительноassign?
MiddleКодЧастоОберните одноразовый callback-API в Future, а поток delegate-колбэков — в publisher на основе subject
Оберните одноразовый callback-API в Future, а поток delegate-колбэков — в publisher на основе subject
Для одноразового вызова — Future: выполните loadUser и передайте Result в promise; срабатывает раз, кэшируя результат. Для delegate держите приватный PassthroughSubject, шлите в него из колбэков и отдавайте eraseToAnyPublisher() для чтения.
Типичные ошибки
- ✗Берут
Futureдля многозначного потока delegate - ✗Открывают
PassthroughSubjectвместо стёртого publisher только для чтения - ✗Ждут, что
Futureзаново запустит замыкание на каждой подписке
Уточняющие вопросы
- →Почему
Futureкэширует и переотдаёт свой единственный результат? - →Как обеспечить поток delegate через
CurrentValueSubject?
MiddleДебаггингЧастоUI обновляется не на главном потоке — что чинит: receive(on:) или subscribe(on:)? Объясните, что переносит каждый.
UI обновляется не на главном потоке — что чинит: receive(on:) или subscribe(on:)? Объясните, что переносит каждый.
Нужен receive(on: DispatchQueue.main) прямо перед sink. receive(on:) переносит значения, завершение и подписчика ниже него на этот scheduler. subscribe(on:) задаёт лишь, где стартует подписка и восходящая работа, поэтому доставку в UI он не чинит.
Типичные ошибки
- ✗Думают, что
subscribe(on:)управляет тем, где доставляются значения - ✗Ставят
receive(on:)выше по цепочке, а не прямо передsink - ✗Считают, что
sinkпо умолчанию запускает замыкание значения на главном потоке
Уточняющие вопросы
- →Что будет, если вызвать
receive(on:)дважды в разных местах? - →Почему
subscribe(on:)не влияет на поток подписчика?
MiddleДебаггингЧасто.sink { self.name = $0 } в self.cancellables — почему view model не освобождается и как это исправить?
.sink { self.name = $0 } в self.cancellables — почему view model не освобождается и как это исправить?
self владеет cancellables, тот — AnyCancellable, чьё замыкание sink сильно захватывает self — цикл удержания, поэтому self не освобождается. Исправьте списком захвата [weak self] или через assign(to: &$name), который self не захватывает.
Типичные ошибки
- ✗Сильно захватывают
selfв замыканииsink, хранимом наself - ✗Винят
store(in:)или@Publishedвместо захвата в замыкании - ✗Забывают, что
assign(to: &$x)не захватываетself
Уточняющие вопросы
- →Почему
assign(to: &$name)не создаёт цикл удержания? - →Когда
[unowned self]тут опаснее, чем[weak self]?
MiddleДизайнИногдаВы поддерживаете поисковый конвейер на системе реактивного программирования Combine (debounce → сетевой вызов → receive(on:) → публикуемые результаты), и его тесты нестабильны. Они зовут sleep, чтобы переждать debounce и асинхронную доставку, поэтому проходят на быстром CI и падают по таймауту на медленном. Опишите, как сделать конвейер детерминированно тестируемым без реальных ожиданий: как внедрить scheduler, чтобы тесты продвигали виртуальное время вместо реального, как продакшн-код должен получать этот scheduler, чем заменить живой сетевой вызов и как проверять точную последовательность значений (и завершение), которую выдаёт конвейер для заданного ввода. Объясните компромисс между абстракцией scheduler за протоколом и прямой зависимостью от конкретного тестового типа scheduler.
Вы поддерживаете поисковый конвейер на системе реактивного программирования Combine (debounce → сетевой вызов → receive(on:) → публикуемые результаты), и его тесты нестабильны. Они зовут sleep, чтобы переждать debounce и асинхронную доставку, поэтому проходят на быстром CI и падают по таймауту на медленном. Опишите, как сделать конвейер детерминированно тестируемым без реальных ожиданий: как внедрить scheduler, чтобы тесты продвигали виртуальное время вместо реального, как продакшн-код должен получать этот scheduler, чем заменить живой сетевой вызов и как проверять точную последовательность значений (и завершение), которую выдаёт конвейер для заданного ввода. Объясните компромисс между абстракцией scheduler за протоколом и прямой зависимостью от конкретного тестового типа scheduler.
Внедряйте scheduler через протокол (или AnyScheduler): в продакшене DispatchQueue.main, в тестах управляемый scheduler с виртуальным временем, без sleep. Сетевой вызов замените фиксированным publisher, собирайте значения и проверяйте точную последовательность и завершение.
Типичные ошибки
- ✗Используют
sleepили таймауты вместо управляемого scheduler - ✗Тестируют против реальной сети вместо заглушечного publisher
- ✗Проверяют лишь итоговое значение, а не выданную последовательность
Уточняющие вопросы
- →Как тестовый scheduler продвигает виртуальное время по требованию?
- →Почему проверять всю последовательность, а не только последнее значение?
SeniorТеорияИногдаСистема реактивного программирования Combine или async/await + AsyncSequence — что Combine делает лучше?
Система реактивного программирования Combine или async/await + AsyncSequence — что Combine делает лучше?
Для линейных запрос-ответ берите async/await — проще, без cancellable, с нативной отменой. Combine оставьте для декларативных конвейеров с debounce/combineLatest, backpressure по спросу и связывания через @Published; мост — через .values.
Типичные ошибки
- ✗Считают, что
async/awaitполностью заменяетCombineв любой фиче - ✗Думают, что
AsyncSequenceдаёт тот же многооператорный конвейер, что и Combine - ✗Упускают, что
Combineдаёт backpressure по спросу, которого нет уawait
Уточняющие вопросы
- →Как превратить publisher в
AsyncSequence? - →Какая модель даёт backpressure по спросу, которого нет у
await?
SeniorКодИногдаРефакторинг UIKit MVVM ProductViewModel на Combine
Рефакторинг UIKit MVVM ProductViewModel на Combine
Откройте состояние через @Published-свойства (или CurrentValueSubject) вместо своего Observable и уберите неиспользуемый context: UIViewController, чтобы модель не зависела от UIKit. Внедряйте репозитории как протоколы, а контроллер пусть подписывается и хранит свои AnyCancellable.
Типичные ошибки
- ✗Подписываются, не сохраняя cancellable, из-за чего связывания сразу рвутся
- ✗Создают репозитории прямо в init вместо внедрения протоколов
Уточняющие вопросы
- →Как внедрение репозиториев через протоколы сделает эту view model тестируемой?
- →Где должно формироваться название кнопки и почему не в замыкании контроллера?