Start Debugging

Flutter の設定保存に shared_preferences と path_provider の JSON ファイルのどちらを使うべきか

少数のフラットなフラグには shared_preferences (SharedPreferencesWithCache) を、構造化された設定オブジェクトには getApplicationSupportDirectory に置く JSON ファイルを使います。アトミックな書き込みパターン、同時保存の競合状態、Windows と Linux での Documents フォルダーの罠、Web 対応、Flutter 3.44.8 で計測した書き込みコストを解説します。

設定が十数個のフラットで互いに独立した値 (テーマ、“オンボーディング表示済み” フラグ、最後に選択したタブなど) であれば、shared_preferences の SharedPreferencesWithCache API を使ってください。同期読み取り、Web 対応、ファイル管理不要という利点があります。設定がネストした map やリスト、マイグレーションが必要なスキーマを持つ 1 つの構造化オブジェクトになった時点で、getApplicationDocumentsDirectory() ではなく getApplicationSupportDirectory() に、アトミックに (一時ファイルに書いてから rename し、書き込みを直列化して) 保存する単一の JSON ファイルに切り替えます。以下の内容はすべて Flutter 3.44.8 (Dart 3.12.2)、shared_preferences 2.5.6、path_provider 2.1.6 を使い、Apple M4 上の実際の macOS デバッグビルドで検証しました。

比較表

shared_preferences 2.5.6path_provider 2.1.6 による JSON ファイル
値の型bool、int、double、String、List<String>jsonEncode が扱えるものすべて (ネストした map、リスト、null)
読み取り API同期 (SharedPreferencesWithCache) または非同期 (SharedPreferencesAsync)一度だけ非同期で読み込み、以降は自前のメモリ上のオブジェクト
書き込みの永続性戻った時点でディスクに書かれている “保証はない” (README)自分で制御できます: flush: true + アトミックな rename
部分更新キー単位で標準対応ファイル全体を書き直し (数百 KB 未満なら問題なし)
スキーマのマイグレーションキーごとに手動schemaVersion フィールド 1 つとマイグレーション関数 1 つ
Web対応 (localStorage)非対応: path_provider に Web 実装はありません
Android での保存先DataStore Preferences (新 API) または SharedPreferences (レガシー)アプリのプライベートなデータディレクトリ内のファイル
iOS/macOS での保存先NSUserDefaults の plistApplication Support 内のファイル
ネイティブコードやウィジェットからの読み取り可能 (プラットフォームの保存領域そのもの)ネイティブコードがそのファイルを読む場合のみ
並行処理の落とし穴isolate ごとのキャッシュが古くなる直列化しないと同時保存が一時ファイルを壊す
自分で書くコードほぼなし約 60 行

どちらも “データベース” ではありません。ユーザーのコンテンツ、増え続けるリスト、クエリする必要があるデータを保存するなら、これらではなく SQLite (drift、sqflite) を使うべきです。

shared_preferences を選ぶべき場面

今日リリースするならこのバージョンにします。README がレガシーな SharedPreferences クラスの代わりに新規コードで推奨している SharedPreferencesWithCache を、allow list 付きで使っています。

// 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') のようなタイプミスは黙って null を返すのではなく ArgumentError (“themMode is not included in the PreferencesFilter allowlist”) を投げます。また clear() はプラグインから見えるすべてではなく、自分のキーだけを削除します。

JSON ファイルを選ぶべき場面

以下が私の使っているストアです。多くのチュートリアルが省略している 3 つの点、つまりディレクトリ、アトミックな書き込み、書き込みキューに注目してください。

// 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、Riverpod の Notifier など、すでに使っているもの) に保持し、変更のたびに save() を呼びます。起動後の読み取りはディスクに触れません。これは SharedPreferencesWithCache が代わりに行っているのと同じトレードオフです。

素朴な JSON ストアを壊す同時保存の競合状態

ネットでよく見かけるこのパターンは、.tmp ファイルに writeAsString してから rename するだけで、キューがありません。アトミックに見えますし、呼び出し元が 1 つならその通りです。しかしスライダーの onChanged につなぐと、同じ一時パスを共有する保存が何度も重なります。素の Dart 3.12.2 で await なしの保存を 50 回同時に発行してみました。

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

結果は、50 回中 49 回の書き込みが PathNotFoundException: Cannot rename file ... (OS Error: No such file or directory, errno = 2) を投げました。最初の rename が一時ファイルを移動してしまい、残りの保存はもう存在しないファイルを rename しようとしたのです。タイミングによっては、別の保存が同じパスにまだ書き込んでいる最中に、ある保存のバイト列が所定の場所に rename されることもあります。上のストアの _tail = _tail.then(...) チェーンを使うと、同じ 50 回の保存でエラーは 0 件、ファイルには最後の値が入っていました。私の実行では壊れたケースでも最終的なファイルは正しかったのですが、だからこそこのバグはコードレビューをすり抜けます。クラッシュレポートに例外として現れるだけなのです。

キー入力やスライダーの動きごとに保存するなら、debounce も入れてください (300 ms で十分です)。キューは同時保存を安全にし、debounce はそれを安くします。

書き込みに実際にかかるコスト

どちらの方式も十分に速いので、パフォーマンスで選ぶべきではありません。とはいえ “十分に速い” には数字が必要です。JSON ストアの書き込み経路 (エンコード、一時ファイルの書き込み、rename) を、927 バイトの設定ペイロード (約 40 キー、ネストした map 1 つ、20 要素のリスト 1 つ) で計測しました。ウォームアップ 200 回の後に 2,000 回を計測し、Apple M4、macOS、APFS 上で JIT の dart run (Dart 3.12.2) と Stopwatch を使っています。

