news 2026/10/21 20:52:11

WinForms+SQLite+EF Core桌面应用开发实战:从建表迁移到避坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
WinForms+SQLite+EF Core桌面应用开发实战:从建表迁移到避坑指南

简介:一套基于 .NET Framework 4.8 的 WinForms 桌面程序示例,演示使用 SQLite 数据库与 EntityFramework ORM 框架完成数据访问,适合希望掌握桌面端轻量级数据库开发及 EF 分层用法的初学者,尤其适合开发小型管理工具或本地数据录入程序。主界面用 ListView 展示表中数据,配合“添加”“删除”按钮调用 EF 仓储结构进行存储与删除,连接串统一放在 App.config 的 connectionStrings 中,便于理解配置与代码分离。资源共 183 个文件,压缩包约 35.51MB,主要为 13 个 C# 源文件、工程与解决方案文件、SQLite 数据库文件 sqlite.db3,以及 40 个运行所需 DLL 和大量 NuGet、配置、构建辅助文件,目录分类清晰,可还原项目直接参考。已有 601 人学习下载。通过该资源能完整走通 WinForms + SQLite + EntityFramework 的搭建、配置、增删查改与首次访问暖机(GetItemCollection)优化,入口处的暖机操作可避免首读数据库过慢,数据库越复杂感知越明显;同时学会用 SQLite Expert Personal 查看 db3 文件,对桌面项目实战很有帮助。

1. 把 SQLite 塞进 WinForms:EF Core 是这组技术最顺手的黏合剂

做内部工具和桌面小应用时,我最后常停在 WinForms + SQLite 数据库 + Entity Framework 这套组合上。WinForms 拖控件很快,SQLite 一个文件就能把数据存下来,用户不用装数据库服务;而 Entity Framework 这类 ORM 把建表、增删改查变成 C# 对象操作,不用自己拼 SQL。这篇文章从一个空项目开始,逐步接入 EF Core 和 SQLite,完成建表、迁移、CRUD、DataGridView 绑定,再把发布期的坑挑明。适合给公司写小工具、做数据采集、或暂时不想上数据库服务的桌面场景。新手按步骤能跑通,熟手能直接看参数和踩坑段。

2. 先跑通最小骨架:WinForms + SQLite + EF Core 的搭建顺序

很多人把 ORM 想复杂了。其实第一步不是写代码,而是想清楚:你这个 WinForms 项目用什么 .NET 版本。如果目标是 .NET Framework 4.x,那通常选 EF6 搭配 SQLite 的第三方驱动;如果是 .NET 5 以上的现代 WinForms,标准答案就是 EF Core 加官方 SQLite 包。我下面按 EF Core 来讲,这也是新项目最常见的选择。

2.1 项目与依赖清单:用 NuGet 装齐这几样就够了

在命令行里操作,省略了 IDE 点击过程,逻辑一样。先建一个空的 WinForms 项目,再引入三个包:

dotnet new winforms -n LocalTool cd LocalTool dotnet add package Microsoft.EntityFrameworkCore.Sqlite dotnet add package Microsoft.EntityFrameworkCore.Design dotnet add package Microsoft.EntityFrameworkCore.Tools

第一条命令创建 WinForms 项目,目标框架默认是当前安装的 .NET SDK 的最新 LTS。你如果习惯用 Visual Studio,新建项目时选 Windows Forms 应用,然后在包管理器控制台里执行Install-Package Microsoft.EntityFrameworkCore.Sqlite加上后面两个包,效果一致。

三个包各司其职:Sqlite 包是 EF Core 查询 SQLite 的驱动,Design 包提供迁移设计时支持,Tools 包让Add-Migration这种 PowerShell 命令在包管理器控制台里可用。如果只装 Sqlite 包,后续迁移命令会提示你补 Design 或 Tools,所以一次装齐最省事。

这里有一个选型细节:不要顺手装System.Data.SQLite。它确实能用,但和 EF Core 配合需要额外的DbProviderFactory注册,容易出幺蛾子。EF Core 的官方 SQLite 包内部用的是Microsoft.Data.Sqlite,你不需要直接引用它,用它提供的 API 就够了。

