跨平台框架层出不穷,但数据存储的底层需求始终没变:应用重启后数据不能丢,读写要快,结构要灵活。在.NET MAUI中,官方没有内置数据库访问层,开发者需要自己引入方案。SQLite凭借零配置、单文件、事务支持等特性,成了移动端本地存储的事实标准。不过,直接把SQLite塞进MAUI项目里,远不是添加一个NuGet包那么简单——版本兼容、线程安全、异步封装、依赖注入,每一环都有讲究。

插件选型:sqlite-net-pcl与Microsoft.Data.Sqlite的取舍
.NET MAUI生态里主流的SQLite访问方式有两种:sqlite-net-pcl(轻量ORM)和Microsoft.Data.Sqlite(官方ADO.NET提供程序)。sqlite-net-pcl的API非常直白,支持对象关系映射、自动建表、LINQ查询,适合中小型应用快速开发。Microsoft.Data.Sqlite则更贴近底层SQL,可以精细控制连接池、参数化查询和事务边界,适合对性能或SQL细节有严格要求的场景。
选型时有一个容易忽略的坑:sqlite-net-pcl在不同平台上的原生库依赖版本并不一致。如果你在Windows上编译通过,但部署到Android时出现DllNotFoundException,十有八九是SQLitePCLRaw.bundle_green没有正确传递到目标平台。建议在项目文件中显式添加SQLitePCLRaw.bundle_green引用,并确保所有平台的项目(包括MAUI的单项目结构中的条件项)都包含该包。另一个实践是直接锁定版本,避免通配符升级导致原生库ABI不匹配。
如果你的数据模型比较复杂,涉及多对多关系或复杂的联合查询,Microsoft.Data.Sqlite配合手写SQL往往更清晰。但大多数移动端应用的数据模型都很简单,sqlite-net-pcl的自动映射和异步扩展足以覆盖90%的需求。本文后面的示例将基于sqlite-net-pcl展开,因为它的集成成本低,代码可读性高。
在MAUI项目中搭建可注入的SQLite数据层
MAUI应用启动后,第一个要解决的是数据库文件路径。不同平台的路径规则不同:Android通常放在FileSystem.AppDataDirectory下,iOS的Documents目录也是合适位置。可以用MAUI自带的FileSystem.AppDataDirectory属性统一获取,它已经处理了平台差异。数据库连接不能频繁创建,应该以单例形式注册,让整个应用共享一个连接池。
依赖注入是让数据层可测试的关键。在MauiProgram.cs中注册SQLiteAsyncConnection时需要传入数据库路径,而路径本身依赖平台环境,所以建议注册一个工厂方法,而不是直接注册连接实例。示例如下:
using Microsoft.Extensions.Logging;
using SQLite;
namespace MyApp;
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.ConfigureFonts(fonts =>
{
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
});
// 注册数据库路径提供者
builder.Services.AddSingleton<IDatabasePathProvider, DatabasePathProvider>();
// 注册数据库连接工厂
builder.Services.AddSingleton<ISqliteConnectionFactory, SqliteConnectionFactory>();
// 注册数据仓储
builder.Services.AddSingleton<ITodoItemRepository, TodoItemRepository>();
#if DEBUG
builder.Logging.AddDebug();
#endif
return builder.Build();
}
}
上面的代码中,IDatabasePathProvider负责返回平台无关的数据库文件完整路径,ISqliteConnectionFactory负责创建SQLiteAsyncConnection实例并执行建表等初始化操作。这样仓储类(如TodoItemRepository)只依赖接口,单元测试时可以替换成内存数据库或模拟实现。
数据库表结构定义使用SQLite-net的特性标注,例如[PrimaryKey, AutoIncrement]、[Indexed]等。值得留意的是,SQLite-net的自动迁移能力有限,它只会在表不存在时创建表,不会自动添加新列。如果后续版本需要修改表结构,必须自己写迁移逻辑。简单做法是在连接初始化时检查PRAGMA user_version,根据版本号依次执行ALTER TABLE语句或重建表。跳过这一步的用户,升级版本后很可能会遇到“no such column”运行时异常。
异步操作与线程安全的实战细节
移动端UI线程不能执行长时间IO操作,SQLite读写必须放到后台线程。SQLiteAsyncConnection内部使用Task.Run包装了同步调用,所以直接await异步方法就可以了。但要注意连接对象的生命周期:SQLiteAsyncConnection本身是线程安全的,可以跨线程并发调用,底层使用了锁序列化操作。因此没有必要为每个操作创建新连接,维持一个全局单例即可。
批量插入或更新时,使用RunInTransactionAsync会带来数量级的性能提升。如果不包裹事务,每一条INSERT都会触发一次磁盘同步,插入1000条记录可能要几秒;包裹在事务里则只需几十毫秒。示例:
await database.RunInTransactionAsync(async tranConnection =>
{
foreach (var item in itemsToInsert)
{
await tranConnection.InsertAsync(item);
}
});
另一个常见的坑是数据库中DateTime类型的存储。SQLite没有原生日期时间类型,sqlite-net默认将DateTime存储为ticks(长整型),读取时自动还原,这对大多数应用没问题。但如果你需要和其他系统交换数据,或希望数据库文件可以直接用SQLite浏览器查看可读日期,建议在模型属性上使用[StoreAsText]特性,或者干脆用long自己转换。注意StoreAsText只影响sqlite-net的序列化,不会更改SQLite底层存储格式。
异步初始化也是一个容易忽视的问题:如果数据库文件很大,首次创建表并执行索引构建可能耗时。App启动时如果同步等待数据库就绪,会拖慢启动速度。推荐在App的OnStart或首屏ViewModel中触发一次异步初始化,期间显示加载状态,完成后才允许用户操作数据。
数据库版本迁移:避免升级即损坏
应用发布后,数据库结构不可能永远不变。增加字段、修改索引、拆分表都是常见需求。sqlite-net没有内置迁移框架,所以需要自己实现一套轻量级迁移机制。核心思想是维护一个版本号,升级时按顺序执行从旧版本到新版本的迁移脚本。
实现方式可以在ISqliteConnectionFactory的创建方法中完成。首先检查SQLite的PRAGMA user_version,这是一个整数,可以存任意值。假设当前代码期望版本为3,实际数据库版本为1,则依次执行版本1到2、2到3的迁移函数。迁移操作包在事务里,任何一步失败都回滚,避免半迁移状态。下面是一个简化版迁移框架:
public async Task InitializeAsync(SQLiteAsyncConnection database)
{
int currentVersion = await database.ExecuteScalarAsync<int>("PRAGMA user_version");
const int targetVersion = 3;
if (currentVersion >= targetVersion)
return;
await database.RunInTransactionAsync(async tran =>
{
if (currentVersion < 1)
{
await tran.ExecuteAsync("CREATE TABLE IF NOT EXISTS TodoItem (Id INTEGER PRIMARY KEY AUTOINCREMENT, Title TEXT NOT NULL, IsDone INTEGER NOT NULL DEFAULT 0)");
currentVersion = 1;
}
if (currentVersion < 2)
{
await tran.ExecuteAsync("ALTER TABLE TodoItem ADD COLUMN DueDate TEXT");
currentVersion = 2;
}
if (currentVersion < 3)
{
await tran.ExecuteAsync("CREATE INDEX idx_todo_title ON TodoItem(Title)");
currentVersion = 3;
}
await tran.ExecuteAsync($"PRAGMA user_version = {targetVersion}");
});
}
这个例子展示的迁移是累加的,每次升级只执行必要的差异步骤。如果迁移涉及数据转换(比如把旧表数据导入新表),需要在事务内复制数据、删除旧表、重命名新表,过程要谨慎处理外键和索引。另外,一定要在应用发布前对迁移逻辑做充分测试,用旧版本数据库文件验证升级路径。
对于更复杂的场景,也可以考虑引入第三方迁移库如SQLiteMigrationHelper,但自己实现几十行代码就足够应对大多数MAUI应用的版本演进。
总结与可维护性建议
SQLite在.NET MAUI中的集成,核心不是“能否连接上”,而是“如何让数据层经得起时间考验”。通过依赖注入隔离平台路径、用工厂方法管理连接生命周期、用事务提升批量性能、用手动迁移守卫表结构演进,这些实践能把一个简单的数据库集成提升到生产级标准。当你开始为下一个功能添加新表时,已有的架构会让你避免重复踩坑。
最后提醒两点:第一,不要在生产环境中打开SQLite的日志模式为WAL后又随意复制数据库文件,WAL模式下的数据可能分布在-wal和-shm文件中,直接复制主文件可能丢失最近事务。第二,定期使用PRAGMA integrity_check检查数据库完整性,尤其在应用崩溃恢复后。这些细节虽小,却能在关键时刻保护用户数据。