简介:这是一份基于 Visual C++ 6.0 MFC 框架开发的桌面版考试系统完整工程包,面向有 C++ 基础、想在 Windows 桌面应用与 Access 数据库结合场景中进阶的读者,也可直接用于课程设计或项目参考。系统覆盖用户注册登录、试题维护、答题管理、成绩管理与统计分析等模块,后台使用 Access 数据库并通过 ADO 方式连接,代码中包含对话框控件与数据库操作之间的完整调用链。压缩包共 57 个文件,以 19 个 .cpp 与 19 个 .h 源码文件为主,辅以 .rc 界面资源、.bmp/.ico 图标素材、项目工程配置文件和 exam.mdb 示例数据库,整体仅约 356KB。已有 210 人学习,适合对照 MFC 对话框、列表控件、文档/视图架构与 ADO 数据访问代码,梳理一套小型考试系统的完整开发思路。资源内用户管理、试题管理、成绩管理均包含可运行的界面与后端交互逻辑,可直接打开工程阅读或二次扩展。
1. 还在用 Visual C++ 6.0 写考试系统?先别急着换框架
如果你接手过一个老单位的教务系统,大概率见过这种场景:一台 Windows XP 工控机,装了 Visual C++ 6.0,跑着一个界面灰扑扑但稳定运行了十来年的考试程序。别笑,在很多高校机房、职业技能鉴定站、企业内部考核场景里,这种基于 Visual C++ 6.0 MFC 开发的桌面版考试系统至今仍在服役。原因很实际:硬件老、系统旧、换框架成本高,而 VC6.0 + MFC 编译出来的原生 Win32 程序,在低配机器上跑得比任何带运行时依赖的新框架都稳。这篇笔记要讲的,就是如何用 MFC 的文档/视图架构、ODBC 数据库访问和定时器机制,从零搭一个能真正投入使用的桌面考试系统——包含题库管理、随机抽题、答题计时、自动评分和成绩留存,并附上我在实际部署中踩过的坑。
适合谁看?两类人。一类是刚被分配去维护老考试系统的工程师,需要快速看懂代码结构并改需求;另一类是学校或企业内部想自建无纸化考试平台,但不想上 Web 端、不想引入 Electron 之类重依赖的技术选型者。读完你会知道:这套方案能做什么、怎么做、哪些地方容易翻车。
2. 考试系统的骨架:MFC 文档/视图架构与类拆分
2.1 为什么考试系统适合用文档/视图架构而不是对话框
很多人写 MFC 程序习惯直接拖一个 Dialog,考试系统也照搬。但实际做过就知道,对话框适合表单类交互,而考试系统是一个典型的「数据 + 展示分离」场景:题库数据要在多个视图之间共享,答题状态要在切换题型时保持一致,考试倒计时要全局生效——这些用 CDocument 持有数据、CView 负责渲染的模式会省力很多。
MFC 的文档/视图架构(Doc/View)本质上是 MVC 的变体,CDocument 负责管理数据模型,CView 负责把模型画到窗口上。考试系统里,我把 CDocument 子类命名为 CExamDoc,它负责三件事:加载题库文件、保存考生作答记录、计算最终得分。窗口上所有控件(下一题按钮、答案选项、状态栏倒计时)都归 CExamView 管,它只做一件事:从 CExamDoc 拿当前题目,显示给用户,再把用户的选择写回文档。
这种拆分带来的直接好处是:如果某天你想从单机版升级成局域网版,只需要改 CExamDoc 的数据加载部分——从读本地文件换成读共享数据库——视图层完全不用动。我第一次没这么做,把所有逻辑堆在 View 里,后来加一个「按题型顺序出题」的需求,改了三天,血泪教训。
2.2 关键类的职责划分与消息映射设计
下面是我会推荐的类结构清单。注意每个类的职责边界要清晰,否则后面加功能一定会乱。
// ExamDoc.h —— 数据模型:题库、考生、作答记录 class CExamDoc : public CDocument { public: // 题库:每道题是一个结构体,见下方 ExamQuestion CArray<ExamQuestion, ExamQuestion> m_questionBank; // 本次考试实际抽到的题号序列(保证随机但可追溯) CArray<int, int> m_paperIndexList; // 考生答案数组,下标与 m_paperIndexList 一一对应 CArray<int, int> m_userAnswerList; BOOL LoadQuestionBank(LPCTSTR lpszFilePath); // 从题库文件读取 BOOL GeneratePaper(int nQuestionCount, BOOL bRandom); // 抽题 int CalcScore(); // 自动评分 BOOL SaveResult(LPCTSTR lpszFilePath); // 写成绩文件 }; // ExamView.h —— 界面交互:显示题目、接收点击、驱动计时器 class CExamView : public CView { protected: CExamDoc* GetDocument(); // MFC 机制,从 View 拿 Doc 指针 afx_msg void OnBnClickedPrevQuestion(); // 上一题 afx_msg void OnBnClickedNextQuestion(); // 下一题 afx_msg void OnBnClickedOptionA(); // 选项 A~D 四个按钮 afx_msg void OnTimer(UINT_PTR nIDEvent); // 考试倒计时驱动 };ExamQuestion 结构体这样定义,覆盖单选、多选、判断三种常见题型,用 m_nType 区分,答案用 4 位二进制位图存——第 0 位代表 A,第 1 位代表 B,以此类推。位图的好处是单选和多选都能表达,判断题可以把 A 映射为「正确」、B 映射为「错误」。
// ExamQuestion 结构体定义 typedef struct tagExamQuestion { int m_nType; // 1=单选,2=多选,3=判断 CString m_strQuestion; // 题干文本 CString m_strOptionA; // 选项文本(判断题为"正确") CString m_strOptionB; // 选项文本(判断题为"错误") CString m_strOptionC; CString m_strOptionD; int m_nAnswer; // 位图:1=1<<0(A),2=1<<1(B),4=1<<2(C),8=1<<3(D) int m_nScore; // 本题分值 } ExamQuestion;这种拆法的核心思路是把「知识」放在 Doc 里,把「交互」放在 View 里。CExamView 有一个指向 CExamDoc 的指针(MFC 会自动维护),每次点击下一题,View 只做两件事:把上一题的用户答案写回 doc 的 m_userAnswerList,然后根据当前题号从 doc 读出下一题的内容刷新界面。状态栏的倒计时则由 View 的 OnTimer 统一处理,时间归零时调用 doc 的 CalcScore 并弹窗提示交卷。
2.3 按需抽题的随机算法与试卷结构生成
抽题逻辑是考试系统的核心之一。如果只是用 rand() 随机挑 N 道题,会连续抽到同一章节的题目,考完学生要骂娘。我一般这样做:先把题库按题型分组,再从每组按比例抽取指定数量,最后组内用洗牌算法打乱。这里用 Fisher-Yates 洗牌,避免 rand() % n 带来的分布偏差。
// 从题库生成一份试卷:按题型比例抽取,组内随机打乱 BOOL CExamDoc::GeneratePaper(int nSingleCount, int nMultiCount, int nJudgeCount) { m_paperIndexList.RemoveAll(); // 分组索引:先把各类题目的下标分别收集 CArray<int, int> arrSingle, arrMulti, arrJudge; for (int i = 0; i < m_questionBank.GetSize(); i++) { switch (m_questionBank[i].m_nType) { case 1: arrSingle.Add(i); break; case 2: arrMulti.Add(i); break; case 3: arrJudge.Add(i); break; } } // 按所需数量随机抽取(组内洗牌取前 N 个) srand((unsigned)time(NULL)); ShuffleArray(arrSingle); ShuffleArray(arrMulti); ShuffleArray(arrJudge); for (int i = 0; i < nSingleCount && i < arrSingle.GetSize(); i++) m_paperIndexList.Add(arrSingle[i]); // 多选、判断题同样处理,代码省略 return TRUE; }ShuffleArray 是一个自己写的静态工具函数:从数组尾部往前遍历,每次把当前位置和随机位置的元素交换。注意要在每次生成试卷前重新 srand,且种子用 time(NULL) 加当前进程 ID 混合,否则同一秒内启动的多个考试进程生成的试卷会完全一样。这个细节当初没注意,一次考试 30 台机器开考,前 10 台抽到的第一题完全相同,被监考老师追着问了半天。
3. 考试流程与界面交互:从答题到交卷的完整闭环
3.1 题目切换与选项状态的正确刷新
界面交互的坑比想象中多。先说题目切换。CExamView 里有一个当前题号成员 m_nCurrentIndex,点击「下一题」时,要先把当前界面上用户勾选的答案通过 GetCheck / GetCheck 读出来,写入 doc 的 m_userAnswerList,然后 m_nCurrentIndex++,再根据新题号刷新界面。很多人忘了第一步,导致用户做完第 5 题切到第 6 题再返回第 5 题时,答案已经丢了。
void CExamView::OnBnClickedNextQuestion() { CExamDoc* pDoc = GetDocument(); if (pDoc == NULL) return; // 1. 先把当前屏幕上勾选的答案保存 SaveCurrentAnswer(); // 2. 如果已经是最后一题,提示交卷 if (m_nCurrentIndex >= pDoc->m_paperIndexList.GetSize() - 1) { // 交卷前弹出确认框,避免误触 if (AfxMessageBox(_T("已是最后一题,确定交卷?"), MB_YESNO) == IDYES) { SubmitPaper(); } return; } // 3. 题号递增并刷新界面 m_nCurrentIndex++; DisplayQuestion(m_nCurrentIndex); }DisplayQuestion 要处理几件事:根据题号找到题库里的原题,把题干和四个选项填到静态文本控件上;根据题型决定四个选项按钮的可见性——判断题隐藏 C 和 D 选项;如果是多选,选项按钮要允许同时选中两项以上;最后把 m_userAnswerList 里已存过的答案恢复到控件勾选状态。这一步用「先取消所有勾选再按位图恢复」实现,比「判断当前状态是否需要翻转」要可靠得多。
这里最容易翻车的是多选和单选共用的选项按钮。我用四个 CButton 控件,风格设为 BS_AUTOCHECKBOX。在单选模式下,每次用户点击某个选项,要把其余三个选项的 SetCheck(0) 全部清掉。多选模式下则互不干扰。判断当前模式的依据就是题目类型,在 DisplayQuestion 里一次性配置好。
3.2 考试倒计时的 Timer 与状态栏联动
倒计时用 SetTimer 实现,每 1 秒触发一次 WM_TIMER 消息。为什么不用 while + Sleep?因为考试系统界面必须保持响应——用户在答题时如果有个 MessageBox 弹出来,while 循环会把界面卡死,倒计时反而走得比实际慢。MFC 的 SetTimer 是基于窗口消息的,在消息循环空闲时触发,不会阻塞用户操作。
// 在 View 的 OnInitialUpdate 中启动考试计时器 void CExamView::OnInitialUpdate() { CView::OnInitialUpdate(); // 从文档中读取考试时长(分钟),换算成秒 m_nRemainSeconds = GetDocument()->m_nExamMinutes * 60; // 设置 1 秒间隔的计时器 SetTimer(TIMER_EXAM_CLOCK, 1000, NULL); } // 每秒触发一次 void CExamView::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent == TIMER_EXAM_CLOCK) { m_nRemainSeconds--; if (m_nRemainSeconds <= 0) { // 时间到,强制交卷 KillTimer(TIMER_EXAM_CLOCK); AfxMessageBox(_T("考试时间到,系统将自动交卷")); SubmitPaper(); return; } UpdateStatusBarClock(); // 刷新状态栏时间显示 } CView::OnTimer(nIDEvent); }状态栏显示倒计时,我用的办法是给状态栏设置一个固定窗格,不随鼠标移动变化。默认的状态栏第一个窗格会实时显示鼠标所在位置的菜单提示,不适合放倒计时。做法是:在 MainFrame 的 OnCreate 里,找到状态栏的 Indicators 数组,把某个索引处的字符串 ID 替换成一个自定义的静态字符串资源,然后定时调用 SetPaneText 更新文本。注意要先把该窗格的 ID 设成 ID_SEPARATOR,否则 SetPaneText 会因为找不到 ID 对应的字符串资源而失败。
窗口大小要锁定,防止用户在考试中途拉大窗口导致布局错乱或误点最大化。在 MainFrame 的 PreCreateWindow 里改 cs.style 去掉 WS_THICKFRAME 和 WS_MAXIMIZEBOX 就行,比在 OnSize 里强制 SetWindowPos 更干净——后者会造成窗口闪烁,看起来非常业余。这个做法在 MFC 里有一个专门的名称,叫「禁止拖动窗口大小」,改成样式即可一劳永逸。
3.3 交卷处理与评分回写的完整链路
交卷分主动交卷和超时自动交卷两条路径,但最终都汇聚到 SubmitPaper 里。这个函数要做的顺序很关键:先把当前屏幕上的答案保存,再调用 doc 的 CalcScore 计算得分,然后把成绩追加写入成绩文件,最后弹窗告知考生得分并关闭窗口。关闭窗口时 MFC 会触发 OnClose,需要在里面拦截一次:如果还没交卷,就强制走交卷流程,防止考生直接点右上角 X 逃掉。
void CExamView::SubmitPaper() { CExamDoc* pDoc = GetDocument(); // 最后一道题的答案可能还留在界面上,先存 SaveCurrentAnswer(); // 计算总分 int nTotalScore = pDoc->CalcScore(); // 记录成绩(文件名带上时间戳,避免多人并发写入同一文件) CString strTime = COleDateTime::GetCurrentTime().Format(_T("%Y%m%d_%H%M%S")); CString strFileName; strFileName.Format(_T("exam_result_%s.txt"), strTime); pDoc->SaveResult(strFileName); // 展示成绩弹窗,同时给提示 CString strMsg; strMsg.Format(_T("考试完成!\r\n得分:%d 分"), nTotalScore); AfxMessageBox(strMsg, MB_OK | MB_ICONINFORMATION); // 关闭窗口 PostMessage(WM_CLOSE); }CalcScore 的评分逻辑要区分单选、多选和判断。单选和判断直接比较 m_userAnswerList 里的值与标准答案是否相等;多选则要检查「用户所选的所有位」是否和「标准答案的所有位」完全一致——注意不能只按位与结果非零判断,那样选错一个选项也算半对,很多系统在这里出了 bug。严格模式下,只有用户答案和标准答案的位图完全相等才给分。
SaveResult 的写入格式我建议用 CSV 或固定长度的文本,字段包括考号、姓名、每题答案序列、每题得分、总分。这样后续即使数据库没接通,也能在 Excel 里直接看到成绩明细。如果有人想核对某道题的标准答案和考生答案,一行数据全都有了。
4. 题库与成绩数据的落点:ODBC 访问 Access 的工程化配置
4.1 为什么 ADO 比 ODBC API 更适合这个场景
桌面考试系统的数据量不大——题库几千道题、成绩几千行——用 Access 数据库足够。访问 Access 的常见方式有两条:DAO(老掉牙,VC6 时代就已经是遗留物)、ODBC、ADO。我推荐 ADO,理由是它可以复用同一个连接对象操作数据库和读取文本文件,而且代码量和 DAO 相比少了一半,错误处理也更清晰。
ADO 在 VC6.0 里的使用方式比较特殊:需要先用#import "C:\Program Files\Common Files\System\ado\msado15.dll" no_namespace rename("EOF", "EndOfFile")导入类型库。注意最后的 rename 很关键:ADO 的 EOF 属性和 MFC 里 CFile 的 EOF 宏重名,不重命名就直接编译报错。这个坑我第一次用时整整查了一上午,最后靠啃 MSDN 才找到答案。
// ExamDoc.cpp 中加载题库:从 Access 的 QuestionBank 表读取 #import "C:\Program Files\Common Files\System\ado\msado15.dll" no_namespace rename("EOF", "EndOfFile") BOOL CExamDoc::LoadQuestionBank(LPCTSTR lpszDbPath) { _ConnectionPtr pConn; _RecordsetPtr pRs; try { // 使用 Microsoft Jet OLE DB Provider 访问 Access 2003 格式 pConn.CreateInstance(__uuidof(Connection)); CString strConn; strConn.Format( _T("Provider=Microsoft.Jet.OLEDB.4.0;Data Source=%s;"), lpszDbPath); pConn->Open((_bstr_t)strConn, "", "", adConnectUnspecified); // 执行查询 pRs.CreateInstance(__uuidof(Recordset)); pRs->Open("SELECT * FROM QuestionBank", pConn.GetInterfacePtr(), adOpenStatic, adLockReadOnly, adCmdText); while (!pRs->EndOfFile) { ExamQuestion q; q.m_nType = pRs->Fields->GetItem("QType")->Value; q.m_strQuestion = (char*)(_bstr_t)pRs->Fields->GetItem("QText")->Value; // 选项和答案的读取代码类似,省略 m_questionBank.Add(q); pRs->MoveNext(); } pRs->Close(); pConn->Close(); return TRUE; } catch (_com_error& e) { // 常见失败原因:数据库文件被占用、连接串格式错误 AfxMessageBox(e.Description()); return FALSE; } }这段代码里_bstr_t转char*是 VC6.0 时代的标准写法。现在用高版本 VS 编译时,从_bstr_t到char*的隐式转换已经被移除,需要手动(char*)_bstr_t(...)或者用CW2A宏转换。如果你的机器上装了 Office 2016 及以上,Jet 4.0 提供程序可能不再被支持,这时候要换成Microsoft.ACE.OLEDB.12.0,这是一个在部署机上最常见的坑,后文单独说。
4.2 三个最实用的题库导入脚本(SQL)
预置题库用 SQL 脚本批量插入比在程序里写死要方便得多——出题老师通常用 Excel 整理题目,导出成 CSV 后再灌进 Access 即可。我用过的、最简单可靠的三个脚本如下。
第一段:创建题库表。字段要预留扩展空间:题干最长设为 500,选项文本 200,因为有些题目带图片路径或较长公式。
CREATE TABLE QuestionBank ( QID COUNTER PRIMARY KEY, -- 自增主键 QType LONG, -- 1=单选,2=多选,3=判断 QText MEMO, -- 题干 QOptionA VARCHAR(200), QOptionB VARCHAR(200), QOptionC VARCHAR(200), QOptionD VARCHAR(200), QAnswer LONG, -- 答案位图:A=1,B=2,C=4,D=8 QScore LONG -- 分值 );第二段:从 CSV 文件批量导入。Access 的IN子句可以直接读取外部文本文件,注意 CSV 的列顺序要与 INSERT 语句一致,且文件第一行不要带表头。
INSERT INTO QuestionBank (QType, QText, QOptionA, QOptionB, QOptionC, QOptionD, QAnswer, QScore) SELECT * FROM [Text;FMT=CSV;HDR=NO;IMEX=1;DATABASE=C:\examdata].QuestionImport.csv;第三段:成绩表的建表语句。成绩表不设计外键,原因是 Access 的 Jet 引擎在处理级联删除时性能极差,而考试系统的成绩需要长期保留,不适合随题库删除而联动删除。
CREATE TABLE ExamResult ( RID COUNTER PRIMARY KEY, StudentNo VARCHAR(20), StudentName VARCHAR(50), AnswerSequence MEMO, -- 用户作答序列,如 "A,C#B,D#A" ScoreSequence MEMO, -- 每题的得分序列 TotalScore LONG, ExamDate DATETIME );这些表结构虽然简单,但在实际项目中够用且稳定。出题老师在 Excel 里改完题,存成 CSV,跑一遍第二段 SQL 就能把新题灌进库里,不需要专门做维护界面——考试系统的精力应该花在答题流程和稳定性上,而不是做一个费时费力的题库管理后台。
4.3 数据库连接失败时的降级策略
一定要给题库加载失败留降级方案。真实考场里,Windows 防火墙可能拦截 Access 的共享连接、U 盘拔太快导致数据库文件未写完、杀毒软件把 Access 的临时文件锁掉——任何一条都能让程序启动时弹出「无法打开题库」然后退出。我的做法是:程序启动时先尝试从指定路径加载;失败后弹窗让监考老师手动浏览选择题库文件;同时内置一份文本格式的紧急题库,保证在没有数据库环境的机器上也能开考。
// 启动时加载题库:数据库优先,文件降级 BOOL CExamDoc::LoadQuestionBankSmart() { // 1. 尝试标准路径的 Access 库 if (LoadQuestionBank(_T("C:\\ExamData\\questions.mdb"))) return TRUE; // 2. 失败后弹窗让用户手动选择题库文件 CFileDialog dlg(TRUE, _T("mdb"), NULL, OFN_FILEMUSTEXIST, _T("考试题库 (*.mdb)|*.mdb||"), NULL); if (dlg.DoModal() == IDOK) { return LoadQuestionBank(dlg.GetPathName()); } // 3. 最后尝试文本格式紧急题库 return LoadQuestionBankFromText(_T("C:\\ExamData\\backup_questions.txt")); }文本格式紧急题库的格式很简单:每行一道题,字段用竖线|分隔。判断题型、题干、选项、答案、分值解析出来填进 ExamQuestion 结构体即可。这个降级文件建议打印一份放在考场信封里,万一所有电子设备出问题,至少还能靠文本文件保证开考。
5. 桌面考试系统的避坑笔记:五条用时间换来的经验
5.1 现象:在 Windows 10 上启动时崩溃,报错内容指向 MFC DLL 缺失
原因:VC6.0 的工程默认使用共享 MFC DLL(MFCD42.DLL / MFC42U.DLL),而 Windows 10 及之后版本不带这些老运行库。新版系统只自带 VC2005 之后的运行库。
解决:项目属性里把「使用 MFC」改成「在静态库中使用 MFC」,或在部署机上把 VC6.0 的 redist 目录下的 MFC42.DLL、MSVCRT.DLL 拷到程序同目录。前者一劳永逸,但生成的 exe 会大一倍;后者文件数量少,适合做绿色版考试系统。我一般两个方案都做:自己的开发机用共享 DLL 加快编译,交付给考场时用静态链接重新编一版。
5.2 现象:VC6.0 的编译器不认 CString::Format 传入中文,输出乱码
原因:VC6.0 默认的字符集是 MBCS(多字节),而 CString 内部存的是窄字符,Windows 的 ANSI 编码取决于系统区域设置。在简体中文系统上没问题,但如果考试系统要部署到繁体中文或英文 Windows 上,ANSI 编码不对就是乱码。
解决:所有界面字符串统一用_T()宏包起来,文件读写用宽字节版本(CStdioFile的ReadString可以自动处理)。如果实在要用 UTF-8 保存题库文本,LoadQuestionBankFromText 里要手动把读进来的字节流做一次 CP_ACP 到 CP_UTF8 的转换,而不能直接赋给 CString。这个问题在考试系统里常见于「考生姓名输入框」——某些生僻字在 GBK 里没有映射,存进去再读出来直接变成了问号。
5.3 现象:多选题目用户选了两个正确答案之一,评分时却得了零分
原因:评分代码用的是按位与判断(userAnswer & correctAnswer) != 0,这会把「选对一半」判定为正确。更隐蔽的是,如果用户选了 A 和 C,标准答案是 A 和 D,按位与后结果是 A 的位非零,也会误给分。
解决:严格比较位图值,而不是做交集判断。if (userAnswer == correctAnswer)才是唯一正确的标准。如果题目声明「答对但不全对给一半分」,则先计算交集点数再乘分值;但绝大多数考试系统为了公平,多选必须全对才给分。我在 CalcScore 里同时保留了两种评分模式,用文档类的成员布尔变量切换——默认严格模式,需要半对模式时由监考老师在界面勾选。
5.4 现象:考试过程中点击右上角 X 不弹确认框,程序直接退出
原因:CView 的OnClose被默认的窗口销毁流程绕过了。VC6.0 里点击 X 触发的是DefWindowProc的WM_CLOSE,如果没有重写OnClose并拦截,框架就直接DestroyWindow了。
解决:在 CExamView 里重写OnClose,先检查考试是否已交卷。没交卷就弹确认框——「确定要终止考试吗?未交卷本次成绩作废!」——再决定是否调用基类的OnClose。注意里面不能直接调用CDocument::OnCloseDocument,否则会触发文档关闭,把未保存的答案也清掉了。
void CExamView::OnClose() { if (!m_bSubmitted) // 尚未交卷,需确认 { if (AfxMessageBox(_T("确定要终止考试吗?未交卷本次成绩作废!"), MB_YESNO | MB_ICONWARNING) == IDYES) { m_bTerminated = TRUE; // 标记非正常结束,成绩文件写 0 分 SubmitPaper(); return; // 由 SubmitPaper 里 PostMessage(WM_CLOSE) 再走一次本函数 } return; // 用户取消关闭,什么都不做 } CView::OnClose(); // 已交卷,正常关闭 }这里还有一个细节,PostMessage(WM_CLOSE) 之后 OnClose 会被再次触发,此时 m_bSubmitted 已经是 TRUE,会直接走基类关闭。这种二次进入的流程,比在 OnClose 里直接调 DestroyWindow 要安全。
5.5 现象:状态栏倒计时不刷新,只有鼠标移动时才变
原因:状态栏第一个窗格默认是ID_INDICATOR_xxx这类的命令路由窗格,它有自动更新机制,但只响应 WM_IDLEUPDATECMDUI。如果在 OnTimer 里SetPaneText(1, ...)更新的是错误的索引,或者没有使能窗格的SetPaneStyle,文字就不会即时绘制,等鼠标触发界面刷新才显示新值。
解决:给倒计时用一个专用的ID_SEPARATOR窗格,并把它的宽度固定。在 OnCreate 里m_wndStatusBar.SetPaneInfo(1, ID_SEPARATOR, SBPS_STRETCH | SBPS_NOBORDERS, 120);。SetPaneText 之前先m_wndStatusBar.InvalidateRect(NULL, TRUE)强制重绘。考试系统的倒计时必须做到一秒变一次,任何「延迟显示」都会让考生和监考老师认为系统卡死。
6. 从单机到考场级部署:验证清单与打包技巧
单机跑通只是第一步,真正常见的落点是一间机房 40 台机器同时开考。验证环节我一般分三步。第一步是功能回归:写一个自动化测试脚本,用脚本模拟 100 次完整答题流程——启动、随机抽题、下一题、勾选答案、交卷,每次随机选择不同答案组合,最后核对成绩文件的正确性。这套脚本我用 Python 写,通过 Win32 API 发送鼠标键盘消息驱动程序,跑一夜能找出不少偶现的界面刷新问题。
第二步是并发验证。40 台机器同时交卷写入成绩文件时,Access 的 Jet 引擎在同一秒内多个进程写同一 MDB 文件会报「文件正被另一用户使用」。我的解法是每台客户端独立写本地成绩文件(文件名带机器名和时间戳),考试结束后监考老师用一个合并工具批量导入服务器,而不是让所有机器直连数据库写成绩。这个合并工具单独写,只做一件事:遍历目录下所有exam_result_*.txt,按学号去重后入 Excel。
第三步是打包部署。VC6.0 的安装程序我一般用 Inno Setup 做,因为它可以预设静默安装 MFC 静态链接库和 ODBC 驱动注册。部署机上如果没装 Access,需要随安装包带上Microsoft.Jet.OLEDB.4.0提供程序的注册命令(通常在系统盘 Windows\System32\msjetoledb40.dll,用 regsvr32 注册一次即可)。安装界面我只放三个选项:安装目录、数据库路径、是否创建桌面快捷方式,其他全部走默认,监考老师不需要动任何配置。
最后分享一个我自己的习惯:VC6.0 工程有一个.dsp/.dsw工作区文件,升级到新版 Visual Studio 时转换向导偶尔会把资源文件搞坏。所以我建议考试系统的源码文件全部用纯文本管理(.cpp/.h/.rc 正常文本,不要存成 UTF-8 带 BOM),万一哪天要从 VC6.0 迁到 VS2022,改动量能降到最小。这也是为什么我坚持用_T()而不是裸字符串、用 SDK 风格函数而不是各种向导生成的依赖代码——为的就是今天写完,五年后有人接手还能编译得起来。希望这些踩坑经验帮到你,哪怕只躲掉其中一两个坑,也算没白写。
本文还有配套的精品资源,点击获取