ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

PyQt6-第16讲-实时数据监控面板

PyQt6-第16讲-实时数据监控面板

第 16 讲 | 实时数据监控面板

本讲目标:做出"数据一直在跳"的监控界面。核心是QTimer驱动的刷新循环,
以及三个让实时界面不卡的性能技巧。

配套代码:cases/c16_dashboard.py,下载链接请看底部


一、QTimer:让界面"自己动起来"

前面所有程序的刷新都是"用户触发的"(点按钮、改输入框)。
实时界面需要定时自动刷新:

fromPyQt6.QtCoreimportQTimer self.timer=QTimer(self)self.timer.setInterval(500)# 500 毫秒self.timer.timeout.connect(self.on_tick)# 每次到点调用 on_tickself.timer.start()

关键点:QTimer的回调是在主线程里执行的(因为它依赖事件循环分发),
所以你可以放心地在on_tick里操作控件——这一点和QThread完全不同。

QTimerQThread
回调在哪个线程主线程子线程
能直接操作控件吗能绝对不能
适用周期性刷新界面、防抖、倒计时一次性耗时任务

别混用:如果你把"读串口 / 请求接口"这类耗时操作放进 QTimer 的回调,
照样会把界面卡住(因为回调在主线程)。正确做法是:QTimer 只负责"刷新界面",
数据获取交给线程或线程池。

本讲为了聚焦在"界面刷新"上,用随机数模拟数据源。真实项目里换成串口/Modbus/HTTP 取数即可,
但取数那一步要放子线程。


二、数据缓冲:用 deque 固定长度

监控曲线一般只显示"最近 N 个点",所以用一个固定长度的队列:

fromcollectionsimportdeque MAX_POINTS=60self.buffers={name:deque([0.0]*MAX_POINTS,maxlen=MAX_POINTS)fornameinself.series}

deque(maxlen=N)的妙处:满了之后自动丢掉最老的元素。
不用自己写"if len > N: pop(0)",也不会像 list 那样越攒越多。

这也顺便解决了内存泄漏问题:如果用普通的 list 一直 append,跑一整天内存就爆了。

曲线因此表现为"整体左移,新点在右边出现"——这就是"滚动"效果的来源。


三、性能关键:replace 比 append 快得多

慢的写法

series.clear()# 清空fori,vinenumerate(buf):# 再加回去series.append(float(i),v)

或者:

series.append(new_x,new_y)# 加新点whileseries.count()>MAX_POINTS:series.remove(0)# 删最老的点

这两种写法在每次刷新时都要改动几十个数据点,Qt 内部会为每一次改动
触发重绘和重新计算坐标。500 毫秒刷新一次、每次改 60 个点,界面会明显发卡。

快的写法

# 一次性算好所有点,整体替换forname,bufinself.buffers.items():points=[QPointF(float(i),v)fori,vinenumerate(buf)]self.series[name].replace(points)

replace(list)是批量操作:Qt 会把它当成"整体数据换了"来处理,
只触发一次重绘、一次坐标计算。相比逐点操作,性能差一个数量级。

记住这个原则:QChart 的数据更新尽量用replace()批量替换,
不要逐点 append/remove。

采样频率怎么定

间隔适用
100 ms需要看出"平滑运动"的曲线,但视觉刷新率的上限也就 60Hz,再快没意义
200~500 ms推荐:既流畅又不浪费 CPU
1000 ms 以上数据本身变化慢(比如每小时一次的温度)

本案例默认 500 ms,界面上放了个滑块让用户调(100~2000 ms):

self.spin_speed.valueChanged.connect(self.timer.setInterval)

注意setInterval对正在运行的定时器也是生效的,不用先 stop。


四、Y 轴动态范围:让曲线始终填满视图

固定范围(比如 0~100)的问题是:数据只在 60~65 之间波动时,曲线就是一条几乎平的线,
看不出细节。

动态调整:

lo=min(min(b)forbinself.buffers.values())hi=max(max(b)forbinself.buffers.values())pad=max(5.0,(hi-lo)*0.1)# 留 10% 余量,至少 5 个单位self.axis_y.setRange(lo-pad,hi+pad)

两个细节:

① 三条曲线共用一个轴时,要取所有曲线的全局最小/最大值
不然某条曲线会跑到视图外面。

②max(5.0, ...)这个兜底很重要
当所有数值几乎相同时(比如都是 62.0),hi - lo ≈ 0,
pad就变成 0,轴范围是[62, 62]——Qt 会画出不可预期的结果(甚至崩溃)。
给一个最小余量就稳了。


五、数字卡片:用 QFrame + 样式表做出仪表盘质感

监控面板除了曲线,通常还要几个醒目的大数字。做法是自定义一个小控件:

classMetricCard(QFrame):def__init__(self,title:str,unit:str="",color:str="#2d7ff9"):super().__init__()self.setFrameShape(QFrame.Shape.StyledPanel)self.setStyleSheet("QFrame { background:#ffffff; border:1px solid #e6e9ef; border-radius:8px; }")self.lbl_title=QLabel(title)self.lbl_title.setStyleSheet("color:#8a8f99; font-size:12px; border:none;")self.lbl_value=QLabel(f"--{unit}")self.lbl_value.setStyleSheet(f"color:{color}; font-size:26px; font-weight:bold; border:none;")self.unit=unit lay=QVBoxLayout(self)lay.setContentsMargins(14,10,14,10)lay.addWidget(self.lbl_title)lay.addWidget(self.lbl_value)defset_value(self,value:float,fmt:str="{:.2f}")->None:self.lbl_value.setText(fmt.format(value)+self.unit)

