介绍
构建移动应用程序时,出于性能原因,通常会将数据本地存储在设备上。.NET MAUI 使您能够在运行应用程序的设备上本地缓存数据。您可以使用几种不同的技术来存储此数据。要存储关系数据,可以使用 SQLite 数据库。
假设您正在构建一个社交媒体应用程序,该应用程序使用户能够相互连接。由于有关用户的数据是远程存储的,因此每次重新启动应用程序时,都必须调用 REST 终结点来获取有关所连接用户的信息。
此设计有效。但是,它效率低下,因为每次重新启动应用程序时都必须从服务器下载用户信息。
设计更高效的应用只需下载一次用户数据,然后将其本地保存到设备中。使用此设计,当您想要建立新连接时,只需为其他用户下载信息。您可以使用本地数据库创建这种更高效的设计。
在本模块中,您将构建一个 .NET MAUI 应用程序,该应用程序将数据本地存储在数据库中。您将首先探索可用的不同数据存储选项。
接下来,您将查看 SQLite 并学习如何插入和检索数据。最后,您将使用 SQLite 中提供的异步方法与数据库进行交互,而不会阻止 UI。
在本模块结束时,您将能够构建一个 .NET MAUI 应用程序,该应用程序将数据本地存储在 SQLite 数据库中。
学习目标
在本模块中,您将学习如何:
- 比较可用于 .NET MAUI 应用程序的不同数据存储选项
- 将关系数据存储在 SQLite 数据库中
- 与数据库异步交互,以确保 UI 保持响应
先决条件
- 安装了 .NET MAUI 工作负载的 Visual Studio 2022
- 熟悉 C# 和 .NET
- SQL和关系数据库的基础知识
比较存储选项
.NET MAUI 提供了多个存储选项,用于在设备上本地缓存数据,具体取决于数据的性质、结构和大小。在 .NET MAUI 应用中本地存储数据的三个最常用选项是:
- 首选项:将数据存储在键值对中
- 文件系统:通过文件系统访问将松散的文件直接存储在设备上
- 数据库:将数据存储在关系数据库中

