ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Qt5实战:从零开发十字路口信号灯模拟器

Qt5实战:从零开发十字路口信号灯模拟器 简介面向Linux环境下QT5初学者的十字路口红绿灯模拟程序适用于嵌入式Linux开发、智能交通课程设计和交通信号控制实验等场景。程序基于QT5的图形界面与信号槽机制搭建使用户能够通过自定义协议控制红绿灯各灯状态的切换界面可实时显示变化也支持快速关闭信号灯方便教学演示与功能验证。在性能优化方面开发者将定时器功能改为独立线程运行避免定时任务阻塞主应用降低CPU占用率这一思路对计算资源有限的嵌入式设备尤为重要。资源包为gz压缩格式总体积仅69KB体积小巧便于下载与部署包内预置红绿灯图标等图像文件可直接引入项目使用。目前已有1334人学习/下载是一份兼顾GUI编程、多线程、实时控制与跨平台开发的实践资源既适合初学者入门也可为智能交通信号模拟和交通规则验证提供基础平台。1. 项目概述一个信号灯模拟器背后的完整Qt5开发路径第一次在Linux下用Qt5做十字路口信号灯模拟程序很多人的第一反应是“这有什么难的画三个圆点写个定时器切换颜色不就完了”。实际上手之后你会发现凡是涉及界面状态切换的程序最核心的问题从来不是“怎么画”而是“怎么让状态切换的时机、节奏、表现方式都和真实世界对得上”。这个项目恰好把Qt5里几个最常用的技术点全部串起来了自定义绘制、定时器驱动、事件循环、对象通信甚至还能把状态机设计的思路一并练到。对初学者来说是特别好的练手项目对已经写过一些Qt程序的人来说用这个题目做一次重构和优化也能梳理出不少平时容易忽略的细节。目标很明确在Linux桌面上跑一个Qt5窗口程序画面中央是一个十字路口四个方向都有红绿灯灯色按照标准交通信号逻辑自动切换同时显示当前相位剩余秒数。程序要能稳定运行不闪烁、不卡顿、不崩并且代码结构清晰方便后续往里面加车辆通行动画、按钮控制、甚至多路口联动。整个项目不需要任何第三方库纯Qt5 Widgets模块就够了编译器用g构建工具用qmake或者CMake都行。开发环境我这边是Ubuntu系统Qt版本5.15编译器GCC 9.4下面所有代码和步骤都基于这套环境验证过。如果你用的是其他Linux发行版或者更高版本的Qt操作上基本一致个别细节我会单独标注。这个程序同样适合作为嵌入式开发上位机demo、Qt初学者课程设计或者面试作品关键不在于它多复杂而在于把“状态绘制驱动”这三件事组织得干净利落。2. 核心设计思路先把状态机理清楚再谈画界面2.1 信号灯相位切换的本质就是四状态循环红绿灯的控制逻辑听起来很简单但很多初学者会直接拿三个布尔变量表示三个灯然后在一个定时器回调里做判断结果代码越写越乱。真实的路口信号控制不是“三个灯分别定时”而是以一个完整的“相位序列”为单位循环运行。标准十字路口从东西方向视角来看一个完整周期包含四个相位东西绿灯、南北红灯东西黄灯、南北红灯东西红灯、南北绿灯东西红灯、南北黄灯。就这么简单四个相位依次轮转周而复始。理解了这一点之后这个项目的核心架构就清楚了程序本质上是一个状态机四个相位就是四个状态每个状态有一个持续时间时间到了就切换到下一个状态。界面层只是把这个状态“画”出来而已。这样设计之后哪怕你后面要增加左转箭头灯、增加夜间黄闪模式也只是在状态机里增加状态的事根本不用去动绘制逻辑。我在设计代码结构时直接按照这个思路把程序拆成了三个类一个负责状态切换和计时逻辑的控制器类一个负责画面绘制的控件类再加上一个承载两者的主窗口类。控制器只对外暴露一个“当前相位”的信号绘图控件只管根据收到的相位和剩余秒数去重绘。两者之间通过信号槽通信互不干扰。这样做的好处是你甚至可以暂时不做任何界面用一个命令行程序先验证状态切换逻辑是否正确界面最后再挂上去。2.2 计时驱动的两种方式逐个递减还是统一tick定时方案上常见做法有两种。第一种是给每个状态设置一个独立QTimer第一次定时结束切到下一个状态并重新设置定时器时长第二种是用一个固定周期比如1秒的QTimer反复触发每触发一次就把当前相位的剩余秒数减1减到0就切换相位。我最终选了第二种。原因是第二个方案天然方便做倒计时显示——界面上的剩余秒数数字就是状态机里现成维护的那个变量不需要额外计算。而且调试的时候把定时器周期改成100毫秒整个红绿灯循环就会快速运转用肉眼看灯色切换逻辑是否正确效率远高于在代码里打断点。这个技巧在真机调试信号机逻辑时特别常用。当然代价是定时器回调函数必须是轻量级的里面除了状态判断和数值递减不能做任何阻塞操作好在信号灯模拟恰恰没有这样的重操作。顺带提一个细节QTimer需要一个事件循环来驱动也就是程序里必须先有QApplication或QCoreApplication对象再调用exec()进入事件循环。如果有人在main函数里直接写了一个while循环去递减秒数而不让出控制权会发现界面完全卡死。这不是Qt的bug而是你忽略了事件循环的控制权问题。正确做法永远是“定时器到点之后在回调里做该做的事”回调结束立刻把CPU还给事件循环。2.3 界面绘制方案QPainter 手绘远比贴图更灵活界面怎么画我这里直接排除掉了用QLabel贴三张图的做法虽然新手用样式表也很容易做出来但图片缩放容易模糊而且不同分辨率下表现不稳定。这个项目的最佳方案是用QPainter在QWidget的paintEvent里手绘先用drawLine画十字路口的道路再用setBrush配合画椭圆的方式画三个圆形灯位最后用drawText把倒计时数字画在灯旁边。全部是矢量绘制窗口拉伸多大都清晰。手绘的第二个好处是可以自由控制每个元素的状态。画灯的时候我根据控制器发过来的当前相位决定哪个灯圈填充颜色、哪个灯圈只留灰色描边这只需要一个switch分支就能处理完。如果是贴图方案你得提前准备黄的、红的、绿的、灰的N多张图状态一多图片管理就头疼。再往后做车辆动画时QPainter手绘的优势还会进一步放大。2.4 方向编号和交互入口要提前定好在开始动手写代码之前还有一个容易忽略的问题方向怎么编号。建议用0代表东、1代表南、2代表西、3代表北顺时针编。虽然现在这个版本只需要画一组灯默认显示东西方向和南北方向的灯但编号定好之后后面做多路口扩展时传参就很清晰不用担心给灯配错方向。主窗口上我还加了一个“重启循环”按钮用来把状态机重置回第一个相位这个按钮在验证代码时非常有用不用重启程序就能重新走一遍完整时序。3. 实操落地从零写出可运行的Qt5信号灯模拟器3.1 Linux下Qt5开发环境准备不同Linux发行版装Qt5开发包的命令略有区别。Ubuntu和Debian系使用下面这条命令即可sudo apt install qtbase5-dev qt5-qmake g make安装完成后用qmake --version检查版本正常能看到类似“Using Qt version 5.15.2 in /usr/lib/x86_64-linux-gnu”的输出。如果装的是Fedora这类用dnf的系统对应命令是sudo dnf install qt5-qtbase-devel gcc-c makeArch系则是sudo pacman -S qt5-base gcc make。如果你需要Qt的图形化IDE支持额外安装Qt Creator即可但本文的项目用纯文本编辑器加命令行编译就完全足够。3.2 项目文件组织与核心代码实现我把整个项目放在一个目录下文件结构如下traffic_light/ ├── traffic_light.pro ├── main.cpp ├── controller.h ├── controller.cpp ├── lightwidget.h ├── lightwidget.cpp ├── mainwindow.h └── mainwindow.cpp先看工程文件。我习惯用qmake做这种小项目它的.pro文件比CMakeLists.txt简洁得多QT core gui widgets TARGET traffic_light TEMPLATE app SOURCES main.cpp controller.cpp lightwidget.cpp mainwindow.cpp HEADERS controller.h lightwidget.h mainwindow.h接下来是状态控制类。这个类不需要继承任何Qt类它本质上就是一个普通的QObject子类负责维护当前相位和剩余秒数并通过定时器驱动状态切换。我直接放核心实现// controller.h #ifndef CONTROLLER_H #define CONTROLLER_H #include QObject #include QTimer class Controller : public QObject { Q_OBJECT public: enum Phase { EWGreen, EWYellow, NSGreen, NSYellow }; explicit Controller(QObject *parent nullptr); void start(); signals: void phaseChanged(Phase phase, int remain); public slots: void tick(); void reset(); private: Phase m_phase; int m_remain; QTimer m_timer; }; #endif成员变量就三个当前相位、剩余秒数、定时器。注意m_timer这里我直接声明成了普通成员而不是指针这样省去了手动new和delete的麻烦只要在构造函数里设置好父对象和连接关系就行。相应地controller.cpp里的关键部分这样实现// controller.cpp #include controller.h static const int kPhaseDuration[4] {30, 5, 30, 5}; Controller::Controller(QObject *parent) : QObject(parent), m_phase(EWGreen), m_remain(kPhaseDuration[0]) { m_timer.setInterval(1000); connect(m_timer, QTimer::timeout, this, Controller::tick); } void Controller::start() { m_timer.start(); } void Controller::tick() { m_remain--; if (m_remain 0) { m_phase static_castPhase((m_phase 1) % 4); m_remain kPhaseDuration[static_castint(m_phase)]; } emit phaseChanged(m_phase, m_remain); } void Controller::reset() { m_phase EWGreen; m_remain kPhaseDuration[0]; emit phaseChanged(m_phase, m_remain); }有几个细节值得解释一下。static const int kPhaseDuration[4]这个数组的四个值分别是东西绿30秒、东西黄5秒、南北绿30秒、南北黄5秒这正好对应四相位循环。(m_phase 1) % 4这个取模操作是状态机循环的核心下一状态和上一状态之间的顺序由这个表达式保证永远不会越界。还有一个容易被忽略的点start()里只调用了m_timer.start()但第一个相位的一秒倒计时从程序启动后的第一秒才开始计时如果你希望界面在程序一启动就立即显示“剩余30秒”可以在main函数里启动定时器之前先手动emit一次phaseChanged信号。3.3 绘图控件画出十字路口和两组信号灯绘图控件是整个程序的视觉核心。我把它单独做成了一个QWidget子类所有绘制逻辑都集中在这里。这个类本身不关心状态怎么切换它只通过一个槽函数接收相位和剩余秒数记录下来后调用update()触发重绘。paintEvent里做的事情可以理解为“把内存里记录的状态画到屏幕上”void LightWidget::paintEvent(QPaintEvent *) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing); // 画灰色沥青路面 painter.fillRect(rect(), QColor(70, 70, 70)); // 画十字路口中心区域留出来四条道路分别向四个方向延伸 int w width(); int h height(); int roadWidth qMin(w, h) / 6; // 横向道路 QRect hRoad(w/2 - roadWidth/2, 0, roadWidth, h); painter.fillRect(hRoad, QColor(50, 50, 50)); // 纵向道路 QRect vRoad(0, h/2 - roadWidth/2, w, roadWidth); painter.fillRect(vRoad, QColor(50, 50, 50)); // 画车道分隔线虚线 QPen dashPen(Qt::white); dashPen.setStyle(Qt::DashLine); dashPen.setWidth(2); painter.setPen(dashPen); // 横向道路中心线 painter.drawLine(w/2, 0, w/2, h/2 - roadWidth/2); painter.drawLine(w/2, h/2 roadWidth/2, w/2, h); // 纵向道路中心线 painter.drawLine(0, h/2, w/2 - roadWidth/2, h/2); painter.drawLine(w/2 roadWidth/2, h/2, w, h/2); }画完道路之后再根据相位画两组灯。这里用了一个辅助函数drawLightGroup参数包括灯组中心坐标、当前应该亮的颜色、剩余秒数。画灯的时候三个灯位从上到下或者从左到右排布我默认采用水平排列的红黄绿三灯因为大多数路口的大灯组都是水平排列或者竖直排列的这里选水平看起来更直观。灯的绘制逻辑是先用灰色画三个圆圈的轮廓和底色然后判断当前相位的对应颜色把该亮的那个圆圈填充成对应颜色其余保持灰色。这样画面看起来就和真实信号灯一致了。倒计时数字直接画在灯组的旁边字体大小我设置了24像素颜色用白色确保在深色背景上清晰可读。3.4 主窗口组合逻辑把控制器和绘图控件接起来主窗口做的事情很纯粹创建控制器和绘图控件用信号槽把两者连接起来再提供重启按钮。连接的代码只有两条一条是phaseChanged信号连到LightWidget::onPhaseChanged槽另一条是按钮的clicked信号连到Controller::reset槽。MainWindow::MainWindow(QWidget *parent) : QWidget(parent) { Controller *controller new Controller(this); LightWidget *light new LightWidget(this); QPushButton *btnReset new QPushButton(QStringLiteral(重启循环), this); connect(controller, Controller::phaseChanged, light, LightWidget::onPhaseChanged); connect(btnReset, QPushButton::clicked, controller, Controller::reset); QVBoxLayout *layout new QVBoxLayout(this); layout-addWidget(light); layout-addWidget(btnReset); controller-start(); }注意一个在实际项目中很重要的问题phaseChanged信号是在Controller的tick槽里发射的而tick又是由定时器触发的。这里不涉及跨线程问题所以直连和排队连接效果一样。如果将来你要把Controller放到单独线程里跑那信号槽连接方式就必须改成Qt::QueuedConnection并且绘图控件的槽函数里不能直接操作QPainter因为QWidget必须在主线程。这些扩展点现在可以不用管但代码组织方式按这个思路写以后迁移成本会很低。3.5 编译、运行与Makefile流程在项目目录下依次执行三条命令qmake make ./traffic_lightqmake会根据.pro文件生成对应的Makefilemake完成编译链接最后一条命令启动程序。如果一切正常你会看到一个灰底、有十字路口、两组信号灯、带倒计时数字的窗口。如果编译报错找不到QApplication头文件大概率是qtbase5-dev这个开发包没装上用前面说的apt命令补装一下即可。如果链接报错undefined reference toQApplication::QApplication(int, char**)之类说明链接阶段没找到Qt库一般在.pro里确保QT core gui widgets这一行存在就不会出这个问题。4. 常见问题与调试技巧实录4.1 Linux下Qt5程序启动失败的几个典型坑第一种常见问题是双击可执行文件或者直接./traffic_light运行时报错“QXcbConnection: Could not connect to display”。这基本说明程序找不到X Server环境或者DISPLAY环境变量没设置。最简单的解决办法是确保你在图形界面终端里运行程序不要用纯命令行SSH方式运行GUI程序。如果确实需要在远程环境做图形调试请自行评估是否需要端口转发方案这个过程涉及网络配置不同环境差异较大。第二种问题是字体乱码。Qt5对中文字体支持虽然已经很成熟但如果系统里没有安装任何中文字体界面上的中文会变成方块或问号。排查方法很简单用fc-list :langzh命令查看系统中是否有可用的中文字体。没有的话安装fonts-wqy-microhei或fonts-wqy-zenhei这类开源中文字体包即可解决。在代码里我统一用了QStringLiteral宏把中文字符串包起来这是Qt5推荐的写法可以避免源码编码问题带来的乱码隐患。第三种问题比较隐蔽在Ubuntu 20.04及以上版本的默认高DPI缩放场景下程序界面可能出现文字模糊或者控件尺寸异常。这是因为Qt5默认没有开启高DPI缩放支持正好又配上系统开启界面缩放。解决办法有两步第一步是在main函数最前面、创建QApplication之前的代码中加入配置QApplication::setAttribute(Qt::AA_EnableHighDpiScaling); QApplication::setAttribute(Qt::AA_UseHighDpiPixmaps);第二步是在.pro文件里加一句QT core5compat不适用于5.15版本这个模块是Qt 6的用法。Qt 5.15环境下只需要设置这两个属性即可不需要额外模块。4.2 状态切换与实际时间不同步先检查定时器周期如果你发现倒计时数字跳得不均匀或者偶尔一次跳两秒优先怀疑是不是CPU负载过高。QTimer的精度依赖操作系统的定时器机制正常桌面环境下1秒的定时误差在几十毫秒内肉眼完全感觉不到。但在虚拟机里跑Linux而且宿主机负载很高时调度延迟可能被放大。调试时可以临时把定时器周期改成100毫秒观察是否恢复正常的节奏。需要注意的是QTimer不是实时定时器它只能保证在事件循环空闲时尽快补上错过的tick这个特性决定了它不适合做高精度计时用途但信号灯这种秒级场景完全没有压力。还有一种我踩过的坑是m_remain减到0之后忘记发信号导致界面上的倒计时停在0不跳了。排查方式是在tick函数开头加一行qDebug()输出当前相位和剩余秒数对照逻辑检查。加上这行输出之后程序的运行状态完全透明很直观就能发现问题在哪。4.3 调试时查看整张二维状态表在Qt Creator里用好断点和监视器项目如果继续往复杂的方向做比如增加多路口联动、每路口的相位用二维数组存储这时候调试数组内容就成了刚需。Qt Creator的调试器在“监视”窗口里直接展开数组对象时默认只能看到第一行看不到完整二维结构。我的做法是右键监视表达式手动输入数组名加下标比如matrix[0][0]8的意思是查看从matrix[0][0]开始连续8个元素。这种方式通用性很强换到GDB命令行调试同样适用省去一遍遍在代码里加qDebug的功夫。4.4 输入控制与界面参数绑定让演示更直观做完基础版之后我发现纯自动循环虽然能用但讲给别人听的时候缺乏交互感。所以往程序里加了几个控制项一个滑块或输入框用来动态调整绿灯持续时长一个按钮用来暂停循环。这里回顾一个很常见的Qt5小问题——QLineEdit限制只能输入数字应该用QIntValidator而不是去重写keyPressEvent。代码只要一行lineEditDuration-setValidator(new QIntValidator(5, 120, this));这样用户就只可能输入5到120之间的整数彻底避免非法字符的干扰。把这些参数从控制器里抽出来做一个公共属性然后用QSpinBox或滑块绑定起来调整会变得非常顺手。5. 真实运行效果与进一步扩展方向程序跑起来后的效果大致是这样的窗口默认大小800x600深灰色路面上一个十字路口路中央被四条道路围出的矩形区域是街道中心两侧分别有一组横向灯和一组纵向灯。每组灯三个灯位从靠近路口的顺序依次是红、黄、绿。当前东西方向绿灯亮时倒计时数字显示30慢慢往下减减到0之后黄灯亮起数字每跳一下黄灯依然亮着持续5秒后东西方向变红同时南北方向绿灯立刻亮起并重新从30开始倒计时。循环往复节奏和现实中的信号灯一致。体验下来会觉得这类“小项目”对整个Qt知识体系的串联效果特别明显。做完基础版之后你可以往几个方向继续扩展。第一个方向是增加车辆动画每辆车用一个小矩形表示只能在自己车道的方向上移动信号灯变绿才允许通过路口。这个扩展需要给车辆建一个类处理位置更新和碰撞检测核心的QPainter能力正好继续复用。第二个方向是加入时间统计模块统计每个方向一个小时内实际通过的绿灯时长然后生成一份直方图。第三个方向是把控制逻辑从QTimer改写为QStateMachine状态机框架用Qt自带的图形化状态机抽象来描述相位切换虽然有点大材小用但当作框架学习案例非常合适。我个人建议的顺序是先加车辆动画因为它会让demo效果发生质变而且会逼你去思考“如何让每个移动物体在定时器回调里更新位置并且只重绘需要重绘的区域”。做到这一步你的Qt5自定义绘制能力基本就稳了。之后再做参数可配置和统计功能对整个系统的认识会更全面。最后分享一个小技巧开发这类带界面刷新的模拟程序时不要一上来就追求代码精简。先按“控制器只管状态、控件只管绘制、主窗口只管组装”的原则写一版结构清晰但可能比较啰嗦的实现跑通了再回头重构把重复的代码抽成工具函数。我见过太多人一上来就玩花活结果是状态逻辑和绘图逻辑混在一起任何一边改动都会牵动另一边改到后面自己都不认识自己的代码。这个项目虽然小但按工程化习惯去组织养成的好习惯会带到下一个更大的项目里去。本文还有配套的精品资源点击获取
返回列表