ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

WPF+Prism 8+MaterialDesign 图书管理系统开发实战

WPF+Prism 8+MaterialDesign 图书管理系统开发实战

说实话,做桌面端管理系统的人多少都会有点“UI 不甘心”——明明业务逻辑不复杂,但界面一出来就是灰扑扑的表格加按钮,自己看着都提不起劲。我这次用WPF + SQL Server + MaterialDesign + Prism 8做了一套图书管理系统,最直观的感受是:原来 .NET 桌面应用也能把界面做得像现代互联网产品一样清爽、干净、有层次感。这篇文章不聊空话,我会把我从技术选型、项目结构、数据层到 UI 细节的实现过程,以及踩过的一堆坑,完整地写出来。

这套系统面向的场景很典型:一个小型图书室或内部资料室,需要管图书档案、读者信息、借阅归还和简单的逾期统计。它不需要多高的并发,但对界面交互、操作流畅度和后期维护性有要求。如果你正准备用 WPF 做管理系统,或者想看看 Prism 8 在实际项目里怎么组织代码、MaterialDesign 怎么调教才不至于“一眼假”,这篇应该能帮你省下不少试错时间。

1. 为什么是 WPF + SQL Server + MaterialDesign + Prism 8 这套组合

1.1 桌面端技术选型的横向对比

先说说为什么没有用更“省事”的方案。做图书管理系统,最容易想到的无非是 WinForms、Web 管理系统、Electron 这几条路。我从实际体验出发,把它们的优缺点摊开来看。

方案开发效率UI 表现力打包体积数据访问长期维护
WinForms较高较弱,控件老旧小方便一般,代码容易腐化
Web 管理系统高,但要做前后端分离强,但需要额外做接口和部署依赖浏览器要走 HTTP前后端两套工程
Electron高,前端技术栈很强非常大需配合本地服务内存占用令人头疼
WPF前期上手曲线陡强,模板和样式自由度极高中等直接连库结构清晰则非常好维护

WinForms 不是不能用,但做过几个项目的人都会发现,一旦遇到自定义样式、动画、数据绑定的复杂交互,WinForms 的控件体系会不断逼迫你写底层绘图代码,越写越痛苦。Electron 做桌面界面确实漂亮,但装一个图书管理系统要带一个 Chromium 内核,内存占用轻松上几百兆,在小内存办公机上直接劝退。Web 版本则要解决部署和网络依赖,内部小系统往往没有 IT 团队帮你维护服务器。

WPF 的优势在于 XAML 描述界面、数据绑定驱动 UI、样式和模板可以做到彻底的自定义。配合 SQL Server 做数据存储,整个系统可以是一个单机或局域网内的独立程序,部署时只需要装 .NET 运行时和数据库,不需要额外起 Web 服务。

1.2 Prism 8 对个人项目是不是过度设计

很多人一看 Prism 8 就紧张,觉得那是企业级框架,做个图书管理系统用不上。我的真实感受是:如果你的项目超过四个页面、有多个业务模块、后续还想继续加功能,Prism 带来的收益非常明显。

Prism 8 解决的核心问题有三个:依赖注入、MVVM 的规范化、页面导航。没有它,WPF 项目通常的做法是每个窗口自己 new 一个 ViewModel,窗口之间互相跳转,写久了就是一阵乱麻。Prism 的模块化能力允许我把“图书档案”“读者管理”“借阅流通”各自封装成独立模块,每个模块只管自己的 View 和 ViewModel,然后用导航框架统一调度。

这套机制落到图书管理系统上最直接的体现是:主窗口的左侧菜单和右侧内容区完全解耦。点击菜单项时,右侧 Region 根据导航名称动态加载对应的 View,不需要在窗口代码里写一堆 if else 去切换页面。后面加一个“统计报表”模块,只需要新增一个 Module 类,在主程序注册一下,菜单和区域自动就支持了,原有的代码一行都不用改。

2. 项目骨架:模块划分与 Prism 8 导航设计

2.1 图书管理系统先拆清楚功能边界

