Стратегии кэширования во Flutter: что хранить локально, когда инвалидировать данные и как поддерживать согласованные экраны при навигации.

Кэширование в мобильном приложении — это хранение копии данных поблизости (в памяти или на устройстве), чтобы следующий экран мог моментально отрисоваться, не дожидаясь сети. Это могут быть списки, профиль пользователя или результаты поиска.
Сложность в том, что кэшированные данные часто немного неверны. Пользователи это быстро замечают: цена не обновляется, счётчик бейджа застрял или экран деталей показывает старую информацию сразу после изменения. Что делает отладку болезненной — это время. Один и тот же эндпойнт может выглядеть нормально после pull-to-refresh, но ошибочным после возврата назад, возобновления приложения или смены аккаунта.
Есть реальная компромисса. Если всегда запрашивать свежие данные, экраны будут медленными и дергаными, а батарея и трафик расходоваться зря. Если кэшировать агрессивно, приложение кажется быстрым, но пользователи теряют доверие к тому, что они видят.
Простая цель помогает: сделать свежесть предсказуемой. Решите, что каждый экран может показывать (свежее, слегка устаревшее или офлайн), как долго данные живут до обновления и какие события обязательно инвалидируют их.
Представьте типичный сценарий: пользователь открывает заказ, затем возвращается к списку заказов. Если список берётся из кэша, он может всё ещё показывать старый статус. Если обновлять каждый раз, список будет мерцать и казаться медленным. Чёткие правила вроде «показать кэш мгновенно, обновить в фоне и обновить оба экрана, когда придёт ответ» делают поведение согласованным при навигации.
Кэш — это не просто «сохранённые данные». Это сохранённая копия плюс правило, когда она всё ещё годна. Если хранить полезную нагрузку, но не хранить правило, вы получите две версии реальности: один экран показывает новое, другой — вчерашнее.
Практичная модель — помещать каждый кэширующийся элемент в одно из трёх состояний:
Такой подход делает UI предсказуемым, потому что он всегда может одинаково реагировать на каждое состояние.
Правила свежести должны основываться на сигналах, которые вы можете объяснить товарищу по команде. Частые варианты: время жизни (например, 5 минут), изменение версии (схема или версия приложения), действие пользователя (pull-to-refresh, submit, delete) или подсказка от сервера (ETag, last-updated или явный ответ «cache invalid»).
Пример: экран профиля загружает кэшированные данные пользователя мгновенно. Если они устарели, но пригодны, показывается имя и аватар из кэша, а обновление происходит тихо. Если пользователь только что изменил профиль — это момент, когда нужно обязательно обновить. Приложение должно сразу обновить кэш, чтобы все экраны оставались согласованными.
Решите, кто владеет этими правилами. В большинстве приложений лучший дефолт: слой данных владеет свежестью и инвалидацией, UI лишь реагирует (показывает кэш, загрузку, ошибку), а бэкенд даёт подсказки по возможности. Это предотвращает изобретение разными экранами собственных правил.
Хорошее кэширование начинается с одного вопроса: навредит ли пользователю, если эти данные немного устареют? Если ответ «скорее всего, нет», это хорошая кандидатура для локального кэша.
Данные, которые часто читают и которые меняются медленно, обычно стоит кэшировать: ленты и списки, каталожный контент (товары, статьи), справочные данные (категории, страны). Настройки и предпочтенния тоже подходят, как и базовая информация профиля — имя и URL аватара.
Рискованная сторона — всё, что связано с деньгами или критично по времени. Балансы, статус платежа, доступность товара, слоты записи, ETA доставки и «последний визит онлайн» могут создавать реальные проблемы, если устарели. Их можно кэшировать ради скорости, но рассматривайте кэш как временное заполнение и принудительно обновляйте в точках принятия решения (например, прямо перед подтверждением заказа).
Производное состояние UI — отдельная категория. Сохранение выбранной вкладки, фильтров, запроса поиска, порядка сортировки или позиции прокрутки делает навигацию плавной. Но это может и запутать, когда старые выборы всплывают неожиданно. Простое правило: храните состояние UI в памяти, пока пользователь остаётся в этом потоке, но сбрасывайте его, когда он явно «начинает заново» (например, возвращается на домашний экран).
Избегайте кэширования данных, создающих риск для безопасности или приватности: секреты (пароли, API-ключи), одноразовые токены (OTP, токены сброса пароля) и чувствительные личные данные, если вам действительно не нужен офлайн-доступ. Никогда не кэшируйте полные данные карт или всё, что увеличивает риск мошенничества.
В магазине стоит кэшировать список товаров — это большая выгода. Экран оформления заказа, однако, всегда должен обновлять суммы и доступность прямо перед покупкой.
Большинству Flutter-приложений нужен локальный кэш, чтобы экраны загружались быстро и не мигали пустыми, пока сеть просыпается. Ключевое решение — где хранить кэш, потому что у каждого уровня разные скорость, ограничения по размеру и поведение при очистке.
Память — самый быстрый вариант. Подходит для данных, которые вы только что запросили и повторно используете в пределах открытого приложения: текущий профиль, последние результаты поиска, продукт, который пользователь только что просматривал. Цена проста: при убийстве процесса всё исчезает, поэтому это не помогает при холодных стартах или офлайн-доступе.
Дисковое key-value хранилище подходит для небольших элементов, которые должны пережить перезапуск: настройки, «последняя выбранная вкладка», небольшие JSON-ответы, которые редко меняются. Держите это намеренно маленьким. Как только вы начнёте класть большие списки в key-value, обновления усложняются и легко появляется раздувание.
Локальная база данных лучше, когда данные большие, структурированные или требуют офлайн-поведения. Она удобна, когда нужны запросы («все непрочитанные сообщения», «товары в корзине», «заказы за прошлый месяц»), а не загрузка одного гигантского блоба и фильтрация в памяти.
Чтобы кэширование оставалось предсказуемым, выбирайте один основной источник для каждого типа данных и избегайте хранения одних и тех же данных в трёх местах.
Короткое правило:
Также планируйте размер. Решите, что считается «слишком большим», как долго хранить записи и как их чистить. Например: ограничьте кэш результатов поиска последними 20 запросами и регулярно удаляйте записи старше 30 дней, чтобы кэш не рос бесконтрольно.
Правила обновления должны быть достаточно просты, чтобы вы могли объяснить их одним предложением для каждого экрана. Именно здесь разумное кэширование приносит пользу: пользователи получают быстрые экраны, а приложение остаётся надёжным.
Самое простое правило — TTL (time to live). Храните данные с меткой времени и считайте их свежими, например, 5 минут. После этого они становятся устаревшими. TTL хорошо подходит для «приятных дополнений», таких как лента, категории или рекомендации.
Полезное улучшение — разделение TTL на soft TTL и hard TTL.
Со soft TTL вы показываете кэш сразу, затем обновляете в фоне и обновляете UI, если данные изменились. С hard TTL вы перестаёте показывать старые данные после истечения: либо блокируете экран загрузкой, либо показываете состояние «офлайн/повторите попытку». Hard TTL подходит там, где ошибочная информация хуже, чем медленное отображение: балансы, статусы заказов или права доступа.
Если бэкенд поддерживает это, предпочитайте «обновлять только при изменении» с помощью ETag, updatedAt или поля версии. Приложение может спрашивать «изменилось ли это?» и пропускать загрузку полного полезного груза, если ничего нового нет.
Дружественный пользователю дефолт для многих экранов — stale-while-revalidate: показать сейчас, тихо обновить, и перерисовать только если результат отличается. Это даёт скорость без случайного мерцания.
Типичные правила по экранам:
Выбирайте правила, исходя из стоимости ошибки, а не только стоимости запроса.
Инвалидация кэша начинается с одного вопроса: какое событие делает кэш менее надёжным, чем стоимость его повторного запроса? Если вы выберете небольшой набор триггеров и будете им следовать, поведение останется предсказуемым и UI — стабильным.
Триггеры, которые наиболее важны в реальных приложениях:
Пример: пользователь меняет фото профиля и возвращается назад. Если полагаться только на тайм-бейзед обновление, предыдущий экран может показывать старую картинку до следующего фетча. Вместо этого считайте правку триггером: обновите кэш-профиль сразу и пометьте его как свежее с новой меткой времени.
Держите правила инвалидации небольшими и явными. Если вы не можете указать точное событие, которое инвалидирует запись кэша, вы либо будете слишком часто обновлять (медленный, дерганый UI), либо недостаточно (устаревшие экраны).
Начните с перечисления ключевых экранов и данных, которые каждый из них требует. Не думайте в терминах эндпойнтов. Думайте о видимых пользователю объектах: профиль, корзина, список заказов, товар каталога, счётчик непрочитанных.
Далее выберите один источник правды для каждого типа данных. Во Flutter это обычно репозиторий, скрывающий, откуда приходят данные (память, диск, сеть). Экраны не должны решать, когда обращаться к сети. Они запрашивают репозиторий и реагируют на возвращённое состояние.
Практический поток:
Метаданные — вот что делает правила выполнимыми. Если ownerUserId изменится (logout/login), вы сможете сразу отбросить или игнорировать старые строки, вместо того чтобы кратковременно показывать данные предыдущего пользователя.
Для поведения UI заранее решите, что означает «устарело». Обычное правило: показывайте устаревшие данные мгновенно, чтобы экран не был пуст, запускайте обновление в фоне и обновляйте, когда приходят новые данные. Если обновление не удалось, оставляйте устаревшие данные видимыми и показывайте небольшую понятную ошибку.
Затем закрепите правила простыми тестами:
Это и есть разница между «мы используем кэш» и «нашe приложение ведёт себя одинаково каждый раз».
Ничто так не подрывает доверие, как ситуация: значение на списке, открытие деталей, правка, возврат и снова старое значение. Согласованность при навигации достигается тем, что каждый экран читает из одного источника.
Прочное правило: запросить один раз, сохранить один раз, отображать много раз. Экраны не должны обращаться к одному и тому же эндпойнту независимо и хранить приватные копии. Поместите кэш в общий стор (слой состояния), и пусть и список, и деталь подписываются на одни и те же данные.
Держите одно место, которое владеет текущим значением и свежестью. Экраны могут запрашивать обновление, но они не должны сами управлять таймерами, ретраями и парсингом.
Практические привычки, которые предотвращают «две реальности»:
Даже при хороших правилах пользователи иногда видят устаревшие данные (офлайн, медленная сеть, приложение в фоне). Делайте это очевидным мягкими индикаторами: метка «Обновлено только что», тонкий индикатор «Обновление…» или бейдж «Офлайн».
Для правок оптимистические обновления обычно ощущаются лучше. Пример: пользователь меняет цену товара на экране деталей. Сразу обновите общий store, чтобы список показывал новую цену при возврате. Если сохранение не удаётся, откатите и покажите краткую ошибку.
Большинство сбоев кэширования скучные: кэш работает, но никто не может объяснить, когда им пользоваться, когда он истекает и кто за него отвечает.
Первая ловушка — кэш без метаданных. Если вы храните только полезную нагрузку, нельзя понять, устарела ли она, какая версия приложения её произвела или какому пользователю принадлежит. Сохраняйте по крайней мере savedAt, простой номер версии и userId. Эта привычка предотвращает множество багов «почему экран показывает неправильно?».
Ещё одна распространённая проблема — несколько кэшей для одних и тех же данных без владельца. Список хранит in-memory массив, репозиторий пишет на диск, экран деталей снова запрашивает и сохраняет в другом месте. Выберите один источник правды (часто слой репозитория) и заставьте все экраны читать через него.
Смена аккаунта — частая подстава. Если кто-то выходит или переключается, очищайте таблицы и ключи, относящиеся к пользователю. Иначе вы можете на мгновение показать фото и заказы предыдущего пользователя — это похоже на нарушение приватности.
Практические исправления:
Пример: список товаров загружается моментально из кэша, затем тихо обновляется. Если обновление не удалось, продолжайте показывать кэш, но явно сообщите, что данные могут быть устаревшими, и предложите Retry. Не блокируйте UI, когда кэш пригоден.
До релиза переведите кэширование из состояния «кажется, работает» в набор правил, которые можно тестировать. Пользователи должны видеть осмысленные данные даже после навигации туда-сюда, офлайна или входа с другого аккаунта.
Для каждого экрана решите, как долго данные можно считать свежими. Это могут быть минуты для быстро меняющихся данных (сообщения, балансы) или часы для медленно меняющихся (настройки, категории). Затем подтвердите, что происходит, когда данные устарели: фоновой обновление, обновление при открытии или ручной pull-to-refresh.
Для каждого типа данных решите, какие события должны очищать или обходить кэш. Частые триггеры: выход, правка элемента, смена аккаунта и обновление приложения, меняющее форму данных.
Убедитесь, что записи кэша содержат небольшой набор метаданных рядом с полезной нагрузкой:
Держите владение ясным: один репозиторий на тип данных (ProductsRepository), а не на виджет. Виджеты должны запрашивать данные, а не решать правила кэша.
Также решите и протестируйте поведение офлайн. Уточните, что показывают экраны из кэша, какие действия отключены и какой текст отображается («Показаны сохранённые данные», плюс видимый контрол обновления). Ручное обновление должно быть на каждом экране, который опирается на кэш, и легко доступно.
Представьте простое торговое приложение с тремя экранами: каталог (список), детали товара и вкладка «Избранное». Пользователи листают каталог, открывают товар и нажимают сердечко, чтобы добавить в избранное. Цель — ощущение скорости даже при медленной сети, без запутанных несоответствий.
Кэшируйте локально всё, что помогает мгновенно отрисовать: страницы каталога (ID, заголовок, цена, URL миниатюры, флаг избранного), детали товара (описание, спецификации, доступность, lastUpdated), метаданные изображений (URL, размеры, ключи кэша) и множество избранного (набор ID товаров, опционально с метками времени).
Когда пользователь открывает каталог, показывайте кэшированные результаты мгновенно, затем ревалидацию в фоне. Если приходит свежее, обновляйте только изменённое и сохраняйте позицию прокрутки.
Для переключения избранного действуйте как для «обязательной консистентности». Немедленно обновляйте локальный набор избранного (оптимистично), затем подправляйте закэшированные строки товара и детали для этого ID. Если сетевой вызов завершился неудачей — откат и маленькое сообщение.
Чтобы навигация оставалась согласованной, подавайте и значок в списке, и сердечко в деталях из одного источника правды (локальный кэш/стор), а не из отдельных состояний экрана. Список обновится сразу при возврате из деталей, экран деталей отражает изменения из списка, а вкладка «Избранное» показывает корректный счёт без ожидания повторного запроса.
Простые правила обновления: кэш каталога истекает быстро (минуты), детали товара чуть дольше, избранное никогда не истекает, но всегда синхронизируется после входа/выхода.
Кэш перестаёт быть загадкой, когда команда может указать одну страницу с правилами и договориться, что должно происходить. Цель не в совершенстве, а в предсказуемом поведении, которое остаётся неизменным между релизами.
Напишите короткую таблицу для каждого экрана: название экрана и основные данные, место кэша и ключ, правило свежести (TTL, событийное или ручное), триггеры инвалидации и то, что видит пользователь при обновлении.
Добавьте лёгкое логирование на этапе настройки. Записывайте попадания в кэш, промахи и почему произошло обновление (TTL истёк, пользователь обновил, приложение возобновилось, завершилось мутацией). Когда кто-то жалуется «этот список выглядит неправильно», такие логи делают баг отлаживаемым.
Начните с простых TTL, затем улучшайте их по метрикам и жалобам пользователей. Лента новостей может позволить 5–10 минут устаревания, а экран статуса заказа может требовать обновления при возобновлении и после любой оплаты.
Если вы быстро собираете Flutter-приложение, имеет смысл заранее набросать слой данных и правила кэша до того, как писать реализацию. Для команд, использующих Koder.ai (koder.ai), режим планирования — удобное место, чтобы сначала прописать правила по экранам, а затем реализовать их.
При тонкой настройке поведения обновления защищайте стабильные экраны во время экспериментов. Снимки и откат помогают, когда новое правило случайно добавляет мерцание, пустые состояния или рассогласованные счётчики при навигации.
Начните с одного ясного правила для каждого экрана: что он может показывать сразу (кэш), когда нужно обновить данные и что видит пользователь во время обновления. Если вы не можете сформулировать правило в одном предложении, приложение со временем начнёт выглядеть непоследовательно.
Рассматривайте кэш как имеющий состояние свежести. Если он свежий, показывайте его. Если устарел, но пригоден, показывайте сейчас и тихо обновляйте в фоне. Если нужно обновить, запрашивайте данные перед отображением (или показывайте состояние загрузки/офлайн). Это делает поведение UI постоянным вместо «иногда обновляется, иногда нет».
Кешируйте то, что часто читается и может немного устареть без вреда: ленты, каталоги, справочные данные и базовую информацию профиля. Будьте осторожны с денежными или критичными по времени данными: балансы, наличие товара, ETA и статус заказа — к ним применяйте принудительное обновление в точках принятия решения.
Память хороша для быстрого повторного использования в сессии (текущий профиль, недавно просмотренные элементы). Дисковое key-value хранение — для небольших простых данных между перезапусками (настройки). База данных нужна для больших, структурированных данных с запросами и офлайн-режимом (сообщения, заказы, инвентарь).
Простой TTL — хороший дефолт: считать данные свежими в течение заданного времени, затем обновлять. Часто лучше подход «показать кэш сразу, обновить в фоне и перерисовать, если что-то изменилось», чтобы избежать пустых экранов и лишнего мерцания.
Инвалидируйте кэш по событиям, которые действительно подрывают доверие к данным: пользовательские правки (create/update/delete), вход/выход или смена аккаунта, возобновление приложения из фонового режима, если данные старее TTL, и явное обновление пользователем. Держите триггеры небольшими и явными, чтобы не обновлять постоянно или вообще не обновлять, когда это важно.
Пусть оба экрана читают из одного источника правды, а не хранят свои приватные копии. При редактировании на экране деталей сразу обновляйте общий кэшный объект, чтобы список на возврате отображал новое значение, затем синхронизируйте с сервером и откатывайте только при ошибке сохранения.
Храните рядом с полезной нагрузкой метаданные, особенно метку времени и идентификатор пользователя. При выходе или смене аккаунта сразу очищайте или изолируйте записи, относящиеся к пользователю, и отменяйте текущие запросы, связанные со старым пользователем, чтобы не показывать данные предыдущего аккаунта.
По умолчанию оставляйте устаревшие данные видимыми и показывайте маленькое, понятное уведомление с предложением повторить попытку, вместо того чтобы очищать экран. Если же старые данные нельзя показывать безопасно — примените правило «обязательно обновить» и показывайте загрузку или сообщение офлайн, а не вводите в заблуждение.
Логику кэша держите в слое данных (например, в репозиториях), чтобы все экраны следовали одним правилам. Если быстро прототипируете в Koder.ai, сначала пропишите правила свежести и инвалидации в режиме планирования, затем реализуйте так, чтобы UI лишь реагировал на состояния, а не принимал решения о кэше.