装完之后,项目文件里会出现三个包引用。如果你是长期维护的项目,把包版本锁定到同一个大版本。比如 .NET 8 配 EF Core 8.x,这个组合经过大量项目验证。版本不匹配最常见的问题是第一行options.UseSqlite报扩展方法找不到,那种情况下先检查 NuGet 包版本是不是和Microsoft.EntityFrameworkCore主版本一致。

2.2 实体类与 DbContext:先想好表结构再动手

既然要做 ORM,表的结构由 C# 类的属性决定。先拿一个简单的任务管理例子讲,后面所有界面操作都基于它。这是一个实体类:

public class TaskItem { public int Id { get; set; } public string Title { get; set; } = ""; public DateTime CreatedAt { get; set; } public bool IsCompleted { get; set; } }

Id 会默认成为主键,EF Core 约定:字段名为Id或类名+Id时,自动配置为自增主键。SQLite 的自增行为和 SQL Server 不一样,EF Core 生成的是INTEGER PRIMARY KEY AUTOINCREMENT,所以不需要手工指定。

然后是 DbContext。它是 ORM 的入口,负责连接数据库、管理实体状态、生成 SQL。最小写法:

using Microsoft.EntityFrameworkCore; public class AppDbContext : DbContext { private readonly string _connectionString; // 默认连接串:开发阶段用相对路径 public AppDbContext() : this("Data Source=app.db") { } // 运行时传入完整路径 public AppDbContext(string connectionString) { _connectionString = connectionString; } public DbSet<TaskItem> Tasks { get; set; } protected override void OnConfiguring(DbContextOptionsBuilder options) { options.UseSqlite(_connectionString); } }

DbSet<TaskItem>表示哪张表对应哪个实体类,名字 Tasks 会作为表名。如果你不喜欢复数,可以用数据标注[Table("Task")]或者 Fluent API 改。OnConfiguring在每次创建上下文时调用,在这里指定连接串。

在写实体之前,建议先想清楚字段是否允许空。Title我给了默认空字符串,这样万一用户没填内容写库时不会出现非空约束错误。DateTime和bool在 SQLite 里的存储细节,我在第 4 章会展开。这里先记住:实体属性类型尽量贴近界面业务,不要一开始就用DateTime?做一堆可空判断。

关于 DbContext 的生命周期,这是 WinForms 和 Web 项目最大的不同。Web 里一个请求一个上下文,很短;WinForms 如果图方便在构造里new AppDbContext(),整个窗体都共用,就会遇到 EF Core 的跟踪缓存问题。比如你在查询里用了AsNoTracking,又手动Update,状态就很乱。我一般坚持在每一个按钮事件里using var db = new AppDbContext(),用完就丢。SQLite 打开连接很便宜,所以这种“短上下文”模式不会拖慢界面,反而让问题更容易排查。

2.3 迁移闭环:Code First 生成数据库文件

实体和上下文写完后,打开包管理器控制台,确保默认项目是当前 WinForms 项目,然后执行:

Add-Migration InitialCreate Update-Database

如果用的是命令行工具,对应的是:

dotnet ef migrations add InitialCreate dotnet ef database update

执行成功的标志是:Migrations 文件夹下出现一个时间戳命名的新类,项目输出目录里多了一个app.db文件。这个文件正是 SQLite 数据库本体,你可以用 SQLite 查看工具打开,里面已经有一张 Tasks 表。

Add-Migration会扫描当前上下文,生成当前模型和上一次模型之间的差异。第一次执行,差异就是全部建表。Update-Database则把迁移应用到数据库。如果目标数据库文件不存在,SQLite 驱动会顺手创建,所以不需要手工建库。

这里有一个新手最容易忽略的环节:迁移生成的 SQL 只执行一次,后续如果你改了实体类,必须再加新迁移。我发现有人改了TaskItem.Title的长度,然后直接跑旧 exe,结果数据库里还是旧结构,程序一查询就报错。改模型后记住三步:改类、加迁移、更新数据库。

关于连接串位置:上面写的是app.db,这是相对路径。EF Core 在执行迁移时,会把数据库生成在命令行当前工作目录下。运行时 WinForms 的当前工作目录大概率不是同一个地方,这也是大量“运行时找不到表”的根源。第 5 章第一个坑我会专门讲。现在先记住,后面统一用绝对路径替代相对路径。

3. 把数据搬到界面上:增删改查与 DataGridView 绑定的正确姿势

骨架搭好,开始让界面动起来。这一章所有代码都围绕一个场景:窗口顶部有输入框,中间是 DataGridView,右侧有添加、保存、删除按钮。我们逐个按钮处理。

3.1 查询绑定:用 DbSet 查询并绑定,注意关闭跟踪

窗体加载时最自然的想法是直接查表填充 DataGridView。但直接给dataGridView1.DataSource = db.Tasks.ToList()也能显示,却有个隐患:如果你在界面上直接改某一行,EF Core 会跟踪实体状态;而这些实体来自一个还开着的上下文,等你保存时状态可能已经不是预期。为了把读和写分开,查询时用AsNoTracking():

private void RefreshGrid() { using var db = new AppDbContext(); var list = db.Tasks .AsNoTracking() .OrderByDescending(t => t.CreatedAt) .ToList(); bindingSource1.DataSource = null; bindingSource1.DataSource = list; dataGridView1.DataSource = bindingSource1; }

AsNoTracking()是告诉 EF Core:这次读出来的实体不要放进变更跟踪器,我不打算通过这个上下文保存。这样执行查询时不会额外占用内存记录快照,也不会有“两个上下文同时跟踪同一个 Id”的冲突。OrderByDescending让最新创建的任务排在最前面,这是列表工具的常见需求。

bindingSource1是 WinForms 里的中间层,它承载当前行信息。上面这段我先把DataSource置空,再赋新集合,这是一个强制刷新技巧,否则后面清空重查时 DataGridView 可能保留旧列头。你要是怕麻烦,也可以直接给 DataGridView 赋值,然后在事件里处理CurrentRow,但 BindingSource 会省很多事,比如后续删除时能直接用bindingSource1.Current拿到当前行。

这里要注意:EF Core 的ToList()会立刻执行 SQL,不是延迟加载。如果你不加 ToList,绑定到 DataGridView 时,WinForms 会尝试枚举IQueryable,此时如果上下文已经被using释放,就会在绑定那一行抛出“无法访问已释放的上下文”。所以必须在上下文生效范围内把查询结果变成内存集合。

3.2 插入与更新:用实体状态管理替代手写 SQL

添加一条记录,最自然的写法是构造实体,调用 Add,SaveChanges:

private void btnAdd_Click(object sender, EventArgs e) { using var db = new AppDbContext(); var task = new TaskItem { Title = txtTitle.Text.Trim(), CreatedAt = DateTime.Now, IsCompleted = false }; db.Tasks.Add(task); db.SaveChanges(); txtTitle.Clear(); RefreshGrid(); }

Add方法会把实体状态标记为 Added。SaveChanges时 EF Core 生成INSERT INTO Tasks (Title, CreatedAt, IsCompleted) VALUES (...),执行完后task.Id被回填为数据库生成的自增值。你可以用 MessageBox 显示新 Id,这算是自增主键正确工作的一个验证点。

更新操作稍微讲究一点。因为我上一节查询用AsNoTracking,所以拿到内存实体后直接修改,然后调用db.Tasks.Update(selected),EF Core 会把实体标记为 Modified,SaveChanges时会生成包含所有列的 UPDATE 语句:

private void btnSave_Click(object sender, EventArgs e) { if (bindingSource1.Current is not TaskItem selected) return; selected.Title = txtTitle.Text.Trim(); selected.IsCompleted = chkCompleted.Checked; selected.CreatedAt = DateTime.Now; using var db = new AppDbContext(); db.Tasks.Update(selected); db.SaveChanges(); RefreshGrid(); }

这段代码有个隐藏技巧:bindingSource1.Current拿到的对象,类型是你在查询时指定的TaskItem,不是 DataGridView 的行。所以你可以直接改它的属性。然后用Update保存。注意,如果你查询时用了AsNoTracking(),那么这个实体和任何上下文都没有关联,Update会让 EF Core 把它当作“全新实体”来更新。这意味着即便某个字段你没改,EF Core 也会写进 UPDATE。

如果你希望只更新修改过的字段,更精细的做法是不用AsNoTracking(),而是让实体的生命周期延续到保存操作。比如在查询时不跟踪,然后db.Attach(selected); db.Entry(selected).Property(x => x.Title).IsModified = true;。这种逐字段控制适合大表,对几张表的工具类程序,全字段更新不算大问题,反而代码短,更容易维护。

3.3 删除与事务:别在主线程里跑慢操作

删除按钮最稳妥的是先拿当前行的 Id,再执行带条件的删除:

private async void btnDelete_Click(object sender, EventArgs e) { if (bindingSource1.Current is not TaskItem selected) return; using var db = new AppDbContext(); await db.Tasks .Where(t => t.Id == selected.Id) .ExecuteDeleteAsync(); RefreshGrid(); }

ExecuteDeleteAsync是 EF Core 7 里引入的批量删除,它不会先把实体加载进内存,而是直接生成DELETE FROM Tasks WHERE Id = ?。在 SQLite 上执行很快,且不会和上下文跟踪产生纠缠。如果你用的还是 EF Core 6 或更早,就需要先查一次,再db.Tasks.Remove(entity),再 SaveChanges。那时要注意,如果一个 Id 被两个上下文同时跟踪,Remove 可能抛异常。

WinForms 界面操作里,数据库操作通常会开async void事件,这和 MVVM 的async void不同。WinForms 的按钮事件支持 async,等await回来时已经切回 UI 线程,所以刷新界面不需要额外 Invoke。但要注意ExecuteDeleteAsync这类方法在提交前如果你改了实体状态,可能产生不可预期行为,所以每个按钮里单独 new 一个上下文,避免状态污染。

如果你的保存操作涉及多张表,比如新增一条任务,同时更新一条统计记录,用事务把它们包起来:

using var db = new AppDbContext(); using var tx = await db.Database.BeginTransactionAsync(); try { db.Tasks.Add(taskA); await db.SaveChangesAsync(); db.Statistics.Update(statB); await db.SaveChangesAsync(); await tx.CommitAsync(); } catch { await tx.RollbackAsync(); throw; }

SQLite 对并发写入很敏感,事务里两个 SaveChanges 会持有写锁直到 Commit。如果事务太长,其他表单的查询会被卡住。我通常的做法是:事务只包裹核心写入语句,查询一律放在事务外;如果事务里需要查数据,用同一个上下文从DbSet查,绝不另开连接。这样才能避免 “database is locked” 报错。

4. 参数与配置:SQLite 文件、连接串和类型映射的细节

实用项目里,连接串不会只写一个文件名。尤其是 SQLite,文件路径决定程序读的是哪个库,也决定用户数据是否会被系统权限挡在 Program Files 外面。这一章把那些容易被忽略的参数逐一讲清楚。

4.1 连接串写法:相对路径、绝对路径和密码

我最终稳定下来的连接串生成逻辑是这样的:

public static string GetConnectionString() { var dataDir = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "MyLocalTool"); Directory.CreateDirectory(dataDir); var dbPath = Path.Combine(dataDir, "app.db"); return $"Data Source={dbPath};Foreign Keys=True;Cache=Shared;"; }

