そのデータはどこに置くのか
画面の状態はアプリを閉じれば消える。残すと決めたデータを、設定値・秘密・構造のあるデータ・ファイルのどれとして扱うかで置き場所は決まる。四つの選択肢と選び分け、そしてスキーマ変更という避けられない問題を整理する。
前回、データを表現するモデルを定めました。今回はそのモデルをどこに置くかです。
連載 I で扱った setState の状態も、Riverpod の Provider が抱える状態も、生きているのは
プロセスのメモリの中だけです。ユーザーがアプリを終了させれば、あるいは OS がメモリを回収
すれば、きれいに消えます。それで困らないものと、困るものがある——その線引きから始めます。
§ 01VOLATILE消えていい状態と、消えては困るもの
「保存する」とは、プロセスの外へ書き出すことです。まず、何を書き出さないかを決めるほうが 早い。
消えていいものは意外と多くあります。スクロール位置、開いているタブ、検索の入力途中、API から 取得したデータのメモリ上のコピー。これらは次の起動でまた作れます。連載 I で「その画面が閉じたら 消えていい値は hooks、共有する値は Provider」と線を引きましたが、その外側にもう一本、 「アプリが終わったら消えていいか」という線が引かれます。
消えては困るのは、ユーザーが作ったものと、もう一度取得できないものです。入力した内容、 選んだ設定、ログイン状態。ここから先が今回の対象です。
§ 02CHOOSE四つの置き場所
Flutter で使う保存先は、実質この四つです。何を保存するかで機械的に決まります。
| 置き場所 | 向くもの | 向かないもの |
|---|---|---|
| SharedPreferences | 設定値、フラグ、小さな文字列 | 件数のあるデータ、秘密 |
| Secure Storage | トークン、鍵、セッション | 大きなデータ、高頻度の読み書き |
| SQLite | 検索・更新する構造化データ | 単発の設定値 |
| ファイル | 画像、エクスポート、バイト列 | 条件で絞り込みたいデータ |
判断は三つの問いで足ります。秘密か?(yes なら Secure Storage)件数があって検索・更新 するか?(yes なら SQLite)バイト列そのものか?(yes ならファイル)。どれでもない小さな値が SharedPreferences です。
「とりあえず SharedPreferences に JSON 文字列で全部入れる」は最初こそ動きますが、件数が増えた ときに部分更新も検索もできないことに気づきます。逆に、ダークモードの ON/OFF ひとつのために SQLite を開くのも過剰です。
§ 03PREFS設定値を key-value で置く
shared_preferences は、プラットフォームごとの小さな設定ストア(iOS の UserDefaults、Android の
SharedPreferences)を同じ API で包みます。
final prefs = SharedPreferencesAsync();await prefs.setBool('isDarkMode', true);await prefs.setString('locale', 'ja');final isDark = await prefs.getBool('isDarkMode') ?? false;
読み書きはどちらも非同期です。値が無ければ null が返るので、既定値は読む側で決めます
(?? false)。ここを忘れると、初回起動だけ挙動が違う、という形のバグになります。
なお、SharedPreferences.getInstance() を使う書き方を多くのコードで見かけますが、これは
レガシー API です。新規に書くなら SharedPreferencesAsync を使います1shared_preferences 2.3.0 以降は SharedPreferencesAsync と SharedPreferencesWithCache が追加され、従来の SharedPreferences.getInstance() はレガシー扱いになった。将来的に非推奨となる予定のため、新規のコードでは新しい API を使う。。
注意点が二つ。中身は平文です——端末を操作できる人には読めるので、トークンや鍵を置いてはいけ ません。そして大量のデータには向きません。起動のたびに読む前提の、小さな値のための場所です。
§ 04SECURE秘密は OS の金庫に預ける
認証トークンやセッションのように、漏れて困るものは flutter_secure_storage に置きます。これは
iOS の Keychain、Android の Keystore に裏付けられた保存先です。
final storage = FlutterSecureStorage();await storage.write(key: 'accessToken', value: token);final token = await storage.read(key: 'accessToken'); // null になりうるawait storage.delete(key: 'accessToken');
API は key-value で SharedPreferences に似ていますが、性質が違います。暗号化と OS 呼び出しを 伴うぶん遅く、頻繁に読む場所ではありません。起動時に一度読んでメモリ(Provider)に載せ、以降は そこから使う形が定石です。
もう一つ、読めないことがあります。端末のパスコード設定の変更や、OS のバックアップからの復元で
値が失われることがあり、read は素直に null を返します。「秘密が無い=未ログイン」として
成立する経路を用意しておく必要があります。
§ 05SQLITE構造のあるデータを持つ
件数があり、条件で絞り、部分的に更新する——そうなったら SQLite です。Flutter では sqflite を
使います。
final dir = await getApplicationDocumentsDirectory();final db = await openDatabase(join(dir.path, 'app.db'),version: 1,onCreate: (db, version) async {await db.execute('''CREATE TABLE items (id TEXT PRIMARY KEY,name TEXT NOT NULL,quantity INTEGER NOT NULL DEFAULT 0)''');},);
onCreate は新規インストールのときだけ呼ばれます。既にデータベースがある端末では呼ばれない
——この一点が、次節の話につながります。
読み書きは Map で受け渡します。前回の Freezed モデルとの相互変換は、fromJson / toJson が
そのまま使えることが多い場所です。
await db.insert('items', item.toJson());final rows = await db.query('items', where: 'quantity > ?', whereArgs: [0]);final items = rows.map(Item.fromJson).toList();
複数の書き込みがまとめて成功するか、まとめて失敗するかであってほしいときは、トランザクションで 囲みます。途中でエラーが出れば、全体が巻き戻ります。
await db.transaction((txn) async {await txn.delete('items', where: 'id = ?', whereArgs: [id]);await txn.insert('history', log.toJson());});
なお sqflite はモバイル向けで、Linux・Windows・macOS では動きません。デスクトップでも動かす
なら、起動時に sqflite_common_ffi で実装を差し替えます2デスクトップでは起動時に sqfliteFfiInit() を呼び、databaseFactory = databaseFactoryFfi を設定する。連載第 1 回で見た「runApp() の前に初期化する」処理の一例で、プラットフォーム分岐を main() に置く典型でもある。。
§ 06MIGRATIONスキーマは必ず変わる
ここが、ローカル保存でいちばん事故が起きるところです。
アプリを更新して列を一つ増やしたとします。新規インストールの端末は onCreate で新しいスキーマが
作られるので何も起きません。しかし既存ユーザーの端末には、古いスキーマのデータベースが既に
ある。onCreate は呼ばれず、新しい列は存在しないまま、アプリだけが新しくなります。
そのための version と onUpgrade です。
openDatabase(path,version: 2, // 1 から上げるonCreate: (db, version) async {// 新規インストール向けの、最新のスキーマ},onUpgrade: (db, oldVersion, newVersion) async {if (oldVersion < 2) {await db.execute('ALTER TABLE items ADD COLUMN memo TEXT');}},);
if (oldVersion < 2) を積み重ねる形にするのが要点です。ユーザーは必ずしも一つずつ更新しません。
version 1 から 5 へ一気に上がる端末があり、その場合は 2・3・4・5 のぶんが順に適用される必要が
あります。
そして忘れやすいのが、onCreate と onUpgrade の両方を更新すること。片方だけ直すと、
「新規インストールでは動くが既存ユーザーで落ちる」あるいはその逆、という再現しにくいバグになり
ます。ここは実際に古い version のデータベースを作ってから更新するテストを書く価値がある場所
です。
§ 07REPOSITORY保存先を UI から隠す
最後に構造の話を一つ。ここまでのコードを、画面から直接呼ばないようにします。
abstract interface class ItemRepository {Future<List<Item>> findAll();Future<void> save(Item item);}
画面や Provider はこの抽象だけを知り、SQLite なのか API なのかを知りません。利点は三つです。
保存先を変えられます。 SharedPreferences から SQLite へ移しても、書き換えるのは実装クラス だけです。
テストが速くなります。 連載 I の最終回で overrides を使って偽物に差し替えました。あれが
できるのは、UI が抽象に依存しているからです。実際のデータベースを開かずに画面のテストが書けます。
プラットフォームの都合が漏れません。 「デスクトップでは ffi の初期化が要る」「Secure Storage は null を返しうる」といった事情を、実装の内側に閉じ込められます。
依存の向きは、連載 I で見た Provider がそのまま担います。
final itemRepositoryProvider = Provider<ItemRepository>((ref) {return SqliteItemRepository(ref.watch(databaseProvider));});
これが DI としての Provider の使い方で、詳しくは別連載「Riverpod と依存性注入」の Riverpod と DI の回で扱います。
§ 08SUMMARY置き場所を決める地図
- 画面の状態はプロセスと一緒に消える。残すのは、ユーザーが作ったものと取り直せないもの
- 秘密か / 件数があるか / バイト列かの三問で置き場所は決まる
- SharedPreferences は小さな設定値。中身は平文で、秘密を置く場所ではない
- Secure Storage は OS の金庫。遅いので起動時に一度読む。読めないことがある前提で書く
- SQLite は検索・部分更新・整合性が要るとき。まとめて成否を決めたいならトランザクション
onCreateは新規インストールでしか呼ばれない。既存端末はonUpgradeが唯一の経路- 保存先は Repository の裏に隠す。差し替えとテストのしやすさがそのまま利益になる
残るは、Flutter だけでは完結しない領域です。次回は連載の最後として、Plugin とネイティブの 境界——Dart のコードが OS の機能に触れるとき、何が起きているのかを扱います。
- [1]
shared_preferences2.3.0 以降はSharedPreferencesAsyncとSharedPreferencesWithCacheが追加され、従来のSharedPreferences.getInstance()はレガシー扱いになった。将来的に非推奨となる予定のため、新規のコードでは新しい API を使う。 ↩ - [2] デスクトップでは起動時に
sqfliteFfiInit()を呼び、databaseFactory = databaseFactoryFfiを設定する。連載第 1 回で見た「runApp()の前に初期化する」処理の一例で、プラットフォーム分岐をmain()に置く典型でもある。 ↩