Внедрение Swift Concurrency в приложениях со старыми SDK
Внедрение Swift Concurrency становится сложным, когда приложение ещё поддерживает старые SDK. Определите границы, оберните старые API и сохраните один стиль вызовов для всей команды.

Почему это быстро усложняется
Внедрение Swift Concurrency становится неудобным, когда приложению ещё долго нужно поддерживать старые SDK. Новый async-код читается сверху вниз. Старый код часто зависит от completion handlers, delegates, notifications и ручных прыжков между очередями. Оба подхода работают, но рассказывают историю по-разному.
Из-за этого одна функция превращается в два способа думать. Разработчик читает async-функцию и ждёт понятный поток, структурированную отмену и одно место для обработки ошибок. А потом тот же экран ещё и слушает delegate, ждёт callback и вручную возвращается на главную очередь. Код по-прежнему собирается, но перестаёт быть предсказуемым.
Один экран очень быстро может превратиться в мешанину из смешанных правил. Представьте экран профиля, который загружает данные через async/await, загружает аватар через callback старого SDK, отслеживает доступность сети через notifications и обновляет геолокацию через delegate. А теперь добавьте timeout, retry и индикатор загрузки. У человека, который смотрит на такой код, больше нет одной ясной модели в голове.
Отмена делает всё ещё хуже. Task может отмениться аккуратно, а старый API может полностью игнорировать отмену. Пользователь ушёл с экрана, задача завершилась, а callback всё равно срабатывает через две секунды. Так появляются странные баги: устаревший UI, двойные предупреждения или состояние, которое откатывается назад уже после ухода пользователя.
Переходы между потоками — ещё одна тихая проблема. Async-код часто прячет часть работы с очередями. Старый код заставляет постоянно думать об этом. Когда в одной функции смешаны оба подхода, люди забывают, что уже работает на main actor, а что всё ещё требует DispatchQueue.main.async. В итоге либо появляются небезопасные обновления UI, либо защитные прыжки между очередями везде подряд.
Потом начинают работать привычки команды. Большинство людей копируют последний пример, который видели, особенно под дедлайном. Если в одном pull request используются callbacks, а в следующем вокруг них уже стоит Task {}, приложение начинает дрейфовать. Через несколько месяцев команда поддерживает не один стиль кодовой базы, а ежедневный переводчик в собственной голове.
Сделайте маленький первый шаг
Многие команды усложняют этот процесс больше, чем нужно. Если пытаться перевести всё приложение сразу, получится наполовину мигрировавший код, смешанные привычки и недели лишних споров. Начните с одной функции, за которую команда отвечает от начала до конца.
Хорошими первыми кандидатами будут небольшие пользовательские сценарии: загрузка экрана аккаунта, сохранение изменений профиля или получение списка, который показывается в одном месте. Выберите то, что идёт от UI к view model и сервису, не проходя через пять общих модулей. Если частью сценария управляет другая команда, пока пропустите его.
Удачный первый участок обычно имеет четыре признака:
- один экран или одно действие пользователя
- код, который ваша команда может менять без согласований в трёх направлениях
- один сетевой вызов или одна операция со хранилищем
- понятный критерий успеха, например «экран загружается» или «сохранение завершается»
Этот выбор важнее объёма кода. Небольшой путь, который вы полностью контролируете, научит команду большему, чем крупная миграция, зависящая от старых delegates, notifications и callback'ов стороннего SDK.
С самого начала держите сторонние SDK за обёрткой. Если SDK для платежей, аналитики или авторизации всё ещё использует completion handlers, оставьте эту деталь внутри маленького адаптера. Код приложения сможет вызывать async-функцию, а обёртка по-прежнему будет общаться со SDK старым способом. Так старый паттерн остаётся в коробке, а не расползается по новым файлам.
Запишите границу одной фразой там, где её увидят все. Например: async начинается в тех view model и сервисах, которые мы контролируем; completion handlers остаются внутри обёрток и нетронутых legacy-модулей. На первый взгляд это мелочь, но в code review такая запись убирает очень много лишних обсуждений.
С первого дня задайте одно правило для нового кода: если команда пишет новый async-путь в приложении, он использует async/await. Если код должен вызвать старый API, он делает это через обёртку. Внедрение Swift Concurrency становится проще, когда люди перестают гадать, какой стиль использовать в каждом новом файле.
Этот первый шаг должен казаться почти скучным. Скучно — это хорошо. Значит, команда может выучить шаблон, не сталкиваясь одновременно с риском для всего приложения.
Проведите жёсткую границу между старым и новым кодом
Команды застревают, когда callbacks и async-код живут в одном слое. View model вызывает один API через await, другой через completion block, а третий — через что-то, где есть и то и другое. Так внедрение Swift Concurrency превращается в два постоянных способа думать вместо одного понятного пути.
Спрячьте старое поведение за адаптерами или сервисными обёртками и держите его там. Граница должна быть близко к SDK, а не в экранах, координаторах или view model. Выше этой линии оставляйте только async-функции. Ниже — все callback'и, прыжки между очередями, методы delegate и странные правила старого SDK.
Сложное держите в одной коробке
Хорошая обёртка делает больше, чем просто переводит синтаксис. Она ещё и забирает на себя неудобные вещи, о которых люди забывают через шесть недель:
- правила retry
- выбор timeout
- переключение потоков
- преобразование ошибок SDK
- странные состояния успеха вроде «nil-результат, но без ошибки»
Это важно, потому что поддержка старых SDK в Swift чаще всего ломается не эффектно, а скучно и однообразно. Один SDK возвращает ошибки в callback. Другой сообщает о сбое через status code и без объекта ошибки. Если исправлять это в трёх view model, у вас получится три разных поведения приложения.
Держите эту логику в одной обёртке и дайте приложению один async API. Например, view model должна вызывать try await paymentsService.charge(...). Ей не нужно знать, использовал ли платежный SDK delegate, completion handler или цикл повторных попыток на таймере.
Назовите границу так, чтобы код-ревью было проще
Названия должны делать разделение очевидным. LegacyPaymentsAdapter, ContactsSDKBridge или OldAuthServiceWrapper звучат просто, и в этом смысл. Проверяющий сразу видит границу и может задать один понятный вопрос: остаётся ли callback-код на старой стороне?
Не позволяйте completion handlers просачиваться обратно вверх. Как только view model принимает тип callback'а, старая модель уже пересекла границу. После этого отмена становится неясной, тестирование — запутанным, а новый код начинает копировать старые формы.
Жёсткие границы сначала кажутся немного тяжёлыми. Потом они экономят время. Если команда знает, что все новые API, с которыми работает приложение, должны быть async, люди перестают спорить о стиле в каждом pull request и начинают решать реальные продуктовые задачи.
Оборачивайте старые API, не меняя поведение
Для внедрения Swift Concurrency обёртки лучше всего работают как адаптеры, а не как переписывание. Старый SDK должен делать ту же работу и вести себя так же в пограничных случаях, а новый async-код получает более чистый интерфейс.
Начните с самого простого случая: один callback, один результат. withCheckedContinuation или withCheckedThrowingContinuation хорошо подходит, когда старый API вызывает ответ один раз и потом замолкает. Это даёт async-функцию без изменения самого SDK.
Многие команды делают обёртку умнее, чем исходный API. Звучит хорошо, но создаёт новые баги. Если старый вызов возвращал «успех или ошибка», оставьте эту форму. Если нужна дополнительная детализация, один раз преобразуйте её в единый тип результата, чтобы все вызывающие читали один и тот же контракт.
Безопасный шаблон обёртки
С delegate-случаем люди чаще всего и обжигаются. Если создать delegate внутри обёртки и никто его не удержит, он может исчезнуть раньше, чем SDK закончит работу. Держите strong reference до окончания работы, а потом освобождайте его при успехе, ошибке или timeout.
Timeout'ы тоже важны. Старые API иногда вообще не вызывают callback или вызывают его дважды, когда сеть ведёт себя странно. Ваша обёртка должна завершаться один раз, а потом игнорировать всё, что пришло позже. Помогает небольшой флаг состояния: когда побеждает первый результат, пометьте операцию как завершённую и сбрасывайте все поздние callback'и.
Простой шаблон выглядит так:
- создайте один объект-обёртку для запроса
- сохраните continuation и delegate в этом объекте
- возобновите continuation ровно один раз
- очистите сохранённые ссылки, когда запрос завершится или истечёт timeout
Используйте одну форму возврата и для успеха, и для ошибки. Это может быть throws, а может быть один enum, если старый SDK уже работает именно так. Выберите один стиль для каждой области приложения. Смешивать оба внутри одной функции быстро становится путано.
Не переводите всё на MainActor только потому, что старый код где-то трогал UI. Оставьте сеть, хранилище и парсинг вне главного потока. Переходите на MainActor только в том месте, где приложение обновляет label, состояние экрана или свойство view model, которое читает UI.
Эта граница кажется мелкой, но потом экономит кучу времени на отладку. Ваша обёртка остаётся скучной, а именно скучный код и нужен там, где старые и новые паттерны должны какое-то время жить вместе.
Дайте команде одну ментальную модель
Команды начинают тормозить, когда каждый метод выбирает свой стиль. View model запускает Task, прыгает на DispatchQueue.main, а потом ждёт completion handler от старого SDK. Приложение может продолжать работать, но код начинает казаться скользким. Люди перестают доверять тому, на каком они потоке и где появятся ошибки.
Внедрение Swift Concurrency становится намного проще, когда команда соглашается на одну публичную форму для кода приложения. Экраны и view model должны вызывать async-функции. Старые паттерны всё ещё нужны, но они должны оставаться за тонким слоем адаптеров, где команда и ожидает их увидеть.
Обычно хорошо работает простой набор правил:
- UI-код и view model вызывают async-функции и используют
await. - Сервисы, которые общаются со старыми SDK, переводят callbacks или delegates в async-результаты.
- Один метод использует один стиль. Если метод начинается с
async, не смешивайте в нём callbacks и прыжки между очередями. - В каждом слое есть одно правило работы с потоками. UI работает на
MainActor, а адаптеры скрывают те правила очередей, которые нужны старому SDK.
Последнее правило экономит много времени. Если каждый вызывающий должен помнить: «этот callback может вернуться из фоновой очереди», баги будут постоянно расползаться. Спрячьте это знание в одном месте. Тогда остальная часть приложения может читаться как обычный последовательный код.
Это ещё и упрощает code review. Проверяющий может посмотреть на экран и задать один вопрос: ждёт ли он вызов сервиса и обновляет ли состояние на main actor? Ему не нужно идти по цепочкам delegate'ов или гадать, сработает callback один раз, два раза или не сработает вовсе.
Начинайте с happy path. Сделайте так, чтобы экран загрузил данные, получил результат и отобразил его. После этого добавьте отмену на случай, когда пользователь уходит со страницы, а затем — преобразование ошибок, которое превращает грязные сбои SDK в несколько ошибок уровня приложения. Когда команды пытаются одновременно спроектировать успех, отмену, retry, мосты для delegate и правки потоков, они обычно сохраняют обе модели на месяцы.
Если граница остаётся жёсткой, старый код может жить там, где это необходимо, а остальная часть приложения будет ощущаться современной и спокойной.
Простой пример из реального приложения
Экран профиля — хорошее место для старта, потому что одна кнопка делает одну задачу. Пользователь выбирает фото, нажимает «Сохранить», а старый SDK загружает аватар с помощью progress callbacks и финального completion handler.
Экран не должен учить оба подхода сразу. Нажатие кнопки вызывает одну async-метод у view model, а старый SDK остаётся за этой линией.
@MainActor
func saveAvatar(_ data: Data) async {
isSaving = true
errorMessage = nil
do {
let url = try await avatarService.uploadAvatar(data) { progress in
self.uploadProgress = progress
}
avatarURL = url
} catch is CancellationError {
errorMessage = "Upload canceled"
} catch {
errorMessage = "Upload failed"
}
isSaving = false
}
Обёртка берёт на себя неудобную часть. Она превращает callback завершения SDK в ожидаемый результат, но оставляет progress отдельно. Такое разделение делает код читаемым. Progress может меняться двадцать раз за одну загрузку. Финальный результат приходит один раз.
func uploadAvatar(
_ data: Data,
onProgress: @escaping (Double) -> Void
) async throws -> URL {
try await withCheckedThrowingContinuation { continuation in
let request = sdk.upload(
data: data,
progress: { value in onProgress(value) },
completion: { result in continuation.resume(with: result) }
)
cancellationStore.set(request)
}
}
В production-коде добавьте небольшой guard, чтобы поздний callback не смог возобновить continuation второй раз. Этот баг встречается чаще, чем ожидают многие команды, особенно когда пользователи быстро отменяют и повторяют действие на медленном соединении.
Тесты должны покрывать раздражающие случаи, а не только happy path:
- успешный сценарий возвращает новый URL аватара
- отмена останавливает запрос и оставляет старый аватар
- повторная попытка после ошибки запускает свежую загрузку
- поздний completion после отмены не обновляет экран
Именно такие изменения делают миграцию на async/await понятной. View model владеет одной async-точкой входа, обёртка прячет callback-мешанину, а команда сохраняет одну ментальную модель в новом коде. Так внедрение Swift Concurrency приживается в приложении, которое всё ещё зависит от поддержки старых SDK в Swift.
Ошибки, из-за которых обе модели живут слишком долго
Команды обычно застревают, когда относятся к миграции как к уборке, а не как к части поставки продукта. При внедрении Swift Concurrency самый быстрый путь к путанице — слишком долго держать оба стиля активными в одной и той же функции.
Одна частая ошибка — оборачивать вообще все старые API до того, как вы что-то выпустите. Это выглядит аккуратно, но превращает миграцию в переписывание. Команда тратит недели на адаптеры, а потом разбирает пограничные случаи, которых раньше никто не касался. Лучше двигаться меньшими шагами: обернуть только те вызовы, которые нужны новому экрану или потоку прямо сейчас, остальное оставить как есть и выпускать продукт.
Ещё одна ловушка — одна функция, которая пытается служить сразу двум мирам. Если метод и возвращает async-результат, и одновременно принимает completion block, никто не понимает, какой путь владеет ошибками, отменой или retry. Именно здесь и начинается дублирование работы. Выберите одну публичную форму для каждой границы. Если нужен мост, спрячьте его внутри небольшой обёртки, чтобы остальная часть приложения видела один стиль.
@MainActor может превратиться в костыль. Когда разработчики добавляют его везде, чтобы убрать падения или предупреждения, они прячут настоящие правила работы с потоками вместо того, чтобы исправить их. Тогда тяжёлая работа незаметно переезжает на main thread, и приложение начинает тормозить. Используйте @MainActor там, где действительно живёт UI-состояние. Для всего остального явно указывайте, где выполняется работа и где возвращаются результаты.
Отмену часто игнорируют, потому что многие старые SDK не умеют отменяться аккуратно. Но это не значит, что ваше приложение тоже должно её игнорировать. Если пользователь ушёл с экрана, ваша задача больше не должна интересоваться результатом. Даже если старый запрос всё-таки завершится, ваш мост может просто отбросить callback вместо того, чтобы обновить устаревшее состояние.
Последняя ошибка скучная и дорогая: нет тестов на странное поведение callback'ов. Старые API часто вызывают callback дважды или вообще не вызывают его, если что-то идёт не так. Async-обёртки делают такие баги труднее заметными, потому что зависание проявляется далеко от источника.
Следите за такими признаками при code review:
- новый async-код ждёт обёртки, которую пока никто не использует
- один API открывает и completion, и async-форму в одной точке входа
@MainActorпоявляется без понятной UI-причины- отменённая работа всё равно обновляет экран
- тесты никогда не симулируют двойной callback или его отсутствие
Если эти паттерны остаются в кодовой базе, команда будет держать две ментальные модели месяцами. Код может собираться, но повседневная отладка станет медленнее.
Быстрая проверка перед каждым merge
Merge может выглядеть чистым и при этом оставить старый callback-стиль жить ещё в одном углу приложения. Именно так команды потом через полгода отлаживают одну и ту же функцию двумя разными способами.
Короткий проход по проверке помогает больше, чем длинный план миграции. Цель проста: каждый новый call site должен казаться скучным, предсказуемым и таким же, как предыдущий.
Используйте это как checklist перед merge:
- Читайте новый код от точки входа экрана или функции внутрь. Если свежий код напрямую тянется к completion handlers, delegates или notification callbacks, остановитесь. Новый код должен вызывать async-функции и отдавать старый API одной обёртке.
- Откройте обёртку и проверьте, что она полностью владеет грязной частью. Callback, настройка delegate, resume continuation и очистка должны жить там, а не просачиваться в view model, контроллеры или бизнес-логику.
- Пройдите каждый async-путь один раз на случаи ошибки. Хорошая обёртка обрабатывает отмену, пропускает реальные ошибки и не ждёт бесконечно, если старый SDK может зависнуть. Timeout'ы часто стоит добавить на границе, даже если в старом API их никогда не было.
- Проверьте каждое обновление UI. Если изменения состояния влияют на labels, alerts или навигацию, убедитесь, что эта работа попадает на MainActor. Многие race-баги выглядят случайными, пока вы не найдёте один фоновой поток, трогающий UI-состояние.
- Посмотрите на тесты для неприятных таймингов. Нужен хотя бы один тест, где callback приходит поздно, и ещё один, где он ошибочно срабатывает дважды. Поведение старых SDK не всегда вежливое, и обёртки должны защищать от него остальную часть приложения.
Одно маленькое правило ускоряет review: если pull request добавляет async-код, проверяющий должен уметь показать одну границу, где старое поведение переводится ровно один раз. Если он находит две или три границы для одной и той же функции, дизайн начинает дрейфовать.
Это звучит строго, но экономит время. Команды, которые занимаются внедрением Swift Concurrency, обычно нормально справляются с синтаксисом и плохо — с границами. Чистые границы и есть то, что не даёт миграции на async/await превратиться в вечную поддержку старого Swift-приложения.
Что делать дальше
Выберите одну функцию на этой неделе и переведите только этот участок. Хорошая цель — экран, sync-задача или upload-flow, который уже работает, использует один старый SDK и каждый день создаёт трение. Держите объём таким, чтобы команда успела закончить работу, проверить её и сделать выводы за несколько дней.
Запишите правила на одной странице и не усложняйте их. Решите, где живут обёртки, когда вы используете actors и как называете bridged API. Например, можно сказать, что код, который работает со SDK, внутренне оставляет completion handlers, код, видимый приложению, открывает async-функции, а actors защищают общее изменяемое состояние, такое как кэши или данные сессии.
Короткий checklist помогает больше, чем длинный план миграции:
- Переносите одну функцию, а не целый модуль
- Оборачивайте старые API, не меняя поведение
- Отклоняйте pull request'ы, где в одном слое смешаны стили
- Отслеживайте все места, где SDK всё ещё заставляет использовать callbacks, delegates или прыжки на main thread
- Через неделю пересмотрите правила и ужесточите их
Ловите смешанный стиль рано. Если pull request добавляет async-код в одном файле и новые completion handlers в следующем, это расхождение быстро расползётся. Команды месяцами отлаживают оба паттерна только потому, что никто не остановил дрейф границы, пока он был маленьким.
Измеряйте препятствия простыми числами. Считайте, сколько вызовов SDK всё ещё требует обёрток, где перестаёт работать отмена и где вы по-прежнему вручную прыгаете обратно на main actor. Эти цифры показывают, является ли поддержка старых SDK небольшой помехой или настоящей причиной, почему миграция на async/await кажется застопорившейся.
Если команде нужен второй взгляд, лучше привлечь его до того, как правила закостенеют вокруг плохих привычек. Oleg Sotnikov консультирует стартапы и продуктовые команды по границам миграции, правилам запуска и AI-first инженерным процессам. Такой внешний обзор особенно полезен, когда в кодовой базе ещё есть ясный путь, а не тогда, когда во все слои уже встроены обе модели.