然后OnConfiguring里直接options.UseSqlite(GetConnectionString())。这样数据库文件被放到当前用户的 LocalAppData 目录下,不同用户登录系统时各用各的数据,也绕开了 Program Files 目录只读权限。

Data Source除了绝对路径,也能写相对路径。但相对路径取决于进程的当前工作目录。WinForms 启动时,如果从 Visual Studio 调试运行,工作目录通常是 bin\Debug\net8.0;如果用户从桌面快捷方式启动,工作目录是快捷方式指定的“起始位置”。这个不稳定,所以一律用AppContext.BaseDirectory或LocalAppData拼绝对路径。

连接串还有其他参数,常用的一张表:

参数作用示例
Data Source数据库文件路径Data Source=D:\data\app.db
Mode读写模式Mode=ReadOnly
Cache共享缓存Cache=Shared
Foreign Keys是否启用外键约束Foreign Keys=True
Journal Mode日志模式Journal Mode=WAL
Default Timeout命令超时秒数Default Timeout=30

其中Foreign Keys=True很重要。SQLite 默认关闭外键约束,EF Core 迁移生成的表虽然有 FOREIGN KEY 定义,但如果你不开启这个开关,删除父表记录时可能不会报错,导致孤儿数据。EF Core 的 SQLite 包在某种情况下会自动开启,但为了明确,我还是写在连接串里。

