.NET MAUIでローカルにデータを保存するなら、ユーザー設定のような小さな値は Preferences、トークンなどの秘密情報は SecureStorage、アプリ独自のドキュメントは FileSystem.AppDataDirectory 配下のファイル、検索や更新を伴う構造化データは SQLite、というように用途で保存先を分けるのが基本です。
保存先を一つに決めて何でも詰め込むと、設定画面のたびに巨大なJSONを読み書きしたり、平文のままトークンを置いてしまったりと、性能とセキュリティの両面で問題が出ます。この記事では、それぞれの保存先の性質、実装の要点、そして再インストールやバックアップで起きがちな落とし穴を整理します。
4つの保存先をどう使い分けるか
最初に、判断の軸を表にまとめます。迷ったときは「秘密か」「構造化されているか」「OSに消されても再生成できるか」の順に確認します。
| 保存先 | 向いているデータ | 注意点 |
|---|---|---|
| Preferences | テーマ、言語、最後に開いたタブ、オンボーディング完了フラグ | 暗号化されない。大きなデータや秘密情報は入れない |
| SecureStorage | トークン、APIキー、ローカルDBの暗号鍵 | 非同期APIで、小さな文字列向け。取得に失敗する場合がある |
| ファイル(AppDataDirectory) | ユーザーが作成した文書、エクスポートしたJSON、ダウンロード済みの資産 | 書き込み途中のクラッシュで壊れうる |
| SQLite | 一覧、履歴、同期対象のレコード | スキーマ変更(マイグレーション)の設計が必要 |
SQLite とオフライン同期の設計については、SQLite+EF Coreオフラインファーストアプリの構築ガイドで詳しく扱っています。ここでは、それ以外の軽量な保存先に焦点を当てます。
Preferences:設定値のための軽量ストア
Preferences は、プラットフォーム標準の仕組み(AndroidのSharedPreferences、iOSのNSUserDefaultsなど)の上に作られたキーと値のストアです。同期的に読み書きでき、呼び出しも簡単です。
Preferences.Default.Set("theme", "dark");
string theme = Preferences.Default.Get("theme", "system");
bool exists = Preferences.Default.ContainsKey("theme");
Preferences.Default.Remove("theme");
Preferences.Default.Clear();Get の第2引数は、キーが存在しない場合に返す既定値です。この既定値の型が、読み出す値の型を決めます。扱える型は基本的な値型と文字列、DateTime に限られるため、オブジェクトを丸ごと入れたい場合は、JSONにシリアライズした文字列として保存するか、ファイルや SQLite を検討します。
共有名で用途を分ける
Set、Get、Remove、Clear などには、任意の sharedName 引数があります。これを指定すると、別の名前空間として値を管理できます。たとえば「ユーザー固有の設定」と「アプリ全体の設定」を分けておけば、ログアウト時に前者だけを Clear できます。
DIで包んでテストしやすくする
Preferences.Default は IPreferences インターフェースを実装しています。静的呼び出しをViewModelに散らさず、設定専用のサービスにまとめ、インターフェース経由で注入するとテストが書きやすくなります。
// MauiProgram.cs
builder.Services.AddSingleton<IPreferences>(Preferences.Default);
builder.Services.AddSingleton<SettingsService>();
// SettingsService.cs
public sealed class SettingsService(IPreferences preferences)
{
public string Theme
{
get => preferences.Get(nameof(Theme), "system");
set => preferences.Set(nameof(Theme), value);
}
public bool OnboardingDone
{
get => preferences.Get(nameof(OnboardingDone), false);
set => preferences.Set(nameof(OnboardingDone), value);
}
}キー名を文字列リテラルで散在させないことが、このラッパーの最大の利点です。ユニットテストでは、辞書を裏に持つ IPreferences の偽実装を渡すだけで、プラットフォームなしに SettingsService を検証できます。
SecureStorage:秘密情報はOSのキーストアへ
SecureStorage は、値を暗号化してOSの安全な保存領域に置く仕組みです。APIは非同期で、扱うのは文字列です。
await SecureStorage.Default.SetAsync("refresh_token", token);
string? saved = await SecureStorage.Default.GetAsync("refresh_token");
if (saved is null)
{
// 未保存。ログイン画面へ
}
SecureStorage.Default.Remove("refresh_token");
// SecureStorage.Default.RemoveAll();キーが存在しない場合、GetAsync は例外ではなく null を返します。一方で、端末側の状態によっては読み書き自体が例外になることがあります。たとえば、Androidでバックアップから復元された後に暗号鍵が一致しなくなるケースです。ログインのたびに落ちないよう、try/catch で包み、失敗したら保存済みの値を削除して再ログインに誘導する設計にしておきます。
public async Task<string?> TryGetTokenAsync()
{
try
{
return await SecureStorage.Default.GetAsync("refresh_token");
}
catch (Exception)
{
SecureStorage.Default.Remove("refresh_token");
return null;
}
}ISecureStorage も SecureStorage.Default が実装しているため、Preferences と同じ方法でDIに登録できます。認証全体の設計、OAuth、証明書ピンニングについてはセキュリティ実践ガイドを参照してください。
SecureStorageに入れてはいけないもの
大きなJSONや画像のような大量のデータは、SecureStorage には向きません。ローカルのデータベースやファイルを暗号化したい場合は、暗号鍵だけを SecureStorage に保存し、本体のデータは通常のファイルや SQLite に置く構成にします。
ファイル:AppDataDirectory とキャッシュの扱い
アプリ専用のファイルは、FileSystem が返すパスを基点にします。パスを直書きせず、必ず Path.Combine と組み合わせます。
FileSystem.AppDataDirectory:アプリが永続的に使うデータ用。ユーザーの作成物はここに置きます。FileSystem.CacheDirectory:再取得や再生成が可能なデータ用。ストレージが逼迫したとき、OSが削除する可能性があるため、消えても動く前提で使います。
書き込み途中のクラッシュに備える
ファイルへ直接上書きすると、書き込み中にアプリが終了した場合、中身が壊れた状態で残ります。一時ファイルに書いてから置き換える方法なら、元のファイルは完全な状態か、新しい完全な状態のどちらかになります。
public async Task SaveProfileAsync(UserProfile profile)
{
string path = Path.Combine(FileSystem.AppDataDirectory, "profile.json");
string tmp = path + ".tmp";
string json = JsonSerializer.Serialize(profile);
await File.WriteAllTextAsync(tmp, json);
File.Move(tmp, path, overwrite: true);
}
public async Task<UserProfile?> LoadProfileAsync()
{
string path = Path.Combine(FileSystem.AppDataDirectory, "profile.json");
if (!File.Exists(path))
return null;
string json = await File.ReadAllTextAsync(path);
return JsonSerializer.Deserialize<UserProfile>(json);
}トリミングやNative AOTを有効にする場合、リフレクションに頼った System.Text.Json の呼び出しは警告や実行時エラーの原因になります。その場合は、ソース生成の JsonSerializerContext を使う構成にしてください。詳細はパフォーマンス関連の最適化ガイドでも触れているNative AOTの考え方と合わせて確認すると安心です。
再インストール・バックアップ・移行の落とし穴
アンインストール後に何が残るか
保存先ごとに、アンインストールや復元時の挙動が異なります。Preferences と AppDataDirectory のファイルは、通常アプリと一緒に削除されます。一方、iOSのKeychainに保存した値は、アンインストール後も残る場合があります。そのため「初回起動時は SecureStorage を一度クリアする」という処理を、Preferences のフラグと組み合わせて入れておくと、再インストール後に古いトークンが生き残る事故を防げます。
if (!Preferences.Default.ContainsKey("first_run_done"))
{
SecureStorage.Default.RemoveAll();
Preferences.Default.Set("first_run_done", true);
}Androidの自動バックアップ
Androidには、アプリのデータをクラウドへ自動でバックアップし、端末の移行時に復元する機能があります。暗号鍵は端末に紐づくため、復元されたデータが SecureStorage から読めなくなることがあります。バックアップの対象から除外する方法は、AndroidManifest の設定とバックアップ規則で制御します。具体的な設定は、お使いのAndroidターゲットと .NET MAUI のバージョンのドキュメントで確認してください。
保存形式を変えるときのバージョン管理
アプリを更新するとき、ファイルのJSON構造や Preferences のキーを変えたくなることがあります。その場合は、スキーマのバージョン番号を Preferences に保存しておき、起動時に古いバージョンなら変換処理を実行してから番号を更新します。
- 起動時に
schema_versionを Preferences から読む(なければ初版として扱う)。 - 現在のバージョンより小さければ、段階的に変換処理を実行する。
- 変換が成功したら
schema_versionを更新する。
この仕組みがあれば、データ量が増えてファイルから SQLite に移行する際にも、「旧ファイルを読み込み、テーブルへ挿入して、旧ファイルを削除する」処理を一度だけ確実に走らせられます。
SQLiteへ移行する目安
次のような要件が出てきたら、ファイルの追加ではなくSQLiteを検討する時期です。
- 一覧を並べ替えたり、条件で絞り込んだりしたい。
- 一件だけを更新したいのに、毎回ファイル全体を書き直している。
- 複数の書き込みをまとめて、成功か失敗のどちらかにしたい(トランザクション)。
- サーバーとの同期状態を、レコード単位で管理したい。
実践的な設計ルール
最後に、保存まわりをコードベースで整理するためのルールをまとめます。
- 保存先へのアクセスはサービスクラスに閉じ込め、ViewModelから
Preferences.Defaultを直接呼ばない。 - キー名は定数か
nameofで一元管理する。 - UIスレッドをブロックしないよう、ファイルの読み書きは非同期APIを使う。
- ログやクラッシュレポートに、SecureStorage の値や Preferences の機密値を出力しない。
- キャッシュは「消えても困らない」ものだけを置き、消えたときの再取得の経路を必ず用意する。
Frequently Asked Questions
Preferences にJSONの大きなオブジェクトを保存してもよいですか?
おすすめしません。Preferences は小さな設定値向けです。オブジェクト全体を保存する場合は、AppDataDirectory 配下のファイルか SQLite を使ってください。
SecureStorage と Preferences の違いは何ですか?
Preferences は暗号化されない設定値用のストアで、同期APIです。SecureStorage は値を暗号化してOSのキーストアに置く秘密情報用のストアで、非同期APIです。トークンやAPIキーは必ず SecureStorage に保存します。
CacheDirectory のファイルは、いつ消えますか?
消えるタイミングはOSが決めます。ストレージが不足したときなどに削除される可能性があるため、いつ消えても再取得できるデータだけを置いてください。
ユニットテストで Preferences や SecureStorage を使うにはどうすればよいですか?
IPreferences と ISecureStorage をDIで受け取るクラスを作り、テストでは辞書などを裏に持つ偽実装を渡します。静的な Preferences.Default を直接呼ぶコードは、プラットフォームの実装に依存するため、テストしにくくなります。