在本单元中,您将了解这些存储选项以及每个选项最适合的情况。
何时使用首选项
当您处理简单的数据片段(如用户选择)时,首选项非常方便。它们通常用于使用户能够配置应用程序。将此数据存储为一组键/值对。例如,假设你希望用户能够指定应用是否应在会话之间保存其用户名和密码。您可以在“首选项”中存储用户的选择。
下面的代码显示了如何将保存在 saveLoginDetails 变量中的布尔值存储到名为 SaveLogin 的首选项中,然后稍后读回此数据。请注意,如果找不到指定的首选项,该方法要求您提供默认值:Get
bool saveLoginDetails = ...;
...
Preferences.Set("SaveLogin", saveLoginDetails);
...
var savedPreference = Preferences.Get("SaveLogin", false);
该类还包含用于确定命名首选项是否存在 ()、删除首选项 () 和删除所有首选项数据 () 的方法。PreferencesContainsKeyRemoveClear
备注
您只应将简单数据类型存储为首选项。不能存储对大型对象(如列表、集合和数组)的引用。对于此类型的数据,请使用文件系统或数据库。
何时使用文件系统
移动和桌面设备具有具有文件夹和文件的分层目录结构的文件系统。当您有松散的文件(如 XML、二进制文件或文本文件)时,使用文件系统非常方便。例如,假设您要在设备上本地存储日志数据。您可以创建一个文本文件,将此文件保存到文件系统,并在事件发生时向其写入日志。还可以将大型数据结构序列化为文件,如果需要在应用关闭时保存,则将其本地缓存在文件系统上。当应用程序重新启动时,您可以将此数据重新读取回内存。下面的代码演示将数据序列化到文件并保存此文件,然后读回数据并在以后将其反序列化回内存的示例。使用的序列化格式是 JSON,但你可以将数据保存为你认为最适合数据性质和应用安全要求的任何格式:
using System.Text.Json;
using System.IO;
// Data to be written to the file system, and read back later
List<Customer> customers = ...;
// Serialize and save
string fileName = ...;
var serializedData = JsonSerializer.Serialize(customers);
File.WriteAllText(fileName, serializedData);
...
// Read and deserialize
var rawData = File.ReadAllText(fileName);
customers = JsonSerializer.Deserialize<List<Customer>>(rawData);
访问应用程序沙盒
当您使用松散文件(如 XML 文件)时,需要将它们存储在文件系统中的合适位置。其中一些数据可能是敏感的,你不希望将其保存到其他应用或用户可以轻松访问的位置。.NET MAUI 应用程序提供应用程序沙盒。应用程序沙盒是应用程序可以使用的专用区域。默认情况下,除操作系统外,其他任何应用程序都无法访问此区域。您可以使用类的静态属性访问沙盒:AppDataDirectoryFileSystem
string path = FileSystem.AppDataDirectory;
在此代码中,path 变量包含指向可存储供应用程序使用的文件的位置的文件路径。您可以使用如何使用文件系统一节中所示的技术在此文件夹中的文件读取和写入数据。
备注
该属性是特定于设备的路径的抽象;它评估到Android,iOS和WinUI3上的不同文件夹。此抽象使您能够编写以独立于沙盒运行平台的方式引用沙盒的代码。优先使用此抽象,而不是在代码中显式引用特定于设备的路径。
FileSystem.AppDataDirectory
在 iOS 应用中保存数据的指南
Apple 有 iOS 指南,用于存储文件的位置。有两个主要文件夹位置:
“库”文件夹。此文件夹由属性返回,如上所述。在存储应用生成的数据时使用“资源库”文件夹。FileSystem.AppDataDirectory
“文档”文件夹。下面的代码段显示了如何在 docFolder 变量中引用此文件夹。使用“文档”文件夹仅存储用户生成的数据。(此数据是在直接响应用户操作的情况下创建的)。例如,如果要创建文本编辑应用程序(如 Microsoft Word),则应将文档存储在“文档”文件夹中。
string docFolder = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
何时使用数据库
当您在数据之间存在关系时,或者当您想要在一段时间内筛选数据时,最好使用本地数据库。例如,在社交媒体示例中,每个帖子都包含有关帖子的数据,例如时间戳和内容。但是,每个帖子也与发布该帖子的用户有关系。在数据库中表示这种关系是有意义的,以防止帖子之间的数据重复,并提高搜索数据的效率。
SQLite 数据库是一个文件,您需要将其存储在适当的位置。理想情况下,您应该在沙盒中的文件夹下创建一个文件夹,并在此文件夹中创建数据库。AppDataDirectory
使用 SQLite 在本地存储数据
SQLite 在具有关系数据时很有用。假设您正在构建一个社交媒体应用程序。您需要存储有关应用程序订阅者的信息。此数据包括每个用户的唯一 ID 及其名称。这种关系可以在SQLite数据库中轻松建模。
在本单元中,您将学习如何通过使用 SQLite-net 在 .NET MAUI 应用程序中使用 SQLite。
什么是 SQLite?
SQLite是一个轻量级的跨平台本地数据库,已成为移动应用程序的行业标准。SQLite不需要服务器。数据库存储在设备文件系统上的单个磁盘文件中。所有读取和写入操作都直接针对 SQLite 磁盘文件运行。
默认情况下,SQLite原生库内置于Android和iOS中;但是,该引擎仅支持 C/C++ API。此方案对于 .NET 开发人员来说并不理想,他们希望 SQLite 和 .NET 以某种方式进行交互。
什么是SQLite-net?
本机 SQLite 引擎周围有几个 C# 包装器可供 .NET 开发人员使用。许多 .NET 开发人员使用一种名为 SQLite-net 的流行 C# 包装器。
SQLite-net 是一个对象关系映射器。它使您能够使用项目中定义的模型作为架构,从而帮助简化定义数据库架构的过程。