Journal Mode=WAL是性能选项。SQLite 默认是 DELETE 日志模式,写操作会阻塞读操作。改成 WAL 后,写和读可以并行,这对 WinForms 多窗口查询很有帮助。但 WAL 模式会额外产生-wal和-shm文件,备份时要一起处理。

加密方面,官方 SQLite 驱动默认不带加密。如果你需要密码,得引用支持加密的 SQLitePCLRaw 扩展。这里不深究,只用普通场景时,连接串不用写 Password。

4.2 类型映射:日期、GUID、Bool 在 SQLite 里的脾气

SQLite 是弱类型数据库,它的存储类型只有 INTEGER、REAL、TEXT、BLOB 和 NULL。EF Core 做了一层适配,但你必须知道最终落库的样子:

C# 类型SQLite 存储对应查询行为
intINTEGER正常比较和排序
longINTEGER正常
stringTEXT用=比较区分大小写?注意默认不区分
boolINTEGER (0/1)SQL 里用WHERE IsCompleted = 1
DateTimeTEXT格式为 yyyy-MM-dd HH:mm:ss
GuidTEXT格式为 UUID 字符串
decimalTEXT存储为字符串,排序可能不对
byte[]BLOB正常

这里最容易翻车的是DateTime。SQLite 没有原生日期类型,EF Core 默认把 DateTime 写成TEXT存储。所以如果你直接写 SQL 查询WHERE CreatedAt > '2024-01-01',实际是字符串比较,必须保证比较的字符串格式和 EF Core 存储格式一致。我建议不要混用原生 SQL 和 EF Core 查询,都用 LINQ 让 EF Core 生成格式正确的参数。

