ATL-2026-010architectureexperimental

Данные из кэша — ещё не актуальные данные. Что должен сообщать репозиторий?

Каталог уже на экране, но обновление не удалось. 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

Связанные эксперименты

experimentcache-freshness-contract

Проверить давность кэша, исходы обновления и сохранения, порядок ответов и восстановление состояния экрана.

Учебный каталог, управляемые источники и часы. 16 проверок репозитория и 3 widget-теста, один полный прогон. Нет настоящей сети, базы данных, устройств, pixel-проверок или бенчмарка. TTL не гарантирует серверную актуальность; запрет доступа проверен в одном экземпляре, не после перезапуска. При сборке публикации опыт не запускался заново.

Открыть паспорт эксперимента →

Версия в Telegram

Читать в ArkTelos Lab

Читать в ArkTelos Lab ↗

КАНАЛЫ ARKTELOS

Новости и инженерные материалы — без смешивания языков.

Основные каналы рассказывают о развитии ArkTelos. ArkTelos Lab публикует архитектурные разборы, эксперименты и воспроизводимые исследования.