Почему оффлайн-режим — это не «приятная фича», а необходимость
Представь: пользователь едет в метро, открывает приложение — и видит белый экран с надписью «Нет соединения». Закрывает, уходит к конкуренту. Это не гипотетика — это происходит каждый день.
По данным исследований App Annie, пользователи проводят в мобильных приложениях больше 4 часов в день. Значительная часть этого времени — в зонах с нестабильным или полностью отсутствующим интернетом: метро, самолёты, загородные поездки, цокольные этажи торговых центров. Приложение, которое падает без сети, теряет не только сессии — оно теряет доверие.
Оффлайн-режим — это архитектурное решение, а не просто «сохраним данные на телефон». Разберём, как это устроено в iOS на практике.
Что значит «работать оффлайн»
Оффлайн-режим бывает принципиально разный. Три основных сценария:
Только чтение. Приложение показывает закэшированные данные, когда нет сети. Новостное приложение отдаёт вчерашние статьи, почтовый клиент — загруженные письма. Просто и предсказуемо.
Чтение и запись. Пользователь может не только смотреть, но и вносить изменения — они сохраняются локально и синхронизируются при появлении сети. Это сложнее: нужно хранить очередь изменений и потом их «применять».
Полная автономность. Приложение работает как полноценный инструмент без интернета вообще. Калькуляторы, заметки, оффлайн-карты. Самый простой сценарий архитектурно.
Самый интересный и сложный — второй. Когда пользователь редактирует данные оффлайн, а потом их нужно «встроить» обратно на сервер.
Инструменты iOS для локального хранения
Core Data
Core Data — ORM-фреймворк от Apple поверх SQLite. Умеет хранить сложные объектные графы, поддерживает миграции схем и нативно интегрируется с CloudKit для синхронизации.
Когда использовать: если у тебя сложная модель данных с отношениями между сущностями. Например, приложение для управления проектами, где у задачи есть исполнители, комментарии, вложения.
let context = persistentContainer.viewContext
let task = Task(context: context)
task.id = UUID()
task.title = "Позвонить клиенту"
task.isCompleted = false
task.createdAt = Date()
try context.save()
Минус: Core Data — тяжёлый инструмент с крутой кривой обучения. При неправильном использовании легко получить проблемы с производительностью на больших объёмах данных.
SQLite напрямую через GRDB
Если Core Data избыточен, можно работать с SQLite напрямую. GRDB — один из лучших вариантов для Swift: быстрый, типобезопасный, хорошо поддерживается.
try db.create(table: "task") { t in
t.autoIncrementedPrimaryKey("id")
t.column("title", .text).notNull()
t.column("isCompleted", .boolean).notNull().defaults(to: false)
t.column("syncStatus", .integer).notNull().defaults(to: 0)
}
Поле syncStatus — ключевое для оффлайн-режима. Значения: 0 — синхронизировано, 1 — ожидает создания на сервере, 2 — ожидает обновления, 3 — ожидает удаления.
UserDefaults и файловая система
UserDefaults подходит для небольших настроек и флагов. Для хранения списков объектов — нет. При злоупотреблении превращается в свалку, из которой сложно что-то вытащить.
Медиа-файлы (фото, аудио, видео) — всегда на файловой системе. Core Data умеет хранить бинарные данные, но хранить большие блобы в базе — плохая практика. Путь к файлу в базе, сам файл на диске.
Архитектура синхронизации
Вот где начинается настоящая инженерия. Самый надёжный подход — очередь операций (Outbox pattern).
Каждое изменение, сделанное оффлайн, пишется в локальную очередь. Когда появляется сеть — очередь обрабатывается.
struct PendingOperation {
let id: UUID
let entityType: String // "task", "user", "comment"
let entityId: String
let operation: OperationType // .create, .update, .delete
let payload: Data
let createdAt: Date
var retryCount: Int
}
Обязательно храни retryCount. Если операция несколько раз падает — не пытайся бесконечно. После 3–5 неудачных попыток уведоми пользователя.
Отслеживание сети через NWPathMonitor
В iOS есть Network.framework с NWPathMonitor — современная замена устаревшему Reachability. Работает надёжнее и поддерживает разные типы соединений.
import Network
class NetworkMonitor: ObservableObject {
private let monitor = NWPathMonitor()
@Published var isConnected = false
func start() {
monitor.pathUpdateHandler = { [weak self] path in
DispatchQueue.main.async {
self?.isConnected = path.status == .satisfied
}
}
monitor.start(queue: DispatchQueue(label: "NetworkMonitor"))
}
}
Когда isConnected меняется на true — запускай синхронизацию очереди.
Разрешение конфликтов
Самая болезненная часть оффлайн-архитектуры. Представь: пользователь отредактировал запись оффлайн, а кто-то другой изменил ту же запись на сервере. Что делать?
Server wins — локальные изменения выбрасываются, применяется версия с сервера. Просто в реализации, но пользователь теряет свои правки.
Client wins — локальные изменения перезаписывают серверные. Тоже просто, но можно потерять чужую работу.
Last write wins — сравниваем временные метки и берём более новое. Работает, если используешь серверное время, а не локальное.
Merge — пытаемся объединить оба изменения. Подходит для текстовых документов, но требует серьёзной логики на обеих сторонах.
Показать конфликт пользователю — дать выбрать, какую версию оставить. Так делает 1Password при синхронизации через несколько устройств.
Для большинства бизнес-приложений достаточно «last write wins» с серверным временем:
func resolveConflict(local: Task, remote: Task) -> Task {
// Используем updatedAt как источник истины
return local.updatedAt > remote.updatedAt ? local : remote
}
CloudKit: когда Apple делает синхронизацию за тебя
Если приложение нацелено на экосистему Apple, CloudKit — отличный выбор. Apple предоставляет бесплатное хранилище (5 ГБ на пользователя), берёт на себя синхронизацию между устройствами и базовое разрешение конфликтов.
NSPersistentCloudKitContainer — расширение Core Data с поддержкой CloudKit. Буквально несколько строк — и синхронизация между iPhone, iPad и Mac работает.
let container = NSPersistentCloudKitContainer(name: "MyApp")
container.loadPersistentStores { description, error in
// Синхронизация включена
}
Ограничения: работает только для авторизованных пользователей iCloud, не подходит для кросс-платформенных приложений с Android, есть лимиты на количество запросов.
Что показывать пользователю
Хороший оффлайн-режим — это не только техника, но и UX. Несколько принципов:
Показывай состояние данных. Если данные не актуальны — говори об этом. «Данные от 14:32, обновите при наличии сети» лучше, чем молчание.
Не блокируй действия. Пользователь должен мочь добавить задачу, написать заметку, оформить заказ — даже без сети. Просто скажи, что изменения сохранятся и применятся позже.
Уведоми о синхронизации. Когда данные успешно синхронизировались — небольшой тост или индикатор. Пользователь спокоен: его работа не потерялась.
Обрабатывай ошибки синхронизации явно. «Не удалось сохранить изменения. Проверьте соединение.» лучше, чем тихое поглощение ошибок.
Тестирование оффлайн-режима
Оффлайн-функциональность легко забывают тестировать. Несколько подходов:
Симулятор с отключённой сетью. В настройках симулятора Xcode можно отключить сеть или имитировать нестабильное соединение через Network Link Conditioner.
Charles Proxy или mitmproxy. Перехватываем трафик и симулируем ошибки — таймауты, 500-е ошибки, обрывы соединений.
Unit-тесты для синхронизации. Логику разрешения конфликтов и очередь операций обязательно покрывай тестами. Это та часть кода, где баги дорого стоят.
Типичные ошибки
Не думать об оффлайне с самого начала. Добавить оффлайн-режим в готовое приложение намного сложнее, чем заложить его в архитектуру с первого дня. Если в приложении есть пользовательские данные — планируй синхронизацию заранее.
Хранить всё в UserDefaults. Удобно для прототипа, но не масштабируется. При большом объёме данных начинает тормозить и усложнять код.
Игнорировать дисковое пространство. Кэш нужно чистить. Устанавливай политику инвалидации: старые данные удаляются через N дней или когда размер кэша превышает X МБ.
Синхронизировать при каждом изменении. Если пользователь вносит изменения быстро — не стоит дёргать API на каждое нажатие клавиши. Используй дебаунс: отправляй изменения через 1–2 секунды после последнего действия.
Забыть про фоновую синхронизацию. iOS позволяет синхронизировать данные в фоне через BGAppRefreshTask. Пользователь открывает приложение — данные уже актуальны.
Когда оффлайн-режим не нужен
Не каждому приложению он нужен. Если у тебя видеостриминг — кэширование каталога разумно, но воспроизведение без сети нереалистично. Если приложение — чат, оффлайн-запись сообщений имеет смысл, но показывать их отправленными до реальной доставки — плохая практика.
Простой вопрос: что пользователь хочет делать без сети? Если ответ «ничего» — не усложняй. Если есть хотя бы один сценарий — вкладывай в оффлайн-режим.
Похожие задачи возникают не только в мобильной разработке. В REEXY при интеграции сервисов и разработке Telegram-ботов мы регулярно решаем проблему асинхронного взаимодействия: когда сообщения и события должны надёжно доходить до адресата даже при временных сбоях. Принципы — очередь, повторные попытки, явная обработка ошибок — универсальны.
Хорошее мобильное приложение не замечает проблем с сетью. Пользователь делает своё дело, а ты тихо синхронизируешь данные в фоне. Это и есть качество.