例如,请考虑以下对 User 进行建模的类:
class User
{
public int Id { get; set; }
public string Username { get; set; }
...
}
通过使用对象关系映射器,可以采用此初始 User 类并创建一个名为 User 的数据库表,该表具有此类中 Id 和 Username 字段的列。
SQLite-net 作为 NuGet 包提供。必须将 sqlite-net-pcl 程序包添加到应用才能使用它。在 Visual Studio 中使用 NuGet 包管理器。此外,如果要在 Android 上运行应用,还必须添加SQLitePCLRaw.provider.dynamic_cdecl包。
如何连接到 SQLite 数据库
通过 SQLite 连接对象从应用程序建立与 SQLite 数据库的连接。此类与 SQLite 提供的其他类型和方法一起在 SQLite 命名空间中定义。实例化此对象时,将传入数据库文件的文件名。然后,构造函数将打开该文件(如果存在),或者如果它不存在,则创建它。
以下代码显示了一个示例:
using SQLite;
...
string filename = ...
SQLiteConnection conn = new SQLiteConnection(filename);
请记住,文件名应指向应用沙盒中的某个位置。
如何创建表
回想一下,SQLite-net 是一个对象关系映射器,这意味着您可以从 C# 类构建数据库架构。SQLite-net 可以从普通的 C# 类生成数据库表,但可以向类中添加许多属性以提供其他元数据。此元数据可帮助 SQLite 强制实施唯一性等功能,并对数据应用约束。
可用的属性包括:
- 表:如果希望表不是类的名称,请指定表的名称。
- 主键:指定列是主键。
- 自动增量:指定在插入新行时,列的值应自动增加。
- 列:如果希望列不是属性名称,请指定列的名称。
- 最大长度:指定列中可以使用的最大字符数。
- 唯一:指定列中的值必须与所有其他行唯一。
下面的代码演示应用这些属性的 User 类的更新版本:
[Table("user")]
public class User
{
// PrimaryKey is typically numeric
[PrimaryKey, AutoIncrement, Column("_id")]
public int Id { get; set; }
[MaxLength(250), Unique]
public string Username { get; set; }
...
}
定义 C# 类后,调用 SQLiteConnection 类上的 CreateTable 泛型方法以在数据库中生成表。将类指定为类型参数。下面是一个示例:
SQLiteConnection conn = new SQLiteConnection(filename);
conn.CreateTable<User>();
如果数据库中已存在该表,则 CreateTable 方法将检查架构以查看是否有任何更改。如果有,该操作将尝试更新数据库架构。
如何执行基本的读写操作
创建表后,可以开始与其交互。若要添加行,请在 SQLiteConnection 实例上使用 Insert 方法,并提供保存要插入的数据的相应类型的对象。下面的代码演示如何向 User 表中添加新行:
public int AddNewUser(User user)
{
int result = conn.Insert(user);
return result;
}
Insert 方法返回一个 ,该值表示插入到表中的行数。在本例中,该数字为 1。int
若要从表中检索行,请使用 Table 方法。此方法返回对象的集合(可能为空):
public List<User> GetAllUsers()
{
List<User> users = conn.Table<User>().ToList();
return users;
}
Table 方法返回一个 TableQuery<T> 对象。若要获取列表,请使用 ToList 方法,如前面的示例所示。
使用 LINQ 执行 SQLite 查询
Table 方法从表中检索所有行。在大多数情况下,您只想返回与一组指定条件匹配的行的子集。对于这些任务,请将 LINQ 与 SQLite-net 结合使用。
SQLite-net 支持许多常见的 LINQ 查询,包括:
- 哪里
- 拿
- 跳
- OrderBy
- OrderByDescending
- 然后由
- 元素在
- 第一
- 第一或缺省
- 然后由卧铺
- 计数
使用这些方法,可以使用扩展方法语法或 LINQ C# 语法。例如,下面是一段代码,可用于检索指定用户的详细信息:
public User GetByUsername(string username)
{
var user = from u in conn.Table<User>()
where u.Username == username
select u;
return user.FirstOrDefault();
}
更新和删除行
使用 SQLiteConnection 对象的 Update 方法更新行。提供一个对象,用于定义要使用其新值更新的行。Update 方法修改与提供的对象具有相同主键值的行。返回的值是更改的行数。如果此值为零,则未找到具有匹配主键的行,并且未进行任何更新。下一个代码段显示了此方法的实际效果:
public int UpdateUser(User user)
{
int result = 0;
result = conn.Update(user);
return result;
}
使用 SQLiteConnection 对象的 Delete 方法从表中删除行。此方法的最简单形式采用要删除的项的主键作为参数,如下所示。这种形式的 Delete 方法是泛型的,它需要一个类型参数。返回的值是从表中删除的行数:
public int DeleteUser(int userID)
{
int result = 0;
result = conn.Delete<User>(userID);
return result;
}
练习:使用 SQLite 在本地存储数据
在本练习中,您将使用 SQLite 通过应用程序在本地存储信息。在示例方案中,你决定缓存社交媒体应用的数据以提高响应能力。本练习创建并使用本地 SQLite 数据库来存储有关人员的信息。您将物理数据库文件保存在本地存储中。
打开入门级解决方案
克隆或下载锻炼存储库。
备注
最好将练习内容克隆或下载到较短的文件夹路径(如 C:\dev),以避免生成生成的文件超过最大路径长度。
使用 Visual Studio 打开“人员.sln解决方案。
备注
不要尝试构建解决方案。代码不完整,在本练习后面添加缺少的元素之前不会编译。
定义 SQLite 实体
将名为“模型”的新文件夹添加到“人员”项目。
在“模型”文件夹中,创建一个名为 Person 的新类。
修改该类并将其标记为 。public
namespace People.Models
{
public class Person
{
}
}
将名为 Id 的属性添加到 Person 类。int
添加一个名为 Name 的属性。该类应如下所示:string
namespace People.Models
{
public class Person
{
public int Id { get; set; }
public string Name { get; set; }
}
}
添加 SQLite 库
右键单击 Visual Studio 中“解决方案资源管理器”中的“人员”项目节点。
在显示的上下文菜单中,选择“管理 NuGet 包”。
Search for sqlite-net-pcl.然后选择“安装”。

