Зачем вообще думать об оффлайне

Большинство разработчиков проектируют приложение так: есть интернет — всё работает, нет интернета — показываем заглушку «Нет подключения». Пользователь открывает приложение в метро, видит белый экран с иконкой облака со слэшем — и закрывает. В App Store летит звезда.

Статистика говорит, что среднестатистический пользователь смартфона теряет соединение 70–90 раз в день. Это лифты, метро, загородные поездки, переполненные стадионы с перегруженными вышками. Если ваше приложение в эти моменты превращается в кирпич — вы теряете аудиторию.

Хорошая новость: сделать нормальный оффлайн на iOS не так страшно, как кажется. Разберём, из чего он состоит.

Что такое оффлайн-режим на самом деле

Оффлайн-режим — это не просто «кешировать картинки». Это архитектурное решение, которое охватывает три вещи:

  1. Локальное хранение данных — что и как мы сохраняем на устройстве
  2. Синхронизация — как обмениваемся с сервером, когда сеть появляется
  3. Управление состоянием — как 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. Лучше проектировать с оффлайном сразу, чем добавлять потом.

Оффлайн-режим — это не фича, это качество. Пользователи не оценивают его, когда он есть. Но моментально замечают, когда его нет.

Три вещи, с которых начинать:

  1. Определить, какие данные критичны для работы без сети
  2. Выбрать хранилище под задачу (Core Data, GRDB, Realm)
  3. Спроектировать синхронизацию с учётом конфликтов и очереди изменений

Остальное — детали реализации, которые решаются итеративно.