Паттерны Swift concurrency для мобильных приложений с активной синхронизацией
Паттерны Swift concurrency помогают мобильным приложениям с активной синхронизацией справляться с retries, офлайн-работой и ограничениями background без дублирующихся задач и сломанного состояния.

Почему синхронизация ломается на телефонах
Телефоны постоянно прерывают работу. Пользователь заходит в лифт, переключает приложения, блокирует экран или на десять секунд теряет связь. Этого достаточно, чтобы сломать sync-логику, которая на тестах выглядела нормально.
Swift concurrency помогает упорядочить async-код, но не меняет сам девайс. Ваше приложение всё равно работает на слабых сетях, в рамках батарейных ограничений и внутри операционной системы, которая может приостановить работу в любой момент.
Частая ошибка возникает в середине многошагового сохранения. Приложение отправляет одну запись, начинает следующую, а сеть пропадает между ними. В итоге на сервере половина изменения, на телефоне — полная версия, и ни одна сторона не понимает, что произошло.
Пользователи только усугубляют ситуацию, не желая этого. Если кнопка Save выглядит зависшей хотя бы на секунду, многие нажимают её ещё раз. Из-за этого одна и та же задача может уйти дважды, появятся дубликаты или начнутся два обновления, которые гонятся друг за другом.
Проблемы создаёт и время. Пользователь нажимает Save, а потом приложение закрывается до завершения работы. Иногда запрос доходит до сервера уже после выхода из приложения. Иногда — нет. Если приложение не видит разницу, оно может повторить уже успешно выполненную работу.
Background-режим добавляет ещё больше сложностей. Когда приложение уходит из foreground, iOS может приостановить задачу или оборвать её раньше времени. Простая на рабочем столе sync-задача в реальной поездке может остановиться на середине.
Особенно неприятны поздние ответы. Пользователь меняет номер телефона клиента, а через несколько секунд исправляет его ещё раз. Если более старый ответ сервера приходит последним, он может перезаписать более новое локальное значение. Код выполнился правильно. Порядок событий — нет.
Именно поэтому приложения с активной синхронизацией ломаются в маленьких, скучных моментах, а не в эффектных падениях. Передача данных — не самая сложная часть. Сложнее всего определить одну единицу работы, когда нажатия повторяются, сеть исчезает, а ответы приходят не по порядку.
Определяйте границы задач вокруг устойчивых шагов
Начинайте с одного реального действия пользователя, а не с потоков, actors или API. Выберите такой flow, как «сохранить заказ», и пройдите его от нажатия кнопки до последнего обновления на сервере.
Многие команды делают границу слишком широкой. Они считают весь процесс сохранения одной задачей, а потом повторная попытка запускает всё заново и создаёт дубликаты. Более маленькие границы проще понимать и проще восстанавливать после сбоя.
Запишите все side effects по порядку: каждую запись в локальную базу, загрузку файла, API-вызов и изменение статуса, которое показывается в приложении. Если вы не можете указать на конкретный side effect, значит, граница ещё не определена.
Хорошо работает простое правило: разделяйте flow везде, где повторный запуск может причинить вред. Если повторение одного шага может создать второй заказ, повторно списать деньги или снова загрузить тот же файл, этому шагу нужна отдельная стадия.
Для flow «сохранить заказ» последовательность может выглядеть так:
- Записать заказ в локальное хранилище.
- Создать sync-задачу со стабильным ID.
- Попросить сервер создать или обновить заказ.
- Загрузить все вложения.
- Пометить задачу как завершённую локально.
У каждого шага должен быть один чёткий критерий успеха. «Начато» — это не успех. «Локальная транзакция зафиксирована» — это успех. «Сервер вернул ID заказа» — это успех. «Все вложения загрузились, и приложение сохранило их remote ID» — это успех.
После каждого устойчивого шага сохраняйте checkpoint. На практике это обычно локальная запись со статусом вроде draft, queued, remoteCreated, filesUploaded или done. Когда приложение снова проснётся, оно должно читать это состояние и продолжать с него, а не гадать.
Так async-код тоже становится чище. Одна задача делает один устойчивый кусок работы, фиксирует результат и останавливается. Следующая задача читает checkpoint и выполняет следующий шаг.
Если граница кажется размытой, скорее всего, так и есть. Хорошая граница позволяет быстро ответить на два вопроса: «Что уже успешно завершилось?» и «Что можно безопасно запустить ещё раз?»
Разделяйте действия пользователя и background-синхронизацию
Когда человек нажимает «Save», сначала завершайте именно действие пользователя. Запишите изменение в локальное хранилище, обновите экран и пометьте запись как ожидающую синхронизацию. Это ощущается быстро и делает приложение полезным даже при пропадающей сети.
Практичный подход простой: экран обрабатывает локальное изменение, а отдельная очередь занимается серверной работой. Если запрос идёт десять секунд, падает дважды или должен ждать, пока телефон снова выйдет в сеть, пользователю не стоит всё это время смотреть на экран загрузки.
Распространённая ошибка — позволить view model управлять долгой сетевой задачей. Экраны появляются и исчезают. Люди закрывают приложение, переключаются между вкладками или блокируют телефон. Если sync-логика живёт внутри недолговечной модели экрана, ей трудно управлять повторными попытками и восстановлением.
Держите слой экрана тонким. Пусть он собирает ввод, сохраняет локально и показывает состояние вроде «pending», «syncing» или «failed». Настоящую sync-задачу вынесите в очередь вне экрана, обычно рядом со слоем хранения данных.
Эта очередь должна надёжно сохранять задачи, чтобы они переживали перезапуск, повторять попытку с тем же job ID вместо создания новой работы, возобновляться при повторном открытии приложения или когда система даёт background time, а также публиковать изменения статуса, чтобы UI мог их показать.
Хороший пример — sales app. Представитель меняет карточку клиента в лифте без связи. Приложение сразу сохраняет новый номер телефона локально и показывает значок ожидания. Позже, когда устройство снова подключается, очередь отправляет обновление. Представителю не нужно ничего вводить заново.
Такое разделение успокаивает код. Действие пользователя имеет одну задачу: зафиксировать локальное намерение. Фоновый worker имеет одну задачу: привести локальное намерение в соответствие с сервером. Когда эта граница ясна, повторные попытки становятся безопаснее, офлайн-работа — проще, а приложение кажется быстрее даже на медленной сети.
Делайте повторные попытки безопасными
Повторная попытка должна повторять одно и то же намерение, а не создавать новое. Если пользователь сохраняет заметку, создаёт заказ или загружает фото, приложению нужен один request ID, который остаётся тем же во всех retry. Сгенерируйте его один раз, сохраните локально и отправляйте снова после timeout, перезапуска приложения или обрыва сети. Это одно решение убирает много дубликатов.
Делайте retry-единицы маленькими. Не запускайте всю sync-цепочку заново, если сломался только один шаг. Если приложение создаёт клиента, загружает вложение, а потом отправляет activity log, это три отдельные стадии. Когда ломается шаг три, повторяйте только шаг три. Если начать с первого шага, можно создать того же клиента дважды.
С timeout'ами нужна особая осторожность. Timeout не означает, что сервер отклонил запрос. Часто он означает лишь, что приложение перестало ждать раньше, чем пришёл ответ. Считайте это состояние неопределённым. Прежде чем повторять запрос, проверьте, не завершил ли сервер работу уже сам. Если проверить нельзя, повторяйте только тогда, когда request ID делает операцию безопасной.
Сохраняйте server ID сразу, как только сервер его вернул. Не ждите, пока закончится весь sync-flow. Если приложение получило server ID для новой записи и упало через секунду, этот ID всё равно важен. При следующем запуске приложение сможет продолжить обновлять ту же запись, а не создавать вторую.
Некоторые повторные попытки могут реально навредить. Отключайте автоматические retries, если ещё одна попытка может повторно списать деньги, отправить сообщение дважды, дважды отправить заказ или запустить на сервере одноразовый workflow.
В таких случаях приостанавливайте задачу, помечайте её на проверку и показывайте в приложении понятный статус. Пользователь спокойно переживёт задержку обновления. Разбирать дублирующиеся счета — намного хуже.
Хорошее простое правило: повторяйте только маленькие idempotent-шаги и сразу сохраняйте все ID, которые возвращает сервер. Sync должен ощущаться скучным.
Проектируйте систему как offline-first
Телефоны теряют связь в лифтах, паркингах и на переполненных мероприятиях. Если приложение работает только тогда, когда каждый запрос сразу доходит до сервера, обычное использование быстро превращается в потерянные изменения и растерянных пользователей.
Лучший default прост: фиксируйте, что хотел сделать пользователь, сохраняйте это локально и синхронизируйте позже. На практике это значит, что вы queue'те намерение, а не сырые нажатия кнопок. Если человек три раза нажимает «Save» в заметке клиента, приложение должно хранить одно понятное действие вроде «обновить текст заметки до этого финального значения», а не три отдельных события нажатия.
Храните небольшой локальный журнал изменений
Держите локальные изменения в простом и предсказуемом порядке. Лёгкой очереди с временными метками, ID записей и ожидаемым изменением часто вполне достаточно. Порядок важен, потому что более поздние действия могут зависеть от более ранних. Если представитель по продажам сначала создаёт новый контакт, а потом меняет номер телефона, приложение должно синхронизировать эти изменения в том же порядке.
Swift concurrency здесь помогает, но само правило несложное. Одна задача записывает локальное намерение. Другая задача читает эту очередь и отправляет работу, когда условия позволяют. Разделение этих задач помогает легче понимать поведение приложения, когда сеть исчезает.
Когда связь возвращается, синхронизируйте маленькими партиями. Десять небольших обновлений обычно безопаснее, чем один огромный push, который зависнет на середине. Маленькие партии также упрощают повторные попытки, потому что вы повторяете только незавершённую часть.
Показывайте состояние синхронизации явно
Пользователь должен видеть, что ещё ждёт синхронизации. Ненавязчивая метка «Pending», счётчик sync или статус «Updated locally» могут убрать массу обращений в поддержку. Тишина заставляет людей нажимать снова, перезагружать экраны или думать, что приложение сломалось.
Конфликты тоже требуют ясности. Если версия на сервере изменилась, пока телефон был офлайн, не стоит гадать, когда вопрос касается денег, имён, дат или клиентских записей. Пометьте такой элемент как требующий проверки и спросите пользователя, какую версию оставить.
Лучший offline-flow ощущается беззаботно. Люди вносят изменения, продолжают работать и доверяют приложению догнать их, когда сеть вернётся.
Работайте в пределах ограничений background в iOS
iOS может приостановить или завершить background-работу почти без предупреждения. Если вашему sync-flow нужно пять минут непрерывной работы, в реальности он будет ломаться, особенно когда пользователь блокирует телефон, теряет сигнал или открывает более тяжёлое приложение.
Считайте background time коротким и непредсказуемым. Делите работу на самый маленький кусок, который всё ещё даёт реальный результат: одна загруженная запись, одна скачанная страница, одно обработанное изображение, одна партия из десяти небольших изменений. Завершите этот кусок, сохраните результат, а потом решите, есть ли время на следующий.
Мыслите небольшими единицами
Один огромный sync-task выглядит аккуратно в коде, но создаёт хрупкое поведение. Если система остановит его на 80 процентах, часто уже невозможно понять, что успело завершиться, а что нет.
Меньшую задачу проще повторить и проще продолжить. Отмена тоже пугает меньше, потому что вы теряете секунды работы, а не минуты.
Практичный подход выглядит так:
- Начинайте с самой маленькой полезной единицы синхронизации.
- Запрашивайте background time только тогда, когда этот кусок действительно в нём нуждается.
- Записывайте progress в хранилище после каждого завершённого куска.
- Останавливайтесь аккуратно, когда приходит отмена.
- На следующем запуске продолжайте с последней сохранённой точки.
Это работает лучше, чем один длинный Task, который пытается сделать всё до того, как система это заметит.
Сохраняйте состояние до cutoff
Не ждите конца sync-цикла, чтобы сохранить состояние. Сохраняйте его после завершения каждого куска. Храните cursor, последний synced change token, ID уже загруженных записей или следующую страницу для загрузки. Когда iOS оборвёт работу, приложение должно точно знать, с какого места продолжать.
Отмена требует такого же внимания. Когда Task.isCancelled становится true, переставайте запускать новую работу. Завершите маленькую запись, которую уже начали, сохраните точку продолжения и выйдите. Так вы получите аккуратную передачу управления вместо наполовину завершённой транзакции.
Если у приложения есть 50 локальных изменений для отправки, не делайте одну задачу, которая отправляет все 50 и обновляет локальное состояние только в конце. Отправьте одно изменение или небольшую партию, отметьте её как завершённую, сохраните progress и двигайтесь дальше. Если приложению дали 20 секунд в background, вы можете успеть 12 изменений. При следующем запуске вы начнёте с 13-го, а не с первого.
Реальный пример: sales rep обновляет карточку клиента
Представитель по продажам завершает встречу, садится в поезд и открывает карточку клиента, пока связь снова не пропала. Она добавляет несколько заметок со встречи и прикрепляет фото подписанного документа. Ошибка — считать всё это одно большое сохранение.
Лучше разбить процесс на маленькие задачи с понятными границами.
Когда она нажимает Save, приложение сразу записывает заметку на устройство. Эта локальная запись должна завершаться быстро и сразу обновлять экран, даже без сети. Если приложение ждёт загрузку фото, прежде чем сохранить текст, всё действие ощущается сломанным.
Фото идёт по другому пути. Приложение создаёт одну upload-задачу, даёт ей стабильный upload ID и кладёт её в очередь. Если поезд проходит через тоннель, iOS приостанавливает приложение или запрос выходит по timeout, приложение не создаёт новую задачу каждый раз. Оно повторяет ту же задачу с тем же upload ID.
Это защищает от частой проблемы. Без стабильного ID три retry могут оставить на сервере три копии одного и того же фото. С одним и тем же ID сервер может сказать: «У меня уже есть этот файл» — и сохранить одну копию.
Запись может проходить через несколько простых состояний: saved locally, waiting for upload, syncing и synced.
Эти состояния важны, потому что «saved» и «synced» — это не одно и то же. Представитель не должен потерять свою заметку, поэтому приложение сначала сохраняет её на устройстве. Но помечать карточку клиента как полностью синхронизированную нужно только после того, как сервер получил обновлённую заметку и загрузка фото завершилась.
Это работает и после перезапуска приложения. Если она закроет приложение на одной станции и откроет его позже, очередь всё ещё будет знать, что ожидает обработки. Экран может сразу показать заметку, показать, что фото ещё нужно загрузить, и завершить задачу, когда сеть вернётся.
Именно к такой форме и стоит стремиться: быстрые локальные записи, отдельная фоновая работа, стабильные ID для retry и sync-бейдж, который означает, что сервер действительно получил всё.
Ошибки, из-за которых появляется лишняя работа
Много дублирующейся работы начинается с одной слишком большой async-задачи. Приложение создаёт запись, загружает три файла, обновляет удалённый статус и помечает всё завершённым одним блоком. В коде это выглядит аккуратно, но ломается в тот момент, когда сеть пропадает после второго шага. При retry приложение часто отправляет весь payload заново и создаёт вторую запись или загружает те же файлы дважды.
Безопаснее разбить работу на маленькие задачи со стабильными ID, чтобы каждая могла повторяться отдельно.
Ещё одна частая ошибка — хранить sync-state внутри объекта экрана. View model знает, что черновик «uploading», но пользователь закрывает экран, приложение приостанавливается или процесс умирает в background. Состояние исчезает вместе с экраном. Когда приложение открывается снова, оно уже не знает, что было выполнено, и начинает всё сначала.
Ситуация становится хуже, когда две очереди трогают одну и ту же запись одновременно. Ручное действие «save now» идёт по одному пути, а background sync просыпается и запускает другой. Обе задачи читают одну и ту же устаревшую версию, обе думают, что им нужно загрузить данные, и обе записывают новые результаты. В итоге получается не синхронизация, а гонка.
Симптомы знакомые: дублированные фото или вложения, одна и та же карточка клиента появляется на сервере дважды, неудачная sync-задача позже «успешно» отправляет старые данные ещё раз или экран выглядит чистым, хотя локально ещё ждёт работа.
Последний случай важнее, чем ожидают команды. Если приложение скрывает pending work, пользователи пробуют ещё раз. Они нажимают Save дважды, прикрепляют тот же файл снова или меняют запись ещё раз, потому что на экране ничего не говорит: «мы сохранили ваше изменение и отправим его позже».
Более устойчивый design прост. Сначала сохраните действие пользователя локально. Дайте записи и каждому вложению свою sync-задачу. Храните sync-state в надёжном хранилище, а не на экране. Пусть один coordinator управляет записями только для одной записи за раз. Тогда повторные попытки будут повторять только незавершённую часть.
Проверки перед релизом
Sync-баги обычно ломаются скучно и повторяемо. Короткий checklist перед релизом находит большинство из них быстрее, чем ещё один раунд code review.
Проверяйте это на реальном устройстве, а не только в симуляторе. Телефоны теряют сигнал, приложения завершаются, а iOS приостанавливает работу в самые неудобные моменты.
- Запустите синхронизацию, включите airplane mode, подождите немного, а затем снова подключитесь. Приложение должно сохранить локальные изменения, аккуратно остановиться и продолжить работу без создания лишних записей.
- Запустите retry, убейте приложение и откройте его снова. Pending-задача должна загрузиться один раз и продолжиться с сохранённого состояния, а не появиться как две задачи.
- Начните длинную загрузку, а затем отправьте приложение в background. Проверьте, сохраняет ли оно progress, использует ли background time только при необходимости и восстанавливается ли, если iOS оборвёт задачу раньше времени.
- Выполните одно понятное действие пользователя, например однократное сохранение заметки клиента, а затем посмотрите результат на сервере. Вы должны увидеть одно изменение записи, а не две почти одинаковые записи.
- После каждого теста смотрите логи клиента и сервера. Ищите зависшие задачи, повторяющиеся request ID и один и тот же payload, отправленный снова и снова.
Timing-bug'и прячутся в маленьком промежутке между «request sent» и «response stored». Если приложение закрывается в этот момент, в локальном состоянии всё равно должно хватать данных, чтобы ответить на простой вопрос: эту задачу нужно повторить, заново загрузить с сервера или пометить завершённой?
Команды часто расслабляются именно здесь. Они проверяют happy path, а потом считают, что retries поведут себя так же. Это не так. Retry — это другой путь, и он часто проходит по другому коду.
Одно правило сильно помогает: у каждой sync-задачи должен быть свой ID, статус и время последней попытки. Когда тестировщик находит проблему, вы должны уметь проследить эту задачу от нажатия до ответа сервера меньше чем за минуту.
Если вы не можете объяснить, почему запрос повторился, пока не релизьтесь. Пользователи прощают небольшую задержку. Они не прощают дублирующиеся списания, потерянные изменения или записи, которые меняются дважды.
Что делать дальше в вашем приложении
Не пытайтесь почистить все sync-пути сразу. Выберите самый проблемный flow, который у вас есть. Возможно, это «изменить клиента, прикрепить фото, потом синхронизировать после переподключения». Нарисуйте этот flow на бумаге так, чтобы у каждого task была одна работа: сохранить локальное изменение, поставить исходящую работу в очередь, отправить один запрос, записать результат сервера, а потом пометить элемент завершённым. Если один шаг сломается, приложение должно понимать, откуда продолжать.
Затем добавьте логи туда, где sync-работа обычно становится неясной. Логируйте, когда начинается retry, когда возобновляется background-task, когда приложение уходит офлайн и когда сервер возвращает conflict. На первом этапе не нужен большой observability-проект. Чёткое имя события, task ID и значение checkpoint часто уже объясняют, почему появляется дублирующаяся работа.
Маленький план лучше, чем переписывание всего. Начните с одной очереди для sync-задач, одного формата checkpoint для всех задач и одного стабильного ID для каждого действия пользователя, чтобы retries не создавали лишние записи. До релиза договоритесь с product- и backend-командами об одном правиле обработки конфликтов.
Этого достаточно, чтобы проверить дизайн в реальном приложении. После этого намеренно прогоняйте неприятные сценарии. Включите airplane mode. Завершите приложение посреди синхронизации. Отправьте одно и то же действие дважды. Дайте background-окну истечь. Вам нужен один результат и никаких сюрпризов.
Если вам нужен второй взгляд перед изменением архитектуры, Oleg Sotnikov на oleg.is работает как fractional CTO и startup advisor. Он помогает командам разбирать архитектуру продукта, инфраструктуру и AI-first workflows разработки, что особенно полезно, когда sync-системы становятся слишком сложными для понимания.
Часто задаваемые вопросы
Что обычно ломает синхронизацию на телефонах?
Телефоны постоянно прерывают работу. Пользователи блокируют экран, переключают приложения, теряют сигнал или нажимают Save дважды — и такие мелкие моменты ломают длинные sync-flow. Поздние ответы сервера тоже могут прийти не в том порядке и перезаписать более новые локальные изменения.
Нужно ли кнопке Save ждать ответа сервера?
Нет. Сначала сразу сохраните изменение локально, обновите экран и пометьте запись как ожидающую синхронизации. А потом отдельная очередь отправит работу на сервер, когда сеть и система позволят.
Насколько маленькой должна быть sync-задача?
Делайте каждый task только для одного durable side effect. Хороший шаг имеет одно понятное правило успеха, например local write committed или server returned an ID, а после завершения приложение сохраняет checkpoint.
Где должна жить sync-логика в приложении?
Выносите долгую sync-логику за пределы слоя экранов. View model'и появляются и исчезают, а durable queue рядом с базой данных или persistence-layer может пережить перезапуск приложения и продолжить работу.
Как не допустить дубликатов при повторных попытках?
Используйте один стабильный request ID или job ID для одного и того же действия пользователя и переиспользуйте его при каждом retry. Также разбивайте flow на маленькие шаги и сохраняйте любой server ID сразу после ответа сервера.
Что делать после timeout?
Считайте timeout неопределенным состоянием, а не ошибкой. Сервер мог завершить работу уже после того, как приложение перестало ждать, поэтому сначала проверьте существующий результат, а потом отправляйте тот же request еще раз.
Как выглядит offline-first на практике?
На практике это значит: queue'те намерение пользователя, а не сам факт нажатия. Если человек три раза редактирует одну и ту же заметку офлайн, сохраните финальное локальное состояние, покажите, что оно еще не синхронизировано, и отправьте его позже по порядку.
Как не дать старым ответам сервера перезаписать новые изменения?
Отслеживайте локальную revision или version для каждого изменения и сопоставляйте ответы сервера с этой revision. Если более старый ответ приходит после нового изменения, игнорируйте его. Один coordinator должен управлять записями для одной записи одновременно, чтобы два task'а не соревновались.
Как учитывать ограничения iOS в background?
Предполагайте, что iOS сократит background time. Делайте работу маленькими кусками, сохраняйте progress после каждого куска и переставайте запускать новую работу, когда приходит отмена. При следующем запуске продолжайте с последней сохраненной точки.
Что стоит протестировать перед релизом sync-heavy приложения?
Прогоняйте неприятные сценарии на реальном устройстве. Включайте airplane mode во время синхронизации, завершайте приложение во время retry, отправляйте его в background во время загрузки и читайте логи клиента и сервера, чтобы убедиться, что одно действие дало один результат.