三个样式细节

①border-radius: 8px圆角
一个圆角就能让"方框"变成"卡片"。

② 子控件要设border:none
父容器的样式表会继承到子控件上:QFrame的边框会在 QLabel 周围再画一圈,
看起来像"框里有个小框"。给子控件明确写border:none解决。

③ 数字字号大、标题字号小、颜色区分
26px 的彩色粗体数字 + 12px 的灰色标题,信息层次立刻清楚。

用 QHBoxLayout 横排四个卡片:

cards_row=QHBoxLayout()forcardinself.cards.values():cards_row.addWidget(card)

卡片会自动等宽(都是 Expanding 策略),不用手动算宽度。

六、暂停/继续:正确做法是 stop 定时器

deftoggle_timer(self)->None:ifself.timer.isActive():self.timer.stop()self.btn_toggle.setText("继续")self.lbl_state.setText("● 已暂停")self.lbl_state.setStyleSheet("color:#faad14; font-size:13px;")else:self.timer.start(self.spin_speed.value())self.btn_toggle.setText("暂停")self.lbl_state.setText("● 运行中")self.lbl_state.setStyleSheet("color:#52c41a; font-size:13px;")

不要用标志位:

# ✘ 不好的做法defon_tick(self):ifself.paused:return# 定时器还在跑,只是什么都不做,白耗 CPU...

QTimer.stop()是真正停下来(不再产生定时事件),比"空转着判断标志位"省资源。

状态显示要跟着变:按钮文字(“暂停”/“继续”)和状态标签(颜色 + 文字)都要更新。
用户需要明确知道"现在是停着的还是在跑的"。


七、完整的刷新回调

defon_tick(self)->None:self.tick+=1t=self.tick/10.0# ① 取数(真实项目里这一步放子线程,这里用正弦+噪声模拟)temp=62+12*math.sin(t)+random.uniform(-1.5,1.5)press=48+20*math.sin(t*0.7+1)+random.uniform(-2,2)flow=35+15*math.sin(t*1.3+2)+random.uniform(-1.5,1.5)# ② 写入缓冲forname,valuein(("温度",temp),("压力",press),("流量",flow)):self.buffers[name].append(value)# ③ 批量更新曲线(性能关键)forname,bufinself.buffers.items():points=[QPointF(float(i),v)fori,vinenumerate(buf)]self.series[name].replace(points)# ④ 动态调整 Y 轴...# ⑤ 更新数字卡片self.cards["temp"].set_value(temp,"{:.1f}")...

回调里的五步是固定套路:取数 → 存缓冲 → 更新曲线 → 调整坐标轴 → 更新数字。
顺序不能乱(坐标轴要根据新数据算)。

注意 ③ 和 ⑤ 之间的顺序:先更新数据再更新轴和数字,否则显示的数值和曲线对不上。


八、四个坑

坑 1:定时器忘了停,程序退出后还在跑
用self.timer = QTimer(self)把定时器设为窗口的子对象,
窗口销毁时定时器自动销毁。传self这个参数别省。

坑 2:曲线越来越卡
多半是用了逐点 append/remove,或者缓冲没设上限(list 无限增长)。
用deque(maxlen=N)+series.replace()。

坑 3:在 on_tick 里做耗时操作,界面卡顿
QTimer 回调在主线程,读串口/发请求超过 100ms 就会看出卡顿。
取数放子线程,回调只负责把最新值画出来。

坑 4:所有曲线共用轴时,最小值取错
要取min(min(b) for b in buffers.values())(所有曲线的全局最小值),
不能只取某一条的。


九、小结

  • QTimer 驱动刷新,回调在主线程,可以直接操作控件
  • deque(maxlen=N)做固定长度缓冲,自动丢弃老数据
  • series.replace(points)批量更新,比逐点 append/remove 快一个数量级
  • Y 轴动态范围要留 10% 余量并设最小值兜底
  • 暂停用timer.stop(),不要用标志位空转
  • 数字卡片靠 QFrame + 圆角 + 字号层次做出来

现在图能画、能实时刷新了。但有个问题:所有图表都是"标准形状"——
柱状、饼、折线。如果老板说"我要一个仪表盘,指针会转的那种",QtCharts 里没有。

这时候唯一的办法是自己画。

下一讲:QPainter 自绘控件。我们会从paintEvent讲起,
说明那个最容易算错的角度换算(Qt 用 1/16 度、逆时针为正),
然后画出带指针、刻度、分段配色的仪表盘,以及一个带渐变色的环形进度条。


本讲是《PyQt6 客户端编程从入门到实战》专栏的第 16 讲。专栏共 22 讲、22 个可运行案例。
**代码下载:git clone https://gitee.com/wuyikui555/pyqt6-client.git。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进