动手写代码之前,我把图书管理系统的功能拆成了四个模块:

  • 图书档案管理:图书的增删改查、封面图、分类、库存数量、ISBN 信息。
  • 读者管理:读者的办卡、信息维护、借阅历史。
  • 借阅流通:借书、还书、续借、逾期计算、借阅记录查询。
  • 统计与设置:图书分类统计、借阅排行榜、系统参数的维护。

这四个模块对应 Prism 里的四个 Module。每个 Module 内部由若干 View 和 ViewModel 组成,比如“借阅流通”模块里有“借书操作页”“还书操作页”“借阅记录列表面”。模块之间不直接互相引用,只在需要时通过 Prism 的导航参数或事件聚合器通信。

一个系统如果一开始不划清模块边界,后面每加一个功能都要动到主窗口的代码,很快就变成意大利面条。Prism 的 Module 概念在这个场景里不是摆设,它逼着你在做项目的时候就思考每段代码到底属于哪个业务域。

2.2 Region 与 View 注入:主窗体怎么组织

主窗体的布局很简单:左侧是一个 ListBox 或者 MaterialDesign 的 Drawer,显示功能菜单;右侧是一个 ContentControl 作为导航区域。Prism 的 Region 就是用来管理这个 ContentControl 的。

在 App.xaml.cs 里,我需要把自定义的 MainWindow 注册到 Prism 容器,并指定一个 Region:

protected override void RegisterTypes(IContainerRegistry containerRegistry) { containerRegistry.RegisterForNavigation<BookListView, BookListViewModel>(); containerRegistry.RegisterForNavigation<ReaderListView, ReaderListViewModel>(); containerRegistry.RegisterForNavigation<BorrowView, BorrowViewModel>(); containerRegistry.RegisterForNavigation<ReturnView, ReturnViewModel>(); containerRegistry.RegisterForNavigation<StatisticsView, StatisticsViewModel>(); } protected override void CreateShell() { var window = Container.Resolve<MainWindow>(); window.Show(); }

主窗口的 XAML 里最关键的是这一句:

<ContentControl prism:RegionManager.RegionName="MainContentRegion" />

然后在菜单按钮的 Command 里调用导航:

private void NavigateCommand(string navigatePath) { RegionManager.RequestNavigate("MainContentRegion", navigatePath); }

Prism 8 自带的RequestNavigate支持在导航路径里携带参数。比如点击“借阅记录”的某一行时,需要同时把当前读者 ID 传给借阅历史页,做法如下:

var parameters = new NavigationParameters { { "readerId", selectedReader.Id } }; RegionManager.RequestNavigate("MainContentRegion", "BorrowHistoryView", parameters);

在目标 ViewModel 中通过INavigationAware接口接收参数:

public void OnNavigatedTo(NavigationContext navigationContext) { var readerId = navigationContext.Parameters.GetValue<int>("readerId"); LoadBorrowHistory(readerId); }

2.3 ViewModel 生命周期里容易忽视的细节

Prism 的导航不是简单地切换界面,它会触发 ViewModel 的OnNavigatedTo和OnNavigatedFrom。这里有个很关键的坑:导航到同一个 View 多次时,Prism 默认每次都会创建新的实例。如果你在图书列表页搜索了一个关键词,然后切到读者管理再切回来,列表会重新加载,搜索状态丢失。

解决方案有两种。第一种是在导航前设置NavigationParameters的IsNavigationInCache之类的行为;第二种更实用:把频繁切换的页面作为单例注册,用RegisterForNavigation后配合容器生命周期管理,或者在 ViewModel 里自己维护状态缓存。

我在图书列表页用了比较简单的做法:ViewModel 里保存一个_searchKeyword字段,每次OnNavigatedTo时检查参数里有没有显式传搜索词,如果没有就用缓存字段刷新列表。这样一来,用户从其他菜单切回来时不会看到空白页面,体验顺畅很多。

如果你用的是 Prism 8 的 PubSubEvent,要注意事件聚合器默认是弱引用订阅,但如果你在订阅时用了 lambda 表达式捕获了 ViewModel 的成员,就可能被编译器转换成强引用,导致 ViewModel 无法被垃圾回收。后面踩坑部分我会专门展开。