bool映射成 INTEGER 0/1,这在 LINQ 里没问题。但如果你的表里有别的程序写入,比如用 ADO.NET 手动插入了 string 类型的 "true",EF Core 读出来可能类型转换异常。所以保持统一的写入通道:要么全走 EF Core,要么全走你封装的 ADO.NET 代码,别混。

Guid在 SQLite 中存成 TEXT。如果你拿 Guid 做主键,并且希望生成有序 GUID 以优化索引,EF Core 默认是随机 GUID,插入大量数据时页分裂明显。对于 WinForms 工具,我一般用 int 自增主键,省心。实在要用 Guid,至少了解这个性能代价。

decimal映射为 TEXT 是 EF Core SQLite 的默认行为,这会导致数值排序按字典序排。比如 "9" 会排在 "10" 后面。如果业务要求精确排序,建议改用double或long,或者在实体属性上用[Precision]配置让 EF Core 存成 REAL(但仍然有精度风险)。大多数 WinForms 工具里的金额,用 double 够用,但要提醒自己:SQLite 不是银行级数据库。

4.3 并发与性能:别让 EF Core 的迁移和查询拖累界面

桌面程序虽然是单用户,但 WinForms 多个窗体同时打开时,每个窗体可能都有查询。SQLite 允许并发读,但写锁是局部的。一旦某个连接持有写事务,其他连接执行写操作就会等待,默认可能等不到直接抛 "database is locked"。解决方法有两个层面。

第一个层面是连接串参数。Cache=Shared让同一个进程内的多个连接共享缓存,减少锁冲突。Default Timeout=30给等待锁的操作一个缓冲。第二个层面是代码习惯:所有数据库操作尽量短平快,不要把用户输入确认、文件选择等慢动作放在事务里。

如果你的程序有后台线程在写库,前台界面在查询,建议给每个连接加上 busy_timeout 的 PRAGMA。EF Core 官方包没有直接暴露这个配置项,但可以用连接拦截器实现:

public class BusyTimeoutInterceptor : DbConnectionInterceptor { public override void ConnectionOpened(DbConnection connection, ConnectionEndEventData eventData) { using var cmd = connection.CreateCommand(); cmd.CommandText = "PRAGMA busy_timeout = 5000"; cmd.ExecuteNonQuery(); } }

