简介:一套面向C#初学者与在校学生的驾照考试模拟程序源码包,完整演示了从试题库加载、随机组卷、界面交互到答案比对与自动评分的考试闭环,可帮助理解窗体应用的事件驱动编程、面向对象设计以及软件模块拆分思路。工程共42个文件,以12个.cs源文件为核心,另有.resx界面资源、.txt题库数据、可直接运行的.exe程序、项目配置文件和doc说明文档,压缩包仅498KB,体量轻、易打开,适合本地研读与二次修改。目前已有321人学习浏览,属于入门级综合实践项目。代码中包含主程序、试题库、随机试卷生成器、评分系统等模块,在随机出题、读取题目、控件事件、异常处理等环节均有清晰注释,可直观看到随机数生成、文件读取、窗体控件等实际用法。适合学完C#基础语法后作为课程设计或自学练手,也可据此扩展用户注册、成绩排名等实用功能。 新电脑装好环境之后,我顺手把以前写的一个 C# 模拟驾照考试小程序翻出来跑了一遍。这个项目功能很单纯:加载题库、随机出题、倒计时、交卷判分,就是一个标准 WinForms 桌面小应用,没有任何高深的东西。但恰恰因为它简单,C# 里最常被面试官问到的集合、字符串处理、文件读写、事件、控件与线程交互这些点,全被它串起来了。如果你刚学完 C# 基础不知道做什么项目,或者想找个能练手又不至于劝退的练功房,这个程序是个不错的参考。
1. 项目整体设计:先看清这个程序要做什么
1.1 需求拆解:一个最简考场系统需要哪些模块
做项目的第一步不是打开 Visual Studio 拖控件,而是把需求掰开揉碎。模拟驾照考试的核心流程是:加载题库、随机抽题、开始考试、答题切题、倒计时、交卷判分、看错题。如果我把它画成一个流程图,其实就是一个状态机:初始化状态、考试中状态、交卷状态。
我按功能边界把它拆成三大块:
| 模块 | 职责 | 涉及的 C# 技术点 |
|---|---|---|
| 题库管理 | 读取题目、解析、乱序 | 文件读写、JSON 序列化/反序列化、List 集合、随机算法 |
| 考试流程 | 计时、切题、记录答案 | 事件、Timer 控件、控件状态管理、Dictionary 存储 |
| 成绩输出 | 判分、错题展示 | 循环遍历、对象比较、字符串拼接、自定义类 |
这样拆分之后,项目的边界就清晰了。你不需要考虑用户登录、成绩上传、云端同步,那些都不是“学习参考”项目该干的事。把这三个模块做好,C# 基础知识已经覆盖了大半。
这里提醒一句:真实科目一考试是 100 道题、45 分钟、每题 1 分、90 分及格,判断题和单选题混合。实现的时候可以把这些参数写成常量,方便以后改成科目四的规则(科目四是 50 题、每题 2 分,同样 90 及格)。刚开始做程序时把参数写死没关系,但最好留个入口,比如在窗体上放一个 NumericUpDown,让用户自己选考试模式,这样代码的复用性会好很多。
1.2 技术选型:为什么是 C# WinForms,而不是 WPF 或网页
这个项目网上有很多实现版本,有人用 WPF,有人用 Blazor,也有人直接用网页。我最后选了 WinForms,原因很直接:它是一个 C# 入门项目,不是炫技项目。
- WinForms 的界面是所见即所得,拖控件、双击写事件,对新手极其友好。
- WPF 虽然界面更现代、数据绑定更强大,但 XAML、样式、触发器、绑定上下文这些概念对刚学完基础语法的人来说,学习曲线太陡。
- 网页方案要考虑浏览器兼容性、HTTP 请求、前后端交互,这已经偏离了“学 C#”的初衷。
另外一个选型问题是:用 .NET Framework 4.8 还是 .NET 6/8?我在做这个小项目时建议直接用 .NET 8,因为System.Text.Json是内置的,不需要额外装 NuGet 包,文件读取和JSON 解析的代码量会少很多。如果你目标机器是老 Windows,而且不想装运行时,那可以退回 .NET Framework 4.8,但 JSON 解析就得引入 Newtonsoft.Json,代码会多一层包装。这个选择没有绝对对错,适合自己当前环境就行。
2. 题库是先决条件:数据结构和加载策略
2.1 题目数据结构与存储格式
题库是整个程序的地基。我第一版用 Excel 存题目,后来放弃了,因为要引入第三方库,对一个小项目来说太重了。第二版用纯文本,用竖线分隔字段,也能跑,但一旦题目里有特殊符号,解析就容易出问题。最后换成了 JSON,这是最适合这个场景的方案。
题目实体类长这样:
public class Question { public int Id { get; set; } public string Content { get; set; } // 题干 public List<string> Options { get; set; } // 选项 A/B/C/D public string CorrectAnswer { get; set; } // 正确答案,如 "B" public string Explanation { get; set; } // 解析,虽然程序里不展示,但错题本要用 }我在第一次写的时候把选项做成了四个独立属性OptionA、OptionB、OptionC、OptionD,后来发现这是个坏味道。因为判断题本质上就是“正确/错误”两个选项,用 List 至少能统一处理两种题型,而且以后如果想加多选题,CorrectAnswer变成List<string>也能平滑过渡。数据结构的弹性,就是这么一点点试出来的。
JSON 文件中的一条题目大概长这样:
{ "Id": 1, "Content": "驾驶机动车在道路上违反道路交通安全法的行为,属于什么行为?", "Options": ["违章行为", "违法行为", "违规行为", "犯罪行为"], "CorrectAnswer": "B", "Explanation": "违反道路交通安全法属于违法行为,需承担相应法律责任。" }这种格式人眼可读、方便修改,System.Text.Json可以直接序列化,不需要任何额外配置。保存题库文件时,记得把编码保存成 UTF-8,否则 Windows 的自带记事本可能给你存成 ANSI,中文解析出来直接乱码。
2.2 题库加载与随机出题:从文件到内存的完整链路
加载题库的代码非常短,但几个小坑值得说清楚:
public List<Question> LoadQuestions(string path) { string json = File.ReadAllText(path, Encoding.UTF8); var questions = JsonSerializer.Deserialize<List<Question>>(json); return questions ?? new List<Question>(); }这里Encoding.UTF8不能省。如果题库文件是 UTF-8 带 BOM 的,File.ReadAllText不指定编码有时也能读对,但一旦文件是无 BOM 的 UTF-8,或者你换到别的机器上重新保存过,编码识别就可能出问题。写代码时显式指定编码,是最稳妥的做法。
然后是随机出题。我看网上很多实现直接questions.OrderBy(q => random.Next()),也能用,但有两个问题:一是OrderBy内部会做多次比较,性能上虽然无所谓,但排序的随机性不如洗牌算法均匀;二是面试时如果被问到“怎么打乱一个 List”,这个回答不够漂亮。
Fisher-Yates 洗牌算法是标准解法,代码短、效率高:
static void Shuffle<T>(List<T> list) { Random random = new Random(); for (int i = list.Count - 1; i > 0; i--) { int j = random.Next(i + 1); (list[i], list[j]) = (list[j], list[i]); } }从最后一个元素开始,每次在0到i之间随机选一个索引,和当前位置交换。因为每个位置都有均等概率参与交换,所以洗牌结果是均匀随机的,而且一遍遍历就完成,时间复杂度 O(n)。
有一个细节:Random实例最好在类里只创建一次,不要每次调用时new Random()。因为默认种子基于当前时间,如果短时间内连续创建多个实例,可能得到相同的随机序列。这种隐蔽 bug 在考试程序里碰到的概率不高,但一旦碰到,现象就是“每次启动程序抽到的题目一模一样”,排查起来非常浪费时间。
2.3 追加:如果题库不是 JSON 而是文本,字符串处理怎么做
我知道很多人拿到题目资源是 txt 格式,或者是一个特定格式的文本文件。这种场景下字符串拆分是个必考基本功。
假设每行格式是:
1|题目内容|选项A|选项B|选项C|选项D|B|选项解析用Split拆分是最直接的方案:
string[] parts = line.Split('|'); if (parts.Length >= 7) { var q = new Question { Id = int.Parse(parts[0]), Content = parts[1], Options = new List<string> { parts[2], parts[3], parts[4], parts[5] }, CorrectAnswer = parts[6].Trim(), Explanation = parts.Length > 7 ? parts[7] : "" }; }我一开始图省事用Substring按固定位置截取,后来题库里有一道题的题干长了些,整个解析直接崩了。用分隔符做拆分,代码的健壮性会好很多,只要保证题库文件里不出现分隔符本身就行。这里还顺带用到了int.Parse和Trim(),都是 C# 入门的高频考点。
3. 界面交互与核心逻辑落地
3.1 答题窗体布局:不要让用户找不到下一题按钮
WinForms 界面设计不需要多精美,但要符合操作直觉。我的布局是这样的:
- 顶部:一个
Label显示倒计时,一个Label显示当前题号/总题数; - 中间左侧:一个
FlowLayoutPanel放题号导航按钮,方便跳题; - 中间右侧:一个
Label显示题干,下面放四个RadioButton作为选项; - 底部:
上一题、下一题、标记本题、交卷四个按钮。
我主要用TableLayoutPanel做分栏,比自己手工调坐标省心得多,而且窗口拉伸时控件会自动布局。题号按钮是动态生成的,每道题生成一个Button,点击就定位到对应题目。这个细节能让整套界面的“考试感”非常强,而不是简单的一张表单往下滚。
关键点:题号和题目内容要一一对应,我搞了一个List<Button> questionButtons和一个List<Question> currentQuestions,两个 List 的下标天然对应,通过按钮的Tag属性存题目索引。这样跳题逻辑就非常简单了,点击按钮直接拿到Tag,转成int,调用ShowQuestion(index)。
3.2 答题状态管理:数据与界面分离,别把答案存在控件里
这是整个项目最核心的设计决策,也最容易踩坑。很多新手做这个程序,判分的时候遍历界面上的RadioButton,挨个判断哪个被选中,再结合当前题号去比对答案。这个思路看着直白,但实际用起来很痛苦——用户可能跳着答题、回看修改、最后直接交卷,你要倒回去把所有控件的状态重新读取一遍,逻辑特别绕。
我采用的方法是存数据,不存控件状态:
private Dictionary<int, string> userAnswers = new Dictionary<int, string>();每个题目的Id对应一个选项答案。用户每点一次RadioButton,就立刻把当前题目的答案写进去:
private void option_CheckedChanged(object? sender, EventArgs e) { if (_isRestoringAnswer) return; if (sender is RadioButton rb && rb.Checked) { string answer = rb.Tag.ToString(); userAnswers[currentIndex] = answer; UpdateQuestionButtonState(currentIndex); } }这里rb.Tag存的是选项字母,比如"A"、"B"。判断分数的时候直接遍历userAnswers和题库比对,不需要关心界面当前显示的是哪一题。切题、跳题、交卷,所有操作只需要维护这个字典,界面永远只是数据的投影。
还有一个细节:当用户点击“上一题”回看时,要根据字典里的答案恢复RadioButton状态。这时候需要临时设置一个_isRestoringAnswer标志位,避免恢复状态时又触发一次CheckedChanged,导致逻辑重复执行。这种“标志位防递归”的小技巧,在任何 WinForms 项目里都会用到。
3.3 倒计时功能:掌握 Timer 的正确用法
倒计时我用的是System.Windows.Forms.Timer,这几乎是 WinForms 倒计时场景的首选控件。核心代码:
private int remainSeconds = 45 * 60; public void StartTimer() { timer = new System.Windows.Forms.Timer(); timer.Interval = 1000; // 1 秒一次 timer.Tick += Timer_Tick; timer.Start(); } private void Timer_Tick(object? sender, EventArgs e) { remainSeconds--; if (remainSeconds <= 0) { timer.Stop(); SubmitExam(); } lblTime.Text = $"剩余时间:{remainSeconds / 60:00}:{remainSeconds % 60:00}"; }为什么选System.Windows.Forms.Timer而不是System.Timers.Timer?因为前者运行在 UI 线程,Tick 事件可以直接访问控件,不需要Invoke跨线程;后者的 Tick 事件在 ThreadPool 线程上触发,直接更新Label会抛出跨线程访问异常,必须写Invoke包装。对于考试倒计时这种一秒触发一次的场景,System.Windows.Forms.Timer的毫秒级误差完全可接受。
交卷的时机也要考虑好:时间到了要自动交卷,点交卷按钮也要交卷,这两个路径都必须调用同一个SubmitExam()方法。在方法内部先停止 Timer,再做判分,防止重复提交。
3.4 交卷判分:做错题本的时候顺便把判分逻辑理清楚
判分逻辑其实很简单:
private void SubmitExam() { timer.Stop(); int correct = 0; var wrongList = new List<Question>(); foreach (var q in currentQuestions) { if (userAnswers.TryGetValue(q.Id, out string answer) && answer == q.CorrectAnswer) { correct++; } else { wrongList.Add(q); } } double score = correct * (100.0 / currentQuestions.Count); bool isPass = score >= 90; ShowResult(correct, score, isPass, wrongList); }这里用TryGetValue而不是ContainsKey再取一次值,是因为一次方法调用就完成了“判断是否存在”和“取值”两个操作,代码更简洁,也避免了两处读取之间数据不一致的极小概率问题。
分数计算用100.0 / currentQuestions.Count,注意除数是double类型,如果写整数除整数,当题目数量是 100 时没问题,但一旦调整题库数量,比如只有 50 题,就会得到2.0而不是2之外的奇怪小数。这个坑我在调试时实实在在遇到过一次,特意提醒。
错题展示的窗口是另一个Form,里面放一个DataGridView,列出题干、你的答案、正确答案和解析。这样用户交卷后可以直接复盘,程序的“学习参考”价值会显得特别足。
4. 实打实的坑:从开发到打包全记录
4.1 控件跨线程访问与 Timer 选型
前面提到System.Windows.Forms.Timer跑在 UI 线程,这是最省心的选择。但如果你就是要用System.Timers.Timer(比如想精确控制回调间隔),那更新控件就必须用Invoke:
this.Invoke(new Action(() => { lblTime.Text = $"剩余时间:{remainSeconds / 60:00}:{remainSeconds % 60:00}"; }));很多新手第一次写 WinForms 计时器就踩这个坑:程序一运行,界面直接抛异常,报错信息是线程间操作无效,从不是创建控件的线程访问它。看到这个报错别慌,解决方案就是上面这行Invoke。但说实话,考试程序真没必要用System.Timers.Timer,UI 定时任务用 UI 线程的 Timer 就够了。
4.2 中文乱码、编码问题与文件路径坑
题库文件的中文乱码是 WinForms 本地程序里的高频问题。我之前在 2.2 说了用Encoding.UTF8读取,这里再补充一个:如果你不需要修改题库文件,就把题库作为“内容”放项目资源里,设置“复制到输出目录”。这样发布后题库文件就在 exe 同目录下,代码里用Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "questions.json")来定位文件。
不要写死绝对路径,比如C:\Users\xxx\Desktop\questions.json。因为用户的电脑上没有你的目录结构,程序到别人机器上就会找不到文件。这是一个非常基础但非常常见的部署问题。
4.3 高 DPI 与字体模糊问题
现在笔记本屏幕基本都是 125% 或 150% 缩放,WinForms 程序默认在高 DPI 下会发虚。解决办法是在app.manifest文件里取消注释这段:
<application xmlns="urn:schemas-microsoft-com:asm.v3"> <windowsSettings> <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness> <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true</dpiAware> </windowsSettings> </application>然后在Main方法的入口处,Application.EnableVisualStyles()之后加一行:
Application.SetHighDpiMode(HighDpiMode.PerMonitorV2);如果不处理,在 2K 屏幕上运行这个程序,界面会糊成一片,体验很差。这种问题网上资料很多,但因为是“能跑就行”项目里最容易忽略的一项。
4.4 打包发布:从 Debug 到一键安装包
程序写完要发给朋友用,总不能让对方装 Visual Studio。WinForm 打包最常用的有三条路,我分别说下:
| 方案 | 适合场景 | 缺点 |
|---|---|---|
| 直接发布文件夹 | 自己用、快速分发 | 没有安装界面 |
| VS Installer Projects 扩展 | 需要在控制面板有“卸载程序” | 需要装扩展,生成速度慢 |
| Inno Setup | 想要轻量、可控的安装包 | 脚本要花几分钟学 |
其中 Inno Setup 是我最推荐的。它完全免费,脚本写起来也不复杂。一个最简脚本大概是这样:
[Setup] AppName=驾照模拟考试 AppVersion=1.0 DefaultDirName={autopf}\DrivingExam OutputBaseFilename=DrivingExamSetup [Files] Source: "publish\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs如果你用的是 .NET 8 自包含发布,生成的一整个文件夹直接拷给朋友就能跑,连安装包都不需要做。右键项目选择“发布”,目标框架里勾选“自包含”,输出的是完整的 exe 和依赖项,虽然体积大了点,但省心。
5. 小项目也有大门道:我的几点体会
5.1 从这个小项目还能延伸出什么
做完基础功能之后,不要急着做下一个新项目,试着在这个项目上叠加需求。我列几个我觉得很有价值的改进方向:
- 选项乱序:不仅题目随机,每题内部的选项顺序也打乱,这才是真正的“模拟考试”,实现上复用一个
Shuffle方法即可; - 错题本:把交卷时收集的
wrongList序列化到本地 JSON,下次启动可以选择“只刷错题”; - 历史成绩:用 SQLite 或者简单的文本文件存储每次考试的分数和时间,做趋势曲线;
- 多题型支持:把判断题统一成两个选项,把
CorrectAnswer从string改成List<string>,就能兼容多选题。
这些改进每一个都不难,但它们会让你自然地接触更多的 C# 知识:选项乱序考验泛型方法,错题本考验序列化,历史成绩考验数据库基础。小项目的价值就在于,你可以在不花费太多精力的情况下,系统地练习一个个小的技术点。
5.2 说几点我做这个小项目的实际感受
这个程序前前后后断断续续写了两天,最后跑通的那一瞬间,感觉比做了个复杂业务系统还有成就感。总结下来,我觉得做这类练习项目有三个非常实际的意义。第一,它能把零散的语法知识串成一条线,比如集合、字符串、文件、事件,在书本上是分散的,在项目里却是一个整体。第二,它能让你直观感受到“设计决策”的重要性,比如把答案存在字典里而不是控件里,这种取舍不是靠背面试题学会的,是踩过坑之后才真正理解的。第三,它能拿得出手,作为简历上的一个具体项目,面试时聊起来,从数据模型到 UI 线程,都有实际经历可以讲。
如果你想拿这个项目练手,我的建议是:先不看任何现成代码,自己把功能清单写出来,从最简单的“加载 10 道题,顺序答题,交卷判分”开始,然后逐步加入计时、随机、错题本。每加一个功能,你都会碰到一个真实的 C# 问题。解决它,这个知识点就是你的了。
本文还有配套的精品资源,点击获取