3. 数据层:SQL Server 与仓储模式的落地

3.1 EF Core 还是 Dapper,我为什么选了 Dapper

数据访问是管理系统绕不开的部分。很多 WPF 教程直接往 ViewModel 里写 ADO.NET,图省事;也有上 EF Core 的,但配置和迁移对一个小系统来说有点重。我最后选的是Dapper + 参数化 SQL的组合。

拿图书列表查询为例,EF Core 如果没做好 AsNoTracking 和分页,很容易写出慢查询;Dapper 则逼着你手写 SQL,对索引和 WHERE 条件更敏感。图书管理系统这种场景,绝大多数查询都是单表或者简单的多表 JOIN,Dapper 完全够用,而且性能非常稳定。

选型时我也做了对比:

方案学习成本灵活性性能适用场景
原生 ADO.NET低高,但代码冗余高不适合快速开发
EF Core中高中,迁移方便中复杂对象模型、频繁改表结构
Dapper低高高管理系统的 CRUD 与报表

库存扣减、借阅记录插入这些操作,用 Dapper 写一个事务非常直观。EF Core 的事务模型能用,但 Session 和状态跟踪一旦出问题,排查成本比 SQL 高不少。

3.2 数据库初始化与连接管理

图书管理系统需要几张核心表:Books、Readers、BorrowRecords、Categories。建表 SQL 我放在项目里的Database文件夹,发布时候用脚本执行。比较关键的是连接管理——Dapper 本身不会替你管理连接,所以必须确保每次操作都要释放。

我封装了一个简单的BookstoreDb类,用来提供连接工厂:

public class BookstoreDb { private readonly string _connectionString; public BookstoreDb(string connectionString) { _connectionString = connectionString; } public IDbConnection CreateConnection() { var connection = new SqlConnection(_connectionString); connection.Open(); return connection; } }

然后在仓储层使用:

public async Task<List<Book>> SearchBooksAsync(string keyword) { using var connection = _db.CreateConnection(); var sql = @" SELECT b.*, c.CategoryName FROM Books b INNER JOIN Categories c ON b.CategoryId = c.Id WHERE b.Title LIKE @Keyword OR b.Author LIKE @Keyword OR b.ISBN LIKE @Keyword"; var result = await connection.QueryAsync<Book>(sql, new { Keyword = $"%{keyword}%" }); return result.ToList(); }

using var保证连接在查询结束后第一时间归还连接池。这一点看起来很基础,但我在做这个项目时一开始没注意,发生过连接未释放导致的“连接池耗尽”错误,后面踩坑部分会细说。

Prism 的容器在这里也发挥了作用。我把BookstoreDb和各个仓储接口注册成单例或瞬态实例,ViewModel 只需要声明构造函数参数,Prism 会自动解析注入:

containerRegistry.RegisterSingleton<BookstoreDb>(); containerRegistry.Register<IBookRepository, BookRepository>();

ViewModel 的构造函数无需关心数据库连接怎么创建,直接面向仓储接口编程。图书管理系统的业务逻辑不复杂,但这样一个看似多余的抽象的层,在后期加单元测试和替换数据库时价值很大。

3.3 异步化与 UI 线程的响应性

WPF 里最忌惮的是 UI 线程被数据库查询阻塞。你点一个“查询”按钮,如果同步执行 SELECT,界面直接卡死几秒,用户会觉得程序坏了。我在所有仓储方法上都加了async,ViewModel 里的命令用AsyncRelayCommand包装。

Prism 8 本身提供了DelegateCommand,但异步命令需要额外扩展。我用的是 Prism 自带DelegateCommand的Execute里写async void,字段如下:

private AsyncRelayCommand _searchCommand; public AsyncRelayCommand SearchCommand => _searchCommand ??= new AsyncRelayCommand(SearchAsync); private async Task SearchAsync() { IsBusy = true; try { Books = await _bookRepository.SearchBooksAsync(Keyword); } finally { IsBusy = false; } }