此外,通过在 NuGet 中搜索SQLitePCLRaw.provider.dynamic_cdecl并单击“安装”来安装它。
添加 SQLite 属性
将 SQLite 命名空间的指令添加到 Person 类的文件中。此指令使您能够使用 SQLite 属性。using
using SQLite;
namespace People.Models
{
public class Person
{
...
}
}
使用 [Table] 属性批注 Person 类,并将表名指定为人员。
将 Id 属性指定为主键。使用 [PrimaryKey] 和 [AutoIncrement] 属性对其进行批注。
向 Name 属性添加批注。将其最大长度指定为 250。指定列中的每个值都应为 Unique。完成的类应如下所示:
using SQLite;
namespace People.Models
{
[Table("people")]
public class Person
{
[PrimaryKey, AutoIncrement]
public int Id { get; set; }
[MaxLength(250), Unique]
public string Name { get; set; }
}
}
连接到数据库
打开“人员存储库.cs文件。
检查人员存储库类。此类包含带有标记的不完整框架代码,您将在其中添加访问数据库的功能。TODO
将 SQLite 和 People.Models 命名空间的指令添加到 PersonRepository.cs 类的文件中。using
将一个名为 conn 的私有 SQLiteConnection 字段添加到函数上方的类中。Init
在函数中,检查 conn 是否不等于 null。如果是这样,请立即返回。Init
if (conn != null)
return;
这样,SQLite 数据库的初始化代码将仅运行一次。
初始化 conn 字段以使用变量连接到数据库。_dbPath
使用连接器。CreateTable 方法,用于创建用于存储人员数据的表。完成的函数应如下所示:Init
using SQLite;
using People.Models;
...
private SQLiteConnection conn;
...
private Init()
{
if (conn != null)
return;
conn = new SQLiteConnection(_dbPath);
conn.CreateTable<Person>();
}
在数据库中插入一行
在 PersonRepository 类中,找到 AddNewPerson 方法。
将此方法中的 TODO 注释替换为插入新的 Person 对象的代码。第一件事是调用以验证数据库是否已初始化。然后使用 SQLiteConnection 对象的 Insert 方法。将结果变量设置为 Insert 方法返回的值,如下面的代码所示。Init
public void AddNewPerson(string name)
{
int result = 0;
try
{
// enter this line
Init();
// basic validation to ensure a name was entered
if (string.IsNullOrEmpty(name))
throw new Exception("Valid name required");
// enter this line
result = conn.Insert(new Person { Name = name });
...
}
...
}
从数据库中检索行
在 PersonRepository 类中,找到 GetAllPeople 方法。
调用以验证数据库是否已初始化。Init
使用泛型 Table<T> 方法检索表中的所有行。指定人员作为类型参数。
使用 ToList() 扩展方法将结果转换为 List<Person>集合并返回此集合。
通过将代码包装在块中来添加错误处理。如果存在错误,请将 StatusMessage 属性设置为异常的 Message 属性,并返回一个空集合。完成的方法应如下所示:try-catch
public List<Person> GetAllPeople()
{
try
{
Init();
return conn.Table<Person>().ToList();
}
catch (Exception ex)
{
StatusMessage = string.Format("Failed to retrieve data. {0}", ex.Message);
}
return new List<Person>();
}
将存储库集成到 UI 中
打开茂宜岛程序.cs文件。
在 CreateMauiApp 函数中,在将主页页面作为单一实例服务添加到应用的语句之后,添加代码以执行以下任务:
创建一个名为 dbPath 的字符串变量。使用表达式 初始化此字符串。应用使用的数据库文件将称为 people.db3,应用会将此文件保存在设备上的本地存储中。FileAccessHelper.GetLocalFilePath("people.db3")
使用依赖关系注入将类作为单一实例服务添加到应用。该类公开一个构造函数,该构造函数将数据库文件的路径作为字符串参数。PersonRepositoryPersonRepository
CreateMauiApp 函数的完整代码应如下所示:
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");
});
// Add this code
string dbPath = FileAccessHelper.GetLocalFilePath("people.db3");
builder.Services.AddSingleton<PersonRepository>(s => ActivatorUtilities.CreateInstance<PersonRepository>(s, dbPath));
return builder.Build();
}
打开 App.xaml.cs 文件。
添加名为 PersonRepo 的属性,以将 PersonRepository 对象保存到 App 类。public
static
通过向构造函数添加 PersonRepository 参数,并将 PersonRepo 属性设置为此参数中的值,初始化构造函数中的 PersonRepo 属性。完成的 App 类应如下所示:
public partial class App : Application
{
public static PersonRepository PersonRepo { get; private set; }
public App(PersonRepository repo)
{
InitializeComponent();
MainPage = new AppShell();
PersonRepo = repo;
}
}
备注
构造函数的 repo 参数将由依赖项注入过程自动填充。
测试应用程序
生成解决方案并开始调试。当 UI 出现时,输入您的姓名,然后点按添加人员。