操作中央値p95p99
エンコード + 書き込み + flush: true + rename177 µs235 µs318 µs
エンコード + 書き込み + rename (flush なし)155 µs247 µs328 µs
読み取り + jsonDecode62 µs69 µsn/a

注意点が 2 つあります。M4 の SSD では flush による中央値の増加は約 20 µs にとどまりましたが、ローエンドの Android のフラッシュストレージではかなり高くつくと考えてください。これも debounce を入れる理由の 1 つです。また shared_preferences の書き込みは計測していません。platform channel を経由して NSUserDefaults や DataStore に届くため、コストはプラグインではなくプラットフォームに依存するからです。設定程度のサイズであれば、どちらもフレーム予算に影響しません。ファイル方式が問題になるのは “設定” が数 MB のキャッシュデータにまで膨らんだ場合だけで、その時点でもはや設定ファイルにすべきではありません。

選択を決定づける落とし穴

デスクトップでは getApplicationDocumentsDirectory() はユーザーのドキュメントフォルダーです。 Windows では path_provider_windows がこれを WindowsKnownFolder.Documents に対応付けます。Linux では XDG のユーザーディレクトリ DOCUMENTS です。あなたの settings.json はユーザーの確定申告書類の隣に置かれ、OneDrive に同期され、フォルダーを整理する人に削除されます。iOS では UIFileSharingEnabled を有効にした瞬間にユーザーから見えるようになります。path_provider のドキュメントも同じことを述べています。Documents はユーザーが生成したデータ用であり、そうでなければ getApplicationSupportDirectory() を検討すべきだ、ということです。Application Support は Windows ではローミング AppData、Linux では XDG_DATA_HOME/<app id>、macOS ではサンドボックス内の Library/Application Support/<bundle id> に対応します (macOS ビルドで settings.json が ~/Library/Containers/com.example.settingsDemo/Data/Library/Application Support/com.example.settingsDemo/ に作られることを確認しました)。Documents を使う JSON 設定のチュートリアルは、デスクトップではすべて間違っています。

Web ではファイル方式は使えません。 アプリが Web を対象にするなら答えは決まっています。shared_preferences を使い、ネストが必要なら構造化オブジェクトを jsonEncode した 1 つの文字列として 1 つのキーに保存します。永続性の保証は失いますが、単一オブジェクトのモデルは保てます。

バックグラウンドの isolate や engine は古いキャッシュを見ます。 shared_preferences の README には、SharedPreferences と SharedPreferencesWithCache が isolate ごとにキャッシュを持つと明記されています。そのため firebase_messaging のバックグラウンドハンドラーや 2 つ目の engine から書き込んだ値は、reload() を呼ぶまでメインの isolate からは見えません。複数の isolate が同じ設定を書き込むなら、キャッシュを使わない SharedPreferencesAsync を使ってください。JSON ファイルはさらに悪い形で同じ問題を抱えます。それぞれ独自の書き込みキューを持つ 2 つの isolate は、依然として処理が交錯し得ます。書き込みはすべて 1 つの isolate に集め、他の isolate からはメッセージを送るようにしてください。すでに CPU バウンドな処理用のバックグラウンド isolate で重い処理をしているなら、その isolate に設定ファイルを持たせないでください。

レガシーのキーにはプレフィックスがあり、新しいキーにはありません。 レガシーの SharedPreferences クラスはすべてのキーを flutter. プレフィックス付きで保存します。SharedPreferencesWithCache と SharedPreferencesAsync は付けません。私の macOS ビルドでは plist にプレフィックスなしの themeMode キーが入っていました。Android では新 API がデフォルトで DataStore を使うため、ファイル自体がまったく別物です。つまり、リリース済みアプリで API を切り替えるのはリファクタリングではなくデータのマイグレーションです。起動時に package:shared_preferences/util/legacy_to_async_migration_util.dart の migrateLegacySharedPreferencesToSharedPreferencesAsyncIfNecessary を実行しないと、アップデート時にユーザーの設定が失われます。

Android のバックアップはどちらにも適用されます。 Auto Backup はデフォルトでアプリのプライベートファイルと設定の保存領域を含むため、JSON ファイルに移ってもクラウドバックアップの対象から外れるわけではありません。デバイス固有の設定 (キャッシュしたサーバー URL、デバッグ用トグルなど) は、どちらの方式でもバックアップルールで除外してください。

私の推奨

フラットなフラグには shared_preferences と SharedPreferencesWithCache から始めてください。ほとんどのアプリにとって正しいデフォルトであり、Flutter が対象とするすべてのプラットフォームで動き、Web では唯一の選択肢です。ドット区切りのキー名を考え出していたり、一緒に変更しなければならない関連キーを 3 つ書いていたり、スキーマバージョンが欲しくなったりしたら、その時点で構造化された部分を getApplicationSupportDirectory() に置く単一の JSON ファイルへ移し、flush、rename、直列化キューで書き込んでください。多くのアプリは最終的に両方を使います。ネイティブコードや最初のフレームが必要とする少数のフラグは shared_preferences に、本来の設定オブジェクトは 1 つの settings.json に置く形です。

データがそもそも設定ではない場合 (ユーザーのコンテンツ、履歴、増え続けるもの) は、どちらも使わずに SQLite を使ってください。その方法でアプリのローカルデータを移行する手順は、Flutter アプリを Hive から drift へ移行する で解説しています。

次に読む

参考資料

Comments

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

< 戻る