IsBusy绑定到界面的加载动画,这样点击查询后书单区域会显示一个转圈动画而不是一片空白。这种小细节对用户感知影响巨大,特别是图书列表几百上千条时。

4. MaterialDesign “美到炸”的实操细节:主题、控件与动画

4.1 版本与资源字典配置

MaterialDesign 在 WPF 里通常指两个包:MaterialDesignThemes和MaterialDesignColors。安装之后,App.xaml 的资源字典必须按顺序合并,顺序错了很多控件样式直接不生效。

我的 App.xaml 里最简配置如下:

<Application.Resources> <ResourceDictionary> <ResourceDictionary.MergedDictionaries> <ResourceDictionary Source="pack://application:,,,/MaterialDesignThemes.Wpf;component/Themes/MaterialDesignTheme.Light.xaml" /> <ResourceDictionary Source="pack://application:,,,/MaterialDesignThemes.Wpf;component/Themes/MaterialDesignTheme.Defaults.xaml" /> <ResourceDictionary Source="pack://application:,,,/MaterialDesignColors;component/Themes/Recommended/Primary/MaterialDesignColor.DeepPurple.xaml" /> <ResourceDictionary Source="pack://application:,,,/MaterialDesignColors;component/Themes/Recommended/Accent/MaterialDesignColor.Lime.xaml" /> </ResourceDictionary.MergedDictionaries> </ResourceDictionary> </Application.Resources>

这里有个坑:如果你把 Primary 颜色字典放到 Defaults 之后,有些按钮的默认色可能不会更新。最好保持上面这个顺序:先主题、再默认样式、最后颜色。

4.2 配色与明暗主题的切换

MaterialDesign 的默认主题偏 Material Design 2 风格,风格上是“亮色 + 主色 + 强调色”。主色我用 DeepPurple(深紫),强调色用 Lime(黄绿),界面会显得有活力但并不刺眼。

暗色模式可以通过BundledTheme配合Theme类动态切换:

var theme = new Theme { BaseTheme = BaseTheme.Dark, Primary = SwatchHelper.Lookup[MyPalette.Primary.DeepPurple900], Secondary = SwatchHelper.Lookup[MyPalette.Accent.LimeA700] }; var paletteHelper = new PaletteHelper(); paletteHelper.SetTheme(theme);

切换的时候整个窗口的按钮、输入框、卡片背景会联动更新。我在设置页加了一个“夜间模式”的开关,绑定到这个代码,虽然是个小功能,但演示的时候非常加印象分。

4.3 卡片式布局与 DataGrid 美化

图书管理系统最核心的页面是列表页。原生 WPF 的 DataGrid 很丑,直接用会拉低整体效果。MaterialDesign 提供了DataGrid的样式,使用MaterialDesignDataGrid这个类或者直接给 DataGrid 设置 Style。

但做得更显质感的方案是:不用原生 DataGrid,改用ItemsControl+ 卡片式数据模板。每个图书项做成一张卡片,包含封面、书名、作者、分类、库存状态和操作按钮,视觉效果比表格高一个档次。

卡片的核心结构是这样的:

<Border Style="{StaticResource MaterialDesignCard}" Margin="0,0,0,8"> <Grid Margin="16"> <Grid.ColumnDefinitions> <ColumnDefinition Width="72"/> <ColumnDefinition Width="*"/> <ColumnDefinition Width="Auto"/> </Grid.ColumnDefinitions> <Border Width="56" Height="72" Background="{StaticResource MaterialDesignPaperLight}" CornerRadius="4"> <TextBlock Text="{Binding TitleShort}" HorizontalAlignment="Center" VerticalAlignment="Center"/> </Border> <StackPanel Grid.Column="1" VerticalAlignment="Center"> <TextBlock Text="{Binding Title}" FontWeight="Bold" FontSize="16"/> <TextBlock Text="{Binding Author}" Foreground="{StaticResource MaterialDesignBodyLight}" FontSize="13"/> </StackPanel> <StackPanel Grid.Column="2" VerticalAlignment="Center"> <TextBlock Text="{Binding StatusText}" Foreground="{Binding StatusBrush}"/> <Button Content="借阅" Command="{Binding DataContext.BorrowCommand, RelativeSource={RelativeSource AncestorType=Window}}" CommandParameter="{Binding}"/> </StackPanel> </Grid> </Border>

