.NET MAUIでSQLiteがiOS/Androidで読み込めない原因と解決策|AppDataDirectoryとResources/Rawで確実に動かす実装ガイド

Windows では動くのに、Android エミュレーターや接続した iPhone でだけ SQLite が「見つからない」「開けない」。この現象の9割は “置き場所” と “配布方法” が原因です。.NET MAUI と .NET 9/Visual Studio 2022(Preview)でも通用する、実機で確実に動かすための実装・設計・落とし穴のすべてを、コピペ可能なコードとチェックリストでまとめました。

目次

.NET MAUI で SQLite が読み込めないときに最初に直すべきこと

結論から言うと、次の2点を徹底するだけで、Windows/Android/iOS を同一コードで安定運用できます。

  • 実行時のDB置き場: FileSystem.Current.AppDataDirectory に必ず集約する。
  • 配布方法: 事前に作った .db や .csv を Resources/Raw に入れ ビルドアクションを MauiAsset にして、初回起動時だけ AppDataDirectory へコピーする。

なぜ Environment.SpecialFolder.Personal ではダメなのか

Environment.SpecialFolder.Personal はプラットフォームごとに実体が異なり、Android/iOS では権限やサンドボックスの都合で読み書きに失敗しやすいのが実情です。MAUI が提供する FileSystem API は各 OS の「アプリ専用領域」に安全に解決されるため、迷わず AppDataDirectory を使うのが最適解です。

典型的な失敗パターン

  • DB や CSV をプロジェクト直下に置いたまま相対パスで開いている。
  • Android 13+ の scoped storage を理解せず、外部ストレージへ出力しようとしている。
  • iOS でパッケージ内(読み取り専用)ファイルをそのまま SQLiteConnection に渡している。
  • Resources/Raw に入れたのに、ビルドアクションが MauiAsset ではない。
  • ファイル名の大文字小文字が一致していない(Android は厳密に区別)。

最小構成の正解コード(コピペ可)

まずは「動く」ことを最優先に、DB の配布〜コピー〜接続までを 1 ファイルに凝縮した最小構成を示します。

DB パスを統一する


// DB の “正しい置き場” を組み立てる
string GetDbPath() =>
    Path.Combine(FileSystem.Current.AppDataDirectory, "pointsdatabase.db");
  

初回起動時だけパッケージ(Resources/Raw)からコピーする


async Task CopyDbIfAbsentAsync()
{
    var dest = GetDbPath();
    Directory.CreateDirectory(Path.GetDirectoryName(dest)!);
if (!File.Exists(dest))
{
    using var input = await FileSystem.Current.OpenAppPackageFileAsync("pointsdatabase.db");
    using var output = File.Create(dest);
    await input.CopyToAsync(output);
}

} 

SQLite への接続(sqlite-net-pcl の例)


using SQLite; // NuGet: sqlite-net-pcl

async Task OpenConnectionAsync()
{
await CopyDbIfAbsentAsync();
var dbPath = GetDbPath();
var conn = new SQLiteConnection(dbPath);
// パフォーマンス向上(必要に応じて)
conn.Execute("PRAGMA journal_mode=WAL;");
conn.Execute("PRAGMA synchronous=NORMAL;");
return conn;

}

// 例: クエリ実行
async Task PickBestCardAsync()
{
using var con = await OpenConnectionAsync();
return con.ExecuteScalar(
"SELECT CreditCard FROM VelocityCreditCards WHERE Own = 1 LIMIT 1");
} 

Microsoft.Data.Sqlite を使う場合

ADO.NET 風の API が好みなら、Microsoft.Data.Sqlite でも同じ考え方です。


using Microsoft.Data.Sqlite; // NuGet: Microsoft.Data.Sqlite

async Task OpenAdoConnectionAsync()
{
await CopyDbIfAbsentAsync();
var dbPath = GetDbPath();
var con = new SqliteConnection($"Data Source={dbPath};Cache=Shared;Mode=ReadWriteCreate;");
await con.OpenAsync();
using (var cmd = con.CreateCommand())
{
    cmd.CommandText = "PRAGMA journal_mode=WAL;";
    await cmd.ExecuteNonQueryAsync();
}
return con;

} 

プロジェクトの配置規約(Resources/Raw と Build Action)

