Start Debugging

shared_preferences или JSON-файл через path_provider для хранения настроек во Flutter

shared_preferences (SharedPreferencesWithCache) подходит для нескольких плоских флагов, а JSON-файл в getApplicationSupportDirectory подходит для структурированного объекта настроек. Разбираем атомарную запись, состояние гонки при параллельных сохранениях, ловушку с папкой Documents в Windows и Linux, поддержку веба и измеренную стоимость записи на Flutter 3.44.8.

Если ваши настройки это дюжина плоских независимых значений (тема, флаг “онбординг пройден”, последняя выбранная вкладка), используйте shared_preferences с API SharedPreferencesWithCache: синхронное чтение, поддержка веба, никакой работы с файлами. Как только настройки превращаются в один структурированный объект с вложенными map, списками или схемой, которую нужно мигрировать, переходите на один JSON-файл, записываемый атомарно (временный файл плюс rename, с сериализацией записей) в getApplicationSupportDirectory(), а не в getApplicationDocumentsDirectory(). Всё описанное ниже проверено на Flutter 3.44.8 (Dart 3.12.2) с shared_preferences 2.5.6 и path_provider 2.1.6 в реальной отладочной сборке под macOS на Apple M4.

Сравнение в одной таблице

shared_preferences 2.5.6JSON-файл через path_provider 2.1.6
Типы значенийbool, int, double, String, List<String>Всё, что умеет jsonEncode (вложенные map, списки, null)
API чтенияСинхронный (SharedPreferencesWithCache) или асинхронный (SharedPreferencesAsync)Одна асинхронная загрузка, дальше ваш собственный объект в памяти
Надёжность записи”Нет гарантии”, что запись попала на диск к моменту возврата (README)Под вашим контролем: flush: true + атомарный rename
Частичные обновленияПо ключу, из коробкиПерезапись всего файла (нормально до нескольких сотен КБ)
Миграция схемыПо каждому ключу, вручнуюОдно поле schemaVersion, одна функция миграции
ВебДа (localStorage)Нет: у path_provider нет веб-реализации
Хранилище на AndroidDataStore Preferences (новые API) или SharedPreferences (устаревший API)Файл в приватном каталоге данных приложения
Хранилище на iOS/macOSplist NSUserDefaultsФайл в Application Support
Доступно нативному коду / виджетамДа, это хранилище платформыТолько если нативный код читает ваш файл
Подводные камни конкурентностиКэши на уровне isolate устареваютПараллельные сохранения портят временный файл без сериализации
Ваш собственный кодПочти ничегоОколо 60 строк

Ни один из вариантов не является “базой данных”. Если вы храните пользовательский контент, растущие списки или что-то, по чему делаются запросы, вам нужен SQLite (drift, sqflite), а не эти подходы.

Когда выбирать shared_preferences

Вот версия, которую я выпустил бы сегодня. Она использует SharedPreferencesWithCache с allow list, которую README рекомендует для нового кода вместо устаревшего класса SharedPreferences:

// Flutter 3.44.8, Dart 3.12.2, shared_preferences 2.5.6
import 'package:shared_preferences/shared_preferences.dart';

class PrefsSettingsStore {
  PrefsSettingsStore._(this._prefs);

  final SharedPreferencesWithCache _prefs;

  static const _themeMode = 'themeMode';
  static const _fontScale = 'fontScale';
  static const _onboardingDone = 'onboardingDone';

  static Future<PrefsSettingsStore> open() async {
    final prefs = await SharedPreferencesWithCache.create(
      cacheOptions: const SharedPreferencesWithCacheOptions(
        allowList: {_themeMode, _fontScale, _onboardingDone},
      ),
    );
    return PrefsSettingsStore._(prefs);
  }

  // Synchronous reads after open(): safe to call from build().
  String get themeMode => _prefs.getString(_themeMode) ?? 'system';
  double get fontScale => _prefs.getDouble(_fontScale) ?? 1.0;
  bool get onboardingDone => _prefs.getBool(_onboardingDone) ?? false;

  Future<void> setThemeMode(String value) => _prefs.setString(_themeMode, value);
  Future<void> setFontScale(double value) => _prefs.setDouble(_fontScale, value);
  Future<void> completeOnboarding() => _prefs.setBool(_onboardingDone, true);
}

Allow list здесь не для красоты. С ней опечатка вроде getString('themMode') бросает ArgumentError (“themMode is not included in the PreferencesFilter allowlist”) вместо того, чтобы молча вернуть null, а clear() удаляет только ваши ключи, а не всё, что видит плагин.

Когда выбирать JSON-файл

Ниже хранилище, которым пользуюсь я. Обратите внимание на три детали, которые большинство туториалов пропускает: каталог, атомарную запись и очередь записей.

// Flutter 3.44.8, Dart 3.12.2, path_provider 2.1.6
import 'dart:convert';
import 'dart:io';

import 'package:path_provider/path_provider.dart';