MaterialDesignCard会自带轻微的阴影和圆角,卡片间距用 Margin 控制,视觉上非常干净。这种布局方式比 DataGrid 更灵活,而且完全符合 Material Design 的信息层级。

加了卡片布局之后,按钮样式也必须统一。MaterialDesign 的按钮默认自带圆角,图标按钮用PackIcon控件配合Style="{StaticResource MaterialDesignToolButton}",鼠标悬停会有淡淡的涟漪效果,细节到位。

4.4 图标、过渡动画与对话框

MaterialDesign 的图标库是MaterialDesignIcons,通过PackIcon使用,几乎覆盖所有常见场景。菜单栏、按钮、空状态提示都可以用它,而不需要额外塞入图片资源。

页面切换动画也是一个容易被忽视的提升点。Prism 8 本身不提供转场动画,我默认没有做很花哨的转场,只利用ContentControl的透明度动画:

<ContentControl prism:RegionManager.RegionName="MainContentRegion"> <ContentControl.Triggers> <EventTrigger RoutedEvent="ContentControl.ContentChanged"> <BeginStoryboard> <Storyboard> <DoubleAnimation Storyboard.TargetProperty="Opacity" From="0" To="1" Duration="0:0:0.15"/> </Storyboard> </BeginStoryboard> </EventTrigger> </ContentControl.Triggers> </ContentControl>

这个动画只做了 150 毫秒的淡入,不夸张、不延误操作感,但切页面时明显比生硬的闪现舒服。过度动画这类东西要克制,图书管理系统是工具型软件,炫技动画做多了反而拖慢使用节奏。

5. 图书管理核心业务功能的实现要点

5.1 图书档案 CRUD 与输入校验

图书信息录入界面有书名、作者、ISBN、分类、库存数量、价格等等。因为要绑定 MaterialDesign 的浮升标签文本框,必填校验我用了 WPF 自带的ValidationRule,配合IDataErrorInfo在 ViewModel 层做二次校验。

在 XAML 里加一个模糊触发校验:

<TextBox Style="{StaticResource MaterialDesignOutlinedTextBox}"> <TextBox.Text> <Binding Path="Book.Title" UpdateSourceTrigger="LostFocus"> <Binding.ValidationRules> <local:RequiredFieldValidationRule/> </Binding.ValidationRules> </Binding> </TextBox.Text> </TextBox>

对应的RequiredFieldValidationRule只需要继承ValidationRule,重写Validate方法,返回ValidationResult.Error并提供错误信息。界面会显示红色边框和提示文字,不需要人为控制样式切换。

这里有一个体验细节:UpdateSourceTrigger我用了LostFocus而不是PropertyChanged,否则用户打字时每敲一个字符就会触发一次校验,浮升标签旁边会不停闪红。等用户填完这个字段、聚焦离开再校验,心理压力会小很多。

5.2 借阅与归还的业务流程:状态机与并发

图书借阅的流程并不是简单的INSERT一条记录。一本书有库存数量和可借数量,借出时要扣减,归还时要加回,同时要生成借阅记录。

我把借书定义为三步:检查读者是否存在、检查图书库存是否大于 0、插入借阅记录并减少库存。这三步必须放在同一个数据库事务里,否则出现“记录插了但库存没扣”的数据错乱,后面怎么查都对不上。

Dapper 的事务写法:

