Qt界面刷新机制与触摸屏HMI工程上传回电脑实践
上周一个做设备的哥们连发两段语音给我。第一段说他们的上位机软件里有个实时曲线界面跑着跑着画面就不动了鼠标点哪都没反应非得把窗口最小化再还原才恢复第二段说现场那台机器的触摸屏里还存着上一版工程原来负责的人离职了想把屏幕里的程序原样取回到电脑上改一改。他脑子里这是两件互不相干的事我听完觉得是同一件事的两面——都绕不开屏这个字。前者问的是Qt 中如何刷新一下屏幕把内存里的状态画到显示设备上后者问的是触摸屏里的程序怎么搬回电脑把屏幕里的状态读出来。方向相反底层思路同源谁持有什么状态什么时候同步同步失败怎么兜底。这篇就顺着这条线把两块内容都摊开讲。前半段是 Qt 界面刷新的机制和实操重点讲清楚调了 update() 为什么还是不动这类反复出现的问题后半段是触摸屏工程上传回电脑的完整链路包括工程文件由什么组成、为什么有的屏能读回来有的读不回来、常见报错怎么对号入座。做上位机开发的、做设备集成的、刚接手别人维护的老项目的都能对得上号。1. Qt 界面代码改了画面没变的四种现场1.1 事件循环被堵死不是没刷新是没人来收这个重绘请求这是出现频率最高的一类。现象很好认窗口本身还能拖动、还能被系统绘制但内容区定格在某一帧或者干脆变成一片白。原因不复杂Qt 的paintEvent是被事件循环驱动的重绘请求会先进队列等主线程空闲下来才分发。如果主线程里塞了一个while循环读串口、一个sleep、一段百万次的计算队列里的重绘事件就永远轮不到执行。新手最容易踩的写法是这样void MainWindow::onStartClicked() { while (device-isRunning()) { QByteArray data device-readAll(); appendToView(data); // 这里改了成员变量 update(); // 调了但不会立刻画 QThread::msleep(20); // 主线程睡死事件循环停摆 } }update()只是把重绘请求塞进队列它自己不会画。后面紧跟一个msleep队列就一直排着。等这个函数终于返回画面才猛地跳一下——所以你看到的是卡死然后突然追帧。判断方法很直接在循环体里打日志看日志有没有在刷。如果日志在跑而画面不动那基本可以确认是事件循环被占住了。另一个办法是看任务管理器里 UI 线程的 CPU 占用长期接近单核跑满八成就是主线程在做重活。正确的解法有两条。第一把耗时逻辑挪到工作线程用信号槽把数据送回 UI 线程// 工作线程里 emit dataReady(chunk); // UI 线程的槽函数里 void MainWindow::onDataReady(const QByteArray chunk) { buffer_.append(chunk); requestRepaint(); // 内部做节流见 3.1 }第二如果逻辑必须留在主线程比如依赖 Qt 的某些对象把它拆成分片用QTimer每 10~20 毫秒处理一小批。这样每两个分片之间事件循环都会回到空闲状态重绘请求就有机会被执行。有些资料会建议在循环里插一句QCoreApplication::processEvents()看起来确实活了。我早期也这么干过后来踩了一次重入的坑就再也不用了——它会把用户点击、定时器、socket 可读事件统统放进来你的onStartClicked还没返回用户的另一个点击可能又进了同一个函数状态全乱。这个问题第 2.3 节单独说。1.2 update() 调了但没画控件不可见、尺寸为零、更新被禁用第二类现场更隐蔽事件循环是通的代码逻辑也对update()确实调了但就是不出图。这时候要挨个排查控件的可见性前提。Qt 里有几个条件任意一个不满足重绘请求都会被直接丢弃连paintEvent都不会进。控件isVisible()为false。被hide()过、父控件没显示、或者还停在QStackedWidget的非当前页。控件的宽或高为 0。常见于布局还没算完就调用update()尤其是刚new出来还没show()的对象。父链上有人调了setUpdatesEnabled(false)。这个状态是会往子控件传的很多人为了批量更新减少闪烁禁用了更新忘了恢复。控件被设置了Qt::WA_UpdatesDisabled属性或者所在的窗口处于最小化状态。你在paintEvent里画的区域压根不在当前可见区内比如滚动视图里滚出去的那部分。我把这些条件整理成一张排查表出问题时按顺序过一遍比瞎猜快得多检查项判断方式说明是否可见widget-isVisible()false 时 update 无效尺寸是否为零widget-size()布局未生效常见于初始化阶段更新是否被禁用widget-updatesEnabled()会被父控件继承窗口是否最小化window()-isMinimized()部分平台最小化时不重绘是否在可见页QStackedWidget::currentIndex()非当前页不会被画还有一个特别容易被忽略的点改了数据但忘了通知。Qt 的控件不知道你的成员变量变了update()必须自己调。自定义控件里如果只有一个setValue()接口改了内部状态却没调update()外面调一万次也不会刷新。我的习惯是在所有改状态的 setter 末尾统一加一句update()宁可多画一帧也不要漏。1.3 遮挡与叠影不透明标志和半透明背景的连锁反应第三类问题表现成画面能刷新但刷新得不干净——旧内容残留在新内容底下字叠着字线叠着线。这多半跟paintEvent里的背景填充有关。QWidget默认会先帮你把背景刷一遍再调paintEvent但如果你给控件设置了Qt::WA_OpaquePaintEvent等于告诉 Qt这块区域我自己负责填满你别管了。这句话是个承诺你paintEvent里没填满就会留下上一帧的残影。同理Qt::WA_NoSystemBackground也会让系统不刷背景。反过来如果你开了Qt::WA_TranslucentBackground做异形窗口或者圆角那每次重绘都要走一遍混合性能会掉一截而且在部分平台上会有肉眼可见的闪烁。做动画的时候尤其明显。我的经验是能用不透明的矩形解决的就别开半透明。真要做圆角用QPainterPath做裁剪比整窗半透明划算得多。还有一个坑是父子控件叠放。父控件paintEvent里用QPainter画了东西子控件又盖在同一块区域上没设WA_OpaquePaintEvent的子控件会透出父控件已经画过的内容视觉上就像糊了一层。定位方法很简单给子控件随便填个纯色背景试一下如果糊影消失那就是背景填充的问题。1.4 高分屏缩放下的半像素位置对不上看着像没刷新第四类容易被误判成刷新问题其实是坐标问题。在缩放比例为 125%、150% 的显示器上逻辑坐标乘上devicePixelRatio之后未必是整数一条 1 像素的线可能落在两个物理像素之间渲染出来是一条 2 像素宽的模糊线。你改了线的位置视觉上看不出变化就以为没刷新。Qt 5 里需要在main开头显式打开QCoreApplication::setAttribute(Qt::AA_EnableHighDpiScaling); QCoreApplication::setAttribute(Qt::AA_UseHighDpiPixmaps);Qt 6 默认开启不用手动设但坐标处理仍然要做对。绘制时尽量用QPainter的浮点接口drawLine之类的调用前后加painter.setRenderHint(QPainter::Antialiasing)。截屏比对的时候用widget-grab()拿到的QPixmap记得它的devicePixelRatio()跟逻辑尺寸不是一回事直接按width()去比会差一截。2. update()、repaint() 和 processEvents() 三个入口的执行模型2.1 三者的差别比多数人想的要大这三个函数都能让画面动起来但机制完全不同混用会出问题。整理成对照表接口是否同步是否合并走事件队列典型用途update()否异步是多次调用合并为一次是绝大多数场景repaint()是立即绘制否每次调用都画否需要立刻见效的特殊场景processEvents()不适用不适用主动处理挂起事件不推荐用于刷新update()的工作方式是把控件标记为脏向后端投递一个QEvent::UpdateRequest等事件循环转回来再统一处理。同一个控件在一轮事件循环里调用 100 次update()最终只触发一次paintEvent。这个合并机制是好事它天然帮你做了节流。repaint()则是同步的调用之后会直接走绘制流程函数返回时画面已经画完了。它看起来更快代价是不合并、不排队高频调用会把 CPU 吃满。更麻烦的是repaint()如果在paintEvent内部被调用会形成递归栈直接爆掉。processEvents()根本不是刷新接口它是把事件队列里挂起的事件取出来处理一遍重绘只是顺带被处理了。用它来强制刷新等于为了倒一杯水把整个水龙头拧开。2.2 定时刷新宁可 30 帧稳不要 200 帧抖实时曲线、仪表盘、数据监控这类界面基本都是定时刷新。这里的核心指标不是最高帧率而是帧间隔的稳定性。人眼对抖动比对低帧率敏感得多20 帧稳定的动画看起来比 60 帧一卡一卡的舒服。QTimer默认是Qt::CoarseTimer允许 5% 的误差省电但不够准。做动画要改成Qt::PreciseTimertimer_ new QTimer(this); timer_-setTimerType(Qt::PreciseTimer); timer_-setInterval(33); // 约 30 帧 connect(timer_, QTimer::timeout, this, CurveView::tick); timer_-start();setInterval(33)是目标值实际到了没有得自己量。我的做法是在tick()里挂一个QElapsedTimer统计最近 100 帧的平均耗时和最大耗时调试期直接打在标题栏上void CurveView::tick() { qint64 elapsed frameClock_.restart(); frameCost_.append(elapsed); // 绘制逻辑 update(); }如果最大耗时明显超过平均值的两倍说明某几帧里有额外开销通常是数据量突然变大或者分配了临时对象。这时候要去看的是绘制代码里有没有在paintEvent里new、有没有反复构造大容器、有没有在画路径时逐点重建QPainterPath。还有一个常见误区是定时器越快越好。我见过设成 5 毫秒的实际上屏幕本身刷新率也就 60Hz再快也是白算。刷新周期不应该小于 16 毫秒30 到 40 毫秒对大多数工业界面来说已经很够用。2.3 processEvents 不是刷新按钮它是把双刃剑QCoreApplication::processEvents()我踩过两次坑都是重入。第一次是在一个模态的导出函数里为了让进度条动起来插了这个调用结果用户在导出过程中又点了一次导出按钮因为按钮的点击事件被processEvents放出来了两个导出任务同时在跑文件直接覆盖成半截。第二次是在paintEvent里间接调用了它导致了递归重绘栈溢出崩掉。它真正的用途是在处理一段长逻辑时主动让出一点控制权给系统典型的正确用法是配合QProgressDialog并限制只处理ExcludeUserInputEventsQCoreApplication::processEvents(QEventLoop::ExcludeUserInputEvents);这样用户输入事件被排除在外只处理重绘和定时器就不会出现上面那种重入。但即便这样我也建议只在导出大文件批量计算这类明确的场景里用别当成常规刷新手段。3. 从脏矩形到垂直同步把刷新压到稳定区间3.1 局部刷新告诉 Qt 只重画这一块update()不带参数时整个控件都标记为脏带QRect或QRegion参数时只标记指定区域。对大面积静态背景、小面积动态数据的界面这个差别很明显。比如一条实时曲线背景网格、坐标轴、图例都是固定的只有最右边一小条在变。全量重绘每帧都要重画整个网格局部刷新只画变化的那一块void CurveView::appendPoint(const QPointF pt) { points_.append(pt); if (points_.size() maxPoints_) points_.removeFirst(); // 只刷新数据可能落到的范围 QRect dirty dirtyRegionForLastPoint(); update(dirty); }Qt 会把同一轮事件循环里的多个脏区域合并成一个QRegionpaintEvent里用event-region()能拿到。配合painter.setClipRegion()可以省掉大量绘制计算。有一点要提醒局部刷新的收益在绘制逻辑本身较重时才明显。如果你的paintEvent里只是画几条线全量刷和局部刷差不出一毫秒反而多了维护脏区域计算的成本。我一般的判断标准是单帧绘制耗时超过 8 毫秒才值得去做局部刷新。另外滚动内容要用scroll()配合。QWidget::scroll(dx, dy)会让 Qt 直接搬移已有像素只重绘新露出来的区域比重新画一遍快一个数量级。但前提是控件本身没有被子控件遮挡成复杂结构否则 Qt 会退化成全量重绘。3.2 帧率与耗时先量出来再谈优化我见过太多我觉得这里慢的优化改完发现压根不是瓶颈。刷新的性能问题一定要先量。量什么三件事一帧从触发到画完的真实耗时、paintEvent内部各段的比例、事件队列里积压了多少个重绘请求。第一项用QElapsedTimer包住整个绘制流程就行。第二项可以在paintEvent里插几个计时点分别记背景、网格、数据、文字的时间。第三项不太好直接拿但可以用一个计数器近似在update()调用处自增在paintEvent入口读出并清零如果这个数长期大于 1说明生产速度超过消费速度得降频或减负。常见的耗时大头按我的经验排序文字绘制尤其是每帧重复调用QPainter::drawText且字体没有缓存。解决方法是把静态文字预渲染成QPixmap缓存起来。抗锯齿开启下的大量细线。曲线点多的时候Antialiasing会让耗时翻几倍。可以考虑只对曲线开抗锯齿网格关掉。每帧重建容器。QVectorQPointF每帧 clear 再 push看似无害实际上内存分配是隐形开销。改成预分配固定大小、循环覆盖写。在paintEvent里做数据计算。绘制函数应该只负责画计算放到数据更新时做一次。3.3 双缓冲、QOpenGLWidget 与画面撕裂QWidget的绘制默认走双缓冲Qt 会先画到后端的缓冲区再整体上屏所以正常情况下不会看到撕裂。但如果你混用了QOpenGLWidget情况就复杂了。QOpenGLWidget有自己的渲染通道跟普通QWidget的合成顺序不完全一致父子关系里夹一个 OpenGL 控件容易出现OpenGL 那块不跟着动或者层级错乱。我的经验是一个窗口里要么全用普通 QWidget 绘制要么把 OpenGL 部分独立成一个顶层控件别搞太复杂的嵌套。真要混至少保证QOpenGLWidget不在QScrollArea里面那个组合出问题的概率很高。至于垂直同步Qt 的窗口后端在多数平台上会跟随系统的合成器节奏普通QWidget不用操心。真正需要关心的是 QML 场景QQuickWindow的渲染循环有独立配置默认跟 vsync 对齐改QSG_RENDER_LOOP环境变量能切模式但这属于调优范畴一般项目碰不到。4. 触摸屏里的工程程序文件在哪、为什么有的能读回来有的读不回来4.1 下载、上传、反编译是三件不同的事这三个词在行业里经常被混着用但含义完全不同搞清楚区别能省掉大量无效沟通。下载是把电脑上的工程编译后写进屏幕方向是电脑到设备。这是日常操作每天都做。上传是把屏幕里当前运行的那份工程读回电脑方向是设备到电脑。前提是设备里存的是可编辑的工程而不是编译后的运行文件。很多品牌为了保护和瘦身下载进屏幕的是压缩过的运行版本里面没有画面编辑所需的全部信息上传回来只能得到一个无法编辑的空壳或者干脆报错。反编译是拿不到工程文件时从运行文件里逆向出画面结构和地址表。这条路能不能走通完全看厂商有没有做保护而且即使能拿到结构脚本、宏、注释这些通常也恢复不了。所以接手老项目时第一件要确认的不是怎么上传而是这块屏当初下载进去的是不是可上传的版本。如果是别人用仅下载运行文件的方式写进去的那上传这条路从一开始就是堵死的早点想别的办法别在软件里反复试。4.2 一个 HMI 工程由哪几块组成把工程文件想象成一个压缩包里面通常包含这几类内容组成部分内容上传时能否完整取回画面与控件每个画面的布局、控件位置、属性一般可以变量表与地址映射变量名、类型、对应 PLC 地址一般可以宏与脚本事件脚本、周期性脚本看厂商是否保护配方与参数配方组、当前配方值静态部分可以运行值可能不在报警与历史报警定义、报警记录定义可以历史记录通常不随工程走系统参数通讯口配置、屏保、权限部分可读这里有个特别容易被忽略的点屏幕里跑着的数据和工程是两回事。现场操作工改过的配方值、累计产量、报警历史这些存在掉电保持区里上传工程的时候不一定跟着回来。我见过有人上传完工程发现配方全是默认值以为上传失败了其实是两套存储本来就不在一块。要保数据得单独做数据备份别指望工程上传顺带把运行数据带出来。4.3 上传权限三个开关决定你能不能拿到源工程能不能上传取决于三处设置任何一处挡住了都不行。第一处是工程属性里的允许上传选项。做工程的时候有个复选框勾掉之后下载进屏幕这块屏就再也读不回源工程了。这个选项默认可能是开的也可能是关的取决于软件版本和模板。很多公司的标准做法是交付时关掉防止工程外流——这也是接手老项目最常见的拦路虎。第二处是上传密码。屏幕在接收上传请求时会弹一个密码框密码在工程里设置。忘了密码基本无解只能走厂商的恢复流程。第三处是软件版本匹配。用低版本软件去读高版本工程一般会提示工程版本过高无法打开。反过来低版本工程用高版本软件读通常没问题会提示升级。所以手头要留一份与现场设备匹配的软件版本别一上来就装最新的。我一般会建议做设备交付的团队在上锁之前先在内部留一份完整工程备份命名里带上日期和设备编号存到版本库里。这事花不了五分钟但能避免以后为了一个密码折腾好几天。5. 把屏幕里的工程搬回电脑一条可复现的操作链路5.1 选通道四种连接方式各有各的脾气上传的物理通道一般有四种选哪种取决于设备支持和你手头的线材。通道优点坑点USB 从机口速度最快免配置需要装驱动有些屏只有主机口没有从机口以太网适合远程稳定必须与屏同网段IP 可能被改过串口兼容性最好速率慢大工程要等几分钟需要转换器U 盘导出不用电脑现场就能做只能导出运行数据或特定格式不一定能导出源工程USB 是最常用的。要注意的是屏幕上的 USB 口分主机口和从机口外形可能一样功能完全不同。从机口才能连电脑被识别为设备主机口是用来插 U 盘的。插错了电脑上什么都不会出现很多人卡在这一步以为是驱动问题。以太网的速度介于两者之间好处是可以远程操作。麻烦在于现场屏的 IP 经常被改过或者跟电脑不在一个网段。排查顺序是先在屏的系统设置里看当前 IP 和子网掩码然后把电脑改成同一网段先用ping通再开软件。别跳过ping这一步软件报的连接失败往往就是这个原因但提示语不会告诉你。串口现在用得少了但有些老设备只有串口。参数一般是固定的几组波特率 9600 或 1152008 位数据位无校验1 位停止位。对不上的话连不上而且不会有明确提示。5.2 软件端的操作顺序按分层菜单走一遍各家组态软件界面长得不一样但逻辑层次大同小异。以常见的国产 HMI 配套软件为例流程大致是这样打开软件不要新建工程直接进工具或文件菜单找上传入口。新建工程会把当前工作区占掉上传回来的内容容易覆盖这个顺序别搞反。选择通讯方式填参数。USB 直接选端口网口填 IP串口选 COM 号和波特率。点测试连接确认能通。有些软件没有独立测试按钮直接点上传也会先握手握手失败会报出来。屏幕端会弹出确认框或者密码框。这时候要去现场在屏幕上点确认、输密码。远程操作的话这一步做不了得提前安排人。输入要保存的工程文件名和路径开始上传。工程越大越慢几十个画面的工程走串口可能要十几分钟。等待进度条走完软件提示成功后立刻用软件打开这份工程验证一遍能正常打开、画面数量对得上、变量表有内容才算真成功。第 6 步特别重要。我遇到过上传到 99% 断电的情况文件生成了大小也差不多但打开就是报错。所以上传完成后不要急着拔线先验证。5.3 常见报错对照表把常见提示和实际原因对一下能省不少时间现象/提示最可能的原因处理方向提示设备无响应通道选错或参数不对换通道、核对 IP/波特率提示工程版本过高软件版本低于工程版本换匹配版本的软件提示不允许上传工程属性里关闭了上传基本无解走备份或厂商流程提示密码错误上传密码不符找原负责人或厂商上传到一半中断线材接触不良、供电波动换线、检查供电、重试上传成功但打不开文件损坏或版本不匹配重新上传换软件版本试上传后画面数量变少屏内存储本身不完整检查是否有部分画面未下载成功5.4 取回来之后必须做的三项核对工程拿回来了不等于活干完了。我每次上传完都会做三件事形成习惯了。第一件核对画面数量和编号连续性。列表里从 0 号或 1 号开始数中间有没有跳号。跳号往往意味着某些画面没下载成功或者上传过程丢了。第二件核对变量表与 PLC 地址。打开变量表看地址是不是连续的、有没有一堆0.0或者空白。如果地址全空说明这份工程本来就没有地址信息得上 PLC 侧重新对一遍。第三件核对通讯参数。串口波特率、站号、协议类型这几项决定屏能不能跟 PLC 通讯。上传回来的参数可能跟现场实际跑的不一样有人现场改过但没存工程所以核对完之后要跟 PLC 那边对一遍再下载回去别直接覆盖。提示上传回来的工程千万不要直接改完就下载回屏幕先另存一份原始备份。现场设备上的那份是能跑的版本你手里这份是可能已经过时的版本两者之间可能有现场调试时做的临时修改。6. 写显示和读工程落到习惯上是同一种思路6.1 一个是把状态推给屏幕一个是把状态从屏幕拉回来写到这儿回头看Qt 刷新和触摸屏上传其实是同一套思维在两个层面的体现。Qt 刷新关心的是内存里的数据变了怎么用最小的代价让屏幕上的像素跟着变。核心矛盾是生产速度和消费速度不匹配解法是合并请求、控制频率、局部更新。触摸屏上传关心的是设备里的状态怎么完整无损地搬到另一台机器上。核心矛盾是信息完整性解法是搞清数据结构、确认权限、验证结果。两者的共同点在于出问题时都不能靠多试几次而要先把状态流动的路径画出来谁产生、谁持有、经过哪些环节、哪一环可能丢。update()没效果就去查请求有没有被丢弃上传失败就去查是权限挡了还是通道不通。把链路拆开问题基本都能定位。6.2 我自己长期保留的几条做法第一条自定义控件的 setter 里永远带 update()。哪怕当前场景用不上也加上。这一条帮我避免过很多次改了没反应的排查。第二条所有长耗时操作一律拆片或者挪线程绝不在主线程里 sleep。这条是从无数次界面假死里换来的。判断标准很简单任何可能超过 50 毫秒的同步操作都不该出现在 UI 线程。第三条每个上位机项目都留一个隐藏的调试面板显示帧率、单帧耗时、事件队列积压数。上线时关掉出问题时打开。这东西写一次能用很多年比事后加日志舒服得多。第四条做设备交付时工程文件命名带日期和设备编号上传回来的原始版本单独存一份只读。看起来是流程上的小事实际上能省掉大量这版到底是不是最终版的扯皮。第五条接手别人的屏先摸清能不能上传再谈改需求。有些项目从第一天起就注定拿不到源工程早点确认这一点比在软件里折腾三天强。最后分享一个我常用来快速判断到底是刷新问题还是绘制问题的小手法给控件临时加一个每帧递增的计数器直接画在角落里。计数器在变而其他内容不动说明paintEvent在正常执行问题出在绘制逻辑计数器也不动那就是事件循环或者可见性问题。两分钟能定位的方向别花两小时去猜。