Почему оффлайн-режим — это не «приятная фича», а необходимость

Представь: пользователь едет в метро, открывает приложение — и видит белый экран с надписью «Нет соединения». Закрывает, уходит к конкуренту. Это не гипотетика — это происходит каждый день.

По данным исследований 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-ботов мы регулярно решаем проблему асинхронного взаимодействия: когда сообщения и события должны надёжно доходить до адресата даже при временных сбоях. Принципы — очередь, повторные попытки, явная обработка ошибок — универсальны.

Хорошее мобильное приложение не замечает проблем с сетью. Пользователь делает своё дело, а ты тихо синхронизируешь данные в фоне. Это и есть качество.