Зачем вообще думать об оффлайне
Большинство разработчиков проектируют приложение так: есть интернет — всё работает, нет интернета — показываем заглушку «Нет подключения». Пользователь открывает приложение в метро, видит белый экран с иконкой облака со слэшем — и закрывает. В App Store летит звезда.
Статистика говорит, что среднестатистический пользователь смартфона теряет соединение 70–90 раз в день. Это лифты, метро, загородные поездки, переполненные стадионы с перегруженными вышками. Если ваше приложение в эти моменты превращается в кирпич — вы теряете аудиторию.
Хорошая новость: сделать нормальный оффлайн на iOS не так страшно, как кажется. Разберём, из чего он состоит.
Что такое оффлайн-режим на самом деле
Оффлайн-режим — это не просто «кешировать картинки». Это архитектурное решение, которое охватывает три вещи:
- Локальное хранение данных — что и как мы сохраняем на устройстве
- Синхронизация — как обмениваемся с сервером, когда сеть появляется
- Управление состоянием — как UI понимает, какие данные актуальны, а какие устарели
Если убрать любой из трёх пунктов — оффлайн будет работать криво.
Локальное хранилище: что выбрать
На iOS есть несколько вариантов, каждый для своей задачи.
Core Data
Стандартный выбор Apple. Core Data — это не база данных, а фреймворк для управления графом объектов, который под капотом использует SQLite. Главные плюсы: тесная интеграция с iOS, NSFetchedResultsController для таблиц и коллекций, CloudKit-синхронизация почти из коробки.
Минусы: крутая кривая обучения, NSManagedObjectContext нужно аккуратно разруливать в многопоточном коде. Одна ошибка — и приложение падает в самый неожиданный момент.
Подходит для: сложных структур данных, приложений с отношениями между объектами (задачи, проекты, теги), когда нужна интеграция с CloudKit.
SQLite напрямую (GRDB, FMDB)
Если Core Data кажется избыточным — берите GRDB.swift. Это обёртка над SQLite с нормальным Swift API, поддержкой Combine и async/await. Очень быстрая, предсказуемая, без магии.
GRDB позволяет писать SQL-запросы там, где нужно, и использовать типобезопасный query builder для простых случаев. Хорошо работает с большими таблицами — миллион строк не проблема.
Подходит для: приложений с табличными данными, финансовых приложений, где важна точность запросов.
Realm
MongoDB Realm — популярная альтернатива. Работает быстро, API простой, есть встроенная синхронизация с Atlas (платная). Из минусов: добавляет 10–15 МБ к бинарнику, и с миграциями схемы нужно быть аккуратным.
Подходит для: стартапов, которым нужно быстро запустить оффлайн с последующей синхронизацией, когда время разработки дороже размера бинарника.
UserDefaults и файловая система
UserDefaults — для настроек и простых флагов, не для бизнес-данных. Если туда положить список из тысячи заказов — приложение станет тормозить при каждом запуске.
Файловая система (Documents, Caches) — для медиа, PDF, бинарных данных. Папка Caches очищается системой при нехватке памяти, Documents — нет. Учитывайте это при проектировании.
Синхронизация: самое сложное
Хранить данные локально — половина задачи. Вторая половина — корректно синхронизироваться с сервером. И вот тут начинаются настоящие сложности.
Стратегии синхронизации
Last Write Wins (последний победил). Самый простой вариант: у каждой записи есть timestamp, при конфликте побеждает более новая версия. Работает для большинства приложений. Проблема: если пользователь редактировал запись на двух устройствах одновременно — одно изменение потеряется.
Merge-based sync (слияние изменений). Каждое изменение логируется как дельта — что именно изменилось. При синхронизации дельты применяются последовательно. Так работают Google Docs и Notion. Сложнее в реализации, но данные не теряются.
Event Sourcing. Приложение хранит не состояние, а историю событий: «пользователь добавил товар», «пользователь изменил цену», «пользователь удалил строку». Сервер воспроизводит события в правильном порядке. Надёжно, но требует тщательного проектирования с самого начала.
Для большинства бизнес-приложений хватает Last Write Wins с дополнительной логикой для критических полей.
Очередь изменений
Когда сети нет, пользователь продолжает работать. Все его действия нужно сохранять в локальную очередь изменений и отправлять на сервер, когда соединение восстановится.
Простая реализация: таблица pending_operations в SQLite. Каждая операция — это JSON с типом действия, данными и временем создания. Когда появляется сеть — берём операции по порядку и отправляем.
struct PendingOperation: Codable {
let id: UUID
let type: OperationType // create, update, delete
let entityType: String
let payload: Data
let createdAt: Date
var retryCount: Int = 0
}
Важный момент: учитывайте идемпотентность. Если запрос ушёл, но ответ не пришёл — приложение не знает, выполнилась ли операция. При повторной отправке нельзя создать дубликат. Решение: использовать UUID операции как идемпотентный ключ на сервере.
Обнаружение сети
На iOS для мониторинга сети используйте NWPathMonitor из фреймворка Network — это современный API, пришедший на замену Reachability.
let monitor = NWPathMonitor()
monitor.pathUpdateHandler = { path in
if path.status == .satisfied {
Task { await syncPendingOperations() }
}
}
monitor.start(queue: DispatchQueue.global())
Один нюанс: status == .satisfied не означает, что сервер доступен. Пользователь может быть подключён к Wi-Fi без интернета (captive portal в отеле). Добавьте простую проверку доступности вашего API перед началом синхронизации.
Фоновая синхронизация
Пользователь закрыл приложение, потом открыл — данные должны быть свежими. Для этого есть BGTaskScheduler.
BGAppRefreshTask
Позволяет системе запускать приложение в фоне для обновления данных. iOS сам решает, когда именно запустить задачу, на основе паттернов использования. Если пользователь открывает приложение каждое утро в 8:00 — iOS начнёт обновлять данные чуть раньше.
BGTaskScheduler.shared.register(
forTaskWithIdentifier: "com.app.refresh",
using: nil
) { task in
handleAppRefresh(task: task as! BGAppRefreshTask)
}
func handleAppRefresh(task: BGAppRefreshTask) {
scheduleAppRefresh()
let syncTask = Task {
await syncData()
task.setTaskCompleted(success: true)
}
task.expirationHandler = { syncTask.cancel() }
}
Важно: у вас есть около 30 секунд на выполнение задачи. Синхронизируйте только критичные данные, не пытайтесь скачать всё сразу.
BGProcessingTask
Для тяжёлых операций — полная синхронизация, индексация, обработка данных. Запускается только на зарядке с Wi-Fi. Времени больше, но запускается реже. Используйте для ночного обновления справочников и каталогов.
Конфликты: как объяснить пользователю
Технически конфликты можно разрешать автоматически, но иногда правильнее спросить пользователя. Особенно в B2B-приложениях, где данные критически важны.
Простое правило: если редактировали разные поля одной записи — мержим автоматически. Если редактировали одно поле с разными результатами — показываем диалог выбора.
Не показывайте технический жаргон: «конфликт версий при слиянии» — это ужас. Напишите по-человечески: «Этот контакт изменился на другом устройстве. Какую версию оставить?»
Индикация состояния данных
Пользователь должен понимать, смотрит ли он на свежие данные или на кеш. Несколько паттернов:
Timestamp последней синхронизации — простой и понятный. «Обновлено 3 минуты назад» лучше, чем ничего.
Индикатор «черновик» — когда изменения не отправлены на сервер, помечайте запись как неотправленную. Иконка «облако с часами» — понятный паттерн.
Pull-to-refresh — дайте пользователю возможность принудительно обновить данные. Стандартный UIRefreshControl справляется с задачей.
Баннер офлайн-режима — тонкая полоска в верхней части экрана: «Нет соединения. Показаны сохранённые данные». Не используйте полноэкранные заглушки — это раздражает.
Типичные ошибки
Кешировать только успешные запросы. Кешируйте данные до запроса, а не после. Если сеть упала после получения данных — они уже есть. Если упала до — покажите старый кеш.
Не учитывать размер кеша. Пользователь с 64 ГБ телефоном не хочет, чтобы ваше приложение заняло 5 ГБ. Устанавливайте лимиты и вытесняйте устаревшие данные по LRU.
Синхронизировать всё разом. При первом запуске после долгого оффлайна не пытайтесь отправить всё сразу — сервер упадёт под нагрузкой. Реализуйте батчинг: отправляйте по 10–50 операций за раз с небольшой паузой.
Забыть про миграции. Если вы изменили схему базы данных в новой версии приложения — старые данные на устройстве нужно мигрировать. Без этого приложение крашится при обновлении. Core Data и GRDB имеют встроенные механизмы миграций — используйте их.
Не тестировать оффлайн. Разрабатываете с быстрым интернетом — конечно, всё работает. Используйте Network Link Conditioner (есть в Xcode Additional Tools) для эмуляции медленной сети и её отсутствия. Тестируйте авиарежим в реальных условиях.
Реальный пример: приложение для полевых сотрудников
Компания обратилась в REEXY за мобильным приложением для торговых представителей. Задача: оформлять заказы у клиентов в полях, где часто нет сети.
Архитектура получилась такой:
- GRDB как локальное хранилище (каталог товаров, клиенты, история заказов)
- Очередь изменений для новых заказов
- NWPathMonitor для запуска синхронизации при появлении сети
- BGAppRefreshTask для обновления каталога в ночное время
При открытии приложения пользователь видит актуальные данные из последней синхронизации. Оформляет заказы офлайн. Как только появляется сеть — заказы уходят на сервер автоматически, без каких-либо действий со стороны пользователя.
Результат: торговые представители перестали терять заказы из-за плохой связи, а количество жалоб на приложение упало в несколько раз.
Сколько это стоит и когда начинать
Разработка нормального оффлайн-режима — это не функция на один день. Это архитектурное решение, которое влияет на всё приложение. Если начинать с нуля — закладывайте 2–4 недели дополнительной разработки.
Если у вас уже есть приложение и вы хотите добавить оффлайн — это сложнее. Придётся переработать слой данных и, скорее всего, изменить API. Лучше проектировать с оффлайном сразу, чем добавлять потом.
Оффлайн-режим — это не фича, это качество. Пользователи не оценивают его, когда он есть. Но моментально замечают, когда его нет.
Три вещи, с которых начинать:
- Определить, какие данные критичны для работы без сети
- Выбрать хранилище под задачу (Core Data, GRDB, Realm)
- Спроектировать синхронизацию с учётом конфликтов и очереди изменений
Остальное — детали реализации, которые решаются итеративно.