class AppSettings {
  const AppSettings({
    this.themeMode = 'system',
    this.fontScale = 1.0,
    this.notifications = const {'replies': true, 'digest': 'weekly'},
  });

  final String themeMode;
  final double fontScale;
  final Map<String, Object?> notifications;

  static const schemaVersion = 2;

  AppSettings copyWith({String? themeMode, double? fontScale}) => AppSettings(
        themeMode: themeMode ?? this.themeMode,
        fontScale: fontScale ?? this.fontScale,
        notifications: notifications,
      );

  Map<String, Object?> toJson() => {
        'schemaVersion': schemaVersion,
        'themeMode': themeMode,
        'fontScale': fontScale,
        'notifications': notifications,
      };

  factory AppSettings.fromJson(Map<String, dynamic> json) => AppSettings(
        themeMode: json['themeMode'] as String? ?? 'system',
        fontScale: (json['fontScale'] as num?)?.toDouble() ?? 1.0,
        notifications:
            (json['notifications'] as Map<String, dynamic>?) ?? const {},
      );
}

class JsonSettingsStore {
  JsonSettingsStore._(this._file);

  final File _file;
  Future<void> _tail = Future<void>.value();

  static Future<JsonSettingsStore> open() async {
    // Application Support, not Documents: see the Windows/Linux gotcha below.
    final dir = await getApplicationSupportDirectory();
    await dir.create(recursive: true);
    return JsonSettingsStore._(File('${dir.path}/settings.json'));
  }

  Future<AppSettings> load() async {
    if (!await _file.exists()) return const AppSettings();
    try {
      final json = jsonDecode(await _file.readAsString()) as Map<String, dynamic>;
      return AppSettings.fromJson(_migrate(json));
    } on FormatException {
      // Truncated or hand-edited file: keep a copy for diagnostics, start fresh.
      await _file.copy('${_file.path}.corrupt');
      return const AppSettings();
    }
  }

  Future<void> save(AppSettings settings) {
    // Chain every write so two saves never share the temp file.
    return _tail = _tail.then((_) => _writeAtomic(settings.toJson()));
  }

  Future<void> _writeAtomic(Map<String, Object?> data) async {
    final tmp = File('${_file.path}.tmp');
    await tmp.writeAsString(jsonEncode(data), flush: true);
    await tmp.rename(_file.path);
  }

  static Map<String, dynamic> _migrate(Map<String, dynamic> json) {
    final version = json['schemaVersion'] as int? ?? 1;
    if (version < 2) {
      // v1 stored a bool 'darkMode'; v2 stores a 'themeMode' string.
      json['themeMode'] = (json.remove('darkMode') as bool? ?? false) ? 'dark' : 'light';
    }
    return json;
  }
}

Загружайте его один раз при старте, держите AppSettings в своём слое управления состоянием (ValueNotifier, Notifier из Riverpod, что вы уже используете) и вызывайте save() при изменении. После старта чтение никогда не обращается к диску, это тот же компромисс, который за вас делает SharedPreferencesWithCache.

Состояние гонки при параллельных сохранениях, которое ломает наивные JSON-хранилища

Самый распространённый вариант этого паттерна в сети: writeAsString во временный файл .tmp, потом rename, без очереди. Выглядит атомарно. Для одного вызывающего так и есть. Подключите это к onChanged слайдера, и вы получите множество перекрывающихся сохранений с одним и тем же временным путём. Я запустил 50 сохранений без await одновременно на чистом Dart 3.12.2:

// Dart 3.12.2
await Future.wait([
  for (var i = 0; i < 50; i++)
    writeAtomic(file, {'counter': i}).catchError((Object e) { errors++; }),
]);

Результат: 49 из 50 записей бросили PathNotFoundException: Cannot rename file ... (OS Error: No such file or directory, errno = 2). Первый rename переместил временный файл, а все остальные сохранения пытались переименовать файл, которого уже не было. В зависимости от тайминга можно также получить ситуацию, когда байты одного сохранения переименованы на место, пока другое ещё пишет по тому же пути. С цепочкой _tail = _tail.then(...) из хранилища выше те же 50 сохранений дали 0 ошибок, а файл содержал последнее значение. В моём прогоне итоговый файл был корректным даже в сломанном варианте, и именно поэтому этот баг переживает код-ревью: он проявляется только как исключения в отчётах о сбоях.

Если вы сохраняете при каждом нажатии клавиши или движении слайдера, добавьте ещё и debounce (300 мс хватает). Очередь делает параллельные сохранения безопасными, debounce делает их дешёвыми.

Сколько на самом деле стоит запись

Оба подхода достаточно быстры, чтобы производительность не определяла выбор, но “достаточно быстро” заслуживает цифры. Я измерил путь записи JSON-хранилища (кодирование, запись временного файла, rename) на полезной нагрузке настроек размером 927 байт (около 40 ключей, вложенная map, список из 20 элементов): 200 итераций прогрева, затем 2000 замеренных итераций, Stopwatch под JIT dart run (Dart 3.12.2) на Apple M4, macOS, APFS:

ОперацияМедианаp95p99
Кодирование + запись + flush: true + rename177 мкс235 мкс318 мкс
Кодирование + запись + rename (без flush)155 мкс247 мкс328 мкс
Чтение + jsonDecode62 мкс69 мксn/a

Две оговорки. На SSD M4 flush добавил к медиане всего около 20 мкс; на бюджетной флеш-памяти Android он обойдётся заметно дороже, это ещё одна причина использовать debounce. И я не замерял запись в shared_preferences, потому что она идёт через platform channel к NSUserDefaults или DataStore, и её стоимость зависит от платформы, а не от плагина. При объёмах, типичных для настроек, ни один из вариантов не появится в бюджете кадра. Подход с файлом становится проблемой, только если “настройки” разрастаются до мегабайт закэшированных данных, но тогда это уже не должно быть файлом настроек.

Подводные камни, которые выбирают за вас

На десктопе getApplicationDocumentsDirectory() это папка “Документы” пользователя. В Windows path_provider_windows сопоставляет её с WindowsKnownFolder.Documents. В Linux это пользовательский каталог XDG DOCUMENTS. Ваш settings.json окажется рядом с налоговыми декларациями пользователя, будет синхронизироваться через OneDrive и удаляться теми, кто наводит порядок в папках. В iOS он становится видимым пользователю, как только вы включаете UIFileSharingEnabled. Документация path_provider говорит то же самое своими словами: Documents предназначен для данных, созданных пользователем, а в остальных случаях стоит рассмотреть getApplicationSupportDirectory(). Application Support соответствует roaming AppData в Windows, XDG_DATA_HOME/<app id> в Linux и Library/Application Support/<bundle id> внутри песочницы в macOS (я убедился, что в сборке под macOS settings.json оказался в ~/Library/Containers/com.example.settingsDemo/Data/Library/Application Support/com.example.settingsDemo/). Любой туториал по JSON-настройкам, который использует Documents, неверен для десктопа.

Веб исключает файл. Если приложение нацелено на веб, решение принято: используйте shared_preferences и, если нужна вложенность, храните структурированный объект как одну строку, полученную через jsonEncode, под одним ключом. Гарантию надёжности вы теряете, но модель одного объекта сохраняете.

Фоновые isolate и engine видят устаревший кэш. README shared_preferences говорит об этом прямо: SharedPreferences и SharedPreferencesWithCache держат кэш на каждый isolate, поэтому значение, записанное из фонового обработчика firebase_messaging или из второго engine, невидимо для главного isolate, пока вы не вызовете reload(). Если одни и те же настройки пишут несколько isolate, используйте SharedPreferencesAsync, который обходит кэш. У JSON-файла та же проблема в худшей форме: два isolate, у каждого своя очередь записи, всё равно могут перемежаться, поэтому держите все записи в одном isolate, а из остальных отправляйте ему сообщения. Если вы уже выполняете тяжёлую работу в фоновом isolate для CPU-нагруженных задач, не делайте его владельцем файла настроек.

У устаревших ключей есть префикс, у новых нет. Устаревший класс SharedPreferences хранит каждый ключ с префиксом flutter.. SharedPreferencesWithCache и SharedPreferencesAsync этого не делают: в моей сборке под macOS plist содержал ключ themeMode без префикса. На Android новые API по умолчанию используют DataStore, совсем другой файл. Поэтому смена API в уже выпущенном приложении это миграция данных, а не рефакторинг. Вызывайте migrateLegacySharedPreferencesToSharedPreferencesAsyncIfNecessary из package:shared_preferences/util/legacy_to_async_migration_util.dart при старте, иначе пользователи потеряют настройки при обновлении.

Резервное копирование Android касается обоих вариантов. Auto Backup по умолчанию включает приватные файлы приложения и его хранилища настроек, так что переход на JSON-файл не исключает вас из облачного бэкапа. Если настройка привязана к устройству (закэшированный URL сервера, отладочный переключатель), исключите её в правилах резервного копирования в любом из вариантов.

Что я рекомендую

Начинайте с shared_preferences и SharedPreferencesWithCache для плоских флагов. Это правильный выбор по умолчанию для большинства приложений, он работает на всех платформах, которые поддерживает Flutter, и это единственный вариант для веба. Как только вы ловите себя на том, что придумываете имена ключей с точками, пишете три связанных ключа, которые должны меняться вместе, или хотите версию схемы, переносите структурированную часть в один JSON-файл в getApplicationSupportDirectory(), записываемый с flush, rename и сериализованной очередью. Многие приложения в итоге используют оба: несколько флагов в shared_preferences, нужных нативному коду или первому кадру, и один settings.json для настоящего объекта настроек.

Если данные вообще не настройки (пользовательский контент, история, всё, что растёт), пропустите оба варианта и используйте SQLite. Как перенести локальные данные приложения таким образом, я показал в статье о миграции Flutter-приложения с Hive на drift.

Что почитать дальше

Источники

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Назад