
Если приложение при потере соединения превращается в тыкву — это бесит. И неважно, показывает оно индикатор загрузки, экран логина или сообщение об ошибке. Пользователь хочет, чтобы программа просто работала — без прерываний и уж тем более без крашей. В современных мобильных приложениях в офлайне должны выполняться все сценарии, которым интернет в действительности не нужен. Пользователь ждёт, что ключевые функции будут доступны всегда — в метро, лифте или за городом. Хотите узнать, как сделать рабочее приложение — эта статья для вас.
В этой статье мы разберём реализацию офлайн-режима на примере образовательного приложения для прохождения тестов и покажем:
· как кэшировать контент (опросники) с помощью SwiftData;
· как сохранять ответы пользователя для последующей отправки.
И дадим готовые примеры кода, которые вы сможете использовать в своём проекте уже сегодня.
А также рассмотрим современные подходы отображения статуса сети и данных через UI-индикаторы. Лучшие практики из популярных приложений, ставшие стандартом — смотрите в разделе «рекомендации по UX».
Какой бывает офлайн-режим?
Офлайн-режим — это не одна функция, а спектр сценариев. Разные приложения реализуют его по-разному, но все подходы можно разделить на два класса.
Read-only: в офлайне только чтение
В этом случае приложение сначала загружает данные по сети и сохраняет их локально, а находясь в режиме без подключения к интернету, предоставляет пользователю доступ на чтение. К примеру, так устроены следующие приложения:
Карты (2GIS, Яндекс.Карты): скачиваете район — находите адрес без сети.
Чтение (ReadEra, Books): сохраняете статью или книгу — читаете в самолёте.
Плееры (VLC): загружаете мультики на планшет — ребенок смотрит по дороге на дачу.
Образование (Coursera, Duolingo): загружаете урок — учитесь в метро.
Реализовать такое хранение данных несложно, причём зачастую оно реализуется в приложении даже для обычной работы. Данные загружаются по сети, потом сохраняются локально в базу данных, а оттуда используются для представления в интерфейсе. Помимо базы данных может использоваться стандартный HTTP-кэш (или модифицированный, позволяющий изменить Cache-Control, приходящий от сервера). Кроме того, часто используется хранение данных на файловой системе, например, в формате JSON или protobuf.
Для того, чтобы свежие данные были доступны, даже если пользователь давно не открывал приложение, используется загрузка в бэкграунде (background fetch). Благодаря поддержке на уровне операционной системы, приложение может запустить или продолжить прерванную загрузку, когда устройство имеет активное подключение к сети, даже если само приложение было свёрнуто. Важно, что этот механизм спроектирован с учетом энергоэффективности, и использует системные механизмы планирования задач, чтобы снизить расход батарейки.
Read-write: когда контент создаётся без сети
Более сложный, но и более мощный вариант: пользователь может создавать данные (письма, документы, заказы), а приложение отправит их на сервер позже. Примеры:
Мессенджеры (Telegram, WhatsApp): пишете сообщение — оно улетает при подключении.
Документы (Notes, GoogleDocs): редактируете файл — изменения синхронизируются автоматически.
Онлайн-магазины (например, пиццерия или суши со статическим меню для заказа с доставкой): собираете корзину офлайн, и подключаетесь к сети для оформления заказа.
Образование: выполняете тесты и заполняете опросники в дороге, отправляете на проверку из дома по вайфай.
Именно этот вариант мы разберём подробно — потому что он превращает приложение из «читалки» в полноценный инструмент, который работает в любых условиях.
Анализ проекта: о чем лучше подумать заранее?
Чтобы правильно реализовать офлайн-функционал в приложении, нужно описать примеры использования. Можно взять за основу те use cases, которые у вас есть, продублировать их и адаптировать: какие функции приложения останутся доступны пользователю без подключения?
Некоторые функции невозможно предоставить без подключения к сети. Например, банковские приложения — яркий пример осмысленной блокировки интерфейса в офлайн, поскольку человек не может воспользоваться онлайн-банкингом без связи. Такое приложение не может даже показать остаток на счете, поскольку эти данные являются «скоропортящимися»: они меняются независимо от приложения, поэтому их нельзя кешировать и показывать, не обновляя. Если вы делаете не онлайн-банкинг, то наверняка многие функции могут быть доступны ;-)
Итак, чтобы точнее определить, где именно необходима поддержка в офлайн-режиме, мы предлагаем проанализировать следующие вопросы.
Каковы основные варианты использования: как именно пользуется приложением ваша ключевая аудитория. В каких типичных случаях пользователь захочет пользоваться приложением именно в момент отсутствия сети?
Важно обдумать и то, какой контент должен быть доступен в офлайне. Это могут быть медиафайлы, книги или меню вашей пиццерии.
Например, человек пользуется дорожным навигатором, когда едет по малознакомой трассе, с перебоями связи. Ему нужны функции поиска адреса и построения маршрута офлайн, или онлайн функция построения и сохранения маршрута — для дальнейшего использования.
А если пользователь — пассажир поезда и создаёт новую заметку, то ему нужен доступ к форме создания заметки и возможность сохранения данных локально с последующей синхронизацией.
Следующий важный вопрос — это анализ ограничений. Сколько места займёт кеш и как долго данные останутся валидными?
В результате этапа анализа у вас должны сформироваться варианты использования и тест-кейсы, например, проверка на варианты офлайна:
резкие обрывы сети на каждом экране поддерживаемой в офлайн фичи;
долгая работа устройства без интернета (сутки+) с запущенным или свёрнутым приложением;
переполнение локального хранилища.
Такой анализ предотвратит проблемы на поздних этапах и позволит вам сделать эффективный и переиспользуемый код.
Для случаев клиент-серверных приложений с возможностью доступа с разных устройств стоит рассмотреть и вопрос разрешения конфликтов при синхронизации. Для большинства приложений это не нужно. Но если вам понадобится это реализовать — используйте проверку по дате последнего обновления. Для всех объектов нужно хранить дату предыдущего обновления на сервере, и передавать её при апдейте. Если на сервере дата обновления больше переданной даты, то он может создавать дубль объекта или выполнять более сложную процедуру. Это зависит от вашей задачи. При любой стратегии обработки конфликта необходимо реализовать в интерфейсе соответствующее отображение состояния объекта.
Рассмотрим код для офлайн-режима
В качестве примера возьмем образовательное приложение для прохождения тестов. Пользователь должен иметь возможность:
Загрузить список доступных опросников (Questionnaire) при наличии сети.
Выбрать и пройти любой из загруженных тестов в любое время, даже без подключения к интернету.
Быть уверенным, что его ответы сохранятся и автоматически отправятся на сервер для проверки, как только соединение появится.
Исходя из этих требований, мы реализуем два ключевых сценария:
Сохранение загруженных опросников (Questionnaire) в локальной базе данных с помощью SwiftData, чтобы обеспечить к ним постоянный доступ.
Сохранение ответов пользователя (Test) когда нет сети и организация их отложенной отправки при появлении связи.
Отправка ответов сразу при наличии сети — задача простая в реализации, поэтому отдельно мы её описывать не будем, а сфокусируемся на офлайн-логике.
Чтобы пользователь мог проходить тест полностью офлайн, нам нужно не только уметь отправлять ответы, но и заранее сохранять сами задания (Questionnaire) в локальную базу. На iOS есть разные способы сохранения данных, и каждый подходит для своих задач. Например, в UserDefaults лучше хранить, какую тему интерфейса пользователь выбрал в настройках, а в файлах — отдельные документы. Нам для хранения набора объектов с текстовыми данными идеально подходит фреймворк SwiftData.
Шаг 1: Определяем модели данных и настраиваем хранилище
Сначала опишем модели Questionnaire
(опросник) и Test
(попытка прохождения теста пользователем), и свяжем их между собой. SwiftData использует описания классов без дополнительных схем базы как это было в CoreData.
Итак, мы имеем тип данных Questionnaire — опросники, которы загружаем с сервера. Используя макрос @Model
, мы помечаем этот тип, как хранимый:
import SwiftData
import Foundation
// Модель для хранения самого опросника, загруженного с сервера
@Model
final class Questionnaire {
@Attribute(.unique) let id: String // Уникальный ID с сервера
let title: String
let descriptionText: String
let questions: [Question] // Массив вопросов (также Codable)
// Связь "один ко многим": один опросник может иметь много попыток прохождения (Test)
@Relationship(deleteRule: .cascade, inverse: \Test.questionnaire)
var tests: [Test] = []
init(id: String, title: String, descriptionText: String, questions: [Question]) {
self.id = id
self.title = title
self.descriptionText = descriptionText
self.questions = questions
}
}
// Вспомогательная модель для вопроса (не является @Model, это часть Questionnaire)
struct Question: Codable {
let id: String
let text: String
let options: [String]
}
Здесь мы также используются макросы SwiftData:
@Attribute(.unique)
: Говорит SwiftData, что полеid
уникально. Это предотвратит дублирование опросников при повторной загрузке.@Relationship
: Определяет связь между моделями.deleteRule: .cascade
означает, что при удалении опросника все связанные с ним попытки (Test
) будут удалены автоматически.inverse
показывает, какое поле на объекте Test хранит обратную связь.answersData: Data?
: Ответы пользователя — это структурированные данные. Мы можем кодировать их в JSON и хранить в видеData
для простоты. Здесь может быть и другая структура, аналогично Question.
Далее, добавим модель для ответов пользователя:
// Модель для хранения попытки прохождения теста и ответов пользователя
@Model
final class Test {
@Attribute(.unique) let id: UUID // Уникальный ID попытки
let dateStarted: Date
var isSubmitted: Bool = false // Отправлены ли ответы на сервер?
// Сами ответы пользователя. Указываем Data, чтобы не углубляться в детали
var answersData: Data?
// Связь: какая анкета была пройдена
var questionnaire: Questionnaire?
init(id: UUID = UUID(), dateStarted: Date = .now, questionnaire: Questionnaire?) {
self.id = id
self.dateStarted = dateStarted
self.questionnaire = questionnaire
}
}
Помимо модельных классов нам понадобится завести как минимум один modelContainer
, через который будут работать контексты. Укажем isStoredInMemoryOnly
, чтобы сделать хранилище персистентным (это добавляет возможности синхронизации между устройствами).
struct LearnOfflineApp: App {
var sharedModelContainer: ModelContainer = {
let schema = Schema([
Questionnaire.self,
Test.self
])
let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
do {
return try ModelContainer(for: schema, configurations: [modelConfiguration])
} catch {
fatalError("Could not create ModelContainer: \(error)")
}
}()
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(sharedModelContainer)
}
}
После этого, контекст доступен нам в иерархии экранов через окружение. Например, таким образом может быть получен доступ «к базе» для сохранения выполненного задания. Используем Task.detached
, чтобы выполнять процедуру параллельно, не загружая основной поток.
struct ContentView: View {
@Environment(\.modelContext) private var modelContext
var body: some View {
// Здесь различные поля для заполнения опросника
// Кнопка для окончания задания
Button {
Task.detached {
let actor = LearnOfflineActor(modelContainer: modelContext.container)
try await actor.createItem(timestamp: .now)
}
} label: {
Text("Сдать на проверку")
}
}
А откуда берется актор? Мы создаём его с помощью ещё одного макроса.
@ModelActor
actor LearnOfflineActor {}
extension LearnOfflineActor {
// Актора мы создаём, передавая контейнер, а он даёт нам доступ к контексту
func createTest(questionnaire: Questionnaire) throws -> Test {
let test = Test(questionnaire: questionnaire)
modelContext.insert(test)
try modelContext.save()
return test
}
func updateTest(_ test: Test, <параметры, полученные с сервера>) throws {
test.params = params
try modelContext.save()
}
}
Шаг 2: Сохраняем загруженные опросники, чтобы обеспечить доступность данных в офлайн-режиме (Read Offline часть)
Чтобы пользователь мог выполнять задания без подключения к сети, нам нужно скачать и сохранить данные в хранилище. В нашем случае мы получили данные с сервера в формате JSON, распарсили их (это можно делать и используя средства языка), и сохраняем, используя SwiftData.
import SwiftData
// Добавляем ещё один экстеншн нашему актору для работы с данными в бэкграунде
extension LearnOfflineActor {
// Функция для сохранения опросника, полученного из сети
func createQuestionnaire(from networkData: Data) throws -> Questionnaire {
// Обычно мы загружаем массив опросников и парсим уровнем выше
// Если нужно — декодируем данные из JSON в структуру или модель
let decoder = JSONDecoder()
let networkQuestionnaire = try decoder.decode(NetworkQuestionnaire.self, from: networkData)
// Конвертируем DTO в массив Question
let questions = networkQuestionnaire.questions.map { Question(id: $0.id, text: $0.text, options: $0.options) }
// Для обновления объектов, сначала ищем опросник по id
let descriptor = FetchDescriptor<Questionnaire>(predicate: #Predicate { $0.id == networkQuestionnaire.id })
let existingQuestionnaire = try modelContext.fetch(descriptor).first
if existingQuestionnaire {
// Обновляем нужные поля
try modelContext.save()
return existingQuestionnaire
} else {
// Создаём объект в нужном контексте
let q = Questionnaire(all params & questions)
modelContext.insert(q)
try modelContext.save()
return q
}
}
}
// Вспомогательная структура для парсинга JSON с сервера
struct NetworkQuestionnaire: Codable {
let id: String
let title: String
let description: String
let questions: [NetworkQuestion]
}
struct NetworkQuestion: Codable {
let id: String
let text: String
let options: [String]
}
Актор в приложении используется для сохранения или апдейта данных (он похож на бэкграундный контекст CoreData). Это помогает избежать зависания пользовательского интерфейса при выполнении тяжелых операций обновления данных.
Шаг 3: Сохранение данных, которые создаёт пользователь
Пока тест не пройден до конца, мы сохраняем его только локально. Иногда вообще можно обойтись StateRestoration, если не выполненное до конца задание не имеет ценности в вашем UserFlow. Если тест разбит на несколько экранов, лучше сохранять после прохождения каждой части. В нашем примере легко обновлять объект, используя «нетипичное» поле Data. Для того чтобы адекватно представлять состояние теста, представим его конечным автоматом:
// Перечисление статусов теста. Сохраняем как Int в базе.
enum TestStatus: Int, Codable, CaseIterable {
case empty = 0
case inProgress = 1
case done = 2 // Тест завершен пользователем, готов к отправке
case sending = 3 // В процессе отправки на сервер
case sent = 4 // Успешно отправлен (ожидает проверки на сервере)
case checking = 5 // На проверке у педагога
case rated = 6 // Проверен, оценка получена
}
// Добавим еще поля модели Test
@Model
final class Test {
// Статус теста (хранится как Int в БД)
var status: TestStatus.RawValue // Важно: храним RawValue (Int)
// Вычисляемое свойство для удобной работы со статусом (игнорируется SwiftData)
var testStatus: TestStatus {
get { TestStatus(rawValue: status) ?? .empty }
set { status = newValue.rawValue }
}
// Данные теста
var answersData: Data? // JSON-encoded ответы
var serverEvaluation: String? // Оценка или комментарий с сервера (после проверки)
}
// Пример структуры для ответа (не Model)
struct Answer: Codable {
let questionId: String
let answer: String
}
// Помним, что работать с базой нужно через актора
extension LearnOfflineActor {
// Метод для завершения теста использует для синхронизации между потоками id объекта
func markAsDoneTest(identifier: PersistentIdentifier, with answers: [Answer], finishedAt date: Date = .now) throws {
guard let item = self[identifier, as: Test.self] else {
throw Error.objectNotFound
}
// Кодируем ответы в Data для хранения
test.answersData = try? JSONEncoder().encode(answers)
test.dateFinished = date
test.testStatus = .done // Устанавливаем статус через вычисляемое свойство
try modelContext.save()
}
}
Менеджер отправки данных выбирает из хранилища тесты со статусом .done
, мониторит статус подключения и отправляет данные при появлении сети. Отслеживание статуса соединения выполняется с помощью NWPathMonitor из фреймворка Network.
import Network
import SwiftData
actor TestSyncManager {
private let networkMonitor = NWPathMonitor()
private let queue = DispatchQueue(label: "NetworkMonitor")
private var modelContext: ModelContext
// Флаг, чтобы не запускать отправку multiple times
private var isSyncInProgress = false
init(modelContainer: ModelContainer) {
self.modelContext = ModelContext(modelContainer)
startNetworkMonitoring()
}
// Запускаем мониторинг сети
private func startNetworkMonitoring() {
networkMonitor.pathUpdateHandler = { [weak self] path in
if path.status == .satisfied {
// Сеть появилась, пытаемся синхронизировать
Task {
await self?.syncTests()
}
}
}
networkMonitor.start(queue: queue)
}
// Основная функция синхронизации
func syncTests() async {
// Защита от множественного запуска
guard !isSyncInProgress else { return }
isSyncInProgress = true
defer { isSyncInProgress = false } // Гарантируем сброс флага
do {
// Ищем тесты для отправки со статусом done
let descriptor = FetchDescriptor<Test>(
predicate: #Predicate { $0.status == TestStatus.done.rawValue }
)
let testsToSend = try modelContext.fetch(descriptor)
// Если готовых тестов нет — выходим
guard !testsToSend.isEmpty else { return }
for test in testsToSend {
// Меняем статус на "sending"
let actor = LearnOfflineActor(modelContext.container)
actor.updateTestStatus(.sending)
// Пытаемся отправить
let success = await sendTestToServer(test)
if success {
// Успешно -> меняем статус на "sent"
actor.updateTestStatus(.sent)
} else {
// Ошибка -> возвращаем статус "done" для повторной попытки
actor.updateTestStatus(.done)
}
}
} catch {
print("Ошибка при синхронизации тестов: \(error)")
}
}
// Функция отправки одного теста на сервер
private func sendTestToServer(_ test: Test) async -> Bool {
// Реализация зависит от ваших предпочтений и API
}
}
Аналогично при подключении к сети приложение загружает новые задания с сервера.
Итак, мы рассмотрели как сделать загрузку данных для чтения в офлайн, прохождения теста с сохранением в базу и отправку при подключении сети.
А что же делать, если пользователь прошел тест и свернул приложение, когда связи еще не появилось. Как настроить проект, чтобы приложение отправило данные при подключении к сети даже если было свернуто?
Шаг 4: Подключаем фоновые таски
Для работы в фоне нам нужно зарегистрировать возможность запускать Background Task в приложении. Это можно сделать настройках проекта в разделе Capabilities: включить background mode и выбрать background fetch. Это позволит приложению загружать данные в фоновом режиме. Кроме того, нужно запланировать фоновую задачу. Для того, чтобы иметь право на исполнение в фоне, задача должна иметь уникальный идентификатор, который прописывается в Info.plist
по ключу Permitted background task scheduler identifiers
. В нашем случае указана строка "com.learnOffline.syncTests". В примере кода повторы заменены соответствующими комментариями.
import SwiftUI
import BackgroundTasks
@main
struct LearnOfflineApp: App {
let testSyncManager: TestSyncManager
let bgTaskIdentifier = "com.learnOffline.syncTests"
init() {
// Регистрируем задачу
registerBackgroundTask()
}
var body: some Scene {
WindowGroup {
// скрыта инициализация вью и бд
}
.onChange(of: phase) { newPhase in
switch newPhase {
case .background: scheduleAppRefresh()
default: break
}
}
}
private func registerBackgroundTask() {
BGTaskScheduler.shared.register(
forTaskWithIdentifier: bgTaskIdentifier,
using: nil
) { task in
scheduleAppRefresh()
// Запускаем задачу отправки данных
Task {
await testSyncManager.syncTests()
task.setTaskCompleted(success: true)
}
}
}
}
// Сообщаем системе о необходимости вызвать эту задачу снова
func scheduleAppRefresh() {
let request = BGAppRefreshTaskRequest(identifier: bgTaskIdentifier)
request.earliestBeginDate = .now.addingTimeInterval(60 * 20)
try? BGTaskScheduler.shared.submit(request)
}
Если нужно выполнить загрузку более объемных данных, например, при наличии медиа-данных в опросниках, лучше использовать URLSession для фонового режима. Код в файле приложения на SwiftUI может содержать разные вызовы для регистрации фоновых задач.
let config = URLSessionConfiguration.background(withIdentifier: "com.learnOffline.session")
config.sessionSendsLaunchEvents = true
let session = URLSession(configuration: config)
struct LearnOfflineApp: App {
var body: some Scene {
WindowGroup {
// скрыта инициализация вью и бд
}
.backgroundTask(.appRefresh(bgTaskIdentifier)) {
// Код, который выполнится в фоновом режиме
await testSyncManager.syncTests()
}
.backgroundTask(.urlSession("myurlsession")) {
// загрузка новых опросников с медиа-материалами
}
}
}
Если остались вопросы по реализации офлайн-режима в приложении — пишите в комментариях.
Принципы UX для офлайн-режима: от проблем к интуитивному интерфейсу
Типичная ситуация при разработке: команда исходит из идеальных условий. Техническое задание предписывает «отобразить список объектов», и реализация сводится к простой схеме: загрузить данные с сервера → показать пользователю. Логично? Да. Достаточно? Нет.
Как только тестировщик активирует авиарежим, открывается неприятная правда: приложение зависает с бесконечным спиннером, крашится при попытке отправить данные или показывает пустоту. Пользователь, который потратил время на заполнение пятиэкранной формы только чтобы увидеть ошибку, скорее всего удалит приложение. Это прямое нарушение современных стандартов UX.
Парадокс в том, что работа без сети часто просто упускается из виду на этапе дизайна. Редко в макетах можно встретить экраны с состоянием «нет соединения».
Что делать, если готового дизайна нет? Применим принцип консистентности (единообразия): одинаковые вещи должны выглядеть и вести себя одинаково. Мы проанализировали поведение Apple Заметок, Карт и других эталонных приложений — и сформулировали ключевые рекомендации, которые сделают ваш интерфейс понятным и предсказуемым в любых условиях.
1. Всегда информируйте пользователя о состоянии сети
Не заставляйте его гадать, есть ли соединение. Сделайте статус видимым на системном уровне.
Глобальный индикатор: Разместите небольшой баннер под в тулбаре или иконку в
navigation bar
. Используйте интуитивно понятные стандартные иконки, например,wifi.slash
для офлайн.Пример из практики: В Telegram при потере соединения в навбаре появляется статус и индикатор, а в стандартных Заметках отображается неброское уведомление «Синхронизация приостановлена». Это снимает тревогу и говорит пользователю: «Я работаю, но в ограниченном режиме».


2. Визуализируйте статус для каждого объекта данных
Пользователь должен понимать судьбу своих действий. Каждый элемент, который может существовать в нескольких состояниях, должен иметь визуальный индикатор.
-
Общепринятые паттерны: Опирайтесь на знакомые пользователям статусы. Для нашего приложения с тестами это:
«Сохранено локально» — данные есть только на устройстве.
«Отправляется...» — процесс в работе.
«Отправлено» — сервер получил данные.
Важно: Статус должен быть краток, точен и виден сразу, при беглом просмотре списка. Не существует ничего понятнее слов.

3. Управляйте ожиданиями через поведение кнопок
Кнопка — это обещание действия. Её состояние должно всегда отражать реальность.
-
Меняйте текст и доступность: Кнопка не должна вести в тупик.
Есть сеть: «Отправить» (активна). Действие выполняется сразу.
Нет сети: «Сохранить локально» или «Отправить при подключении» (активна!). Четко объясняет, что произойдет.
Данные отправляются: «Отправляется...» (неактивна). Блокирует повторное нажатие.
Пример плохого UX: Серый и неактивный вид кнопки «Отправить» без сети. Пользователь думает, что что-то сломалось, и не понимает, сохранились ли его данные.
4. Используйте системные уведомления для фоновых событий
Многие процессы (как синхронизация) происходят без активного участия пользователя. Сообщить ему о результатах можно с помощью локальных уведомлений.
После восстановления связи: мы добавляем уведомление «Тест отправлен на проверку». Пользователь видит, что система работает и его данные в безопасности.
Главный принцип, который объединяет все рекомендации: прозрачность. Не заставляйте пользователя разбираться в сложностях сетевого взаимодействия. Интерфейс должен наглядно и честно отражать внутреннее состояние данных. Это создает то самое ощущение надежности и контроля, которое помогает пользователю доверять вашему приложению.
Следуя этим принципам, вы не просто «добавите офлайн-режим» — вы создадите по-настоящему устойчивый и предсказуемый пользовательский опыт, который выделит ваше приложение на фоне конкурентов.
В заключение хотим только пожелать: делайте офлайн-режим — это юзер-френдли :)
Сохраняйте статью в закладки, если собираетесь делать прилож с продуманным офлайн-режимом!