MFC红绿灯程序实战:定时器状态机与自绘控件详解

发布时间:2026/10/13 19:40:34
MFC红绿灯程序实战:定时器状态机与自绘控件详解
简介这份资源是一个基于MFC框架实现的红绿灯模拟程序面向正在学习Windows图形界面开发、希望理解文档/视图架构与事件驱动编程的C/C初学者和进阶者。程序模拟十字路口交通灯的周期性切换将MFC对话框类、链表数据结构、快捷键响应与C语言特性融入同一实例适合作为课程设计或自学练手项目。压缩包为rar格式共43个文件约42KB其中25个bmp位图用于红绿灯各状态与界面素材4个h头文件与3个cpp源文件承载对话框类及主程序逻辑另有dsp、dsw工程文件、rc资源脚本、ico图标与ReadMe说明工程结构完整可直接用Visual Studio打开编译。目前已有1903人学习下载。通过阅读源码读者能掌握CWinApp与CDialog的继承关系、消息映射与快捷键绑定方式并理解如何用链表组织灯态切换顺序是理解MFC项目组织与Windows API交互的实用参考案例。1. 红绿灯程序为什么成了 MFC 入门的试金石很多人第一次接触 MFC都是从对话框程序开始的。而红绿灯程序几乎是绕不开的一个练手项目。它看起来简单——三个圆红黄绿轮着亮加个倒计时数字能有多难但真正动手写起来你会发现它把 MFC 里最核心的几块东西全串起来了对话框资源、控件绑定、定时器、绘图、状态机。一个红绿灯程序写下来MFC 的骨架基本就摸清了。这篇文章面向的是有 C 基础、想用 MFC 做一个能跑起来的红绿灯模拟程序的开发者。不管你是刚学 MFC 的学生还是需要快速验证某个界面逻辑的工程师这套方案都能直接复现。我会从资源搭建讲到定时器驱动再到绘图和状态切换最后把常见的坑一个个拆开。代码基于 VS 的 MFC 对话框工程不依赖任何第三方库。2. 用对话框资源搭出红绿灯的骨架2.1 资源视图里该放哪些控件打开 VS新建一个 MFC 对话框工程工程名随意比如 TrafficLightDemo。资源视图里找到 IDD_TRAFFICLIGHTDEMO_DIALOG这就是主对话框模板。红绿灯程序的界面不需要太复杂但有几个控件是必须的。先拖三个 Static 控件用来画红黄绿三个灯。为什么用 Static 而不是直接画因为 Static 控件有现成的窗口句柄可以单独设置背景色比在对话框的 OnPaint 里手动算坐标要省事得多。三个 Static 的 ID 分别改成 IDC_LIGHT_RED、IDC_LIGHT_YELLOW、IDC_LIGHT_GREEN方便后面代码里引用。再拖一个 Static 显示倒计时数字ID 设为 IDC_STATIC_COUNTDOWN。这个控件不需要绑定变量直接用 SetWindowText 更新就行。然后加两个 Button一个 StartID 为 IDC_BTN_START一个 StopID 为 IDC_BTN_STOP。最后可以加一个 Edit 控件让用户设置每个灯持续的时间ID 为 IDC_EDIT_INTERVAL默认值填 5。布局上三个灯建议做成竖排红灯在上绿灯在下中间黄灯。每个灯的尺寸设成 60x60 左右圆形效果靠后面的绘图代码实现。倒计时数字放在灯旁边字体调大一点方便观察。注意Static 控件的 ID 如果保持默认的 IDC_STATIC后面没法单独操作。必须改成唯一的 ID。2.2 给控件绑定变量和消息映射资源搭好后右键对话框类添加变量。三个灯的 Static 控件不需要绑定 C 变量因为后面是通过 GetDlgItem 获取句柄再操作。倒计时 Static 同理。Edit 控件可以绑定一个 int 类型的变量比如 m_nInterval范围设成 1 到 60默认 5。按钮需要添加消息处理函数。双击 Start 按钮VS 会自动生成 OnBnClickedBtnStart。Stop 按钮生成 OnBnClickedBtnStop。这两个函数就是整个程序的入口和出口。还需要一个定时器。MFC 里用 SetTimer 启动KillTimer 停止。定时器的 ID 可以定义一个宏比如 #define TIMER_ID 1。定时器消息 WM_TIMER 需要手动添加消息映射。在对话框类的头文件里声明 afx_msg void OnTimer(UINT_PTR nIDEvent);然后在 cpp 文件的 BEGIN_MESSAGE_MAP 和 END_MESSAGE_MAP 之间加上 ON_WM_TIMER()。// TrafficLightDemoDlg.h #define TIMER_ID 1 class CTrafficLightDemoDlg : public CDialogEx { // ... 其他声明 protected: afx_msg void OnTimer(UINT_PTR nIDEvent); DECLARE_MESSAGE_MAP() };// TrafficLightDemoDlg.cpp BEGIN_MESSAGE_MAP(CTrafficLightDemoDlg, CDialogEx) ON_WM_SYSCOMMAND() ON_WM_PAINT() ON_WM_QUERYDRAGICON() ON_BN_CLICKED(IDC_BTN_START, CTrafficLightDemoDlg::OnBnClickedBtnStart) ON_BN_CLICKED(IDC_BTN_STOP, CTrafficLightDemoDlg::OnBnClickedBtnStop) ON_WM_TIMER() END_MESSAGE_MAP()这段代码的关键是 ON_WM_TIMER() 宏它把 WM_TIMER 消息和 OnTimer 函数关联起来。没有这一行定时器触发了也不会进你的处理函数。很多新手在这里翻车SetTimer 调了但 OnTimer 死活不进八成是消息映射没加。2.3 初始化状态和灯的颜色定义在对话框的 OnInitDialog 里做初始化。先把三个灯的初始颜色设好红灯亮黄绿灭。颜色可以用 RGB 宏定义比如红灯用 RGB(255, 0, 0)灭灯用 RGB(80, 80, 80)。灭灯不要用纯黑纯黑在灰色对话框背景上看不出灯的轮廓用深灰更自然。BOOL CTrafficLightDemoDlg::OnInitDialog() { CDialogEx::OnInitDialog(); // ... 其他初始化 // 设置三个灯的初始颜色 GetDlgItem(IDC_LIGHT_RED)-SetWindowText(_T()); GetDlgItem(IDC_LIGHT_YELLOW)-SetWindowText(_T()); GetDlgItem(IDC_LIGHT_GREEN)-SetWindowText(_T()); // 用背景色模拟灯的颜色 SetLightColor(IDC_LIGHT_RED, RGB(255, 0, 0)); SetLightColor(IDC_LIGHT_YELLOW, RGB(80, 80, 80)); SetLightColor(IDC_LIGHT_GREEN, RGB(80, 80, 80)); m_nInterval 5; UpdateData(FALSE); return TRUE; }SetLightColor 是一个自定义函数用来修改 Static 控件的背景色。它的实现依赖 WM_CTLCOLOR 消息。在对话框类里添加 OnCtlColor 的处理根据控件 ID 返回不同的画刷。HBRUSH CTrafficLightDemoDlg::OnCtlColor(CDC* pDC, CWnd* pWnd, UINT nCtlColor) { HBRUSH hbr CDialogEx::OnCtlColor(pDC, pWnd, nCtlColor); if (nCtlColor CTLCOLOR_STATIC) { int nID pWnd-GetDlgCtrlID(); if (nID IDC_LIGHT_RED || nID IDC_LIGHT_YELLOW || nID IDC_LIGHT_GREEN) { pDC-SetBkMode(TRANSPARENT); return m_brushes[nID]; // 预先创建好的画刷 } } return hbr; }这里用了一个 map 或者数组来存每个灯对应的画刷。每次颜色变化时先删除旧画刷创建新画刷然后调用 Invalidate 让控件重绘。画刷的管理要小心创建了不删除会泄漏 GDI 对象程序跑久了会出问题。3. 定时器驱动状态机红绿灯的核心逻辑3.1 状态定义和切换规则红绿灯的状态可以用一个枚举表示红灯、红灯转绿灯的过渡也就是红黄同时亮有些路口有、绿灯、黄灯。简化一点就用四个状态RED、RED_YELLOW、GREEN、YELLOW。状态切换顺序是 RED - RED_YELLOW - GREEN - YELLOW - RED循环。每个状态持续的时间不一样。红灯和绿灯持续时间长黄灯短。可以用一个结构体数组来配置enum LightState { STATE_RED, STATE_RED_YELLOW, STATE_GREEN, STATE_YELLOW }; struct StateConfig { LightState state; int duration; // 持续秒数 bool redOn; bool yellowOn; bool greenOn; }; StateConfig g_states[] { { STATE_RED, 5, true, false, false }, { STATE_RED_YELLOW, 2, true, true, false }, { STATE_GREEN, 5, false, false, true }, { STATE_YELLOW, 2, false, true, false } };这个数组把每个状态的灯亮灭情况和持续时间都写死了。实际项目中红灯和绿灯的时间应该从界面上的 Edit 控件读取黄灯时间可以固定 2 秒。所以代码里可以动态调整 g_states[0].duration 和 g_states[2].duration。状态机的当前状态索引用一个成员变量 m_nCurrentState 表示倒计时用 m_nCountdown 表示。每次定时器触发m_nCountdown 减一。减到 0 时切换到下一个状态重置倒计时。3.2 SetTimer 和 OnTimer 的配合Start 按钮按下时读取 Edit 里的时间间隔初始化状态机然后启动定时器。定时器间隔设为 1000 毫秒也就是 1 秒触发一次。void CTrafficLightDemoDlg::OnBnClickedBtnStart() { UpdateData(TRUE); if (m_nInterval 1 || m_nInterval 60) { MessageBox(_T(时间间隔请在 1 到 60 秒之间), _T(提示), MB_ICONWARNING); return; } // 更新状态配置中的红灯和绿灯时间 g_states[0].duration m_nInterval; g_states[2].duration m_nInterval; m_nCurrentState 0; m_nCountdown g_states[0].duration; ApplyState(g_states[0]); UpdateCountdownDisplay(); SetTimer(TIMER_ID, 1000, NULL); }ApplyState 函数根据传入的状态配置设置三个灯的颜色。UpdateCountdownDisplay 把 m_nCountdown 显示到 Static 控件上。OnTimer 里做倒计时和状态切换void CTrafficLightDemoDlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent TIMER_ID) { m_nCountdown--; if (m_nCountdown 0) { // 切换到下一个状态 m_nCurrentState (m_nCurrentState 1) % 4; m_nCountdown g_states[m_nCurrentState].duration; ApplyState(g_states[m_nCurrentState]); } UpdateCountdownDisplay(); } CDialogEx::OnTimer(nIDEvent); }这段逻辑很直白每秒减一减到零就换状态。但有一个细节要注意m_nCountdown 减到 0 的那一秒应该立即显示新状态的倒计时而不是显示 0。所以判断条件用 0并且在切换后重新赋值。Stop 按钮就简单了KillTimer 停掉定时器把倒计时显示清空或者保留当前值。void CTrafficLightDemoDlg::OnBnClickedBtnStop() { KillTimer(TIMER_ID); }3.3 倒计时数字的刷新和界面重绘倒计时数字用 SetWindowText 更新就行。但 Static 控件默认不会自动重绘需要调用 Invalidate 或者 UpdateWindow。更稳妥的做法是获取控件指针后直接 SetWindowText然后调用控件的 Invalidate。void CTrafficLightDemoDlg::UpdateCountdownDisplay() { CString str; str.Format(_T(%d), m_nCountdown); GetDlgItem(IDC_STATIC_COUNTDOWN)-SetWindowText(str); GetDlgItem(IDC_STATIC_COUNTDOWN)-Invalidate(); }灯的颜色更新稍微麻烦一点。因为 OnCtlColor 返回的画刷是预先创建的每次颜色变化都要替换画刷。可以封装一个 SetLightColor 函数void CTrafficLightDemoDlg::SetLightColor(int nCtrlID, COLORREF color) { // 删除旧画刷 if (m_brushes.find(nCtrlID) ! m_brushes.end()) { m_brushes[nCtrlID]-DeleteObject(); delete m_brushes[nCtrlID]; } // 创建新画刷 CBrush* pBrush new CBrush(color); m_brushes[nCtrlID] pBrush; // 触发重绘 GetDlgItem(nCtrlID)-Invalidate(); }m_brushes 可以用 std::mapint, CBrush* 来存。注意在对话框析构时要把所有画刷删除否则 GDI 对象泄漏。这个坑在后面避坑章节还会细说。ApplyState 函数就是把状态配置里的三个 bool 值映射成颜色void CTrafficLightDemoDlg::ApplyState(const StateConfig config) { SetLightColor(IDC_LIGHT_RED, config.redOn ? RGB(255, 0, 0) : RGB(80, 80, 80)); SetLightColor(IDC_LIGHT_YELLOW, config.yellowOn ? RGB(255, 255, 0) : RGB(80, 80, 80)); SetLightColor(IDC_LIGHT_GREEN, config.greenOn ? RGB(0, 255, 0) : RGB(80, 80, 80)); }到这里一个能跑的红绿灯程序就成型了。按下 Start红灯亮 5 秒然后红黄同时亮 2 秒接着绿灯 5 秒黄灯 2 秒循环。倒计时数字每秒刷新。Stop 按钮随时停止。4. 把灯画成圆形自绘 Static 控件的正确姿势4.1 为什么默认的 Static 是方的前面用背景色模拟灯的颜色但 Static 控件默认是矩形看起来像三个方块不像红绿灯。要让它变成圆形必须自绘。MFC 里自绘控件有几种方式一种是派生一个 CStatic 子类重写 DrawItem另一种是在对话框的 OnPaint 里直接画。推荐第一种因为封装性好每个灯独立管理。创建一个 CMyLight 类继承自 CStatic。在类向导里添加 WM_DRAWITEM 消息处理不对CStatic 默认不支持 DrawItem需要把控件的 Owner Draw 属性设为 True。在资源视图里选中每个灯的 Static 控件属性面板里找到 Owner Draw改成 True。然后为每个灯绑定一个 CMyLight 类型的变量。CMyLight 类里重写 DrawItem 虚函数void CMyLight::DrawItem(LPDRAWITEMSTRUCT lpDrawItemStruct) { CDC dc; dc.Attach(lpDrawItemStruct-hDC); CRect rect lpDrawItemStruct-rcItem; int nRadius min(rect.Width(), rect.Height()) / 2; CPoint center(rect.left rect.Width() / 2, rect.top rect.Height() / 2); // 画外圈 CPen pen(PS_SOLID, 2, RGB(0, 0, 0)); CPen* pOldPen dc.SelectObject(pen); CBrush brush(m_color); CBrush* pOldBrush dc.SelectObject(brush); dc.Ellipse(center.x - nRadius, center.y - nRadius, center.x nRadius, center.y nRadius); dc.SelectObject(pOldBrush); dc.SelectObject(pOldPen); dc.Detach(); }m_color 是 CMyLight 的成员变量外部通过 SetColor 修改后调用 Invalidate 触发重绘。4.2 双缓冲消除闪烁自绘控件在频繁重绘时会闪烁尤其是倒计时每秒刷新的时候。解决办法是双缓冲先在内存 DC 里画好再一次性拷贝到屏幕 DC。void CMyLight::DrawItem(LPDRAWITEMSTRUCT lpDrawItemStruct) { CDC* pDC CDC::FromHandle(lpDrawItemStruct-hDC); CRect rect lpDrawItemStruct-rcItem; // 创建内存 DC CDC memDC; memDC.CreateCompatibleDC(pDC); CBitmap bmp; bmp.CreateCompatibleBitmap(pDC, rect.Width(), rect.Height()); CBitmap* pOldBmp memDC.SelectObject(bmp); // 在内存 DC 上绘制 memDC.FillSolidRect(rect, GetSysColor(COLOR_3DFACE)); int nRadius min(rect.Width(), rect.Height()) / 2 - 2; CPoint center(rect.Width() / 2, rect.Height() / 2); CPen pen(PS_SOLID, 2, RGB(0, 0, 0)); CPen* pOldPen memDC.SelectObject(pen); CBrush brush(m_color); CBrush* pOldBrush memDC.SelectObject(brush); memDC.Ellipse(center.x - nRadius, center.y - nRadius, center.x nRadius, center.y nRadius); memDC.SelectObject(pOldBrush); memDC.SelectObject(pOldPen); // 拷贝到屏幕 pDC-BitBlt(rect.left, rect.top, rect.Width(), rect.Height(), memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBmp); }双缓冲的关键是 CreateCompatibleBitmap 和 BitBlt。内存 DC 上的绘图操作不会直接显示最后一次性拷贝避免了闪烁。这个技巧在 MFC 自绘控件里是标配血泪经验不用双缓冲倒计时数字跳一下闪一下用户体验直接崩。4.3 圆形灯的边框和渐变效果如果想让灯看起来更真实可以加一点渐变。GDI 没有直接的渐变画刷但可以用多个同心圆模拟。从外到内颜色逐渐变亮中心最亮。void CMyLight::DrawGlow(CDC dc, CPoint center, int radius, COLORREF baseColor) { for (int r radius; r 0; r - 2) { int ratio (radius - r) * 100 / radius; COLORREF color RGB( GetRValue(baseColor) * (100 ratio) / 200, GetGValue(baseColor) * (100 ratio) / 200, GetBValue(baseColor) * (100 ratio) / 200 ); CBrush brush(color); CBrush* pOld dc.SelectObject(brush); dc.Ellipse(center.x - r, center.y - r, center.x r, center.y r); dc.SelectObject(pOld); } }这个函数从外圈往内圈画每圈颜色稍微亮一点形成一种发光的效果。参数 baseColor 是灯的基础色比如红灯传 RGB(255, 0, 0)。ratio 控制亮度增量可以根据实际效果调整。注意画的时候要用无边框的椭圆否则每圈都有黑边看起来像同心圆环。5. 避坑指南红绿灯程序里最容易翻车的五个地方5.1 定时器不触发或者触发多次现象按下 Start 后界面没反应倒计时不动。或者 Stop 之后定时器还在跑再按 Start 倒计时跳得飞快。原因第一种情况通常是 ON_WM_TIMER 消息映射没加或者 SetTimer 的返回值没检查。第二种情况是 Stop 时没有 KillTimer或者多次按 Start 导致多个定时器叠加。解决在 OnBnClickedBtnStart 里先 KillTimer(TIMER_ID) 再 SetTimer确保只有一个定时器在跑。Stop 按钮里必须调 KillTimer。另外检查消息映射里有没有 ON_WM_TIMER()这个宏的参数是空的不要写成 ON_WM_TIMER(TIMER_ID)。5.2 GDI 对象泄漏导致程序越跑越卡现象程序运行几分钟后界面卡顿任务管理器里 GDI 对象数持续上涨。原因每次 SetLightColor 都 new 一个 CBrush但没有删除旧的。OnCtlColor 里返回的画刷也没有释放。解决在 SetLightColor 里先删除旧画刷再创建新的。对话框析构时遍历 m_brushes把所有画刷 DeleteObject 后 delete。另外OnCtlColor 里不要每次创建新画刷应该用成员变量保存。CTrafficLightDemoDlg::~CTrafficLightDemoDlg() { for (auto pair : m_brushes) { pair.second-DeleteObject(); delete pair.second; } m_brushes.clear(); }5.3 倒计时数字显示不全或者不刷新现象倒计时 Static 控件里的数字只显示一半或者数字变了但界面没更新。原因Static 控件默认大小不够字体太大导致裁剪。不刷新是因为 SetWindowText 后没有触发重绘。解决把 Static 控件拉大一点字体调小。刷新问题在 SetWindowText 后调用 Invalidate。如果还不行检查是不是在 OnTimer 里 UpdateData 用错了方向。UpdateData(TRUE) 是从控件读数据到变量UpdateData(FALSE) 是从变量写到控件。倒计时显示应该用 SetWindowText 直接操作控件不涉及数据交换。5.4 状态切换时灯的颜色不更新现象倒计时走到 0 了但灯的颜色没变还是上一个状态。原因ApplyState 里调用了 SetLightColor但 SetLightColor 里 Invalidate 的是控件而 OnCtlColor 返回的画刷没有更新。或者 m_brushes 里存的画刷指针失效了。解决确保 SetLightColor 里先删除旧画刷再创建新画刷然后调用 Invalidate。OnCtlColor 里从 m_brushes 取画刷时要判断 key 是否存在。如果用的是 map用 find 而不是下标操作符避免意外插入空指针。5.5 程序退出时崩溃现象点关闭按钮程序报错退出或者卡死。原因定时器没关消息还在队列里。或者 GDI 对象在窗口销毁后还在被引用。解决在对话框的 OnDestroy 或者 OnClose 里先 KillTimer再清理 GDI 对象。OnDestroy 是最后清理的好时机。void CTrafficLightDemoDlg::OnDestroy() { KillTimer(TIMER_ID); CDialogEx::OnDestroy(); }另外如果用了线程或者异步操作要确保在窗口销毁前结束。红绿灯程序一般用不到多线程但定时器消息本身是异步的KillTimer 后可能还有一条 WM_TIMER 在队列里OnTimer 里要判断 nIDEvent 是否等于 TIMER_ID避免处理已失效的定时器。6. 从红绿灯到通用状态机一个可复用的定时器框架红绿灯程序写完后你会发现它的核心其实是一个定时器驱动的状态机。这个模式可以复用到很多场景交通信号模拟、工业控制流程、游戏里的回合逻辑甚至动画帧调度。与其每次重写不如把它抽象成一个小的框架。我一般会定义一个 CTimerStateMachine 类内部维护一个状态数组、当前状态索引、倒计时计数器和定时器 ID。外部只需要注册状态和对应的回调函数启动时传入定时器间隔即可。class CTimerStateMachine { public: using StateCallback std::functionvoid(); void AddState(int duration, StateCallback onEnter) { m_states.push_back({ duration, onEnter }); } void Start(CWnd* pWnd, UINT_PTR timerID, int intervalMs 1000) { m_pWnd pWnd; m_timerID timerID; m_currentIndex 0; m_countdown m_states[0].duration; m_states[0].onEnter(); pWnd-SetTimer(timerID, intervalMs, NULL); } void Stop() { if (m_pWnd) m_pWnd-KillTimer(m_timerID); } void OnTick() { if (m_states.empty()) return; m_countdown--; if (m_countdown 0) { m_currentIndex (m_currentIndex 1) % m_states.size(); m_countdown m_states[m_currentIndex].duration; m_states[m_currentIndex].onEnter(); } } int GetCountdown() const { return m_countdown; } private: struct State { int duration; StateCallback onEnter; }; std::vectorState m_states; int m_currentIndex 0; int m_countdown 0; CWnd* m_pWnd nullptr; UINT_PTR m_timerID 0; };这个类把状态切换和倒计时逻辑封装起来红绿灯程序只需要在初始化时添加四个状态每个状态的 onEnter 回调里调用 ApplyState 设置灯的颜色。倒计时显示可以在 OnTimer 里通过 GetCountdown 获取。用这个框架新增一个状态或者调整持续时间只需要改配置不用动核心逻辑。比如要加一个“全红”状态在红灯和绿灯之间插入一个所有灯都灭的 1 秒间隔只需要 AddState(1, { /* 全灭 */ })。这种灵活性在调试阶段特别有用改参数不用重新编译整个工程。验证框架是否正常可以写一个简单的测试添加三个状态持续时间分别是 2、3、4 秒每个状态的 onEnter 里弹一个 MessageBox 或者输出调试信息。启动后观察切换顺序和时间是否符合预期。如果切换太快或太慢检查 SetTimer 的间隔和 OnTick 的调用频率是否匹配。最后说一个我自己的习惯每次写完状态机都会在 OnTimer 里加一行日志把当前状态索引和倒计时打到 Output 窗口。这样一旦切换逻辑出问题看日志就能定位是状态没切换还是倒计时算错了。这个习惯帮我省了很多调试时间希望帮到你。本文还有配套的精品资源点击获取