然后在 OnConfiguring 里注册:

options.UseSqlite(GetConnectionString()) .AddInterceptors(new BusyTimeoutInterceptor());

这个拦截器会在每个连接打开时执行PRAGMA busy_timeout = 5000,告诉 SQLite:当数据库被锁时,等待最多 5000 毫秒再报错。这样即使偶尔撞上写锁,程序也会重试而不是直接崩。如果你的程序是单窗口操作,这个拦截器可有可无;一旦涉及后台任务或并行查询,它就是救命稻草。

性能上,EF Core 的最大开销不是 SQL 执行,而是表达式编译和实体物化。WinForms 列表小,感觉不明显。但如果你要加载几千行,且每行有十几个字段,第一次查询会有可见的延迟。可以启用 EF Core 的查询计划缓存,它默认就是开着的。实在不行,对这种展示型数据可以考虑用SqliteDataReader直接读,但那就绕过 ORM 了。我的原则:除非性能测试证明 EF Core 是瓶颈,否则不要提前优化。

5. 避坑记录:这 4 个坑我遇到过一次就记住了

这一章专门写坑。按现象、原因、解决三步来。有些坑看起来玄学,其实就是路径或平台问题。

5.1 坑一:“No such table” 但数据库文件就在眼前

现象:程序启动,登录后查询列表,直接抛出SQLiteException: no such table: Tasks。断点一看,实体类没问题,连接串指向的 app.db 文件在 bin 目录下也存在。用数据库工具打开那个文件,里面确实没有 Tasks 表。

原因:通常有两个。第一,迁移时生成的数据库不是在 bin 目录,而是项目根目录,因为dotnet ef在开发时使用的工作目录是项目目录。第二,连接串里的相对路径在运行时不稳定,导致程序读到的 app.db 是另一个位置。两个文件重名,但内容不同。

解决:把连接串改成绝对路径,并把迁移时的数据库也指向同一个路径。最简单的方法是先删除所有意外生成的 app.db,然后统一用Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "app.db")生成运行时路径。迁移时在OnConfiguring里也用它。这样开发环境、生产环境都读同一个文件。这里有一个细节:AppDomain.CurrentDomain.BaseDirectory在 WinForms 里就是 exe 所在目录,比Environment.CurrentDirectory可靠。

5.2 坑二:x86/x64 SQLite 原生库导致启动崩溃

现象:开发时一切正常,发布后拷到另一台机器,双击 exe 直接崩溃,事件查看器里是System.DllNotFoundException: Unable to load DLL 'e_sqlite3'。或者弹窗说找不到SQLitePCLRaw.core.dll。

原因:EF Core 的 SQLite 驱动底层使用 SQLitePCLRaw 这个包,它包含了不同 CPU 架构的原生二进制文件。在开发机上,NuGet 会根据项目平台选择正确的 native 库;但发布时如果项目是 AnyCPU,且没有设置预处理,可能拷贝的 native dll 不匹配目标系统。另外,如果你在 csproj 里配置了 RuntimeIdentifier 错误值,也会干扰。

解决:把项目的平台目标改成明确值。右键项目 -> 属性 -> 生成 -> 平台目标,选择 x64(如果你的目标机器都是 64 位 Windows)。同时,发布时选择dotnet publish -r win-x64 --self-contained false,让 publish 输出包含 runtimes 文件夹。发布完成后检查输出目录里是否有runtimes\win-x64\native\e_sqlite3.dll。如果没有,说明你的项目没有正确拷贝原生库。

还有一个常见迷惑:在 NuGet 包管理器里看到SQLitePCLRaw.bundle_green被引用。这是 EF Core SQLite 包的依赖,我们不要手动改它的设置。只要保持默认,同时平台明确,崩溃就能解决。

5.3 坑三:迁移时生成了但不是想要的位置

现象:执行Update-Database成功,项目根目录下出现 app.db,但程序运行时没有表。你怀疑是不是迁移没生效,可Add-Migration又是成功的。

原因:这是坑一的变体,但更阴险。EF Core 的设计时上下文和运行时上下文不是同一套配置。设计时,dotnet ef会找IDesignTimeDbContextFactory;如果找不到,就尝试运行程序入口来获取上下文。这一过程里的工作目录和默认配置可能和运行时不同。

