Qt界面刷新机制与触摸屏HMI工程上传回电脑实践

发布时间:2026/11/3 8:06:45
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::PreciseTimer:

timer_ = 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并限制只处理ExcludeUserInputEvents:

QCoreApplication::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 会把同一轮事件循环里的多个脏区域合并成一个QRegion,paintEvent里用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会让耗时翻几倍。可以考虑只对曲线开抗锯齿,网格关掉。
  • 每帧重建容器。QVector<QPointF>每帧 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 或 115200,8 位数据位,无校验,1 位停止位。对不上的话连不上,而且不会有明确提示。

5.2 软件端的操作顺序,按分层菜单走一遍

各家组态软件界面长得不一样,但逻辑层次大同小异。以常见的国产 HMI 配套软件为例,流程大致是这样:

  1. 打开软件,不要新建工程,直接进"工具"或"文件"菜单找"上传"入口。新建工程会把当前工作区占掉,上传回来的内容容易覆盖,这个顺序别搞反。
  2. 选择通讯方式,填参数。USB 直接选端口;网口填 IP;串口选 COM 号和波特率。
  3. 点"测试连接",确认能通。有些软件没有独立测试按钮,直接点上传也会先握手,握手失败会报出来。
  4. 屏幕端会弹出确认框或者密码框。这时候要去现场,在屏幕上点确认、输密码。远程操作的话这一步做不了,得提前安排人。
  5. 输入要保存的工程文件名和路径,开始上传。工程越大越慢,几十个画面的工程走串口可能要十几分钟。
  6. 等待进度条走完,软件提示成功后,立刻用软件打开这份工程验证一遍,能正常打开、画面数量对得上、变量表有内容,才算真成功。

第 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在正常执行,问题出在绘制逻辑;计数器也不动,那就是事件循环或者可见性问题。两分钟能定位的方向,别花两小时去猜。