
Привет, Хабр! Это Ахмед Шериев, сооснователь стартапа VoxOps, а сегодня — еще и гостевой автор блога Friflex. Моя статья — про опыт разработки офлайн-приложений.
Я делал офлайн-поддержку в приложениях на самых разных языках. Как вовремя и успешно, так и с факапами. Например, обещал за две недели внедрить офлайн, а потом появлялись скрытые кейсы, и разработка затягивалась до месяца, двух, трех…
Здесь постараюсь поделиться, как не зарыться в тонну инфраструктурного кода и избежать некоторых других ошибок.
Я внедрял поддержку offline как в роли разработчика, так и в роли руководителя и директора по разработке. Поэтому тема мне хорошо знакома с обеих сторон. Кто помнит, с ней я выступал на CrossConf.
Зачем вообще нужны офлайн-приложения?
Можно задать логичный вопрос — зачем в 2025 году делать офлайн-приложения, когда везде есть интернет, UI подгружается с бэкенда на лету, и все гибко обновляется?
На практике все не так просто:
Связь нестабильна. Например, мы разрабатывали сервис для строительных компаний. На стройках за городом очень часто нет стабильного доступа к интернету.
Законодательные требования. К примеру, в здравоохранении во многих странах офлайн-доступ — обязательное условие сертификации даже при наличии надежного стабильного интернета. Врач не должен остаться без доступа к данным пациента в экстренных случаях.
Поэтому офлайн жив. Более того, в некоторых сценариях он — must have. Поэтому давайте рассмотрим разные способы внедрение поддержки offline — от простого к сложному.
Кэширование – самый простой способ
Кэширование — знакомая история для многих разработчиков приложений. Сначала мы пытаемся получить данные из сети, а если интернета нет — берем из кэша, куда мы их предварительно сложили:
Future<T?> get(Future<T> networkService) async {
if (await connectivityService.isConnected) {
final value = await networkService;
await cacheRepository.save(value);
return value;
}
return await cacheRepository.read();
}

Но с такой реализацией прибегает менеджер и говорит: приложение используют на стройке, там плохой сигнал мобильной связи, интернет очень медленный. Пользователи (рабочие) — ругаются, потому что с интернетом приложение дико тормозит. А вот если интернет вдруг пропадает, приложение начинает летать. Все моментально открывается, все запускается.
Сложнее: сначала кэш, потом интернет
Для решения вышеозвученной проблемы перейдем к сценарию посложнее. Мы лезем в кэш, проверяем, что данные там есть, показываем его и только потом подключаемся к интернету (здесь и далее код будет максимально упрощен для отображения идеи):
Future<T?> get(Future<T> networkService) async {
final cacheValue = await cacheRepository.read();
if (cacheValue != null) return cacheValue;
final value = await networkService;
if (value != null) await cacheRepository.save(value);
return value;
}

В этой реализации самое важное — не забывать про инвалидацию кэша. Очень часто бывает, что приложение релизят с максимально просто реализацией и забывают, что кэш может устареть (особенно когда данные меняются очень редко, например, настройки или список товаров). Потом становится невозможно обновить информацию у пользователей, пока они не получат исправленную версию приложения. Это может быть критично для бизнеса — и в некоторых случаях приносит финансовый и репутационный ущерб.
Если данные часто обновляются, у нас еще остаются проблемы с пониманием, когда именно их надо обновлять. Кроме того, если часто инвалидировать кэш, у нас все еще будут тормоза в приложении.
Еще сложнее: стриминг и прогресс
Увеличиваем производительность приложения и поддерживаем данные актуальными: сначала отдаем данные из кэша, потом фоном подтягиваем свежие и обновляем UI:
final stream = BehaviorSubject<CacheResult<T>>();
Stream<CacheResult<T>> resultStream() => stream.asBroadcastStream();
Такой поток позволяет отдавать сначала кэш, потом — результат из интернета, и поддерживать актуальное состояние в UI. Например мы можем использовать Block для управления состоянием UI:
BlocBuilder<WeatherBloc, WeatherState>(
builder: (context, state) {
return switch (state) {
WeatherInit() => const CircularProgressIndicator(),
WeatherLoaded() => WeatherView(cache: state.result),
WeatherError() => const ErrorView(),
};
},
)