解决:为项目提供显式的设计时工厂。创建一个类实现IDesignTimeDbContextFactory<AppDbContext>:

public class DesignTimeDbContextFactory : IDesignTimeDbContextFactory<AppDbContext> { public AppDbContext CreateDbContext(string[] args) { var options = new DbContextOptionsBuilder<AppDbContext>() .UseSqlite("Data Source=design.db") .Options; return new AppDbContext(options); } }

但这又会产生一个新问题:设计时数据库和运行时数据库不在一起。我一般建议设计时也使用和运行时一致的路径,比如在工厂方法里调用GetConnectionString()。如果你的路径基于 LocalApplicationData,迁移时会生成到那个目录。这样运行时不会出现找不到表的问题。如果你希望把数据库随程序走,也可以用 BaseDirectory,但发布到 Program Files 后可能无法写入。所以这是个取舍。

还有一点:如果项目里同时存在多个 DbContext,需要指定-Context参数,否则命令会报“有歧义”。这是小菜一碟。

5.4 坑四:DataGridView 绑定 List 后不会自动刷新

现象:查询后把List<TaskItem>赋给 BindingSource.DataSource,界面显示正常。但新增一条记录后调用RefreshGrid(),界面还是旧数据。有时候你甚至需要点一下窗口标题栏才刷新。

原因:List<T>不是BindingList<T>,它不会向 BindingSource 发通知。BindingSource 持有的是旧列表的引用,当你重新赋值时,需要强制重置。如果你直接dataGridView1.DataSource = list,但 DataGridView 已经绑定了一个 DataSource,它不会知道外部变化。

解决:在刷新方法里先把 BindingSource 的 DataSource 置 null,再赋新值。这是我上面代码里采用的做法。更优雅的方案是使用BindingList<TaskItem>,并让实体类实现INotifyPropertyChanged。但 EF Core 查询返回的是List<T>,你需要自己转成 BindingList:

var list = db.Tasks.AsNoTracking().ToList(); var bindingList = new BindingList<TaskItem>(list); bindingSource1.DataSource = bindingList;

BindingList会监听列表的新增和删除,但不会监听TaskItem属性的变化。如果希望在界面上改完字段后立刻反映到实体,需要配合INotifyPropertyChanged。对于普通展示型页面,我建议就用最简单的DataSource = null; DataSource = list;,代码直观,没有回调糟心事。

这四个坑都是我在实际项目里踩过的。最后想提醒的是:当你遇到“看起来没效果”的怪现象,先怀疑是不是缓存、是不是两个同名文件、是不是绑定了旧对象。这三类问题占了 WinForms 数据库项目的大半。

6. 一个收尾技巧:发布时让 SQLite 文件跟着程序走,并给数据加备份

这个技巧不算高级,但能救数据。WinForms 程序发布后,用户可能把 exe 放在任意目录,也可能被杀毒软件隔离写入。如果你把数据库固定到用户的本地目录,再在启动时自动做一次初始化,发布和运维都会轻松很多。

具体做法分三步。第一步,在 Program.Main 里启动窗体前初始化数据库目录和文件:

[STAThread] static void Main() { ApplicationConfiguration.Initialize(); var dbPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "MyLocalTool", "app.db"); Directory.CreateDirectory(Path.GetDirectoryName(dbPath)); using var db = new AppDbContext(dbPath); db.Database.Migrate(); Application.Run(new MainForm()); }

Migrate()会检查数据库结构,如果表不存在就根据所有迁移创建,不会破坏已有数据。比EnsureCreated()更稳,因为EnsureCreated()在数据库已存在时会跳过所有创建逻辑,后续更新模型它不管。所以新项目直接上Migrate()更符合迭代习惯。

第二步,给数据库做一个轻量备份。SQLite 文件是单个文件,最直接的备份就是文件复制。但程序运行时,数据库可能处于 WAL 模式,直接复制主文件可能漏掉未合并到主文件的事务。常见做法是调用 SQLite 的在线备份 API:

