ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

WPF打造大学选课系统:MVVM架构与内存仓储实战复盘

WPF打造大学选课系统:MVVM架构与内存仓储实战复盘 简介这是一份面向计算机专业本科生及WPF初学者的实战型毕业设计与大作业参考项目聚焦C#语言在WPF框架下构建高校选课系统的完整实现。资源解决多角色权限管理、课程数据维护、用户信息操作等典型业务场景开发难题特别适合课程设计、实训开发与技术入门实践。压缩包共114个文件含27个核心C#逻辑文件、6个XAML界面定义文件、21个PNG图标资源及2个可直接运行的EXE程序辅以ICO、BAML、CSProj等工程必需文件整体仅503KB轻量易导入VS调试。已有25578人学习下载热度高、反馈好。源码注释详尽、结构清晰、功能解耦涵盖登录验证、密码修改、课程增删查改、个人信息维护等模块支持开箱即用同时提供独立可执行程序与完整VS解决方案便于快速体验效果、理解MVVM雏形与WPF数据绑定机制。 前两个月有朋友问我想找一个拿 WPF 写的小项目给新人练手最好功能完整、能跑起来又不至于把战场铺到数据库设计上去。我说那不如我直接把大学选课系统那个 Demo 翻出来讲讲——C# 服务端逻辑、WPF 做界面该有的增删改查、筛选校验都有但数据层用内存仓储顶住新人拿到就能看懂主链路。这篇文章就当作一次复盘。我不打算只贴代码而是把当时设计这个项目时的思考过程、踩过的坑、为什么要用某种写法都讲清楚。如果你正准备学 WPF或者想找一个能放进简历的项目练手这个选题的含金量其实比想象中高界面复杂度适中业务规则又能把委托、集合、绑定、命令这几个 WPF 高频知识点全部串起来。1. 为什么要用WPF写大学选课系统功能边界与选型思路1.1 一个选课系统到底要管哪些事很多新手一听到大学选课系统就下意识觉得要做成网页端但其实桌面端配合 WPF 做内部演示或教学系统非常常见。从功能讲一个最小可用版本至少包含四块学生账号登录、课程信息浏览与筛选、选课/退课操作、已选课程和学分统计。再往细一点拆就是课程名称、授课教师、所属院系、学分、容量、已选人数、上课时间这些字段几乎就是大学教务系统里课程表的一行数据。我建议不要把范围扩大什么成绩录入、教师排课、教室冲突检测、在线抢课并发控制这些对于练手项目来说都是负担。核心目标应该是把学生视角的选课流程走通登录进来看到课表按条件筛选选中一门课点选课按钮系统检查会不会和已选课程冲突会不会超学分没问题就加入已选列表同时把课程剩余名额减一。这套闭环做完WPF 的 MVVM、数据绑定、命令、集合通知这些知识点基本就全覆盖了。1.2 为什么是WPF而不是WinForms或Web我见过太多选课系统 Demo 还停留在 WinForms 时代——双击按钮写 Click 事件SQL 语句直接拼在界面层里。不是说 WinForms 不能写但对一个教学向项目来说它的界面和逻辑耦合得太紧后面每改一次需求都要去翻窗体代码新人很容易养成坏习惯。Web 端当然也是一个思路但选课系统要频繁操作表格、弹窗确认、实时刷新学分Web 端要做得顺手需要引入前端框架对纯后端转过来的同学学习成本反而更大。WPF 最核心的价值在于数据绑定和 MVVM。界面不直接操作业务数据按钮只是绑定一条命令列表只是绑定一个 ObservableCollection。这个分层方式让界面和业务可以分开测试后续想换皮肤、加功能、甚至把 UI 层换成 Web API 都相对容易。对大学选课系统这种数据变化频繁、界面反馈要及时的场景WPF 的 INotifyPropertyChanged ICommand 机制简直是天生对口。1.3 数据层别急着上数据库内存仓储的取舍这一点是我最想强调的。很多教程一上来就建 SqlConnection、装 Entity Framework、写建表脚本结果新手还没看懂选课逻辑先被数据库连接字符串和迁移命令劝退了。选课系统核心是选课规则不是数据库设计。所以我当时决定数据层先用内存仓储模拟课程表和用户表在内存里初始化选课结果通过 JSON 序列化保存到本地文件。内存仓储的本质就是把数据库接口先定义好后续换真数据库时不用动业务层。我在项目里单独建了一个 Repository 层接口暴露 GetAllCourses、GetStudents、Save 这几个方法内部实现用的是 List 和 Dictionary。这样做的另一个好处是单元测试极其方便不需要真数据库就能把冲突检测、学分上限这些规则测一遍。等 Demo 跑通有精力再把仓储实现换成 SQLite那只是替换一个实现类的问题。2. 项目骨架三个层次怎么摆2.1 实体类课程表上的一行数据该有哪些字段项目第一步是定义实体。我建了三个实体类Student、Course、ClassTime。Student 只保存学号、姓名、密码、专业以及该学生已选的课程集合。Course 保存课程号、课程名、教师、院系、学分、容量、已选人数还有一个 List 。ClassTime 是专门用来表达这门课什么时间上的实体字段包括 DayOfWeek、StartPeriod从第几节开始、PeriodCount持续几节。StudentId、CourseId 这种主键字段要尽早加后面做去重、关联、比较都用得上。注意实体类里不要直接加 INotifyPropertyChanged属性通知是 ViewModel 层的事情实体类保持纯数据即可。如果你实在想要偷懒可以给实体类也加上通知但那样做会让实体和 ViewModel 职责混乱代码越多越难维护。2.2 仓储与业务服务把规则从界面里剥出来实体定义好之后接着建 Service 层。我用 CourseService 统一管理课程相关操作CourseService 里有两个方法值得拿出来说CanSelect 和 SelectCourse。CanSelect 是规则校验返回值是一个 bool外加一个 out string reason 传出失败原因。选课规则一共有四道闸门该学生是否已经选过这门课、课程是否已满员、加上这门课后是否超过学分上限、上课时间是否与已选课程冲突。这些判断不能放在 ViewModel 里。原因是 ViewModel 很容易被界面事件干扰而业务规则需要稳定、可测试。我把所有规则集中在 CourseService 里ViewModel 只管调用和展示这样做后面对账、加规则、写单元测试都是顺手的事。public class CourseService { private readonly CourseRepository _repository; public CourseService(CourseRepository repository) { _repository repository; } public bool CanSelect(Student student, Course course, out string reason) { if (student.SelectedCourses.Any(c c.CourseId course.CourseId)) { reason 你已经选过这门课了; return false; } if (course.SelectedCount course.Capacity) { reason 该课程容量已满; return false; } if (student.SelectedCourses.Sum(c c.Credit) course.Credit 25) { reason 总学分将超过25分上限; return false; } if (IsTimeConflict(student.SelectedCourses, course)) { reason 上课时间与已选课程冲突; return false; } reason string.Empty; return true; } public void SelectCourse(Student student, Course course) { course.SelectedCount; student.SelectedCourses.Add(course); } }2.3 ViewModel与视图MVVM在这里的边界MVVM 在这个项目里的边界很简单View 只负责布局和绑定的声明ViewModel 负责把所有交互命令和属性暴露出来Model 和 Service 负责数据和规则。我创建了 MainViewModel里面有 ObservableCollection AvailableCourses、ObservableCollection SelectedCourses、SelectedCourse、CurrentStudent、TotalCredit 这些属性。这里有个实践经验集合属性用 ObservableCollection普通标量属性用 INotifyPropertyChanged 手动触发。为什么不用 List因为 List 在元素增删时不会通知界面重绘界面上的 DataGrid 就永远刷新不出来。很多人第一次写 WPF 项目就栽在这界面上数据明明在变就是显示不出来一查发现集合用的是 List。RelayCommand 是 WPF 里最常见的 ICommand 实现。我提供了一个简单的通用版本构造函数接收 Execute 和 CanExecute 两个委托。CanExecute 返回 false 时按钮会自动置灰这是 WPF 命令机制最舒服的一点你不用手动去改按钮的 IsEnabled。3. 界面层核心交互从登录到课程列表3.1 登录框的校验逻辑登录窗口我用的是经典设计学号输入框、密码输入框、登录按钮。学号用 TextBox密码用 PasswordBox。StudentService 内部维护一个 Dictionarystring, Student以学号为 key。为什么用 Dictionary因为登录时只按学号精确查找字典查找是 O(1)比遍历 List 快得多也能保证学号唯一。校验逻辑就是先从字典里取学生取不到直接提示学号不存在取到了再比较密码不匹配则提示密码错误。登录成功后将 CurrentStudent 传到主窗口通过构造函数注入不在窗口之间用全局静态变量这是保持模块独立的小习惯。密码我默认初始化为 123456真实项目里肯定要加哈希和盐但作为教学 Demo这个点可以先放一放。3.2 主窗口布局与DataGrid配置主窗口用 Grid 分三块区域左侧是课程列表比例占 7右侧是已选课程列表和学分信息占 3底部放一个状态栏显示当前登录学生和总学分。课程列表的载体用 DataGridAutoGenerateColumns 设为 False手动定义列。这样每列显示什么、宽度多宽、格式如何都完全可控。DataGrid 的列我用了三种模板普通文本列显示课程名、教师、学分模板列放一个选课按钮还有一个列直接用绑定显示剩余容量并用 DataTrigger 在容量为 0 时把字体变成红色。这些样式用 WPF 写起来非常顺手前提是你要把绑定路径写对。我这里要强调一下DataGrid 的 SelectedItem 必须绑定到 ViewModel 里的 SelectedCourse 属性而且要设为 TwoWay。很多人在这一步会漏掉 UpdateSourceTrigger导致点击行后 ViewModel 里拿不到当前选中课程后面命令执行时总是面向 null 操作。3.3 分组、筛选与只看可选让列表真正可操作光把课程列出来还不够一门一门翻太累了。我给课程列表加了三个筛选维度关键字搜索框、院系下拉框、还有一个 CheckBox 用来勾选只看可选课程。筛选不重新组装集合而是通过 CollectionView 的 Filter 属性做。CollectionView 相当于集合的视图它负责排序、筛选、分组原始集合本身不动。用 CollectionView 的优势是筛选后界面自动刷新而且保留选中状态、分组信息这些视图层面的东西。如果重新组装集合会丢失 DataGrid 的选中项目这恰恰是第 5 节要讲的坑之一。分组用 CollectionViewSource.GetDefaultView 拿到默认视图后加一个 PropertyGroupDescription按 Course.Department 分组DataGrid 就会自动出现分组头。CheckBox 的样式我简化处理只绑定了 IsChecked 到 OnlyAvailable 属性在 setter 里触发筛选刷新。不要为了好看去从零做模板WPF 的默认 CheckBox 已经够用新手把时间花在业务逻辑上更值。4. 选课与退课的业务逻辑冲突检测和学分约束4.1 选课命令的完整链路按钮到方法选课按钮绑定的命令是 SelectCommand命令内部调 CourseService.CanSelect通过 out reason 拿到失败原因如果校验不通过弹 MessageBox 提示通过后调用 SelectCourse把课程加入 SelectedCourses重新刷新 AvailableCourses 的视图最后触发 OnPropertyChanged(nameof(TotalCredit))。CanExecute 在这里的作用是当 SelectedCourse 为 null 时选课按钮置灰。这个细节很关键它避免用户没选任何课程时误点按钮。WPF 的 CommandManager 会自动在界面状态变化时重新查询 CanExecute所以只要你正确绑定了 SelectedItem按钮的可用性就能自动跟随选中状态变化。有一个顺序上的小坑先做校验再做刷新操作。我以前遇到过把刷新写在选课之前的版本结果是按钮点击后列表先被 Filter 刷了一遍SelectedCourse 被重置为 null后面的取课程对象就成了空引用。排查了半天才意识到是顺序问题。4.2 时间冲突检测为什么用分钟而不是直接比较节次课程时间是一个很容易写错的地方。假设你只在 ClassTime 里存了 DayOfWeek 和 StartPeriod然后判断冲突时去比较周几和节次是否相同看起来没问题但真实情况是周一第1-2节和周一第2-3节是有交集的——因为第2节被重叠了。直接比较字符串1-2和2-3永远得不到交集结论。解决方案是把节次换算成绝对分钟区间再做区间重叠判断。假设每节课 50 分钟课间休息 10 分钟那么第1节课开始时间是 0 分钟结束是 50第2节开始是 60结束是 110第3节开始是 120。一门课如果持续两节它占用的区间就是 [start, end]其中 end start periodCount * 50 (periodCount - 1) * 10。这个换算规则很简单但一旦想清楚后面的区间重叠判断就非常简洁。public static (int Start, int End) ToMinutes(ClassTime t) { int start (t.StartPeriod - 1) * 60; int end start t.PeriodCount * 50 (t.PeriodCount - 1) * 10; return (start, end); } public static bool IsTimeConflict(ClassTime a, ClassTime b) { if (a.Day ! b.Day) return false; var (startA, endA) ToMinutes(a); var (startB, endB) ToMinutes(b); return startA endB startB endA; }两个区间重叠的判断标准是A 的开始时间小于 B 的结束时间且 B 的开始时间小于 A 的结束时间。这个公式叫作区间相交判断通用且不容易记错。选课系统只需要遍历学生已选课程的 ClassTime 列表和待选课程的每个 ClassTime 做一次判断即可。4.3 退课与名额回收退课逻辑比选课简单但也要注意一致性。退课时要把课程对象的 SelectedCount 减一从学生 SelectedCourses 集合里移除然后刷新学分、刷新课程列表。名额回收这个动作经常被忽略很多人只做移除集合操作结果课程容量没有恢复别人还是选不了这堂课。退课操作同样通过命令完成我也给它加了 CanExecute只有当 SelectedCourse 存在于当前学生的已选课程中时才允许执行。退课后要触发 OnPropertyChanged 通知 TotalCredit 更新同时刷新课程列表视图让剩余名额重新显示。这个闭环看起来平平无奇但实际项目里最容易漏的就是各处的状态保持同步。5. 踩坑实录我在这个项目里翻过的车5.1 DataGrid刷新后SelectedItem变成null这个坑我在上面已经预告了。现象是DataGrid 中点击一行课程再点选课按钮命令却报参数为 null。排查链路是这样的先给 SelectCommand 加断点发现 SelectedCourse 确实有值但进入 Execute 方法后第一次访问就抛空引用异常说明数据在命令执行前被重置了。再看执行顺序原来我在选课逻辑里先调了 RefreshCourses()这个方法内部调用了 CoursesView.Refresh()而 Refresh 会重置 DataGrid 的选中状态。解决办法是把选中项先存到一个局部变量里在刷新操作之后继续用这个局部变量而不是直接访问属性。更稳妥的做法是刷新之后根据课程 ID 重新从集合中查找并恢复选中项。这个坑给我最大的教训是涉及 DataGrid 的操作顺序很重要刷新和读选中值永远不要颠倒。5.2 ObservableCollection跨线程更新导致列表空白有段时间我想模拟课程容量实时变化的效果于是用 Task.Run 在后台线程定时修改课程数据结果发现课程列表纹丝不动既不报错也不刷新。后来查找资料才意识到ObservableCollection 只会在 UI 线程触发 CollectionChanged 时通知界面更新后台线程修改集合时界面收不到通知偶尔还会抛出调用线程无法访问此对象的异常。解决办法是把数据修改扔回 UI 线程执行用 Application.Current.Dispatcher.Invoke 包一层。在实际的选课系统里后台线程场景主要是异步加载数据和模拟并发选课。我给新手的建议是只要涉及集合变更确保所有 Add/Remove 都发生在 UI 线程或者使用专门封装过的线程安全集合不要指望默认行为。5.3 总学分不更新绑定字段放错了位置一开始我把 TotalCredit 设计成学生实体上的普通属性界面绑定到 CurrentStudent.TotalCredit结果选课成功后学分纹丝不动。原因很简单实体类没有实现 INotifyPropertyChanged属性值改变时界面根本不知道。正确的做法是把 TotalCredit 放到 MainViewModel 中并且通过已选课程集合实时计算出来。绑定到 ViewModel 属性然后在任何修改集合的地方统一调用 OnPropertyChanged(nameof(TotalCredit))。虽然写起来要手动多调几行通知但它让数据流变得清晰所有学生相关状态统一经过 ViewModel 暴露给界面不依赖实体类的额外实现。5.4 时间冲突漏判字符串比较在真实课表下的失灵最初实现冲突检测时我用的是周几开始节次的字符串比较以为周一第1-2节和周一第3-4节只要起始节次不同就不冲突。结果遇到周一第2-3节这种跨节次课程后检测直接漏掉了。真实的大学课表里课程常常是两节连上甚至三节连上只比较起始节次完全不可靠。这里我吃到的教训是设计数据模型时宁可多花一点时间定义清晰的时间粒度也不要为了省事偷懒用模糊字段。统一用 StartPeriod 和 PeriodCount 表达课程时长冲突检测时再换算成分钟区间任何跨节次组合都能正确判断。这个改动虽然在前期看起来代码多了不少但换来了逻辑的正确性和可测试性。为了让你直观感受几个高频问题我整理了一个速查表现象根本原因修复方向DataGrid刷新后选中的课程取不到Refresh重置SelectedItem先缓存局部变量刷新后恢复选中后台线程改集合界面不刷新ObservableCollection跨线程限制用Dispatcher.Invoke调度到UI线程学分不更新实体类没有属性通知把可显示属性提升到ViewModel并触发OnPropertyChanged冲突检测漏判直接用节次字符串比较统一换算成分钟区间再做相交判断6. 项目还能往哪走三个可行的扩展方向6.1 把仓储换成SQLite现阶段数据是进程内内存和 JSON 文件重启后虽然数据还在但并发场景下不够可靠。下一步可以把 CourseRepository 换成一个 SQLite 实现课程表字段不变接口保持不变业务层几乎不用动。EF Core 或 Dapper 都可以我建议选 Dapper轻量、SQL 可控、写起来直观。接入数据库时注意一个问题课程 ID 要作为主键选课关系表要加唯一约束防止同一学生重复选同一门课。这份唯一约束在内存仓储中靠业务代码判断在数据库里则要靠数据库约束兜底。6.2 增加选课时间窗口与定时任务真实选课系统是分时间段开放的比如2025年3月1日到3月7日可以选课。可以在 CourseService 里加一个 IsOpen 判断时间窗口外不允许选课和退课。窗口的开启状态可以用 System.Timers.Timer 定时刷新界面提示这就是 c# 定时任务在桌面项目里最常见的用法。定时事件触发后更新一个全局状态属性界面通过绑定自动显示选课已开始/已结束。这个扩展的难点不在定时器本身而是要让定时器触发的状态变更能被 WPF 界面感知。做法是定时器里调用 Dispatcher 跳回 UI 线程再修改 ViewModel 属性。对这个项目来说这是把事件和线程调度两个知识点串起来的最简单练法。6.3 导出课表到Excel很多教务系统需要把已选课程导出成 Excel 或 CSV。WPF 项目里做这件事最稳妥的方式是生成 CSV 文件然后用 Office Open XML 或第三方库转成 xlsx。CSV 导出本身很简单遍历已选课程集合把字段拼接成带分隔符的文本写文件时注意 UTF-8 带 BOM不然 Excel 打开会乱码。如果你想把课表做成按周展示的视图可以考虑用 ItemsControl 嵌套外层按星期分组内层按节次排布。但这一块要谨慎涉及嵌套模板和数据绑定的配合建议在基础功能全部稳定之后再动手别一上来就做花哨的课表视图。这套选课系统我后来拿给几个刚学 C# 的朋友看他们最大的收获不是学会了某个控件而是理解了界面、逻辑、数据这三个角色各管一摊事改动需求时不用从头翻一遍代码。写 WPF 项目最容易陷进去的状态是急着堆控件、堆事件写完一个跑起来就算完事但其实把数据流捋顺了后面的每一个功能都是在已有的干净结构上加砖这比任何需求文档都有用。本文还有配套的精品资源点击获取
返回列表