public async Task<bool> BorrowBookAsync(int readerId, int bookId) { using var connection = _db.CreateConnection(); using var transaction = connection.BeginTransaction(); try { var book = await connection.QueryFirstOrDefaultAsync<Book>( "SELECT * FROM Books WHERE Id = @BookId FOR UPDATE", new { BookId = bookId }, transaction); if (book == null || book.Stock <= 0) { transaction.Rollback(); return false; } await connection.ExecuteAsync(@" UPDATE Books SET Stock = Stock - 1, BorrowedCount = BorrowedCount + 1 WHERE Id = @BookId", new { BookId = bookId }, transaction); await connection.ExecuteAsync(@" INSERT INTO BorrowRecords (ReaderId, BookId, BorrowDate, DueDate, Status) VALUES (@ReaderId, @BookId, @BorrowDate, @DueDate, 'Borrowed')", new { ReaderId = readerId, BookId = bookId, BorrowDate = DateTime.Now, DueDate = DateTime.Now.AddDays(30) }, transaction); transaction.Commit(); return true; } catch { transaction.Rollback(); throw; } }

FOR UPDATE是把对应图书的行锁住,防止两个人同时借同一本书时出现库存扣成负数。

归还流程类似,但需要注意逾期处理。逾期费这件事我没有做得很复杂,只在归还时判断DueDate是否早于当前时间,弹出一个确认对话框,提示用户“该读者逾期 X 天”,然后继续走归还流程。逾期天数写入记录,方便统计。

5.3 搜索与分页的实现

图书列表的搜索框支持书名、作者、ISBN 模糊匹配,但这里有一个隐患:直接用参数化 SQL 做LIKE '%关键词%',性能在小数据量下没问题,但长远看还是应当做分页。

我做了最简单的分页方式:SQL 里用OFFSET ... ROWS FETCH NEXT ... ROWS ONLY,配合页码和每页条数。Dapper 的查询参数增加Offset和PageSize。

SELECT b.*, c.CategoryName FROM Books b INNER JOIN Categories c ON b.CategoryId = c.Id WHERE b.Title LIKE @Keyword OR b.Author LIKE @Keyword OR b.ISBN LIKE @Keyword ORDER BY b.Id OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY;

ViewModel 里维护CurrentPage和PageSize,点“下一页”时重新查询。翻页时还要拉一下总条数来计算页数,这里可以额外执行一个SELECT COUNT(*)。

真正影响体验的是搜索的“防抖”。如果用户每次按键都触发查询,图书量一大就会产生大量无效 SQL。我在 ViewModel 里给搜索命令加了一百毫秒的延迟:

private async Task SearchAsync() { using var cts = new CancellationTokenSource(500); try { await Task.Delay(150, cts.Token); } catch (TaskCanceledException) { return; } Books = await _bookRepository.SearchBooksAsync(Keyword, CurrentPage, PageSize); }

用户在连续打字时,每次新输入都会取消上一次延迟,只有停顿时才真正发查询,体验明显更跟手。

6. 踩坑记录:从编译失败到运行时卡顿的完整排查链路

6.1 MaterialDesign 与 Prism 的版本兼容问题

做这个项目时我遇到的最早的坑,是 MaterialDesignThemes 的某个新版本需要 .NET 5 以上,而 Prism 8 某条主线还在用旧的 .NET Core 3.1 目标框架。如果直接从 NuGet 拉最高版本,编译时会报一堆程序集版本不匹配。

排查过程是这样的:项目建的是 .NET 6.0,引用 Prism 8.1 和 MaterialDesignThemes 4.0,编译本身能过,但运行到资源字典加载时抛 XamlParseException,提示找不到MaterialDesignCard资源。我一开始以为是 XAML 拼写问题,检查了半天都正确。后来把 MaterialDesignThemes 降级到 3.2,才恢复正常。

这个问题的根本原因是 ResourceDictionary 的 Source 格式需要匹配具体的包版本。4.x 的样式资源路径比 3.x 多了一层。把 MaterialDesignThemes 锁在 3.x 的稳定版,配合 Prism 8 的依赖交集,是最省心的组合。如果你想用 4.x,记得把 XAML 里的组件资源地址也一起升级,不能混用。

6.2 Prism 8 EventAggregator 的事件订阅内存泄漏

借阅成功后,我需要通知图书列表页刷新库存。因为图书列表和借阅操作页是不同的模块,不方便直接用导航参数回传,所以我用了 Prism 的EventAggregator发一个BorrowCompletedEvent。

