Выбирайте по форме данных: key-value для настроек, настоящая база для записей, платформенное хранилище для секретов.
**SharedPreferences**: несколько маленьких пар ключ-значение: тема, флаги, пройден ли онбординг. В новом коде берите SharedPreferencesAsync, getInstance() уже legacy.
sqflite (SQLite): реляционная база с SQL, транзакциями и миграциями; для данных, которые ищут, соединяют и считают. Сверху ставят drift ради типизированных запросов.
Hive: NoSQL key-value в «боксах», чистый Dart, очень быстрый на чтении объекта целиком; берите поддерживаемый форк hive_ce.
**flutter_secure_storage**: шифруется платформой: Keychain на iOS, Keystore на Android. Единственное место для токенов и паролей.
FlutterMiddle
Локальное хранилище
SharedPreferences, sqflite, Hive, flutter_secure_storage
Закрыто вопросов
0 / 25
- 0Повторить
- 2Часто спрашивают
Вопросы
Откройте файл один раз через openDatabase (путь берётся из getDatabasesPath()), создайте таблицы в onCreate, а дальше пользуйтесь insert, query, update и delete; значения передаются в map или как плейсхолдеры ? в where, но никогда не через интерполяцию строк.
Один экземпляр: открывайте базу один раз и раздавайте её через репозиторий или DI; каждый вызов возвращает Future
Плейсхолдеры: where: 'id = ?', whereArgs: [id], а не интерполяция строк: значение биндит sqflite, и кавычки не сломают SQL
Что возвращается: insert отдаёт id новой строки, update и delete отдают число изменённых строк, а query отдаёт List<Map<String, Object?>>, который вы маппите в модели
SharedPreferences представляет собой маленький, нетипизированный, нереактивный и незашифрованный key-value файл: загрузите его до того, как он понадобится UI, храните в нём только небольшие настройки и никогда не храните секреты.
Первая загрузка асинхронная: читайте в main() до runApp или показывайте значение по умолчанию, пока данные грузятся
Он не реактивный: после set никто не получает уведомления; оборачивайте в ValueNotifier или в ваш стейт-менеджер
Ключи без типов: ключ, прочитанный не тем геттером, бросает ошибку типа в рантайме, а не при компиляции; держите все ключи и их типы в одном классе констант
Нет транзакций: приложение могут убить между двумя set; значения, которые связаны между собой, сохраняйте одной JSON-строкой
Не для секретов и не для записей: это обычный XML- или plist-файл, поэтому токены идут в flutter_secure_storage, а списки записей идут в базу
path_provider возвращает платформенные директории вашего приложения; выбирайте, ответив на вопрос: это контент пользователя, данные приложения или то, что можно получить заново.
**getApplicationDocumentsDirectory(): то, что создал пользователь и что нельзя терять (заметки, экспорт); попадает в бэкап
getApplicationSupportDirectory(): данные приложения, которые пользователь не видит: базы, скачанные модели, конфиг; в бэкап попадают и не вычищаются
getApplicationCacheDirectory() / getTemporaryDirectory(): всё, что можно скачать или посчитать заново; ОС вправе удалить это при нехватке места, бэкапы это пропускают
Частая ошибка**: превью в Documents: они раздувают iCloud-бэкап пользователя, и их никто никогда не чистит
sqflite умеет биндить только num, String, Uint8List и null, потому что SQLite хранит лишь числа, текст и байты; всё остальное преобразуйте в toMap/fromMap модели (или через TypeConverter из drift), но не в местах вызова.
**DateTime**: падает с Invalid argument: Instance of 'DateTime'; сохраняйте millisecondsSinceEpoch (целое число без часового пояса) и читайте через DateTime.fromMillisecondsSinceEpoch(ms, isUtc: true); если хочется текст, берите toUtc().toIso8601String(), потому что локальная ISO-строка не содержит смещения и сортируется неверно
**bool**: неподдерживаемый тип; одни платформы его приводят, другие отвергают, поэтому всегда биндите 1 и 0, в том числе в whereArgs
Enum: сохраняйте value.name, а не index: вариант, вставленный в середину, молча поменяет смысл всех сохранённых строк
Они различаются тем, где находится источник истины: legacy SharedPreferences кеширует все ключи в Dart, SharedPreferencesAsync ходит на платформу при каждом вызове, а SharedPreferencesWithCache кеширует только разрешённые вами ключи.
**Legacy getInstance()**: один раз загружает все ключи в Dart-map; чтения синхронные, но запись из другого изолята или из нативного кода не видна до reload(). Пакет планирует объявить его устаревшим
**SharedPreferencesAsync**: без Dart-кеша, каждый get требует await; данные всегда свежие, поэтому это безопасный выбор, когда пишут и фоновые изоляты
**SharedPreferencesWithCache**: создаётся один раз через create(cacheOptions: ...), дальше синхронные чтения из кеша, ограниченного allowList; запись обновляет и кеш, и платформу
Бэкенд на Android: два новых API по умолчанию работают через Jetpack DataStore, а legacy работает через старый XML-файл, поэтому новые API не видят значений, записанных legacy API
Миграция: один раз на старте вызовите migrateLegacySharedPreferencesToSharedPreferencesAsyncIfNecessary; он скопирует старые ключи и сохранит флаг завершения
Любое хранилище представляет собой просто файл в песочнице приложения, поэтому вытащите файл на свою машину и откройте обычным десктопным инструментом.
Android: /data/data/<applicationId>/: databases/ для sqflite, shared_prefs/ для legacy API настроек, files/datastore/ для SharedPreferencesAsync, app_flutter/ для documents-директории (туда обычно кладёт файл drift)
iOS: контейнер приложения; на симуляторе путь печатает xcrun simctl get_app_container booted <bundle id> data; на реальном iPhone откройте Xcode, Window, Devices and Simulators, выберите приложение, Download Container
Как забрать: adb exec-out run-as <applicationId> cat databases/app.db > app.db, работает только на debuggable-сборке
**Берите и файл -wal: база в режиме WAL, скопированная в одиночку, не содержит свежих записей, и это выглядит ровно как баг сохранения
Чем открыть**: DB Browser for SQLite или расширение drift для DevTools прямо на работающем приложении
Увеличьте version в openDatabase и доводите старые базы до актуальной схемы в onUpgrade шагами if (from < N); onCreate должен сразу создавать последнюю схему.
onCreate выполняется один раз на пустой базе, onUpgrade срабатывает, когда версия в коде выше, чем на диске, а onDowngrade срабатывает в обратном случае. Без onDowngrade sqflite не падает: он просто открывает файл и записывает меньшую версию. Передайте onDatabaseVersionChangeError, чтобы падать явно, или onDatabaseDowngradeDelete, чтобы стереть базу и создать заново.
Мигрируйте по номеру версии: шагами от старой к новой через if (from < N), а не «добавить колонку, если её нет»
Колбэки выполняются в транзакции: упавший шаг откатывает весь апгрейд, и версия остаётся старой; изменения схемы после открытия базы этой защиты не имеют
**ALTER TABLE урезан**: DROP COLUMN появился только в SQLite 3.35, а старые версии Android поставляют более старый SQLite, поэтому изменить колонку обычно значит создать новую таблицу, скопировать данные, удалить старую и переименовать
Проверяйте все пути: в CI открывайте заполненную базу каждой старой версии и обновляйте её до текущей, а также создавайте чистую
На чистой установке sqflite вызывает только onCreate, а не onUpgrade, и сразу помечает файл версией 2. Этот onCreate по-прежнему создаёт таблицу версии 1, поэтому колонка due_at так и не появляется.
Почему у обновившихся работает: их файл на версии 1, поэтому выполняется onUpgrade и добавляет колонку
Правило: onCreate должен создавать актуальную схему: либо полный текущий CREATE TABLE, либо прогон всех шагов миграции с версии 0
Второй вариант надёжнее: один упорядоченный список шагов для обоих колбэков не может разъехаться, как две схемы, написанные руками
Почему тесты не поймали: устройство разработчика обновлялось, а не ставилось с нуля; в CI стоит открывать пустую базу и сравнивать её PRAGMA table_info с обновлённой
Hive хранит объекты в «боксах», то есть в файлах с ключами-строками или числами. Чтобы сохранить свой класс, ему нужен TypeAdapter, который умеет читать и писать каждое поле: его пишут руками или генерируют из аннотаций.
**Свой typeId у каждого класса: уникальный в приложении и неизменный, потому что он записан в каждую сохранённую запись
Номера полей навсегда**: не переиспользуйте и не меняйте @HiveField; чтобы убрать поле, оставьте номер занятым и перестаньте его читать
Шифрование через AES: Hive.openBox(name, encryptionCipher: HiveAesCipher(key)) с 256-битным ключом из Hive.generateSecureKey()
**Ключ храните в flutter_secure_storage**: не в SharedPreferences, иначе замок лежит рядом с ключом
Какой пакет: у hive 2 нет стабильных релизов уже несколько лет; в новых проектах берут форк сообщества hive_ce с тем же API
Это тонкая обёртка над платформенным защищённым хранилищем, поэтому реальные гарантии даёт платформа: Keychain на iOS и ключ в Keystore на Android.
iOS: Keychain, на современных устройствах аппаратный; доступность по умолчанию означает «читается, только пока телефон разблокирован», поэтому фоновой задаче нужен IOSOptions(accessibility: KeychainAccessibility.first_unlock)
Сюрприз на iOS: записи Keychain переживают удаление приложения, и после переустановки может всплыть токен прошлого пользователя; стирайте их по флагу первого запуска
Android: с 10-й версии плагин держит в Android Keystore RSA-ключ, которым оборачивает AES-GCM ключ данных, вместо устаревшего EncryptedSharedPreferences из Jetpack Security; обычный AndroidOptions() остаётся верным дефолтом
Чего не остановит: на рутованном устройстве с разблокированным экраном keystore всё равно можно попросить расшифровать
Только мелочи: учётные данные и ключи, а не файлы; чтения асинхронны и могут бросить PlatformException, например на заблокированном устройстве
Берите Drift, когда нужны проверяемые при компиляции запросы, стримы, которые переизлучают данные при изменении таблиц, и тестируемые миграции; для горстки запросов хватит чистого sqflite.
Типобезопасные запросы: опечатка в имени колонки не компилируется, а не падает в рантайме
Наблюдаемые запросы: .watch() превращает любой запрос в стрим, который переизлучает результат при изменении таблиц
Миграции, которые можно тестировать: версионированные шаги и снимки схемы, на которых их прогоняют
Один код везде: мобильные, десктоп и веб (sqlite3, собранный в WASM)
Останьтесь на sqflite, если: запросов горстка и не хочется шага build_runner
Drift и Hive не конкуренты: drift отвечает за реляционные запросы и join-ы, а Hive за объект по ключу.
Isar и ObjectBox представляют собой NoSQL объектные базы на нативном коде с Dart-биндингами: вы кладёте в них Dart-объекты напрямую, без раскладывания по таблицам и строкам.
Почему быстрые: чтение через memory-mapped файл, без копирования и десериализации, плюс настоящие индексы
Где обходят sqflite: тысячи записей в секунду, глубокие графы объектов, которые иначе пришлось бы разложить в join-ы, встроенные полнотекстовые индексы (Isar)
Они реактивные: подпишитесь на запрос и получайте стрим результатов
Цена: приложение тяжелее (нативная библиотека под каждый ABI) и экосистема меньше, чем у SQLite
Риск заброшенности: у Isar нет стабильных релизов с 2023 года, на практике берут форк isar_community; ObjectBox выпускается активно
Правило: SQLite или drift для реляционных данных, объектная база для горячего хранилища объектов, где SQL избыточен.
Внутри транзакции вызывается db.rawQuery вместо txn.rawQuery. sqflite ставит любой вызов на db в очередь до конца открытой транзакции, а транзакция ждёт этот вызов, то есть ждёт сама себя вечно.
Почему: Database в sqflite выполняет операции по одной; пока транзакция открыта, соединением может пользоваться только её объект txn
Симптом: никакого исключения, просто зависание и предупреждение database has been locked for 0:00:10.000000. Make sure you always use the transaction object for database operations during a transaction
Исправление: каждый вызов внутри колбэка идёт через txn, в том числе во вспомогательных функциях: пусть они принимают DatabaseExecutor, общий супертип Database и Transaction
Замаскированный вариант: await метода репозитория, который пользуется общим db, изнутри транзакции даёт ровно то же самое
openDatabase возвращает один общий Database на путь, поэтому close() в finally одного вызова закрывает его под всеми остальными вызовами, которые уже выполняются и ещё не дошли до базы; они падают с DatabaseException о закрытой базе (вызовы, начатые позже, просто откроют её заново).
Почему тесты проходят: вызовы, идущие один за другим, по очереди открывают и закрывают базу; ломается только при пересечении, например когда экран грузит список, а в это время идёт сохранение или фоновая синхронизация
Почему экземпляр общий: sqflite кэширует одно открытое соединение на путь и отдаёт его каждому, кто вызывает openDatabase; close() закрывает это соединение и убирает его из кэша, а не какой-то личный хендл
Исправление: откройте базу один раз и держите соединение всё время жизни приложения; если кэшировать Future<Database>, а не Database, два одновременных первых вызова тоже разделят одно открытие. Если первое открытие упало, сбросьте _db, чтобы следующий вызов повторил попытку. Закрывайте только при завершении работы или логауте
Цена даже без падения: каждый вызов проходит через блокировку открытия на путь и обращение к нативной стороне (плюс onConfigure/onOpen, если заданы), так что открытие и закрытие на каждый вызов добавляет задержку к каждому запросу
Как поймать в следующий раз: падение зависит от тайминга: юнит-тест на sqflite_common_ffi, запускающий три пересекающихся вызова, Future.wait([dao.all(), dao.add('a'), dao.all()]), падает стабильно, а два пересекающихся вызова могут пройти; на ревью любой close() вне dispose уровня приложения даёт повод присмотреться
await лишь уступает управление между кусками работы, но не уносит их из изолята: зависание даёт синхронный Dart-код (jsonDecode на 20 МБ и цикл сборки моделей).
**jsonDecode на 20 МБ**: один длинный синхронный кусок Dart; await перед вызывающей функцией не меняет ничего
Трафик канала: await db.insert на каждую строку кадры не морозит, потому что уступает управление после каждой строки, но переход на платформу на каждую строку делает импорт долгим; один гигантский Batch кодируется одним синхронным куском, поэтому коммитьте пачками по несколько тысяч строк
Решение: в Isolate.run уносите только CPU-работу (разбор в обычные map), а пишите на UI-изоляте пачками по 2000 через db.batch() и commit(noResult: true)
sqflite держите в главном изоляте: sqflite рекомендует работать с ним из главного изолята, потому что его транзакции небезопасны между изолятами; если второй изолят неизбежен, используйте singleInstance: false в обоих и никогда не закрывайте там базу
За разрастанием хранилища почти всегда стоит кэш, которому не задали правило вытеснения. Сначала измерьте каждую директорию, потом задайте бюджет каждой корзине.
Измерьте: обойдите директории через Directory.list(recursive: true), просуммируйте File.stat().size по корзинам (картинки, загрузки, база, логи) и шлите это метрикой
Обычные виновники: кэш картинок без предела, скачанная медиа, которую никто не удаляет, .part-файлы сорвавшихся загрузок, логи и очереди, которые никто не подрезает
Данные, которые можно получить заново, храните в cache или temp: ОС вправе это почистить, и бэкап их пропускает; тот же файл в Documents ещё и зальётся в iCloud
База только растёт: удалённые строки оставляют свободные страницы, поэтому изредка делайте VACUUM и checkpoint WAL-файла
Вытесняйте давно не используемое: держите индекс «путь, размер, последний доступ», подрезайте по расписанию, а не посреди скролла, и дайте видимую кнопку «очистить кэш»
sqflite не сообщает об изменениях вообще никак, поэтому либо все записи идут через один объект, который заодно публикует событие, либо берите хранилище, которое само следит за таблицами.
Единственный владелец: спрячьте чтение и запись за репозиторий; после успешной записи он перечитывает данные и кладёт новое значение в broadcast-StreamController
Правило, на котором всё держится: снаружи репозитория базу не трогает никто; достаточно одного постороннего db.update, чтобы экран снова протух
Заполните поток текущим значением: голый broadcast-поток не даст новому подписчику ничего до следующей записи
Хранилища, которые умеют сами: .watch() в drift, box.watch() в Hive, запросы Isar и ObjectBox; drift замечает только записи, сделанные через сам drift
Частая ошибка: вызов repo.watchOpen() внутри build() даёт новый поток и новый запрос на каждой перестройке; создайте его один раз в initState (ручному listen ещё нужен cancel в dispose)
Выход из аккаунта означает уничтожение всех копий данных пользователя на устройстве, а не переход на экран входа.
Очевидная часть: токены в secure storage, строки пользователя, кэш ответов, скачанные файлы, пользовательские ключи настроек (тему и локаль оставьте)
Состояние в памяти: синглтоны, блоки и DI-скоупы всё ещё держат старого пользователя; пересоздайте пользовательский скоуп, иначе следующий вход покажет прошлый аккаунт
Очередь синхронизации: отправленная после следующего входа, она уводит записи одного пользователя под токеном другого
Keychain на iOS переживает удаление приложения: после переустановки может воскреснуть старый токен, поэтому стирайте secure storage, когда флага первого запуска в настройках нет
Ещё забывают: кэш картинок, cookie в WebView, push-токен, идентификатор пользователя в аналитике
Порядок: сначала отзыв сессии на сервере по best effort, потом локальная очистка, даже если вызов не прошёл: выход обязан работать офлайн
Локальная база кажется однопользовательской, но в неё одновременно пишут UI, фоновая синхронизация и обработчик уведомлений, часто из разных изолятов.
Потерянные обновления: две пары «прочитал, потом записал» по одной строке: вторая затирает первую, и этого никто не замечает
Писатель блокирует читателей: SQLite лочит файл на время записи; включите WAL (PRAGMA journal_mode=WAL), и один писатель с многими читателями перестанут ждать друг друга
Два соединения, один файл: drift или sqlite3, открытые в двух изолятах, дают два соединения; SQLite убережёт файл, но конкурирующие писатели получат database is locked (SQLITE_BUSY), поэтому направляйте записи через одно соединение (DriftIsolate в drift) или задайте busy timeout
Собственный дедлок sqflite: внутри db.transaction любой вызов на db вместо txn ждёт транзакцию, которая ждёт его
Защита: многошаговые записи в одной транзакции, все записи через один репозиторий, короткие транзакции без сетевых вызовов внутри
Оптимистичная конкурентность: держите колонку revision и обновляйте с WHERE id = ? AND revision = ?; ноль изменённых строк значит, что кто-то успел раньше
Сначала выполните EXPLAIN QUERY PLAN для этого запроса. На таком объёме ответ почти всегда один: SQLite сканирует таблицу там, где должен искать по индексу.
SCAN orders значит, что читается каждая строка; нужно SEARCH orders USING INDEX .... USE TEMP B-TREE FOR ORDER BY значит, что и сортировка не покрыта индексом.
Добавьте индекс: по колонкам из WHERE, JOIN и ORDER BY; в составном сначала колонки равенства, потом диапазон и сортировка
Следите, чтобы он работал: ведущий % в LIKE, функция поверх колонки или число, сохранённое текстом, отключают его
Уберите N+1: каждый вызов sqflite идёт через platform channel, поэтому запрос на строку в цикле убийственен; сделайте join, IN (...) или Batch
Пагинируйте в SQL: LIMIT с keyset-условием лучше OFFSET, и выбирайте только нужные колонки
Вторая половина этих двух секунд: строки всё равно едут через канал и превращаются в Dart-объекты на UI-изоляте, так что 50 000 строк дадут jank при любом плане
Восстановление возвращает ваши файлы, но не ключи, привязанные к устройству (ключи Android Keystore его никогда не покидают), поэтому на новом телефоне оказывается зашифрованная база, ключа от которой больше нет.
Что попадает в бэкап: на iOS Documents/ и Library/Application Support/, но не кэши; на Android Auto Backup по умолчанию забирает файлы, базы и shared_prefs
Ключи остаются: ключи Keystore не покидают устройство, записи Keychain приезжают только из зашифрованного бэкапа, а записи *_this_device не переносятся никогда, и файл открывается с «file is not a database»
**База в WAL без своего -wal: восстановится снимком на момент последнего checkpoint и тихо потеряет свежие строки
Задвоенный install id: идентификатор, сгенерированный один раз и положенный в Documents, теперь живёт на двух телефонах
Что делать**: восстановимое держите в cache-директории, зашифрованную базу исключите из бэкапа Android в data_extraction_rules.xml, а неудачную расшифровку считайте чистой установкой: сотрите данные и синхронизируйтесь заново
Считайте это односторонней миграцией данных, которая выполняется не больше одного раза на установку, переживает убийство процесса и проверяется до удаления старой копии.
Выкатите оба хранилища: новое рядом со старым, а маркер миграции держите строкой в новой базе и пишите в той же транзакции, что и последнюю порцию данных
Копируйте порциями: иначе смерть процесса между «скопировал» и «отметил» запустит всё заново
Считайте, что запустится дважды: каждый шаг идемпотентный (ConflictAlgorithm.replace), точка входа под локом
Не на первом кадре: 200 000 записей на дешёвом телефоне занимают секунды, поэтому делайте в изоляте за экраном прогресса
Считайте записи на входе и на выходе: логируйте разницу и ставьте на неё алерт: «нет крешей» не значит «нет потери данных»
**hive в hive_ce**: те же файлы боксов и адаптеры, то есть смена зависимости, а не копирование данных
Фоновый обработчик работает в собственном изоляте со своей Dart-памятью, поэтому любое хранилище, кеширующее состояние в Dart, получает две копии, которые протухают и затирают друг друга.
**Legacy SharedPreferences.getInstance()**: один раз загружает все ключи в Dart-map и отвечает из неё; запись фонового изолята до платформы доходит, но UI-изолят отдаёт старую карту, пока кто-нибудь не вызовет reload()
Hive: держит бокс в памяти каждого изолята и дописывает в файл; оба изолята считают себя владельцем, а compaction одного может выбросить записанное другим
Исключение составляет sqflite: соединение живёт на стороне платформы, поэтому оба изолята говорят с одной нативной базой и сразу видят записи друг друга (при singleInstance: true по умолчанию; транзакции при этом всё равно не безопасны между изолятами)
Решение: у SharedPreferencesAsync нет Dart-кеша, каждый вызов идёт на платформу; для Hive в hive_ce есть IsolatedHive, держащий боксы в одном изоляте
Изолят, который вы запускаете сами: не видит плагинов, пока не выполнится BackgroundIsolateBinaryMessenger.ensureInitialized(rootIsolateToken); с фоновым обработчиком FCM на Android дело обстоит иначе: плагин поднимает отдельный фоновый engine с уже зарегистрированными плагинами
SQLite почти никогда не портит себя сам, поэтому каждое такое сообщение читайте как «файл трогали снаружи», и проектируйте так, чтобы потеря локальной базы была неудобством, а не потерей данных.
**-wal и -shm разошлись с базой: самодельный экспорт, бэкап или восстановление забрали основной файл без спутников
Не та директория: база в cache или temp, которую ОС удалила под нехватку места прямо во время работы
Мало места на диске: запись при почти нулевом свободном месте может оставить обрезанные страницы; логируйте свободное место вместе с ошибкой
Похожие симптомы, но не порча**: заблокированное iOS-устройство с NSFileProtectionComplete даёт ошибку ввода-вывода или open_failed, а недоделанная миграция даёт неверные данные, а не повреждённый образ; отсейте их по коду ошибки в первую очередь
Диагностика: логируйте расширенный код результата, journal mode, директорию, размер файла и свободное место; PRAGMA quick_check гоняйте на первом старте после креша, а не на каждом
Восстановление, а не ремонт на месте: отложите файл и спутников в сторону, откройте свежую базу, синхронизируйтесь с сервером и скажите пользователю, если что-то было только локальным