配布したい DB や CSV は Resources/Raw に置き、ビルドアクションを必ず MauiAsset にします。これにより アプリパッケージ内部 に資産として組み込まれ、FileSystem.Current.OpenAppPackageFileAsync("ファイル名") で開けるようになります。

配置場所ビルドアクション実行時の読み出し方法用途
Resources/Raw/*.dbMauiAssetOpenAppPackageFileAsync("xxx.db")配布用の初期 DB
Resources/Raw/*.csvMauiAssetOpenAppPackageFileAsync("xxx.csv")初期データ・マスタの配布
AppDataDirectory(実行時に生成)Path.Combine(...)実際に読み書きする運用 DB

CSV から DB を構築したい? 実は「事前作成」のほうが速くて安全

アプリ起動時に大量の INSERT を実行すると、端末性能や OS タイムアウトの影響で起動遅延やクラッシュの原因になります。可能な限り PC で DB を事前作成し、完成済み .db を Resources/Raw へ配布するのが安定策です。

それでも CSV から組み立てたい場合は、以下の設計を守ってください。

  • 起動直後ではなく、スプラッシュ後の非同期で実行する。
  • トランザクション+バルク INSERT(InsertAll など)を徹底する。
  • 1度でも構築に成功したら、フラグ(例:Preferences)を立てて次回以降スキップ。

CSV 取り込みのテンプレート


public record CardRow(string CreditCard, int Own, int Priority);

async Task ImportFromCsvIfNeededAsync()
{
const string FlagKey = "CsvImported";
if (Preferences.Get(FlagKey, false)) return;
// パッケージ内の CSV を開く
using var stream = await FileSystem.Current.OpenAppPackageFileAsync("VelocityCreditCards.csv");
using var reader = new StreamReader(stream, detectEncodingFromByteOrderMarks: true);

var rows = new List<CardRow>();
string? line;
while ((line = await reader.ReadLineAsync()) != null)
{
    if (string.IsNullOrWhiteSpace(line)) continue;
    // 例: CreditCard,Own,Priority の順
    var cols = line.Split(',');
    if (cols.Length < 3) continue;
    rows.Add(new CardRow(cols[0].Trim(), int.Parse(cols[1]), int.Parse(cols[2])));
}

using var con = await OpenConnectionAsync();
con.BeginTransaction();
try
{
    con.Execute(@"
        CREATE TABLE IF NOT EXISTS VelocityCreditCards(
            Id INTEGER PRIMARY KEY AUTOINCREMENT,
            CreditCard TEXT NOT NULL,
            Own INTEGER NOT NULL,
            Priority INTEGER NOT NULL
        );");

    con.InsertAll(rows.Select(r => new VelocityCreditCard {
        CreditCard = r.CreditCard,
        Own = r.Own,
        Priority = r.Priority
    }));

    con.Commit();
    Preferences.Set(FlagKey, true);
}
catch
{
    con.Rollback();
    throw;
}

}

public class VelocityCreditCard
{
[PrimaryKey, AutoIncrement] public int Id { get; set; }
public string CreditCard { get; set; } = "";
public int Own { get; set; }
public int Priority { get; set; }
} 

どこにコピーされたかを即座に確認する

動作確認を劇的に早くするには、実際のパスをデバッグ出力に出します。


var dbPath = GetDbPath();
System.Diagnostics.Debug.WriteLine($"DB Path: {dbPath}");
  
OSAppDataDirectory の概念的な位置備考
Windowsユーザーのローカルアプリデータ配下開発時は見つけやすいが、相対パス放置の原因になりがち
Androidアプリ専用内部ストレージ(外部と分離)Android 10+ の scoped storage により外部直書きは不可
iOSアプリコンテナの Documents/Library 配下バックアップ対象か否かは場所で変わる(後述)

iOS のバックアップ方針と一時的な回避策

iOS の AppDataDirectory は iCloud バックアップ対象に含まれる場合があります。キャッシュ的で復元不要な DB であれば、FileSystem.CacheDirectory を選ぶことでバックアップ対象から外せます。開発中に「なぜか開けない」と感じる場合の一時切り替えにも有効ですが、本番は AppDataDirectory に統一してください。


// 一時的な動作検証:CacheDirectory へコピーしてみる
string GetTempDbPath() =>
    Path.Combine(FileSystem.Current.CacheDirectory, "pointsdatabase.db");
  

アプリ起動ロジックへの組み込み(DI/サービス化の例)

規模が大きくなると、初期コピーやマイグレーションはサービスに切り出したほうが保守性が上がります。


// 1) インターフェイス
public interface IDatabaseBootstrapper
{
    Task InitializeAsync(); // コピー+マイグレーション
    string DbPath { get; }
}

// 2) 実装
public sealed class DatabaseBootstrapper : IDatabaseBootstrapper
{
public string DbPath => Path.Combine(FileSystem.Current.AppDataDirectory, "pointsdatabase.db");
public async Task InitializeAsync()
{
    // 初回コピー
    if (!File.Exists(DbPath))
    {
        using var s = await FileSystem.Current.OpenAppPackageFileAsync("pointsdatabase.db");
        Directory.CreateDirectory(Path.GetDirectoryName(DbPath)!);
        using var o = File.Create(DbPath);
        await s.CopyToAsync(o);
    }

    // 必要ならバージョン判定してマイグレーション
    var current = Preferences.Get("DbSchemaVersion", 0);
    if (current < 2)
    {
        using var con = new SQLiteConnection(DbPath);
        con.Execute("ALTER TABLE VelocityCreditCards ADD COLUMN Note TEXT DEFAULT '';");
        Preferences.Set("DbSchemaVersion", 2);
    }
}

}

// 3) 登録(MauiProgram.cs)
builder.Services.AddSingleton();

// 4) 起動時に呼ぶ(App.xaml.cs など)
public partial class App : Application
{
public App(IDatabaseBootstrapper bootstrapper)
{
InitializeComponent();
MainPage = new AppShell();
// 非同期でキック(待たない)
_ = bootstrapper.InitializeAsync();
}
} 

ビルド/発行時の “あるある” と対策

症状原因対策
パッケージにファイルが入っていないビルドアクションが MauiAsset ではないプロパティを MauiAsset にし、ビルドし直す
名前が一致しているのに開けない(Android)大文字小文字の不一致物理ファイル名と OpenAppPackageFileAsync("...") の引数を厳密一致に
更新版に入れ替えたのに古い DB が使われる初回コピー後は AppDataDirectory の DB が使われ続けるバージョンフラグでマイグレーション or 再コピーの条件分岐を実装
「読み取り専用で開けない」エラーパッケージ内の資産を直接 SQLite に渡している必ず AppDataDirectory にコピーしてから開く
Android 13+ でクラッシュ外部ストレージへ書き込みを試みている常にアプリ専用内部ストレージ(AppDataDirectory)を使用

性能と信頼性を両立する設定

  • WAL(Write-Ahead Logging):同時読み取り性能が上がる。PRAGMA journal_mode=WAL;
  • synchronous=NORMAL:フラッシュ頻度のバランスを取り、体感を改善。
  • インデックス:検索列に CREATE INDEX を忘れない。
  • 非同期 API:UI ブロック回避に SQLiteAsyncConnection の採用を検討。

SQLiteAsyncConnection の使用例


using SQLite;

SQLiteAsyncConnection CreateAsyncConnection()
{
var dbPath = Path.Combine(FileSystem.Current.AppDataDirectory, "pointsdatabase.db");
return new SQLiteAsyncConnection(dbPath);
}

async Task> LoadOwnedAsync()
{
var con = CreateAsyncConnection();
await con.ExecuteAsync("PRAGMA journal_mode=WAL;");
return await con.Table().Where(x => x.Own == 1).ToListAsync();
} 

開発時デバッグの作法

  • パスの可視化: Debug.WriteLine で DB の実体パスを毎回出力。
  • 存在確認: 例外時には File.Exists(dbPath) をログに出す。
  • サイズ・バージョン: new FileInfo(dbPath).Length とスキーマバージョンのログで「違うファイル」を早期発見。

スキーマ更新(マイグレーション)戦略

配布後にテーブル定義が変わるのは日常茶飯事です。アプリ側で「DB バージョン」を持ち、差分マイグレーションを適用しましょう。


const int TargetSchema = 3;

void Migrate(SQLiteConnection con)
{
var current = Preferences.Get("DbSchemaVersion", 0);
if (current < 1)
{
con.Execute("CREATE TABLE IF NOT EXISTS ...;");
Preferences.Set("DbSchemaVersion", 1);
}
if (current < 2)
{
con.Execute("ALTER TABLE ...;");
Preferences.Set("DbSchemaVersion", 2);
}
if (current < 3)
{
con.Execute("CREATE INDEX IF NOT EXISTS IX_VCC_Priority ON VelocityCreditCards(Priority);");
Preferences.Set("DbSchemaVersion", 3);
}
} 

Windows/Android/iOS の差分を最小化する設計

プラットフォーム差分を書くほど不具合は増えます。以下の原則で “差分ゼロ” を狙いましょう。

  • パスは FileSystem API でのみ構築(絶対に生パス直書きしない)。
  • 資産はすべて Resources/Raw + MauiAsset。
  • 初期コピーは 1 回だけ。存在チェックで冪等に。
  • 更新はマイグレーションで管理(再コピーでデータ消失を防ぐ)。

よくある質問(FAQ)

Q: DB を直接パッケージから開けませんか?
A: できても 読み取り専用 です。更新やトランザクションが必要な運用 DB は必ず AppDataDirectory へコピーしてください。

Q: なぜ CSV から構築は非推奨?
A: 端末性能のバラツキと I/O の遅さがボトルネックになり、初回起動の体感が悪化します。事前作成 DB のほうが小さく、信頼性・速度ともに安定します。

Q: 更新で初期データを追加したいときは?
A: ALTER TABLE と INSERT をスクリプト化し、バージョンフラグで 1 回だけ適用します。既存データを消さないことが最重要です。

Q: iOS だけ読めないことがあるのはなぜ?
A: 署名/ビルド設定やバックアップ領域の差異、資産の大文字小文字違いなどが影響します。疑わしいときは一時的に CacheDirectory を使って挙動を切り分け、最終的に AppDataDirectory へ戻すのが定石です。

トラブル切り分けのためのチェックリスト

  • Resources/Raw に置き、ビルドアクションが MauiAsset になっている。
  • 初回コピーのコードが存在し、File.Exists で冪等になっている。
  • 実行ログに DB Path が出ている(実ファイルを特定できる)。
  • Android では外部ストレージに触れていない。
  • iOS のファイル名の大文字小文字が一致している。
  • 更新時はマイグレーションでデータを保持している。

まとめ:正しい置き場と正しい配布で、端末差を “無視” できる

SQLite が iOS/Android で見つからない最大の理由は、「どこに置いて、どう配るか」が曖昧なまま実装してしまうことに尽きます。FileSystem.Current.AppDataDirectory に一本化し、配布資産を Resources/Raw(MauiAsset)へ置き、初回にだけコピーする。この黄金パターンを守れば、Windows/Android/iOS を同一コードでシームレスに運用できます。加えて、事前作成 DB+マイグレーション戦略を採れば、初回起動も高速で、更新時の信頼性も高いアプリを実現できます。

完全版サンプル(ひとまとめ)

最後に、この記事のエッセンスを 1 ファイルにまとめたサンプルを掲載します。まずはこれで動作確認し、必要に応じてサービス化・分割してください。


using System.Text;
using Microsoft.Maui.Storage;
using SQLite;

namespace MauiApp.Data;

public static class DatabaseBoot
{
private const string DbFileName = "pointsdatabase.db";
private const string CsvFileName = "VelocityCreditCards.csv";
public static string DbPath =&gt; Path.Combine(FileSystem.Current.AppDataDirectory, DbFileName);

public static async Task InitializeAsync()
{
    await EnsureDbCopiedAsync();
    await EnsureSchemaAndSeedAsync();
}

private static async Task EnsureDbCopiedAsync()
{
    Directory.CreateDirectory(Path.GetDirectoryName(DbPath)!);
    if (!File.Exists(DbPath))
    {
        using var s = await FileSystem.Current.OpenAppPackageFileAsync(DbFileName);
        using var o = File.Create(DbPath);
        await s.CopyToAsync(o);
        System.Diagnostics.Debug.WriteLine($"[DB] copied to {DbPath}");
    }
    else
    {
        System.Diagnostics.Debug.WriteLine($"[DB] exists at {DbPath}");
    }
}

private static async Task EnsureSchemaAndSeedAsync()
{
    using var con = new SQLiteConnection(DbPath);
    con.Execute("PRAGMA journal_mode=WAL;");
    con.Execute("PRAGMA synchronous=NORMAL;");

    // スキーマ作成(初回)
    con.Execute(@"
        CREATE TABLE IF NOT EXISTS VelocityCreditCards(
            Id INTEGER PRIMARY KEY AUTOINCREMENT,
            CreditCard TEXT NOT NULL,
            Own INTEGER NOT NULL,
            Priority INTEGER NOT NULL
        );
    ");

    // シード済みフラグ
    const string SeedFlag = "SeedV1";
    if (!Preferences.Get(SeedFlag, false))
    {
        // パッケージ同梱の CSV から最小限の初期データを投入(任意)
        if (await PackageHasAsync(CsvFileName))
        {
            var rows = await ReadCsvAsync(CsvFileName);
            con.BeginTransaction();
            try
            {
                con.InsertAll(rows.Select(r =&gt; new VelocityCreditCard
                {
                    CreditCard = r.CreditCard, Own = r.Own, Priority = r.Priority
                }));
                con.Commit();
                Preferences.Set(SeedFlag, true);
            }
            catch
            {
                con.Rollback(); throw;
            }
        }
    }

    // マイグレーション例
    var current = Preferences.Get("DbSchemaVersion", 1);
    if (current &lt; 2)
    {
        con.Execute("CREATE INDEX IF NOT EXISTS IX_VCC_Own ON VelocityCreditCards(Own);");
        Preferences.Set("DbSchemaVersion", 2);
    }
}

private static async Task&lt;bool&gt; PackageHasAsync(string assetName)
{
    try
    {
        using var _ = await FileSystem.Current.OpenAppPackageFileAsync(assetName);
        return true;
    }
    catch { return false; }
}

private static async Task&lt;List&lt;CardRow&gt;&gt; ReadCsvAsync(string assetName)
{
    var list = new List&lt;CardRow&gt;();
    using var s = await FileSystem.Current.OpenAppPackageFileAsync(assetName);
    using var r = new StreamReader(s, detectEncodingFromByteOrderMarks: true);
    string? line;
    while ((line = await r.ReadLineAsync()) != null)
    {
        if (string.IsNullOrWhiteSpace(line)) continue;
        var cols = line.Split(',');
        if (cols.Length &lt; 3) continue;
        list.Add(new CardRow(cols[0].Trim(), int.Parse(cols[1]), int.Parse(cols[2])));
    }
    return list;
}

public static SQLiteConnection Open()
{
    var con = new SQLiteConnection(DbPath);
    con.Execute("PRAGMA journal_mode=WAL;");
    con.Execute("PRAGMA synchronous=NORMAL;");
    return con;
}

public record CardRow(string CreditCard, int Own, int Priority);

public class VelocityCreditCard
{
    [PrimaryKey, AutoIncrement] public int Id { get; set; }
    public string CreditCard { get; set; } = "";
    public int Own { get; set; }
    public int Priority { get; set; }
}

} 

実装後の検証ポイント

  • Android 実機/エミュレーター、iOS 実機のいずれでも 初回起動 で DB がコピーされ、2 回目以降はコピーがスキップされる。
  • CSV からの取り込みは 1 回のみ行われ、Preferences のフラグで制御されている。
  • DB の場所は常に AppDataDirectory を参照し、相対パスや外部ストレージへは触れていない。
  • ファイル名の大文字小文字が一致し、MauiAsset 設定が正しい。

この設計で解決できる「課題1」「課題2」

課題1(置き場所): FileSystem.Current.AppDataDirectory に統一することで、OS 差や権限に悩まされず運用できます。配布は Resources/Raw + MauiAsset で完結。

課題2(初期データ): 事前作成 DB を推奨しつつ、必要であれば CSV からの構築も「初回のみ」「非同期」「トランザクション」で安全に。将来的な更新はマイグレーションで無停止適用。

最後に:チームで共有すべき “運用ルール” の雛形

  • DB/CSV はすべて Resources/Raw に置く。ビルドアクションは MauiAsset。
  • 実行時 DB は AppDataDirectory にだけ存在する。
  • 初回コピーは冪等・例外安全・非同期で実装する。
  • 更新はマイグレーションで行い、ユーザーデータは絶対に破棄しない。
  • ログには常に DB 実体パス・サイズ・スキーマバージョンを吐く。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次