Данные из кэша — ещё не актуальные данные. Что должен сообщать репозиторий?
Каталог уже на экране, но обновление не удалось. Dart/Flutter-эксперимент разделяет содержимое, давность подтверждения, ошибки сети и сохранения, а Ark MVP передаёт эти различия в состояние экрана.
- Опубликовано
- 23 сентября 2026 г.
- Проверено
- 18 сентября 2026 г.
Данные из кэша — ещё не актуальные данные. Что должен сообщать репозиторий?
Приложение открывает каталог. Сохранённый список появляется сразу, рядом вращается индикатор обновления. Через несколько секунд сеть отвечает ошибкой. Список исчезает, а на его месте остаются надпись «Не удалось загрузить данные» и кнопка повтора.
Только данные уже были загружены. Пользователь успел их увидеть, возможно, даже найти нужную позицию. Не удалось другое действие — получить более новое значение. Почему отказ этой попытки уничтожил полезный результат предыдущей?
Обратное решение тоже не вполне честное: оставить список и никак не сообщить о сбое. Экран выглядит благополучно, хотя приложение не знает, изменился ли каталог на сервере. Значок «из кэша» добавляет сведений о происхождении, но всё ещё не отвечает на вопрос об актуальности.
Проблема начинается раньше обработки ошибки в виджете. Она возникает в контракте, через который приложение получает данные.
Список есть. Обновление не удалось
Компонент, объединяющий доступ к локальным данным и серверу, обычно называют репозиторием. Здесь это не Git-репозиторий, а граница работы с данными: остальному приложению не требуется самостоятельно выбирать источник, читать сохранённую запись и согласовывать её с ответом сети.
Для простого чтения достаточно метода вроде Future<List<String>> loadCatalog(). Он возвращает список либо завершается ошибкой. Но экран из начала статьи задаёт сразу несколько вопросов: что можно показать сейчас, выполняется ли обновление, чем закончилась последняя попытка и удалось ли сохранить полученное значение на устройстве?
Один результат чтения этих вопросов не разделяет. Особенно если любое исключение превращается в общее состояние error, заменяющее предыдущий список.
Руководство Flutter по offline-first рассматривает несколько вариантов: сеть с локальным резервным источником, последовательную выдачу локального и удалённого значения, чтение локальных данных с отдельной синхронизацией. Выбор зависит от требований. Но сам способ доставки значений ещё не определяет, что означает неудачное обновление уже показанного содержимого.
Для разбора ArkTelos Lab подготовила небольшой каталог с управляемыми источниками. В нём данные и попытка их обновить представлены отдельно:
final class CatalogState {
const CatalogState({
this.data,
this.refresh = RefreshStatus.idle,
this.problem,
this.persistence = Persistence.absent,
this.cacheRead = CacheRead.unread,
this.freshness = Freshness.unknown,
});
final CatalogData? data;
final RefreshStatus refresh;
final RefreshProblem? problem;
final Persistence persistence;
final CacheRead cacheRead;
final Freshness freshness;
}
data — имеющееся содержимое. refresh сообщает, выполняется ли обновление, а problem сохраняет вид его неудачи. persistence относится к сохранению текущего значения на устройстве. cacheRead — к результату чтения локальной записи. Наконец, freshness показывает оценку давности по выбранной политике; к ней ещё стоит вернуться отдельно.
Это не шесть независимых переключателей для виджета. Новый снимок собирает репозиторий, а экран получает уже согласованное сочетание. В частности, список вместе с refresh == failed — допустимое состояние, не противоречие. Оно означает: содержимое есть, последняя попытка обновления не удалась.
Пустой список тоже является содержимым. data == null означает отсутствие значения, а data.items.isEmpty — полученный пустой каталог. Если оба случая показывать как «данных нет», интерфейс потеряет различие между неизвестным результатом и известным отсутствием позиций.
Откуда получено — не то же самое, что когда подтверждено
Содержимое каталога хранится вместе с источником текущего значения и временем последнего успешного подтверждения:
final class CatalogData {
CatalogData(List<String> items, this.origin, this.confirmedAt)
: items = List.unmodifiable(items);
final List<String> items;
final Origin origin;
final DateTime? confirmedAt;
}
origin принимает cache или remote. Это происхождение значения в текущем экземпляре приложения, не оценка его качества. Локальная запись могла быть подтверждена десять секунд назад. Ответ сети, оставшийся в памяти, через час всё ещё имеет происхождение remote, но не становится от этого вечным доказательством актуальности.
Для учебного каталога выбрана политика TTL — допустимый возраст подтверждения. В опыте он составляет пять минут. До этой границы значение укладывается в политику; на самой границе уже считается просроченным:
Freshness classify(
DateTime? confirmedAt,
DateTime now,
bool clockUncertain,
) {
if (confirmedAt == null || clockUncertain) {
return Freshness.unknown;
}
final age = now.difference(confirmedAt);
if (age.isNegative) return Freshness.unknown;
return age < ttl ? Freshness.withinTtl : Freshness.expired;
}
Название withinTtl намеренно не обещает «данные точно актуальны». Оно говорит только о прохождении локального критерия. Сервер мог измениться сразу после ответа. Более того, настоящий API сам может отдать закэшированное значение. В таком случае успешный HTTP-ответ ещё не устанавливает момент подтверждения содержимого: эту семантику требуется определить в контракте с источником.
Учебный источник проще: успешный ответ считается новым наблюдением, а время его получения задают управляемые часы. Это допущение стенда, не гарантия любого backend.
Если время подтверждения отсутствует или находится в будущем, результат — unknown. Отдельно репозиторий замечает движение часов назад: даже когда метка ещё остаётся в прошлом, прежнее вычисление возраста уже нельзя продолжать как ни в чём не бывало. В примере неопределённость сохраняется до следующего успешного ответа. Такой подход не обнаруживает все возможные манипуляции часами и не заменяет проверку сохранённых метаданных.
Важна и доставка изменения времени. Список может не меняться, пока его возраст пересекает TTL. Поэтому у репозитория есть recheckTime(): вызывающая сторона должна обращаться к нему при возвращении приложения на экран или по своему таймеру. Скрытого таймера в примере нет. Без такого вызова интерфейс не узнает о смене категории только потому, что прошла ещё одна минута.
Сеть ответила. Диск — нет
Следующая граница появляется после успешного обновления. Новый каталог уже получен, но запись в локальное хранилище завершается ошибкой. Если получение и сохранение окружены одним общим catch, эту ситуацию легко назвать сетевым сбоем и вернуть старый кэш.
Так приложение само выбросит более новое значение и скроет настоящую причину.
В стенде выбран другой порядок: сначала принять данные в память, затем отдельно сохранить их. Во время записи состояние содержит новый список и Persistence.pending. После записи — saved либо failed. Существенная часть реализации выглядит так:
try {
await local.write(data);
if (_active(generation)) {
_publish(
data: data,
refresh: RefreshStatus.idle,
persistence: Persistence.saved,
);
}
} catch (_) {
if (_active(generation)) {
_publish(
data: data,
refresh: RefreshStatus.idle,
persistence: Persistence.failed,
);
}
}
Здесь data — уже принятый сетевой ответ, _publish создаёт полный снимок, а generation обозначает текущий цикл репозитория. Проверка _active не позволяет завершению старой работы публиковать состояние после закрытия владельца. Она не отменяет физически запрос или запись.
Исключение в этом фрагменте относится именно к сохранению. Получение данных имеет собственную обработку. Учебный код объединяет ошибки записи в одну категорию; для рабочего приложения понадобятся диагностика и более точное различение причин.
Цена выбранной политики тоже явная: если процесс завершится до успешной записи, новое значение может не пережить перезапуск. Поэтому экран вправе сообщить «Обновлено, но не сохранено на устройстве», а не обещать offline-доступность. Если продукт требует сначала надёжно сохранить данные и только потом показывать их, порядок будет другим.
При этом время подтверждения фиксируется при ответе источника и сохраняется вместе с ним. Завершившаяся позднее запись не делает каталог моложе.
Два ответа, пришедшие не в том порядке
Локальное чтение обычно воспринимается как быстрое действие. Это не основание рассчитывать, что оно обязательно завершится раньше сети. Асинхронная операция может задержаться, а когда вернётся — принести старое содержимое поверх уже принятого нового.
В эксперименте чтение кэша и сетевой запрос запускаются независимо. Но право кэша заменить содержимое ограничено: если сетевой результат уже принят, поздняя локальная запись игнорируется. Это проверено отдельным сценарием: сначала завершается сеть со значением new, затем кэш со значением old; текущее значение остаётся new.
Одновременные нажатия «Обновить» решаются иначе. Второй вызов присоединяется к уже выполняющемуся циклу и получает тот же Future, вместо запуска ещё одного запроса. В тесте проверяется и идентичность возвращённого Future, и единственное обращение к источнику.
У этого решения есть практическое ограничение: общий цикл ждёт также чтение кэша и запись. Если адаптер зависнет навсегда, ожидание не закончится. Ограничивать время работы обязаны адаптеры; стенд не выдаёт объединение запросов за механизм отмены и таймаутов.
Повреждённый кэш, в свою очередь, не становится пустым каталогом. Ошибка чтения отмечается отдельно, повреждённое значение не публикуется, запрос сети продолжает работу. Иначе испорченная запись выглядела бы как вполне корректный бизнес-ответ «позиций нет».
Не каждый отказ разрешает оставить кэш
До этого речь шла о недоступности транспорта: получить обновление не удалось, но известного запрета использовать уже имеющееся содержимое нет. Отказ в доступе означает другое.
Если источник явно запрещает использование каталога, универсальное «при любой ошибке покажем кэш» противоречит полученному решению. В примере AccessDenied очищает видимое содержимое и запрещает этому экземпляру репозитория принимать локальное значение. Даже если чтение кэша завершится позже, список не вернётся. Следующая транспортная ошибка также не восстановит его; успешное разрешённое чтение сети может дать новое содержимое.
Это узкая проверка поведения, не законченная модель авторизации. Стенд не удаляет запись с диска и не доказывает защиту после перезапуска процесса. В реальном приложении нужны правила хранения, смены аккаунта и действия сохранённых разрешений. Для статьи достаточно не смешивать сетевую недоступность с известным запретом.
Как передать всё это экрану
Репозиторий пока не решал, каким текстом подписать список или где разместить предупреждение. Он сообщает факты: содержимое, результат обновления, состояние сохранения. Представление преобразует их в то, что должен увидеть человек.
Эту границу можно реализовать адаптером над привычным state manager. В стенде для неё используются ark_mvp 1.1.0 и ark_mvp_flutter 1.1.0. Они не вычисляют TTL и не объединяют кэш с сервером — эту работу уже выполнил репозиторий.
ModelBinding связывает его полный снимок со слоем представления:
binding = ModelBinding(
read: () => widget.repository.state,
changes: [widget.repository.changes],
);
Сигнал изменения означает, что снимок требуется прочитать заново. Затем Presenter — объект преобразования модели в состояние экрана — выбирает надпись, признак загрузки и отображаемое содержимое. В примере он наследует FlutterPresenter, а готовое состояние получает обычный Flutter-виджет через MvpView.
Сообщение «Не удалось обновить» остаётся частью состояния экрана, а не одноразовым toast. Иначе после повторного открытия списка исчезло бы предупреждение, хотя факт неудачного обновления никуда не делся. Это продолжает различие между состоянием, результатом и эффектом: важное пояснение к показанным данным должно быть доступно при повторном чтении.
Widget-тест сначала показывает сохранённый список во время запроса. Затем источник возвращает транспортный отказ. Проверка требует, чтобы список остался, индикатор исчез, а предупреждение появилось. После удаления экрана и его нового создания с тем же репозиторием список и предупреждение должны восстановиться. Этот сценарий прошёл без воспроизведения разового эффекта.
Ещё два widget-теста отличают пустой каталог от отсутствия данных и проверяют новый список вместе с предупреждением о несохранённом результате. Сам репозиторий при закрытии экрана остаётся у владельца приложения; Flutter-адаптер не получает права уничтожить его источники.
Что получилось проверить
На Flutter 3.44.7 и Dart 3.12.2 прошли 16 проверок репозитория и три widget-теста; анализатор замечаний не нашёл. Источники управляются через Completer, часы задаются тестом. Настоящих сетевых запросов, ожидания пяти минут и измерения производительности в опыте нет. Использованы опубликованные версии Ark MVP, а не локально изменённые копии пакетов.
Проверки не доказывают надёжность конкретной базы данных, работу на телефоне или корректность всех конкурентных последовательностей. Они подтверждают более ограниченный результат: для перечисленных сценариев данные не исчезают из-за транспортного сбоя, запись имеет отдельный исход, TTL не смешивается с происхождением, а позднее чтение не отменяет принятое решение.
Практический вопрос для ревью репозитория поэтому звучит не «есть ли здесь кэш?». Важно, какие сведения потеряются между источником и экраном. Можно ли отличить пустой ответ от отсутствия ответа, неудачное обновление от отсутствия данных, старое подтверждение от недавней записи и ошибку хранения от ошибки сети?
Если эти различия исчезли из контракта, виджетам придётся восстанавливать их догадками. Дополнительный state manager не вернёт сведения, которые до него не дошли.
Исходники и проверки эксперимента ArkTelos Lab позволяют повторить переходы с управляемыми источниками и посмотреть адаптер представления отдельно от контракта данных.
ArkTelos: официальный сайт · новости RU · news EN
ArkTelos Lab: лаборатория · Telegram RU · Telegram EN
Границы результата
- Учебный каталог, управляемые источники и часы. 16 проверок репозитория и 3 widget-теста, один полный прогон. Нет настоящей сети, базы данных, устройств, pixel-проверок или бенчмарка. TTL не гарантирует серверную актуальность; запрет доступа проверен в одном экземпляре, не после перезапуска. При сборке публикации опыт не запускался заново.
CODE / DATA / AGENTS
Связанные эксперименты
cache-freshness-contractПроверить давность кэша, исходы обновления и сохранения, порядок ответов и восстановление состояния экрана.
Учебный каталог, управляемые источники и часы. 16 проверок репозитория и 3 widget-теста, один полный прогон. Нет настоящей сети, базы данных, устройств, pixel-проверок или бенчмарка. TTL не гарантирует серверную актуальность; запрет доступа проверен в одном экземпляре, не после перезапуска. При сборке публикации опыт не запускался заново.
Открыть паспорт эксперимента →Версия в Telegram