轻点“获取所有人员”,然后验证您的姓名是否显示。

通过添加更多名称并检索存储人员列表进行试验。
返回到 Visual Studio 并停止调试。
重新启动应用,然后轻点“获取所有用户”。验证以前存储的名称是否仍存储在数据库中。
异步使用 SQLite
如果以同步方式对数据库运行查询,则可能会导致性能问题和应用程序无响应。SQLite-net 具有异步 API,可用于保持应用程序的响应速度。
在本单元中,您将学习如何使用 SQLite-net 异步 API 来确保您的应用程序保持高度响应。
了解异步查询
到目前为止,您所做的一切都已在 UI 线程上执行。但是,要构建响应速度快的移动应用程序,您需要以不同的方式执行操作。如果在 UI 线程上运行数据库操作,则可能会导致 UI 冻结(如果操作需要很长时间才能完成)。
为了解决这个问题,SQLite-net通过SQLiteAsyncConnection类包含了一个异步API。例如,若要异步创建表,可以使用 CreateTableAsync 方法:
var conn = new SQLiteAsyncConnection(dbPath);
await conn.CreateTableAsync<User>();
使用 SQLite-net 执行异步操作
类公开与同步对应项类似的操作。但是,这些操作都是基于任务的,以供后台使用。
可用的常见异步操作包括:
- CreateTableAsync:基于指示的类创建表
- DropTableAsync:删除与指示的类相关的表
- GetAsync:获取表中与指示的类相关的记录,并与传递到构造函数中的主键匹配
- 插入异步:使用传递到构造函数中的项插入新记录
- 更新异步:使用传递到构造函数中的项更新现有记录
- DeleteAsync:删除表中映射到指示类的记录,并与传递到构造函数中的主键匹配
- 查询异步:运行直接 SQL 查询并返回对象
- 执行异步:运行直接 SQL 查询并返回受影响的行数。
- ExecuteScalarAsync:运行直接 SQL 查询并返回单个结果
- ToListAsync:异步执行 Table 方法
下面的代码显示了如何使用 ToListAsync 方法异步检索记录的示例:
SQLiteAsyncConnection conn;
ObservableCollection<User> userList; // Bound to UI
...
public async Task AddAllUsersAsync()
{
List<User> users = await conn.Table<User>().ToListAsync();
// Must be on UI thread here!
foreach (var u in users)
userList.Add(u);
}
在此示例中,ToListAsync 方法以异步方式从数据库中提取所有用户。如果使用此方法,则即使数据库中有大量用户,UI 也会保持响应状态。
练习:异步使用 SQLite
应用程序运行良好,但如果数据库包含许多行,则当应用执行数据库查询和其他操作时,UI 可能会变得无响应。在本练习中,您将应用程序从同步 SQLite API 转换为异步版本。这样,无论您对数据库进行多少次查询,您的应用程序都将始终具有响应能力。
创建异步连接
在人员项目中打开人员存储库.cs文件。
将 Init 方法的定义修改为 。将方法的返回类型更改为 。async
Task
将 conn 属性更改为 SQLiteAsyncConnection。并更新初始化连接的 Init 方法中的代码。
将对同步 CreateTable 方法的调用替换为异步 CreateTableAsync 方法。完成的代码应如下所示:
private SQLiteAsyncConnection conn;
private async Task Init()
{
if (conn != null)
return;
conn = new SQLiteAsyncConnection(_dbPath);
await conn.CreateTableAsync<Person>();
}
将项异步插入到表中
将 AddNewPerson 方法的定义修改为 。将方法的返回类型更改为 。async
Task
将关键字添加到 Init 方法调用中,因为 Init 现在是一个方法。await
async
更新 AddNewPerson 方法以使用异步插入操作插入新的人员。
using System.Threading.Tasks;
...
public async Task AddNewPerson(string name)
{
int result = 0;
try
{
// Call Init()
await Init();
// basic validation to ensure a name was entered
if (string.IsNullOrEmpty(name))
throw new Exception("Valid name required");
result = await conn.InsertAsync(new Person { Name = name });
StatusMessage = string.Format("{0} record(s) added [Name: {1})", result, name);
}
catch (Exception ex)
{
StatusMessage = string.Format("Failed to add {0}. Error: {1}", name, ex.Message);
}
}
异步获取表中的所有项目
修改 GetAllPeople 的定义。此方法应返回一个对象。async
Task<List<Person>>
将关键字添加到 Init 方法调用中。await
更新方法以使用异步调用返回结果。
public async Task<List<Person>> GetAllPeople()
{
try
{
await Init();
return await conn.Table<Person>().ToListAsync();
}
catch (Exception ex)
{
StatusMessage = string.Format("Failed to retrieve data. {0}", ex.Message);
}
return new List<Person>();
}
测试异步功能
在 MainPage.xaml.cs 文件中,修改两个按钮单击事件处理程序,以使用 PersonRepository 类中的异步方法。使用 和 关键字:asyncawait
public async void OnNewButtonClicked(object sender, EventArgs args)
{
statusMessage.Text = "";
await App.PersonRepo.AddNewPerson(newPerson.Text);
statusMessage.Text = App.PersonRepo.StatusMessage;
}
public async void OnGetButtonClicked(object sender, EventArgs args)
{
statusMessage.Text = "";
List<Person> people = await App.PersonRepo.GetAllPeople();
peopleList.ItemsSource = people;
}
在Windows和Android上构建并运行该程序,以验证它是否仍像以前一样运行。
总结
在移动设备上本地存储数据对于提高性能非常有用。无需不断调用远程服务器来获取数据,而是可以将重要数据存储在本地并快速检索。
根据您拥有的数据类型,有不同的可用存储选项。当您使用本质上是关系数据时,数据库是最佳选择。
您可以使用 SQLite 在 .NET MAUI 应用程序中创建本地数据库。SQLite-net是围绕SQLite的C#包装器。SQLite 公开了一个异步 API,以帮助确保应用程序的 UI 始终保持响应。
在本模块中,你了解了如何在 .NET MAUI 应用中本地存储数据。具体而言,您了解了:
- 可用于 .NET MAUI 应用程序的不同数据存储选项
- 将关系数据存储在 SQLite 数据库中
- 与数据库异步交互以确保 UI 保持响应