public void BackupDatabase(string backupPath) { using var db = new AppDbContext(); var conn = (Microsoft.Data.Sqlite.SqliteConnection)db.Database.GetDbConnection(); conn.Open(); conn.BackupDatabase(backupPath); conn.Close(); }

BackupDatabase是 SQLitePCLRaw 提供的备份 API,它能把当前主文件连同 WAL 内容一起复制出来,生成的备份文件直接可用。这样即使程序正在运行,也可以安全备份。你只要在界面上放一个“备份数据”按钮,调用这个方法即可。

第三步,启动时清理无效的-wal文件。如果你用过 WAL 模式,异常退出时可能留下残留的 wal 和 shm 文件。正常情况下下次打开会自动恢复,但如果文件损坏,程序可能无法正常打开。我习惯在启动时检测主文件和 wal 文件大小,一旦 wal 超过主文件一半,就先执行一次PRAGMA wal_checkpoint(TRUNCATE),合并日志再继续。这个操作有点深,但对长时间运行的 WinForms 工具有实际价值。

最后说一句我的教训:最早我图省事把数据库放在程序目录,结果某次重装系统,数据全没了。后来改成 LocalAppData 并写入备份逻辑,心里才算踏实。如果你也做工具类软件,不管项目多小,数据备份这一环一定要在发布前想好。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 3:24:28

Unity实战:从零搭建太空飞机射击游戏,掌握对象池与事件驱动核心机制

简介&#xff1a;《Unity 3D期末作业&#xff1a;太空飞机射击游戏》是一份面向Unity初学者、高校游戏专业学生及期末大作业参考者的完整项目资源。该项目围绕太空背景下的飞机射击玩法&#xff0c;涵盖场景搭建、C#脚本控制、子弹对象池、碰撞检测、音频播放、UGUI界面、动画系…

作者头像 李华
网站建设 2026/10/15 1:52:51

VC++ MapX 图层开发实战:SerialSample 工程解析与避坑指南

简介&#xff1a;这份资源是面向VC开发者与GIS入门者的MapX控件实战示例包&#xff0c;聚焦在Visual C环境下集成地图控件、管理图层与处理地图交互这一典型场景。包内共30个文件&#xff0c;以11个h头文件与10个cpp源文件为核心&#xff0c;配合ico、bmp图标位图资源及dsp、ds…

作者头像 李华
网站建设 2026/10/15 14:27:16

多租户中台化低代码生成器实战:从架构选型到踩坑指南

简介&#xff1a;多租户架构是企业级应用中应对多组织、多渠道场景的常见设计范式&#xff0c;其核心在于数据隔离与共享边界如何平衡。低代码生成器并非替代编码&#xff0c;而是将租户隔离、工作流引擎、在线表单等横切能力预置为可配置工程骨架&#xff0c;让团队聚焦业务而…

作者头像 李华
网站建设 2026/10/21 3:29:33

IP地址精准定位系统源码解析:从市级到百米级误差的工程实践

简介&#xff1a;这是一套面向Web开发初学者与需要IP查询功能的开发者准备的IP地址精准定位系统源码&#xff0c;在传统IP归属地查询只能定位到市级单位的基础上&#xff0c;将定位精度提升至误差100米以内&#xff0c;并支持直接在地图上可视化展示结果&#xff0c;适合用于位…

作者头像 李华
网站建设 2026/10/15 14:09:46

WinForms+SQLite管道缺陷管理系统实战项目

简介&#xff1a;这是一套面向计算机及相关专业&#xff08;如计科、人工智能、通信工程、自动化等&#xff09;在校学生与初学者的管道内检测缺陷数据库管理系统完整开发资源&#xff0c;可用于课程设计、毕业设计、项目实训或技术进阶学习。系统基于.NET平台构建&#xff0c;…

作者头像 李华
网站建设 2026/10/15 2:27:58

Java+MySQL+JDBC+Swing超市管理系统课设实战:从建表到事务与界面刷新

简介&#xff1a;这是一套面向高校计算机相关专业学生的期末数据库课程设计完整方案&#xff0c;以JavaMySQLJDBCJavaSwing技术栈实现超市管理系统&#xff0c;适合正在准备课程设计、需要可运行参考项目的初学者与进阶学习者。资源包共24个文件&#xff0c;包含17个Java源码文…

作者头像 李华