一开始的写法:

_eventAggregator.GetEvent<BorrowCompletedEvent>().Subscribe(RefreshBooks);

Prism 8 的这个订阅默认是弱引用,理论上不会导致泄漏。但当我改用 lambda 表达式时:

_eventAggregator.GetEvent<BorrowCompletedEvent>().Subscribe(async () => await RefreshBooksAsync());

问题就出现了:lambda 捕获了this,编译器生成一个委托,弱引用机制不一定能正确处理,导致页面关闭后 ViewModel 并没有被回收。我后来在OnNavigatedFrom里显式调用Unsubscribe,才彻底解决。

提示:在 WPF + Prism 项目里,事件的订阅和退订一定要成对出现。尤其是页面生命周期较短的 ViewModel,OnNavigatedFrom是退订事件的最佳位置。

6.3 SQL Server 连接池耗尽问题

项目跑到后期,我偶尔会在日志里看到System.InvalidOperationException: Timeout expired. The timeout period elapsed prior to obtaining a connection from the pool. This may have occurred because all pooled connections were in use and max pool size was reached.

第一反应是数据库连接数设太小。但排查后发现问题出在自己身上:有几处仓储方法用了裸SqlConnection,但在异常分支里没有关闭连接,连接池被占满,后续查询全部排队超时。

修复方式是统一使用using var connection = CreateConnection();,并在CreateConnection里设置合理的连接超时时间。这个错误是最隐蔽的,因为程序不是一开始就崩,而是运行一段时间后随机卡死。如果你的应用也出现偶发卡顿,建议先检查所有打开连接的地方是否都有Dispose。

6.4 绑定失效和线程更新 UI 的坑

WPF 里 UI 线程是不能被后台线程直接更新的。Dapper 的异步方法默认不会回到 UI 线程,如果一个属性没有通过Dispatcher更新,界面可能不刷新,或者直接报NotSupportedException。

我在第一次跑异步加载图书列表时,列表控件一直空白,但数据确实查回来了。因为Books属性是普通ObservableCollection,在后台线程里给它Add,WPF 直接忽略。后来我在 ViewModel 的属性绑定里确保所有 UI 相关属性的赋值都发生回 UI 线程,最简单的方式是依赖AsyncRelayCommand自动捕获上下文——但 Prism 的DelegateCommand不保证这一点。

所以全部命令我都改成了自定义的AsyncRelayCommand,内部用await之后回到调用线程上下文,这样属性刷新不会丢。

6.5 发布部署时的运行时缺失问题

dotnet publish默认可能不会把 SQL Server 的原生客户端相关的依赖全部打包出来,如果部署到没有联网的机器上,连接数据库会报Could not load file or assembly。

这个问题的排查路径是:本地跑没问题,复制文件夹到另外一台机器跑不起来。后来我改用dotnet publish -r win-x64 --self-contained,把运行时和依赖全部带上,才稳定。

图书管理系统这种内部工具,部署目标机器的环境不可控,自包含发布是最稳妥的方案。虽然体积大一倍,但省去在客户机器上安装各种运行库的折腾。

最后再分享一点个人体会

做完这套系统,我最大的感受是:WPF 并没有过时,它只是长期被固化的“企业级丑界面”印象拖累了。MaterialDesign 只是提供了样式基础,真正决定界面质感的是你对布局间距、卡片层级、字体大小、反馈反馈这些细节的控制。技术栈不是 UI 丑的理由,懒惰才是。

如果你准备动手做一个类似的系统,我建议不要一上来就追求把所有功能做完。先搭好 Prism 的壳子,把图书列表这一条链路完整跑通——数据库建表、仓储查询、ViewModel 绑定、MaterialDesign 卡片展示——然后再去补其他模块。骨架顺了,后面加功能只是复制粘贴的事。

另外要特别提醒:任何项目里数据库的事务和连接管理都要当成核心逻辑来对待。UI 做得再漂亮,借阅时库存扣错了,用户对你的评价只会是“这系统垃圾”。系统的可信度始终建立在数据正确性上。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进