「Windows では見えるのに、Android エミュレーターでは SQLite の既存データが表示されない」。.NET 9 の .NET MAUI でよく相談されるこの症状は、ほぼ例外なく“同じファイル名の別ファイル”を各 OS が個別に保持していることが原因です。本稿では、根本原因の仕組みから、最短で解決する実装パターン、運用時のマイグレーションまでを、コピペで使えるコードと手順で徹底解説します。
.NET 9 MAUI × SQLite:Windows では見えるのに Android で見えない現象の正体
再現している症状の整理
- Windows(WinUI)では、DB Browser などで事前に挿入したレコードが読み取れる。
- Android エミュレーターで実行すると、その既存レコードが見えない(空に見える)。
- 逆に、Android 側でコードから追加したレコードは Android では見えるが、Windows では見えない。
- 結果として、OS ごとに「別のデータベース」を参照しているように振る舞う。
根本原因(ファイルの所在が OS ごとに違う)
SQLite は“単一ファイル”をデータベースとして扱います。.NET MAUI では、プラットフォームごとにアプリが書き込み可能なローカルフォルダー(サンドボックス)が異なります。アプリから FileSystem.AppDataDirectory を使って DB パスを作ると、各 OS の既定の場所に物理ファイルが生成されます。したがって、Windows と Android は別ファイルを見ています。
| OS | 典型的な実体パス(例) | 備考 |
|---|---|---|
| Windows (WinUI) | C:\Users\<User>\AppData\Local\Packages\<PackageFamilyName>\LocalState など | デバッグ構成や配布形態により実体は変わるが、ユーザー毎のローカル領域 |
| Android | /data/data/<パッケージ名>/files | アプリ専用の内部ストレージ。外部ツールから直接は見えない |
このため、同名の app.db3 を作っても、Windows と Android で別々に存在します。事前に PC 上で投入したレコードは Windows の DB ファイルだけにあり、Android の DB には存在しない、というのが現象の正体です。
解決の考え方(3 つの王道)
| 方針 | 狙い | 適用シーン | 実装ポイント |
|---|---|---|---|
| ① 事前作成 DB をバンドル → 初回コピー | 全 OS に同じ初期データを配布 | 読み取り中心/小〜中規模の初期データ | Resources/Raw に seed.db3 を置き、初回起動時に AppDataDirectory へコピー |
| ② 空スキーマ+起動時 INSERT で初期化 | バイナリサイズを最小化しつつ、コードで初期化 | 初期レコードが少ない/生成が容易 | テーブル作成後、ガード付きで INSERT 実行(重複禁止) |
| ③ クラウド同期 | 端末間でデータを統一・同期 | 更新を多端末で共有したい | オンライン DB(例:後方 API、BaaS)+ローカルキャッシュ戦略 |
本稿では、多くのケースで最短解となる① 初回コピー方式を中心に、② のコード初期化も併記します。
実装ガイド:① 事前作成 DB を各プラットフォームへコピー
全体像
- PC で「完成済みの SQLite ファイル(例:
seed.db3)」を作る。 - .NET MAUI プロジェクトの
Resources/Rawに配置(ビルドアクション:MauiAsset)。 - 初回起動時だけアプリの書き込み可能領域(
FileSystem.AppDataDirectory)へコピー。 - 以降はそのコピー先ファイルを開いて読み書きする。
プロジェクト構成例
MyMauiApp/
├─ Resources/
│ └─ Raw/
│ └─ seed.db3 ← ここに事前作成 DB を置く(MauiAsset)
├─ Services/
│ ├─ DatabasePath.cs
│ └─ DatabaseInitializer.cs
├─ MauiProgram.cs
└─ ...
.csproj の確認(自動で入らない場合)
Resources/Raw 配下は通常自動で MauiAsset 扱いになります。必要に応じて以下を追記します。
<ItemGroup>
<MauiAsset Include="Resources\Raw\seed.db3" />
</ItemGroup>
DB パスの統一化(プラットフォーム差異の吸収)
using System.IO;
using Microsoft.Maui.Storage;
namespace MyMauiApp.Services;
public static class DatabasePath
{
public const string DbFileName = "app.db3";
public static string GetDbPath()
{
var folder = FileSystem.AppDataDirectory; // OS に依存せず書き込み可能な領域
return Path.Combine(folder, DbFileName);
}
}
初回コピーの実装(存在チェック → パッケージから展開)
using Microsoft.Maui.Storage;
namespace MyMauiApp.Services;
public static class DatabaseInitializer
{
private const string SeedFileName = "seed.db3";
public static async Task EnsureCreatedAsync()
{
var dbPath = DatabasePath.GetDbPath();
if (File.Exists(dbPath))
{
return; // 既に展開済み
}
// アプリパッケージ内のシード DB を開き、書き込み可能領域へコピー
using var src = await FileSystem.OpenAppPackageFileAsync(SeedFileName);
Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!);
using var dst = File.Create(dbPath);
await src.CopyToAsync(dst);
}
}
起動時に一度だけ呼び出す(安全なタイミング)
ポイント: MauiProgram.CreateMauiApp() は await が使いにくいので、最初に表示されるページの OnAppearing など UI スレッドが確立したタイミングで呼ぶのが簡単です。
// AppShell.xaml.cs など
using MyMauiApp.Services;
public partial class AppShell : Shell
{
private bool _initialized;
public AppShell()
{
InitializeComponent();
}
protected override async void OnAppearing()
{
base.OnAppearing();
if (_initialized) return;
_initialized = true;
try
{
await DatabaseInitializer.EnsureCreatedAsync();
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"DB 初期化に失敗: {ex}");
await Application.Current!.MainPage!.DisplayAlert("Error", "DB 初期化に失敗しました。", "OK");
}
}
}
SQLite 接続(sqlite-net-pcl を例に)
sqlite-net-pcl を使うと簡潔に扱えます(NuGet: sqlite-net-pcl)。
using SQLite;
using MyMauiApp.Services;
namespace MyMauiApp.Services;
public static class SQLiteFactory
{
private const SQLiteOpenFlags Flags =
SQLiteOpenFlags.ReadWrite |
SQLiteOpenFlags.Create |
SQLiteOpenFlags.SharedCache;
public static SQLiteAsyncConnection CreateConnection()
{
var path = DatabasePath.GetDbPath();
return new SQLiteAsyncConnection(path, Flags);
}
}
モデルと CRUD の最小例
using SQLite;
namespace MyMauiApp.Models;
public class Todo
{
[PrimaryKey, AutoIncrement]
public int Id { get; set; }
[Indexed]
public string Title { get; set; } = "";
public bool IsDone { get; set; }
}
using MyMauiApp.Models;
using SQLite;
namespace MyMauiApp.Services;
public class TodoRepository
{
private readonly SQLiteAsyncConnection _conn;
public TodoRepository()
{
_conn = SQLiteFactory.CreateConnection();
}
public async Task InitAsync()
{
// 事前作成 DB にテーブルが既にある場合は No-Op
await _conn.CreateTableAsync<Todo>();
}
public Task<int> AddAsync(Todo item) => _conn.InsertAsync(item);
public Task<List<Todo>> GetAllAsync() => _conn.Table<Todo>().OrderBy(x => x.Id).ToListAsync();
}
DI 登録と使用例
// MauiProgram.cs
using MyMauiApp.Services;
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp()
.ConfigureFonts(fonts =>
{
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
});
builder.Services.AddSingleton<TodoRepository>();
return builder.Build();
}
}
// どこかの ViewModel など
public class TodoViewModel
{
private readonly TodoRepository _repo;
public TodoViewModel(TodoRepository repo)
{
_repo = repo;
}
public async Task InitializeAsync()
{
await _repo.InitAsync();
var items = await _repo.GetAllAsync();
// ここで UI にバインド
}
}
これで「Windows と Android で初期データが一致」する理由
各 OS のローカル領域に、同一のシード DB(seed.db3)が初回起動時にコピーされるため、起点となるレコードが同じになります。以降の更新は各 OS のローカル DB に対して行われます。端末間で更新内容を共有させたい場合は、後述のクラウド同期を検討します。
実装ガイド:② 空スキーマ+起動時 INSERT で初期化
初期データ数が少ない、あるいはバイナリサイズを増やしたくない場合の手法です。
public static class DatabaseInitializer2
{
public static async Task EnsureCreatedAndSeededAsync(SQLiteAsyncConnection conn)
{
await conn.CreateTableAsync<Todo>();
// 既に 1 件でも入っていればスキップ
var count = await conn.Table<Todo>().CountAsync();
if (count > 0) return;
var initial = new[]
{
new Todo { Title = "Welcome", IsDone = false },
new Todo { Title = "MAUI × SQLite を始めよう", IsDone = false },
};
await conn.InsertAllAsync(initial);
}
}
メリットは「シード DB の作成・更新が不要」な点。デメリットは大量データの初期化が遅くなる点です。
運用に不可欠:スキーマバージョニングとマイグレーション
いずれの方針でも、アプリのアップデートでテーブル構造が変わることがあります。SQLite の PRAGMA user_version を使ってスキーマのバージョンを記録し、差分を適用しましょう。
public static class MigrationRunner
{
public static async Task RunAsync(SQLiteAsyncConnection conn)
{
// 現在のバージョンを取得
var current = await conn.ExecuteScalarAsync<int>("PRAGMA user_version;");
// v0 → v1 の例:列追加
if (current < 1)
{
await conn.ExecuteAsync("ALTER TABLE Todo ADD COLUMN CreatedAt TEXT DEFAULT '';");
await conn.ExecuteAsync("PRAGMA user_version = 1;");
}
// v1 → v2 の例:インデックス追加
if (current < 2)
{
await conn.ExecuteAsync("CREATE INDEX IF NOT EXISTS IX_Todo_Title ON Todo(Title);");
await conn.ExecuteAsync("PRAGMA user_version = 2;");
}
}
}
アプリ起動時に EnsureCreatedAsync → MigrationRunner.RunAsync の順で呼び出すことで、既存ユーザーの DB を安全に更新できます。
トラブルシューティング(つまずきポイントのチェックリスト)
| 症状 | 原因候補 | 対処 |
|---|---|---|
| Android で初期データが入らない | seed.db3 がパッケージに入っていない/ファイル名が不一致 | Resources/Raw 直下に配置し、OpenAppPackageFileAsync("seed.db3") の名前と一致させる |
| Asset を直接開いて書き込もうとして失敗 | パッケージ内 Asset は読み取り専用で、Android では圧縮されることがある | 必ず 書き込み可能領域へコピー してから開く |
| Windows と Android でデータがずれる | 起動時コピーをしていない/タイミングが早すぎて失敗 | 最初に表示されるページの OnAppearing で await してから画面を構築 |
| DB の場所がわからない | プラットフォームごとの実体パスを知らない | FileSystem.AppDataDirectory をログに出す(コード例後述) |
| テーブルが「存在しない」と言われる | 別ファイルを開いている/コピー前に新規作成されてしまった | コピー → 接続 → CreateTableAsync の順を厳守。新規作成ファイルが残っていれば削除 |
実体パスのログ出力ユーティリティ
public static class DebugTools
{
public static void DumpPaths()
{
var appData = Microsoft.Maui.Storage.FileSystem.AppDataDirectory;
var dbPath = MyMauiApp.Services.DatabasePath.GetDbPath();
System.Diagnostics.Debug.WriteLine($"[AppDataDirectory] {appData}");
System.Diagnostics.Debug.WriteLine($"[DB Path] {dbPath}");
}
}
アプリ起動直後に DebugTools.DumpPaths() を呼び、Windows と Android で違う場所を見ていることを開発者が確認できるようにしておくと、以降の混乱を避けられます。
検証・デバッグの実務テクニック(Android)
- Android Studio の Device File Explorer で
/data/data/<パッケージ名>/files配下を確認(エミュレーター推奨)。 - CLI 派は
adb shell run-asを使い、アプリ実行ユーザー権限で DB を/sdcard/Downloadに一時コピーしてからadb pullで取得する方法が便利です。
# アプリの内部ファイル一覧(パッケージ名は自分のものに置換)
adb shell run-as com.example.mymauiapp ls files
# DB を一時領域にコピー
adb shell run-as com.example.mymauiapp cp files/app.db3 /sdcard/Download/app.db3
# PC 側へ取り出す
adb pull /sdcard/Download/app.db3
取り出したファイルは DB Browser などで中身を確認できます。Windows と比較することで、同一データであることを確かめられます。
補足:Microsoft.Data.Sqlite で接続したい場合
Microsoft.Data.Sqlite を使う場合は接続文字列でパスを指定します。初回コピーの手順は同じです。
using Microsoft.Data.Sqlite;
using MyMauiApp.Services;
var path = DatabasePath.GetDbPath();
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
using var conn = new SqliteConnection($"Data Source={path};Cache=Shared");
await conn.OpenAsync();
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = "CREATE TABLE IF NOT EXISTS Todo (Id INTEGER PRIMARY KEY AUTOINCREMENT, Title TEXT, IsDone INTEGER)";
await cmd.ExecuteNonQueryAsync();
}
読み取り専用で開きたい場合は Mode=ReadOnly などのオプションも利用できます(ただし、Asset 直接ではなくコピー先に対して指定します)。
クラウド同期が必要なときの考え方
「Windows で追加したデータを Android でも見たい」という要件は、ローカル SQLite だけでは満たせません。オンライン DB(自前の Web API、あるいは BaaS)を“いちばんの正”とし、ローカル SQLite はキャッシュとして使います。
- 起動時:サインイン → 差分同期(プッシュ/プル) → ローカル DB 更新
- オフライン時:ローカル DB へ書き込み→ 後で同期
- 競合解決:更新日時/世代番号/サーバー勝ちなどポリシーを決める
このアーキテクチャを選ぶと、OS をまたいでも同じデータが見えるようになります。ローカル DB の“分断”は本稿の初期コピーで解消できますが、更新結果の共有は同期設計の話題になります。
よくある誤解・やってはいけないこと
- ファイルパスをハードコードする: プラットフォームや配布形態で変わるため非移植。
FileSystem.AppDataDirectoryを必ず使う。 - Asset(
Resources/Raw)内の DB を直接開いて書き込む: 読み取り専用かつ Android では圧縮されることがある。必ずコピー。 - コピーの前に接続を開く: 存在しないファイルが新規作成され、以降ずっと「空の DB」を参照してしまう。順序を守る。
- スキーマ変更をベタ書き: 将来の改修で破壊的変更になる。
PRAGMA user_versionで管理する。 - Windows の DB を Android へそのまま配布: 配置場所が違う。必ずパッケージにバンドルするか、初期化スクリプトで投入する。
ミニ FAQ
Q. DB ファイル名は何でも良い?
A. はい。ただし拡張子 .db3 や .sqlite に統一し、コードの定数で 1 箇所に定義しましょう。
Q. 既存ユーザーのデータを保持しつつ初期データを増やしたい。
A. マイグレーションの一環として INSERT OR IGNORE を使うか、存在確認してから追加します。
Q. エミュレーターで DB を“リセット”したい。
A. アンインストール(アプリを削除)すると内部ストレージも消えます。もしくは AppDataDirectory の DB を削除するユーティリティを一時的に用意しても良いでしょう。
Q. Windows で「パッケージングした MSIX」と「デバッグ実行」でパスが違うのは?
A. 配布形態により LocalState の実体は変わります。だからこそ、FileSystem.AppDataDirectory の抽象化が重要です。
実用テンプレート(コピペ可):初期コピー+マイグレーション一体化
using Microsoft.Maui.Storage;
using SQLite;
using System.IO;
namespace MyMauiApp.Services;
public static class AppDatabase
{
private const string Seed = "seed.db3";
private const SQLiteOpenFlags Flags =
SQLiteOpenFlags.ReadWrite | SQLiteOpenFlags.Create | SQLiteOpenFlags.SharedCache;
public static string Path => System.IO.Path.Combine(FileSystem.AppDataDirectory, "app.db3");
public static async Task<SQLiteAsyncConnection> GetConnectionAsync()
{
// 1) 初回コピー
if (!File.Exists(Path))
{
using var src = await FileSystem.OpenAppPackageFileAsync(Seed);
Directory.CreateDirectory(System.IO.Path.GetDirectoryName(Path)!);
using var dst = File.Create(Path);
await src.CopyToAsync(dst);
}
// 2) 接続
var conn = new SQLiteAsyncConnection(Path, Flags);
// 3) スキーマ保証
await conn.CreateTableAsync<Models.Todo>();
// 4) マイグレーション
await MigrationRunner.RunAsync(conn);
return conn;
}
}
まとめ:原因は“別ファイルを開いている”だけ。対策は“同じスタートラインを配る”。
本稿で扱った現象は、難しいバグではなくファイルの所在が OS ごとに違うことから起こる 仕様通りの挙動です。
最短解は「事前作成 DB をアプリに同梱し、初回だけ AppDataDirectory にコピーする」。これで Windows と Android の初期データが一致します。さらに、PRAGMA user_version によるスキーマ管理を組み合わせれば、運用フェーズでの変更にも安全に対応できます。
「端末間で更新内容を共有したい」段階では、ローカル SQLite を“キャッシュ”、クラウドを“正”にするアーキテクチャへ進みましょう。
付録:実装チェックリスト(保存版)
- パス: DB の実体は
FileSystem.AppDataDirectory配下か? - 展開: 初回コピーは 接続より前に実行しているか?
- 資産:
Resources/Raw/seed.db3はパッケージに含まれているか?ファイル名は一致しているか? - スキーマ:
PRAGMA user_versionに基づくマイグレーションを実装したか? - ログ: 実体パスを
Debug.WriteLineに出して比較したか? - デバッグ: エミュレーターではアンインストールで内部 DB が消えることを理解しているか?

コメント