简介:本资源是WPF结合MVVM模式与SqlSugar ORM的入门实战教程配套工程,面向具备一定C#基础、希望掌握桌面端分层架构与数据库访问的开发者。内容围绕MySql数据库展开,涵盖数据库类库创建、数据功能接口定义以及泛型方法实现,帮助读者理解业务逻辑层与数据访问层的解耦思路,并熟悉ViewModel中的数据绑定与命令处理。压缩包共1612个文件,以549个dll、450个xml、52个cs源码、6个xaml界面文件及3个csproj工程文件为主,另含nupkg依赖包与png示意图,整体约123.95MB,目录结构完整,可直接还原项目环境。目前已有576人学习下载。通过该实例,读者可获取一套可运行的WPF+MVVM+SqlSugar项目骨架,掌握接口抽象、泛型复用与单元测试的落地方式,为后续构建结构清晰、易维护的桌面应用提供参考。
1. 从一次“能跑但不敢改”的 WPF 项目说起
很多 WPF 项目死在第二周:界面能点,数据库能连,但没人敢动代码。ViewModel 里塞着 SqlConnection,XAML 后台写着 SQL 字符串,改一个字段要翻五个文件。这个标题要解决的正是这件事——用 MVVM 把界面和逻辑拆开,用 SqlSugar 把数据库访问收口,做成一个能长期维护的桌面项目骨架。
它适合两类人:刚学完 WPF 基础语法、想找一个真实项目练手的开发者;以及手上有一堆 WinForm 老项目、准备迁到 WPF 的工程师。SqlSugar 在这里的角色不是“又一个 ORM”,而是把增删改查、事务、连接池这些脏活从 ViewModel 里彻底剥离出去的那层。这一篇是系列第三部分,重点落在数据库实例的完整落地,前面讲过的界面骨架不再重复。
2. 为什么 MVVM 项目里数据库层必须单独抽出来
2.1 ViewModel 直接连数据库的三个真实代价
先看一段很多人写过的代码。ViewModel 里直接 new 一个连接,查询结果塞进 ObservableCollection,界面上看着没问题:
// 反例:ViewModel 里直接操作数据库 public class UserViewModel : INotifyPropertyChanged { public ObservableCollection<User> Users { get; set; } = new(); public void LoadUsers() { using var conn = new SqlConnection("Server=.;Database=Demo;Trusted_Connection=True;"); conn.Open(); var cmd = new SqlCommand("SELECT * FROM Users", conn); var reader = cmd.ExecuteReader(); while (reader.Read()) { Users.Add(new User { Id = (int)reader["Id"], Name = reader["Name"].ToString() }); } } }这段代码能跑,但代价在后面才显现。第一,单元测试没法写,因为 ViewModel 和真实数据库绑死了,想测一个排序逻辑得先装一个 SQL Server。第二,连接字符串散落在各个 ViewModel 里,换一次环境要全局搜索替换。第三,异步查询、事务、异常重试这些逻辑会在每个 ViewModel 里重复一遍,改一处漏三处。
MVVM 的核心不是“ViewModel 不能碰数据”,而是“ViewModel 不应该知道数据从哪来”。它只关心拿到一个List<User>,至于这个列表来自 SQL Server、SQLite 还是内存缓存,是仓储层的事。
2.2 SqlSugar 在分层里的位置和选型理由
SqlSugar 是一个国产 ORM,在中小型桌面项目里比 EF Core 更轻。它的几个特性正好对上 WPF 项目的痛点:启动快,没有 EF 那种首次查询编译模型的延迟;支持 DbFirst 和 CodeFirst 两种模式,老项目迁过来可以先映射现有表;内置连接池和异步 API,配合 WPF 的 async/await 不会卡 UI 线程。
分层结构我一般这样切:
| 层 | 职责 | 依赖 |
|---|---|---|
| View (XAML) | 只做绑定和样式 | 无 |
| ViewModel | 命令、状态、调用 Service | Service 接口 |
| Service | 业务规则、事务边界 | Repository 接口 |
| Repository | SqlSugar 的增删改查 | ISqlSugarClient |
| Entity | 纯 POCO 类 | 无 |
关键点是 Repository 依赖ISqlSugarClient接口而不是具体实例,这样测试时可以换成内存实现。SqlSugar 官方提供了SqlSugarScope,它是线程安全的,适合在 WPF 这种多线程环境里做单例。
2.3 用 SqlSugarScope 注册全局客户端
在App.xaml.cs里做一次性注册,整个应用共用一个 Scope:
// App.xaml.cs public partial class App : Application { public static ISqlSugarClient Db { get; private set; } protected override void OnStartup(StartupEventArgs e) { base.OnStartup(e); Db = new SqlSugarScope(new ConnectionConfig { ConnectionString = "Server=.;Database=DemoDb;Trusted_Connection=True;TrustServerCertificate=True;", DbType = DbType.SqlServer, IsAutoCloseConnection = true, // 用完自动归还连接池 InitKeyType = InitKeyType.Attribute // 用特性标记主键和自增 }, db => { // 全局调试事件,开发期打开,上线关掉 db.Aop.OnLogExecuting = (sql, pars) => { Debug.WriteLine(sql); }; }); } }IsAutoCloseConnection = true是桌面项目的必设项。WPF 应用不像 Web 有请求生命周期,如果手动管理连接,很容易在某个 ViewModel 里忘了 Close,连接池耗尽后界面直接卡死。设成 true 后 SqlSugar 在每次操作结束自动归还连接。
InitKeyType.Attribute表示主键信息从实体类的特性上读,下面定义实体时会用到。Aop.OnLogExecuting是开发期的后悔药,SQL 打出来一眼就能看出是参数传错还是索引没走。
3. 实体映射与 CodeFirst 建库的完整步骤
3.1 用特性标注实体类
实体类保持纯净,只加 SqlSugar 的特性,不继承任何基类:
// Models/User.cs [SugarTable("Sys_User")] // 指定表名,不写则用类名 public class User { [SugarColumn(IsPrimaryKey = true, IsIdentity = true)] public int Id { get; set; } [SugarColumn(Length = 50, IsNullable = false)] public string UserName { get; set; } [SugarColumn(Length = 100, IsNullable = true)] public string Email { get; set; } [SugarColumn(ColumnDataType = "datetime", IsNullable = true)] public DateTime? CreatedAt { get; set; } [SugarColumn(IsIgnore = true)] // 不映射到数据库的字段 public string DisplayText => $"{UserName} ({Email})"; }IsIdentity = true告诉 SqlSugar 这个主键由数据库自增,插入时不用赋值。IsIgnore = true用在只给界面看的计算属性上,避免 SqlSugar 尝试把它建成列。ColumnDataType在跨数据库时有用,比如从 SQL Server 换到 SQLite,datetime 的写法不一样,显式指定能减少迁移时的意外。
3.2 CodeFirst 初始化数据库和种子数据
在应用启动时调用一次初始化,表不存在就建,存在就跳过:
// Data/DbInitializer.cs public static class DbInitializer { public static void Init(ISqlSugarClient db) { // 建库(如果数据库本身不存在) db.DbMaintenance.CreateDatabase(); // 建表,泛型传入所有实体 db.CodeFirst.InitTables(typeof(User), typeof(Order)); // 种子数据:只在表为空时插入 if (!db.Queryable<User>().Any()) { db.Insertable(new List<User> { new User { UserName = "admin", Email = "admin@demo.com", CreatedAt = DateTime.Now }, new User { UserName = "guest", Email = "guest@demo.com", CreatedAt = DateTime.Now } }).ExecuteCommand(); } } }CreateDatabase()只对 SQL Server、MySQL 这类支持 CREATE DATABASE 的库有效,SQLite 会直接跳过。InitTables是幂等的,重复调用不会报错,它内部会对比表结构差异并尝试 ALTER,但生产环境我建议关掉自动改表,只用来首次建表。
种子数据用Any()判断而不是Count() == 0,前者生成SELECT TOP 1,在数据量大时快得多。这个细节在用户表上万行后能省下明显的启动时间。
3.3 在 ViewModel 里通过仓储调用
仓储接口定义在 Service 层之下,ViewModel 只看到接口:
// Repositories/IUserRepository.cs public interface IUserRepository { Task<List<User>> GetAllAsync(); Task<int> AddAsync(User user); Task<bool> UpdateAsync(User user); Task<bool> DeleteAsync(int id); } // Repositories/UserRepository.cs public class UserRepository : IUserRepository { private readonly ISqlSugarClient _db; public UserRepository(ISqlSugarClient db) => _db = db; public Task<List<User>> GetAllAsync() => _db.Queryable<User>().OrderBy(u => u.Id).ToListAsync(); public Task<int> AddAsync(User user) => _db.Insertable(user).ExecuteReturnIdentityAsync(); public Task<bool> UpdateAsync(User user) => _db.Updateable(user).ExecuteCommandAsync().ContinueWith(t => t.Result > 0); public Task<bool> DeleteAsync(int id) => _db.Deleteable<User>().Where(u => u.Id == id) .ExecuteCommandAsync().ContinueWith(t => t.Result > 0); }ExecuteReturnIdentityAsync在插入后返回自增主键,省去再查一次。Updateable(user)默认按主键更新所有列,如果只想更新部分字段,用.UpdateColumns(u => new { u.UserName, u.Email })指定,能减少不必要的写入。
ViewModel 里注入接口,命令绑定到异步方法:
// ViewModels/UserListViewModel.cs public class UserListViewModel : ObservableObject { private readonly IUserRepository _repo; public ObservableCollection<User> Users { get; } = new(); public IAsyncRelayCommand LoadCommand { get; } public UserListViewModel(IUserRepository repo) { _repo = repo; LoadCommand = new AsyncRelayCommand(LoadAsync); } private async Task LoadAsync() { var list = await _repo.GetAllAsync(); Users.Clear(); foreach (var u in list) Users.Add(u); } }AsyncRelayCommand来自 CommunityToolkit.Mvvm,它自动处理命令执行期间的 CanExecute 状态,避免用户连点按钮触发多次查询。Users.Clear()后逐个 Add 而不是直接替换集合引用,是因为界面已经绑定了这个集合实例,替换引用不会触发刷新。
4. 增删改查落地时最容易翻车的五个地方
4.1 现象:界面卡死三秒,原因是没有异步
现象是点击加载按钮后窗口无响应,鼠标转圈。原因是用了同步的ToList()而不是ToListAsync(),查询在 UI 线程上执行。解决是把所有仓储方法改成异步版本,ViewModel 里用await调用。SqlSugar 的异步 API 底层是Task.Run包装,不会阻塞 UI 线程。
4.2 现象:插入中文变问号,原因是连接字符串缺字符集
现象是插入“张三”后数据库里显示乱码。原因多半是 MySQL 连接串没指定CharSet=utf8mb4,或者 SQL Server 的列排序规则不是中文。解决是在连接字符串里显式加字符集参数,建表时字符串列用nvarchar而不是varchar。SqlSugar 的[SugarColumn(Length = 50)]默认生成 nvarchar,但如果手动改过表结构就要检查。
4.3 现象:更新整行导致并发覆盖,原因是没加版本列
现象是两个用户同时编辑同一条记录,后保存的覆盖了先保存的。原因是用Updateable(entity)全列更新,没有乐观锁。解决是在实体里加一个[SugarColumn(IsEnableUpdateVersionValidation = true)] public int Version { get; set; },SqlSugar 会在更新时自动带上版本条件,版本不匹配则更新影响行数为零,业务层据此提示用户刷新。
4.4 现象:连接池耗尽,原因是 Scope 被重复创建
现象是运行一段时间后报“超时时间已到,但是未能从池中获取连接”。原因是在每个 ViewModel 里 new 了一个 SqlSugarScope,每个 Scope 有自己的连接池。解决是全局只创建一个 Scope,通过依赖注入或静态属性传递。WPF 里没有内置 DI 容器时,用App.Db静态属性最简单,项目大了再引入 Microsoft.Extensions.DependencyInjection。
4.5 现象:CodeFirst 把生产表删了,原因是误用 InitTables
现象是部署后表结构被改,数据丢失。原因是InitTables在检测到列类型不匹配时会尝试 ALTER,某些情况下会重建表。解决是生产环境禁用 CodeFirst,改用 DbFirst 生成实体,或者把InitTables包在#if DEBUG里。上线前用 SqlSugar 的DbMaintenance.GetColumnInfosByTableName做一次结构对比,人工确认后再执行变更。
5. 把查询性能压到 50ms 以内的三个进阶技巧
5.1 用 Select 投影减少数据传输
很多查询只需要两三个字段,但Queryable<User>().ToList()会把所有列拉回来。改成投影:
var dtos = await _db.Queryable<User>() .Where(u => u.CreatedAt > DateTime.Today.AddDays(-7)) .Select(u => new UserDto { Id = u.Id, UserName = u.UserName }) .ToListAsync();生成的 SQL 只 SELECT 需要的列,在宽表上能减少一半以上的网络传输。配合Mapper还能直接映射到 DTO,省去手动赋值。
5.2 分页查询必须带 OrderBy
SqlSugar 的分页ToPageListAsync(pageIndex, pageSize)如果不加 OrderBy,SQL Server 会报错,SQLite 则返回不确定的顺序。正确写法是先 OrderBy 再分页:
var page = await _db.Queryable<User>() .OrderBy(u => u.Id, OrderByType.Desc) .ToPageListAsync(pageIndex, pageSize);OrderByType.Desc在列表页很常用,最新数据排前面。注意 pageIndex 从 1 开始,不是 0,这个和很多前端框架的习惯不一样,传错会少一页数据。
5.3 用缓存扛住高频只读查询
用户列表这类变化不频繁的数据,可以在仓储层加一层内存缓存:
private List<User> _cache; private DateTime _cacheTime; public async Task<List<User>> GetAllAsync() { if (_cache != null && (DateTime.Now - _cacheTime).TotalSeconds < 30) return _cache; _cache = await _db.Queryable<User>().ToListAsync(); _cacheTime = DateTime.Now; return _cache; }30 秒的过期时间对大多数桌面场景够用。写操作后手动把_cache置空,保证下次读拿到新数据。这个模式比引入 Redis 之类的方案轻得多,桌面应用没必要上分布式缓存。
5.4 验证方法:用 Aop 统计慢查询
在OnLogExecuting里加一个计时,超过阈值的 SQL 打到日志:
db.Aop.OnLogExecuting = (sql, pars) => { var sw = Stopwatch.StartNew(); db.Aop.OnLogExecuted = (sql2, pars2) => { sw.Stop(); if (sw.ElapsedMilliseconds > 100) Debug.WriteLine($"[慢查询 {sw.ElapsedMilliseconds}ms] {sql2}"); }; };这个习惯帮我抓过好几次问题:一次是忘了加索引导致全表扫描,一次是 N+1 查询在循环里查关联表。SqlSugar 的Includes可以一次性把关联数据拉回来,避免循环查询。
我自己的习惯是每个新项目先把 Aop 日志和慢查询阈值配好,再写业务代码。等出问题再回头加,往往已经积累了一堆难查的慢 SQL。数据库层这东西,前期多花半小时搭骨架,后期能省下几十个小时的排查。希望帮到你。
本文还有配套的精品资源,点击获取