Такой подход часто называют «кэшем с догрузкой» или stream-first UX — пользователь сразу видит хоть какие-то данные, даже если они неактуальны, и не сидит перед спиннером при принудительном обновлении кеша.
В этой реализации от вас потребуется больше, чем просто изменение в коде: вместе с дизайнерами надо будет продумывать:
Состояния, когда пользователи видят анимацию первой загрузки или когда данные не удалось подгрузить в первый раз;
Как сделать анимацию загрузки данных для страницы, где данные из кэша уже отобразились и данные еще могут измениться;
Показывать ли общую анимацию загрузки для всей страницы (самый простой вариант), или для каждого элемента отображения данных отдельно.
Возможно, вы захотите показать данные из кэша, но при этом показать, что они устарели и желательно обновить кэш. А в некоторых случаях — захотите запретить пользоваться слишком устаревшими данными, пока пользователи точно не получат обновление.
Обновление кеша в фоне
Еще один вариант обновления данных — делать это в фоне, даже когда приложение не запущено, а из UI работать только с базой данных или кэшем.
Во Flutter есть уже готовый WorkManage. На практике оказалось, что в некоторых версиях Android у некоторых производителей WorkManager может быть подавлен операционной система или настройками производителя, даже если вы все сделали правильно.
Если это единственный способ обновления данных, то лучше всего обратиться к нативным разработчикам, у которых был уже опыт имплементации WorkManager во Flutter. В противном случае можете обнаружить что на QA все работает, а у половины живых пользователей данные не обновляются.
Можно также пойти простым путем: использовать WorkManager как дублирующую систему, которая может отработать, а может и не отработать, и сделать основную логику обновления при запущенном приложении.
Кэш становится проблемой: сериализация и обратная совместимость
Если хотим «тяп-ляп продакшн», можно пойти по простому пути: сохранить JSON с сервера на диск и потом десериализовать его при старте. Например, у нас была такая модель:
@JsonSerializable()
class Product {
final ProductId id;
final String name;
final int price;
}
А потом мы добавили новое поле:
final String category;
Если использовать стандартный генератор сериализации — при попытке десериализовать старые закэшированные данные, новая версия приложения упадет с ошибкой:
type 'Null' is not a subtype of type 'String' in type cast
Что тут можно сделать?
1. Сделать новое поле nullable:
@JsonSerializable()
class Product {
final ProductId id;
final String name;
final int price;
final String? category;
}
Приложение не упадет, но ваш код обрастет костылями if (field != null), и вся бизнес-логика станет трудночитаемой. Чем больше новых версий, тем больше будет этих nullable-полей и соответствующих костылей плохого кода в бизнес-логике. Это будет очень сильно мешать с развитием приложения.
2. Простой вариант решения — это сброс кэша, если не удалось прочитать данные с диска. Да, это значит, что в офлайне пользователь может остаться без данных, но это лучше, чем падение приложения.
Но даже с обработкой ошибок чтения и сбросом кэша вы можете получить ситуацию, когда пользователь выходит на объект, где нет интернета, запускает приложение — и обнаруживает, что данные недоступны и все исчезло просто потому что ОС обновила приложение когда появился доступ к интернету.
3. В качестве альтернативы можно отделить логику сериализации и десериализации в отдельные методы или классы.
Отделение логики сериализации и десериализации
Логично встает вопрос: где и как вообще хранить логику сериализации и десериализации? Можно просто в самих объектах сгенерировать или написать вручную логику сериализации и десериализации, но ваши доменные объекты тогда обрастут кучей грязного кода.
Здесь может помочь вынесение логики сериализации и десериализации в отдельные классы. Как это сделать? В том же Flutter это достаточно неочевидно, я редко видел, чтобы в Dart это использовалось. Но можно написать отдельный класс сериализации и уже для каждой сущности создать логику, как она будет сериализована и десериализована.
Например, можно создать ProductSerializer, который будет отдельно отвечать за то, как сохранять и восстанавливать Product из JSON.
Такой код будет ручной, скучный, рутинный и неинтересный. Но один раз пишете — и получаете полный контроль над сериализацией и десериализацией сущностей:
abstract class Serializer<T> {
Map<String, dynamic> toJson(T object);
T fromJson(Map<String, dynamic> json);
}
class ProductSerializer implements Serializer<Product> {
@override
Product fromJson(Map<String, dynamic> json) {
return Product(
id: json['id'],
name: json['name'],
price: json['price'],
);
}
@override
Map<String, dynamic> toJson(Product product) {
return {
'id': product.id,
'name': product.name,
'price': product.price,
};
}
}
В отдельном классе вы сможете обработать кейсы, когда какие-то данные отсутствуют на диске и надо обеспечить обратную совместимость. Кроме того, при необходимости можно из этого класса обращаться к другим модулям приложения.
Если модель изменилась, вы просто дописываете логику прямо здесь:
class ProductSerializer implements Serializer<Product> {
@override
Product fromJson(Map<String, dynamic> json) {
return Product(
id: json['id'],
name: json['name'],
price: json['price'],
category: json['category'] ?? 'no category',
);
}
}
Соответственно, использование сериализатора:
final serializer = ProductSerializer();
final json = serializer.toJson(product);
Теперь через конструктор можете добавлять дополнительные данные, логгер, информацию о том, как именно инициализировать и создавать сериализатор, отсутствующие поля, откуда брать информацию и так далее.
Основной минус в том, что вы получите массу рутинного тупого кода.
Плюс в том, что ваша бизнес-логика останется максимально чистой, и вы сможете сосредоточиться именно на своих продуктовых фичах. Кроме того, сможете эффективно покрывать тестами логику обновления данных.
Кэширование на стевом слое
Следующий способ ускорить работу приложения — это договориться бэкенд-разработчиками, как именно вы можете оптимизировать его работу. Давайте рассмотрим, как можно адаптировать протокол:
Самый простой способ — получить от бэкэнда max-age. Это время в секундах, в течение которого данные будут валидны.
max-age: как долго данные считаются свежими
Допустим, бэкенд при ответе отправляет заголовок Cache-Control: max-age=86400 — это значит, что данные действительны в течение суток.
Вы сохраняете не только сами данные, но и время, когда они были получены. При следующем обращении к данным, если время еще не истекло — просто читаете из кэша. Если истекло — делаете запрос в сеть за обновлением.
Например:
if (response.headers['max-age'] != null) {
final maxAge = int.tryParse(response.headers['max-age'] ?? '');
return Response(
value: parse(response),
expiration: maxAge == null
? null : DateTime.now().add(Duration(seconds: maxAge)),
version: response.headers['ETag'] ?? '',
);
}
Этот трюк можно использовать даже в другую сторону: Бэкенд-разработчики могут реализовать динамческое увеличение max-age в пиковые часы, чтобы снизить нагрузку на сервер.
ETag / Last Modified: уменьшаем объем запросов
Допустим, вам надо закэшировать большой каталог, который обновляется раз в сутки из 1С. Нет никакого смысла обращаться к серверу чаще, чем раз в сутки. Но и ждать сутки перед каждым запросом — тоже не вариант. Вам же нужно знать, прямо сейчас есть новая версия или нет.
Здесь на помощь приходит заголовок ETag — стандартный HTTP-механизм. Что вы делаете: при первом запросе сохраняете ETag, который вернул сервер.
А при следующем — отправляете его обратно в заголовке If-None-Match. Сервер проверяет: если данные не менялись, отвечает статусом 304 Not Modified, и вы просто используете локальный кэш. Если менялись — возвращает 200 OK с новыми данными, которые вы сохраняете.
Например:
Future<NetworkResponse<List<Todo>>> getTodos(String etag) async {
final response = await http.get(Uri.parse('https://dom.com/todos'),
headers: etag != null ? {'If-None-Match': etag} : null,);
if (response.statusCode == 304) {
return const NoUpdates();
}
return Response(
value: parse(response.body),
version: response.headers['ETag'] ?? '',
);
}
Так вы существенно можете сэкономить заряд батарейки и трафик пользователя, который в некоторых странах достаточно дорогой.
Инкрементный update
Тем не менее, каталог может быть действительно большим. Допустим, у вас — миллион позиций, и общий вес данных ~10 мегабайт. Но за сутки меняется максимум тысяча позиций. Зачем каждый раз скачивать весь миллион и более позиций?
Здесь можно использовать инкрементное обновление: вы отправляете версию ETag, которую сохранили ранее, и в ответ получаете только изменившиеся записи.
Например:
if (response.statusCode == 206) {
return Response(
value: parse(response.body),
version: response.headers['ETag'] ?? '',
);
}
В этом примере 206 Partial Content — это статус-код, который говорит клиенту: «Вот только те данные, которые обновились».
Здесь есть нюанс, о котором часто забывают: а как узнать, что что-то было удалено? Если сервер просто отдает вам список обновленных объектов — вы никак не узнаете, какие из старых позиций стали неактуальны.
Поэтому важно продумать формат ответа. Некоторые просто добавляют отдельное поле deleted, но я предпочитаю, чтобы сервер возвращал отдельный список ID удаленных объектов. Тогда на клиенте можно безопасно зачистить локальный кэш:
{
"updated": [...],
"deleted": ["id123", "id456"]
}
Обработка ошибок со стороны бэкенда
Это не всегда требуется, но если у вашей системы есть ролевая модель, и пользователь может потерять доступ — это критично. Особенно если с клиента (например, с мобильного приложения) необходимо удалить все локальные данные, если бэкенд запретил доступ. Такие кейсы нужно продумывать заранее: прокладывать логику, добавлять спецстатус-коды от бэкенда, которые бы говорили грохнуть все.
Чек-лист кэширования
Инвалидация
Обновление UI
Обратная совместимость данных в новых версиях
Кеширование на сетевом слое
Инкрементальное обновление списка
Обработка ошибок с бекенда
Ограничение доступа
Ресурс не найден
На этом с кэшированием — все. Но офлайн — это не только чтение. Очень часто пользователю нужно вносить изменения: создать заказ, отредактировать запись, отметить задачу выполненной. И все это — когда нет интернета.
Вот тут мы и подходим к следующей большой теме — локальному CRUD и синхронизации изменений с сервером. Об этом расскажу в следующей статье :)