Python PyQt 上位机开发基础
为什么是 PyQt?它真的适合做工业级上位机吗?
很多人认为 Python 做上位机太慢或不稳定,但现代 PyQt 的实际能力已能满足需求。
PyQt 是什么
PyQt 是 Python 版的 Qt,直接调用原生 C++ Qt 库。这意味着界面渲染走系统级图形 API(DirectX / OpenGL),控件外观与本地应用一致,内存管理由底层引擎处理,性能接近原生程序。
| 关注点 | 实际情况 |
|---|
介绍使用 Python 和 PyQt 构建上位机软件的完整流程。内容涵盖 PyQt 框架优势分析、基础窗口搭建、多线程串口通信实现、实时波形图绘制(pyqtgraph)、系统架构设计以及常见开发陷阱(如乱码、资源占用、内存泄漏)的解决方案。通过模块化设计和配置管理,实现高性能、可维护的工控界面应用。
很多人认为 Python 做上位机太慢或不稳定,但现代 PyQt 的实际能力已能满足需求。
PyQt 是 Python 版的 Qt,直接调用原生 C++ Qt 库。这意味着界面渲染走系统级图形 API(DirectX / OpenGL),控件外观与本地应用一致,内存管理由底层引擎处理,性能接近原生程序。
| 关注点 | 实际情况 |
|---|
| 跨平台 | Windows/Linux/macOS 一键运行,代码几乎不用改 |
| 界面美观 | 支持 QSS 样式表,可做出媲美 Web 的视觉效果 |
| 多线程 | 完全支持,有安全机制避免 UI 卡顿 |
| 绘图性能 | 配合 pyqtgraph,轻松实现每秒上千点的实时刷新 |
| 发布方便 | 用 PyInstaller 打包成单个 exe,客户双击即用 |
网上教程常直接甩代码,我们拆解关键步骤。
import sys
from PyQt5.QtWidgets import QApplication, QWidget, QPushButton, QVBoxLayout
class MainWindow(QWidget):
def __init__(self):
super().__init__()
self.initUI()
def initUI(self):
# 创建按钮
self.btn = QPushButton('开始采集', self)
self.btn.clicked.connect(self.on_start_clicked)
# 布局管理
layout = QVBoxLayout()
layout.addWidget(self.btn)
self.setLayout(layout)
self.setWindowTitle('上位机主界面')
self.resize(300, 200)
def on_start_clicked(self):
print("数据采集已启动...")
if __name__ == '__main__':
app = QApplication(sys.argv)
window = MainWindow()
window.show()
sys.exit(app.exec_())
clicked 信号。信号与槽机制:这是 PyQt 的灵魂。通过 self.btn.clicked.connect(self.on_start_clicked),当用户点击按钮时自动调用函数,让界面和逻辑解耦。
在主线程直接读串口会导致界面卡死。正确做法是将通信放入独立线程。
import serial
import threading
from PyQt5.QtCore import pyqtSignal, QObject
class SerialWorker(QObject):
data_received = pyqtSignal(str) # 自定义信号
def __init__(self, port, baudrate=115200):
super().__init__()
self.port = port
self.baudrate = baudrate
self.is_running = False
def start(self):
self.is_running = True
self.thread = threading.Thread(target=self._read_loop)
self.thread.start()
def _read_loop(self):
try:
ser = serial.Serial(self.port, self.baudrate, timeout=1)
while self.is_running:
if ser.in_waiting > 0:
line = ser.readline().decode('utf-8').strip()
self.data_received.emit(line)
ser.close()
except Exception as e:
self.data_received.emit(f"ERROR: {str(e)}")
def stop(self):
self.is_running = False
重点在于 data_received = pyqtSignal(str) 是跨线程安全的通信通道。子线程发射信号,由 Qt 内部机制投递到主线程处理。
接入主程序:
# 在主窗口中初始化串口模块
self.serial_worker = SerialWorker('COM3', 115200)
self.serial_worker.data_received.connect(self.handle_serial_data)
self.serial_worker.start()
# 定义数据处理函数
def handle_serial_data(self, data):
print("收到:", data)
# 可以在这里更新文本框、触发绘图、解析协议...
假设传感器返回格式为 TEMP:23.5,HUMI:45.2,需结构化展示。
from PyQt5.QtWidgets import QTableWidget, QTableWidgetItem
self.table = QTableWidget(100, 2) # 100 行 2 列
self.table.setHorizontalHeaderLabels(['时间', '数值'])
def add_table_row(self, value):
row = self.table.rowCount()
for i in range(row - 1): # 向上滚动
for col in range(2):
item = self.table.item(i+1, col)
if item:
self.table.setItem(i, col, QTableWidgetItem(item.text()))
# 插入新行
self.table.setItem(row-1, 0, QTableWidgetItem(time.strftime("%H:%M:%S")))
self.table.setItem(row-1, 1, QTableWidgetItem(value))
温度超过 30℃变红:
from PyQt5.QtGui import QColor
from PyQt5.QtWidgets import QLabel
self.status_label = QLabel("正常")
self.status_label.setStyleSheet("background-color: green; color: white; padding: 5px")
def update_status(self, temp):
if float(temp) > 30:
self.status_label.setText("高温报警!")
self.status_label.setStyleSheet("background-color: red; color: white; padding: 5px")
else:
self.status_label.setText("正常")
self.status_label.setStyleSheet("background-color: green; color: white; padding: 5px")
Matplotlib 不适合实时监控,推荐使用专为科学计算优化的 pyqtgraph。
import pyqtgraph as pg
import numpy as np
# 初始化绘图区域
plot_widget = pg.PlotWidget()
plot_curve = plot_widget.plot(pen='y') # 黄色曲线
# 设置坐标轴
plot_widget.setLabel('left', '电压', units='V')
plot_widget.setLabel('bottom', '时间', units='s')
plot_widget.setTitle('实时采样波形')
# 缓冲区(固定长度,形成环形队列)
buffer_size = 1000
x_data = np.linspace(0, 10, buffer_size)
y_data = np.zeros(buffer_size)
def update_plot(new_value):
global y_data
y_data = np.append(y_data[1:], new_value) # 移除旧值,添加新值
plot_curve.setData(x_data, y_data) # 快速更新
# 每 50ms 刷新一次
from PyQt5.QtCore import QTimer
timer = QTimer()
timer.timeout.connect(lambda: update_plot(np.random.rand()))
timer.start(50) # 20 FPS
关键点:使用 NumPy 数组存储数据,setData() 增量更新,配合定时器形成稳定动画节奏。
标准四层架构:
┌─────────────────────┐
│ 用户界面层 (UI) │ ← 用户看到的一切
├─────────────────────┤
│ 控制逻辑层 (Controller)│ ← 参数处理、状态切换
├─────────────────────┤
│ 数据通信层 (Serial) │ ← 串口/网络收发
├─────────────────────┤
│ 数据模型层 (Model) │ ← 缓冲区、协议解析、文件保存
└─────────────────────┘
每一层只和相邻层交互。好处包括修改通信协议不影响界面、更换连接方式只需替换底层模块、单元测试更容易。
原因:编码不统一 + 未处理粘包问题。 ✅ 正确做法:
# 统一使用 UTF-8
line = ser.readline().decode('utf-8', errors='ignore').strip()
# 或者采用定长帧接收
data = ser.read(32) # 固定每次读 32 字节
更高级的做法是定义帧头帧尾,例如 $DATA,23.5,*7F\n。
原因:异常退出时没有正确释放资源。 ✅ 解决方案:
def closeEvent(self, event):
if hasattr(self, 'serial_worker'):
self.serial_worker.stop()
self.serial_worker.thread.join(timeout=1.0) # 等待线程结束
event.accept()
记得绑定 closeEvent,确保优雅退出。
原因:不断往列表追加数据却不清理。
✅ 解决办法:限制缓冲区大小、定期清理日志文本框、使用 weakref 防止循环引用。
下次打开自动填上次的串口号和波特率:
from PyQt5.QtCore import QSettings
settings = QSettings("MyCompany", "SensorMonitor")
settings.setValue("port", "COM3")
settings.setValue("baudrate", 115200)
# 启动时读取
last_port = settings.value("port", "COM1")
last_baud = int(settings.value("baudrate", 9600))
加个 QTextEdit 实时显示通信日志,故障排查效率翻倍。
一键保存当前波形为 CSV 文件:
with open('data.csv', 'w') as f:
for t, v in zip(x_data, y_data):
f.write(f"{t:.3f},{v:.3f}\n")
用 QSS 给按钮加渐变背景、圆角边框:
btn.setStyleSheet("""
QPushButton {
background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #5a9, stop:1 #488);
color: white;
border-radius: 8px;
padding: 10px;
}
""")

微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
解析常见 curl 参数并生成 fetch、axios、PHP curl 或 Python requests 示例代码。 在线工具,curl 转代码在线工具,online
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML 转 Markdown 互为补充。 在线工具,Markdown 转 HTML在线工具,online
将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML 转 Markdown在线工具,online
通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online