
简介这份资源是一个基于MFC框架实现的红绿灯模拟程序面向正在学习Windows图形界面开发、希望理解文档/视图架构与事件驱动编程的C/C初学者和进阶者。程序模拟十字路口交通灯的周期性切换将MFC对话框、链表数据结构、快捷键响应等知识点融入一个可运行的小型项目中适合作为课程设计或自学练手案例。压缩包为rar格式共43个文件约42KB其中25个bmp位图用于绘制红、黄、绿及熄灭等灯态外观4个h头文件与3个cpp源文件承载对话框类与主程序逻辑另有dsp、dsw等Visual Studio工程文件及rc资源脚本、ico图标等工程结构完整可直接编译。目前已有1903人学习下载。通过阅读源码读者可以掌握CWinApp与CDialog的继承关系、消息映射与快捷键绑定方式并借鉴链表管理灯态顺序的思路为后续开发更复杂的Windows应用打下基础。1. 从消息映射到状态机MFC红绿灯程序到底在练什么很多人第一次看到“MFC实现的红绿灯程序”这个标题会以为只是画三个圆、定时变色的小把戏。但真正动手写一遍就会发现它逼着你把 Win32 消息循环、GDI 绘图、定时器精度、状态切换逻辑这四件事串起来。我带过几个刚接触 Windows 桌面开发的同学做这个模拟项目十个人里有八个卡在同一个地方用Sleep做延时界面直接假死。红绿灯程序麻雀虽小却是一个完整的“消息驱动 状态机 自绘控件”训练场。它适合谁适合已经能写简单 MFC 对话框、但对定时器机制和绘图刷新一知半解的人。把它写扎实后面做仪表盘、工控状态面板、设备监控界面底层套路完全一样。2. 先想清楚红绿灯的状态机怎么建模才不翻车2.1 为什么不能用三个布尔变量硬编码新手最容易写出的版本是这样的定义bool red, yellow, green定时器里判断当前哪个为真然后切换。这种写法在只有三个灯的时候勉强能跑但一旦要加“黄灯闪烁”“夜间模式”“紧急全红”这些需求代码立刻变成一团乱麻。血泪经验是红绿灯的本质是一个有限状态机状态数量固定、迁移条件明确应该用枚举把状态显式定义出来。常见做法是定义一个enum class LightState { Red, Green, Yellow, AllRed }每个状态对应一个持续时间再配一张迁移表。这样加需求时只改表不改逻辑。下面是我一般会用的状态定义和迁移结构// 红绿灯状态枚举显式命名避免魔法数字 enum class LightState { Red, // 红灯亮 Green, // 绿灯亮 Yellow, // 黄灯亮 AllRed // 全红过渡态用于路口清空 }; // 每个状态的持续时间毫秒集中配置便于调整 struct StateConfig { LightState state; UINT durationMs; }; // 迁移表按顺序循环执行最后回到第一个 static const StateConfig kCycle[] { { LightState::Red, 5000 }, { LightState::AllRed, 1000 }, // 红绿之间的安全间隔 { LightState::Green, 5000 }, { LightState::Yellow, 2000 }, };逻辑说明kCycle是一个静态数组程序启动时从索引 0 开始每次定时器到期就切到下一个索引走到末尾回到 0。AllRed这个过渡态是很多教学版本会漏掉的真实路口在红绿切换之间会有一个全红缓冲防止两个方向同时放行。参数方面durationMs用UINT而不是int因为SetTimer接受的是UINT类型避免隐式转换警告。持续时间我习惯用 5000/1000/5000/2000 这组值调试时把整体缩小到 500/100/500/200 能快速看完整循环。2.2 状态迁移和定时器的绑定关系状态机建好之后要解决“谁来推动状态迁移”。MFC 里有两个选择SetTimer和std::threadPostMessage。我强烈建议用SetTimer原因是它天然在 UI 线程的消息队列里排队回调里可以直接操作控件和绘图不需要加锁。用后台线程的话你得PostMessage回主线程多一层转发新手很容易在这里写出跨线程访问 GDI 对象的崩溃。绑定方式是在对话框初始化时启动定时器定时器 ID 用一个常量回调里做状态切换并重绘// 定时器 ID避免和 MFC 内部 ID 冲突选一个较大的值 #define TIMER_LIGHT_CYCLE 1001 BOOL CTrafficLightDlg::OnInitDialog() { CDialogEx::OnInitDialog(); m_nCycleIndex 0; // 用当前状态的持续时间启动定时器 UINT duration kCycle[m_nCycleIndex].durationMs; SetTimer(TIMER_LIGHT_CYCLE, duration, nullptr); return TRUE; } void CTrafficLightDlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent TIMER_LIGHT_CYCLE) { // 先杀掉旧定时器再切状态再用新时长重启 KillTimer(TIMER_LIGHT_CYCLE); m_nCycleIndex (m_nCycleIndex 1) % _countof(kCycle); UINT duration kCycle[m_nCycleIndex].durationMs; SetTimer(TIMER_LIGHT_CYCLE, duration, nullptr); // 触发重绘Invalidate 会异步投递 WM_PAINT Invalidate(FALSE); } CDialogEx::OnTimer(nIDEvent); }逻辑说明OnTimer里先KillTimer再SetTimer是关键。因为每个状态的持续时间不同不能用一个固定周期的定时器去数 tick那样会累积误差。每次切换都重新设定下一次到期时间精度更高。Invalidate(FALSE)的FALSE参数表示不擦除背景减少闪烁配合后面的双缓冲绘图效果更好。参数上_countof是安全求数组长度的宏比手写4更抗改动。注意SetTimer的精度受系统时钟节拍限制默认约 15.6ms。如果你把持续时间设成 10ms 想跑“流水灯”会发现实际间隔明显偏大。红绿灯场景用秒级时长完全够用不要拿它做毫秒级精确控制。3. 用 GDI 把三个灯画得像那么回事3.1 双缓冲绘图消除闪烁直接在OnPaint里画圆每次Invalidate都会先擦背景再画灯切换时会有明显闪烁。这是 MFC 自绘控件的经典坑。解决办法是内存 DC 双缓冲先在内存位图上画好整幅画面再一次性BitBlt到屏幕 DC。下面是我常用的OnPaint结构void CTrafficLightDlg::OnPaint() { CPaintDC dc(this); // 屏幕 DC CRect rcClient; GetClientRect(rcClient); // 内存 DC 兼容位图尺寸和客户区一致 CDC memDC; memDC.CreateCompatibleDC(dc); CBitmap bmp; bmp.CreateCompatibleBitmap(dc, rcClient.Width(), rcClient.Height()); CBitmap* pOldBmp memDC.SelectObject(bmp); // 先填充背景 memDC.FillSolidRect(rcClient, RGB(40, 40, 40)); // 计算三个灯的圆心和半径 int cx rcClient.Width() / 2; int radius min(rcClient.Width(), rcClient.Height()) / 8; int gap radius / 2; int totalH radius * 6 gap * 2; int topY (rcClient.Height() - totalH) / 2 radius; // 依次绘制红、黄、绿三个圆 DrawLamp(memDC, CPoint(cx, topY), radius, m_nCycleIndex 0 ? RGB(255, 0, 0) : RGB(80, 0, 0)); DrawLamp(memDC, CPoint(cx, topY radius * 2 gap), radius, m_nCycleIndex 3 ? RGB(255, 255, 0) : RGB(80, 80, 0)); DrawLamp(memDC, CPoint(cx, topY radius * 4 gap * 2), radius, m_nCycleIndex 1 ? RGB(0, 255, 0) : RGB(0, 80, 0)); // 一次性拷贝到屏幕 dc.BitBlt(0, 0, rcClient.Width(), rcClient.Height(), memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBmp); }逻辑说明CreateCompatibleDC创建的内存 DC 默认只有 1x1 单色位图必须SelectObject一张和屏幕兼容的位图才能画彩色。DrawLamp是一个辅助函数用Ellipse画圆并填充。亮灯用饱和色灭灯用暗色视觉上区分明显。参数上radius取客户区短边的八分之一gap取半径的一半这样三个灯在垂直方向居中排列间距均匀。topY的计算保证整组灯在窗口里垂直居中窗口缩放时自动适配。3.2 画圆的辅助函数和颜色参数DrawLamp单独抽出来方便复用和调整// 在指定 DC 上画一个圆形灯center 为圆心r 为半径color 为填充色 void CTrafficLightDlg::DrawLamp(CDC dc, CPoint center, int r, COLORREF color) { CBrush brush(color); CBrush* pOldBrush dc.SelectObject(brush); CPen pen(PS_SOLID, 2, RGB(200, 200, 200)); // 灰色边框 CPen* pOldPen dc.SelectObject(pen); dc.Ellipse(center.x - r, center.y - r, center.x r, center.y r); dc.SelectObject(pOldBrush); dc.SelectObject(pOldPen); }逻辑说明Ellipse的参数是外接矩形的左上角和右下角坐标所以用圆心加减半径得到。边框用浅灰色让灯看起来有外壳感。颜色参数用COLORREF亮灯传RGB(255,0,0)这类饱和色灭灯传RGB(80,0,0)这类暗色。这里有个细节CBrush和CPen是栈对象函数返回时析构但必须先SelectObject恢复旧对象否则 GDI 对象泄漏长时间运行会耗尽 GDI 句柄。这是 MFC 绘图最常见的翻车点之一。提示如果你想让灯有立体感可以在圆内再画一个偏移的小高光圆用RGB(255,255,255)加透明度模拟。但别过度红绿灯要的是清晰可辨不是拟物大赛。4. 避坑与排查红绿灯程序最容易翻车的五个地方4.1 界面卡死不动点关闭也没反应现象程序启动后窗口能显示但灯永远停在第一个状态点关闭按钮无响应。原因在OnInitDialog或按钮响应里用了Sleep或while循环做延时阻塞了 UI 线程的消息泵。解决所有延时必须交给SetTimer绝不在主线程里同步等待。检查代码里有没有Sleep、WaitForSingleObject带长超时、或者死循环。4.2 灯切换时闪烁严重现象每次变色整个窗口白一下再重绘。原因OnEraseBkgnd默认擦背景加上OnPaint里直接画屏幕 DC两次操作之间露出底色。解决重写OnEraseBkgnd直接返回TRUE表示已处理同时在OnPaint里用双缓冲。两处配合才能彻底消除闪烁。BOOL CTrafficLightDlg::OnEraseBkgnd(CDC* pDC) { // 背景已在 OnPaint 的双缓冲里填充这里不再擦除 return TRUE; }4.3 定时器越跑越不准现象跑了几分钟后灯切换的节奏明显变慢或变快。原因用固定周期定时器累加计数每次OnTimer的微小误差累积起来。解决每次切换都KillTimer再按新状态时长SetTimer让误差不累积。另外不要在OnTimer里做耗时操作否则下一次定时器消息会被推迟。4.4 窗口缩放后灯跑偏或消失现象拖动窗口边缘改变大小灯的位置错乱或者画到客户区外面。原因绘图坐标写死了没有基于GetClientRect动态计算。解决所有坐标都从客户区尺寸推导圆心取宽度一半半径取短边比例。OnSize里调用Invalidate触发重绘。4.5 GDI 对象泄漏导致程序越跑越卡现象程序运行一段时间后界面刷新变慢任务管理器里 GDI 对象数持续上涨。原因CreateCompatibleDC、CreateCompatibleBitmap、SelectObject创建的对象没有正确释放。解决内存 DC 和位图用完后DeleteDC、DeleteObjectSelectObject换入新对象前保存旧对象并在结束时换回。用任务管理器的“GDI 对象”列观察稳定不涨才算过关。5. 进阶把红绿灯做成可配置的状态面板5.1 从硬编码迁移到配置文件驱动前面kCycle是写死在代码里的。实际项目里红绿灯的配时经常要调每次改代码重新编译不现实。我一般会把它抽到一个简单的配置文件程序启动时读取。不引入第三方库用 MFC 自带的CStdioFile逐行解析就够// 配置文件 traffic.ini 格式每行 状态名毫秒 // Red5000 // AllRed1000 // Green5000 // Yellow2000 void CTrafficLightDlg::LoadCycleConfig(LPCTSTR path) { m_cycle.clear(); CStdioFile file; if (!file.Open(path, CFile::modeRead | CFile::typeText)) return; // 打不开就用默认配置 CString line; while (file.ReadString(line)) { line.Trim(); if (line.IsEmpty() || line[0] _T(#)) continue; int pos line.Find(_T()); if (pos 0) continue; CString key line.Left(pos); UINT ms (UINT)_ttoi(line.Mid(pos 1)); LightState st ParseState(key); // 字符串转枚举 m_cycle.push_back({ st, ms }); } file.Close(); }逻辑说明CStdioFile以文本模式打开ReadString逐行读取。#开头的行为注释空行跳过。ParseState是一个简单的字符串到枚举的映射函数用if-else链实现即可。m_cycle用std::vectorStateConfig替代静态数组支持运行时动态长度。参数上_ttoi兼容 Unicode 和 ANSI 构建。读取失败时保留默认配置保证程序不会因为配置文件缺失而崩溃。5.2 用按钮手动干预状态真实路口的红绿灯支持手动控制比如紧急情况下全红。在对话框上加一个按钮点击后强制切到AllRed并暂停自动循环void CTrafficLightDlg::OnBnClickedBtnEmergency() { KillTimer(TIMER_LIGHT_CYCLE); m_bEmergency true; m_nCycleIndex 3; // 指向 AllRed Invalidate(FALSE); } void CTrafficLightDlg::OnBnClickedBtnResume() { if (!m_bEmergency) return; m_bEmergency false; m_nCycleIndex 0; // 从红灯重新开始 SetTimer(TIMER_LIGHT_CYCLE, kCycle[m_nCycleIndex].durationMs, nullptr); Invalidate(FALSE); }逻辑说明紧急按钮杀掉定时器并强制切到全红恢复按钮重新从红灯开始循环。m_bEmergency标志位防止重复点击造成状态混乱。这里的关键是“恢复时从红灯开始”而不是从暂停处继续符合真实路口的复位逻辑。5.3 验证状态机是否正确的三个检查点写完别急着交付用下面三个检查点过一遍。第一把持续时间全部改成 500ms观察完整循环是否按 红→全红→绿→黄 的顺序走中间没有跳状态或卡住。第二连续运行十分钟用任务管理器看 GDI 对象数和内存是否稳定涨了就是泄漏。第三拖动窗口改变大小灯始终居中且比例正常没有跑到客户区外面。这三点过了这个红绿灯程序才算真正能拿得出手。我自己写这类状态面板时养成了一个习惯先把状态迁移表画在纸上确认每个状态的进入条件和退出条件都明确再动手写代码。红绿灯程序教会我的不是怎么画圆而是怎么用消息驱动的方式管理一个持续运行的状态系统。这个思路迁移到设备监控、流程控制、动画调度上都是同一套骨架。希望帮到你。本文还有配套的精品资源点击获取