PyQt 快速上手
前言
- 嵌入式软件与常规应用程序的不同在于其高度依赖特定硬件,这使得软件的性能和稳定性直接受到硬件的影响。因此,嵌入式设备在生产过程中,往往都要经过 IQC(来料检验)、FCT(功能测试)、PQC(过程检验)等多种测试流程。然而,产测人员通常缺乏深厚的技术背景,他们依赖高效的测试工具来完成工作。为了提升测试效率和准确性,引入用户友好的图形界面工具变得尤为重要。这些工具提供了直观的操作界面,使产测人员能够更轻松地执行和记录测试过程,确保最终产品的质量符合标准。
- 另一方面,如果作为设备、模块供应商或产品合作厂商,客户通常也需要图形界面工具去简化测试操作和数据分析,使客户无需深入了解底层技术。直观的图形界面不仅帮助客户实时监控产品质量,还提高了测试的透明度和可操作性。通过减少对技术支持的依赖,这些工具使客户能够更加自主地管理和验证产品性能与质量。
- 因此,掌握一个高效的 GUI 开发工具将使我们在工作中更加主动,也能更好地提升工作质量。
什么是 PyQt
PyQt 是一个用于开发 Python 图形用户界面(GUI)应用程序的库,它是 Qt 应用程序框架的 Python 绑定,其主要特点有:
- 功能丰富:提供了大量的GUI组件和工具,支持复杂的用户界面和交互。
- 跨平台:代码可以在 Windows、macOS 和 Linux 上运行,确保应用的广泛兼容性。
- Python语言:利用 Python 的简洁语法进行开发,减少代码复杂性,提高开发效率。
- 强大的Qt框架:继承了Qt的强大功能,如信号与槽机制、图形绘制、网络支持等。
- Qt Designer支持:可通过Qt Designer 图形化设计界面,提升开发效率。
因为结合了 Python 的易用性和 Qt 框架的强大功能,为开发者提供了一个强大的工具来创建跨平台的桌面应用程序,逐渐成为开发高质量桌面应用程序的热门选择。
PyQt5+VSCode 环境搭建
VSCode
- Visual Studio Code(VSCode)是一个开源、轻量级的代码编辑器,支持多种编程语言。它提供了智能代码补全、调试、版本控制集成和扩展插件等功能。VSCode 运行在 Windows、macOS 和 Linux平台上,界面简洁且高效,非常适合各种开发。
- 从官网下载适合自己系统平台的软件安装包并安装 官方下载地址
PyQt5安装
Windows
- python 安装
- 去官网下载 python 包安装
- pip 镜像源更改
- 在用户名文件夹下,新建文件夹 pip,然后将下面的配置文件 pip.ini 放入 pip 文件夹中
[global] index-url = http://mirrors.aliyun.com/pypi/simple/ [install] trusted-host=mirrors.aliyun.com
- 使用 pip 安装 pyqt5
pip install PyQt5 pip install PyQt5-tools
Linux
- python 安装
sudo apt update sudo apt install -y software-properties-common sudo add-apt-repository ppa:deadsnakes/ppa sudo apt update sudo apt install -y python3.8
- 更新pip的源配置
mkdir -p ~/.pip nano ~/.pip/pip.conf
[global] index-url = https://mirrors.aliyun.com/pypi/simple/
- 安装 pyqt5
sudo apt install -y python3-pyqt5 pyqt5-dev-tools pip install pyqt5
VSCode插件安装
-
在 vscode 插件栏中搜索安装以下插件:
-
插件配置
-
在插件安装完毕之后,需要将 pyqt 的各个工具路径配置到插件中,windows 系统一般工具路径在以下目录:
C:\Users\xxxx\AppData\Roaming\Python\Python39\site-packages\qt5_applications\Qt\bin
将路径中的 “xxxx” 替换成你的用户名,可以看到目录下有一些 exe 类型的文件:
找到 pyrcc5、pyuic5、desinger 的路径,其中最重要的就是 designer.exe,这个文件就是用于设计界面的工具。 -
Linux 系统下可以通过
sudo find / -name "designer"
找到对应的安装目录
-
-
VSCode 打开 PYQT Integration 的配置窗口,配置 pyrcc5、pyuic5、desinger 的路径:
到此就可以开始 PyQt5 开发了。
新建UI
在 VSCode 的目录树窗口右键,在菜单栏里会出现 “PYQT: New Form”,选中此菜单项,
就会出现窗体设计器:
编译 UI 为 Python 源码
使用 designer 设计好一个 ui,并保存为 .ui 文件之后,可以在文件名上右键,选择弹出菜单的 Compile Form,
这样 ui 文件就会被编译为 .py 文件:
PyQt 基本知识
主线程
在 PyQt 应用程序中,主线程(又称“UI线程”)主要负责以下几个关键任务:
- 事件循环: 主线程运行事件循环,这个循环负责接收和处理各种事件,如用户输入(鼠标点击、键盘输入等)、定时器事件、网络数据等。事件循环确保应用程序对用户操作和系统事件做出响应。
- 界面更新和渲染: 主线程负责更新和渲染用户界面。如果 UI 组件的状态或显示内容发生变化,主线程会处理这些更改并重新绘制界面。这也意味着,在主线程进行一些耗时操作,会影响 UI 的流畅性。
- 信号和槽机制: PyQt 使用信号和槽机制来实现组件之间的通信。当一个信号发射时,主线程会处理对应的槽函数。信号和槽的处理都是在主线程中进行的,因此如果槽函数执行时间过长,会影响界面的响应性。
- UI组件的创建和管理: 创建和管理 UI 组件(如窗口、按钮、标签等)都在主线程中进行。任何对这些组件的修改必须在主线程中完成。
QThread - 线程管理
QThread 是用于多线程处理的组件,可以让我们在后台执行长时间运行的任务或者耗时操作,避免阻塞主线程和 UI 响应。
run() :QThread 的 run() 方法是线程执行的入口点。
start() :QThread 的 start() 方法用于启动线程,线程运行时,后台自动执行 run() 方法中的代码。
示例:
- WorkerThread 类继承自 QThread,并在 run 方法中执行后台任务。
- 使用 pyqtSignal 定义信号,用于从线程中发送数据到主线程。
- 在 MyApp 类中,创建线程并连接信号到槽函数。
import sys
from PyQt5.QtCore import QThread, pyqtSignal
from PyQt5.QtWidgets import QApplication, QWidget, QPushButton, QVBoxLayout, QLabel
class WorkerThread(QThread):
# 定义信号
update_signal = pyqtSignal(str)
def run(self):
import time
for i in range(5):
time.sleep(1)
self.update_signal.emit(f"Update {i}")
class MyApp(QWidget):
def __init__(self):
super().__init__()
self.initUI()
def initUI(self):
self.setWindowTitle('QThread Example')
self.setGeometry(100, 100, 300, 200)
self.label = QLabel('Waiting for updates...', self)
start_button = QPushButton('Start Thread', self)
start_button.clicked.connect(self.start_thread)
layout = QVBoxLayout()
layout.addWidget(self.label)
layout.addWidget(start_button)
self.setLayout(layout)
def start_thread(self):
self.thread = WorkerThread()
self.thread.update_signal.connect(self.update_label)
self.thread.start()
def update_label(self, message):
self.label.setText(message)
def main():
app = QApplication(sys.argv)
ex = MyApp()
ex.show()
sys.exit(app.exec_())
if __name__ == '__main__':
main()
QTimer - 定时器
QTimer 用于定期执行任务,如定时更新界面、周期性检查等。
start(): QTimer 的 start() 方法用于启动定时器。
示例
- 创建一个 QTimer 实例,并连接到超时的槽函数 update_time。
- start_timer 方法开始定时器,并设置间隔为 1000 毫秒(1 秒)。
- update_time 方法更新界面上的标签,显示经过的时间。
import sys
from PyQt5.QtCore import QTimer
from PyQt5.QtWidgets import QApplication, QWidget, QPushButton, QVBoxLayout, QLabel
class TimerApp(QWidget):
def __init__(self):
super().__init__()
self.initUI()
def initUI(self):
self.setWindowTitle('QTimer Example')
self.setGeometry(100, 100, 300, 200)
self.label = QLabel('Time elapsed: 0', self)
start_button = QPushButton('Start Timer', self)
start_button.clicked.connect(self.start_timer)
layout = QVBoxLayout()
layout.addWidget(self.label)
layout.addWidget(start_button)
self.setLayout(layout)
self.timer = QTimer()
self.timer.timeout.connect(self.update_time)
self.elapsed_time = 0
def start_timer(self):
self.elapsed_time = 0
self.timer.start(1000) # 1000 ms = 1 second
def update_time(self):
self.elapsed_time += 1
self.label.setText(f'Time elapsed: {self.elapsed_time} seconds')
def main():
app = QApplication(sys.argv)
ex = TimerApp()
ex.show()
sys.exit(app.exec_())
if __name__ == '__main__':
main()
槽(Slots)
在 PyQt 中,槽(slots)是处理信号的函数或方法。当信号发射时,相关的槽函数会被调用。槽可以是类的成员函数,也可以是全局函数。
示例:
- Communicator 类定义了一个自定义信号 my_signal。
- SlotExample 类创建一个按钮,点击按钮会发射信号。
- emit_signal 方法发射信号,handle_signal 方法是槽函数,用于处理信号并更新标签内容。
import sys
from PyQt5.QtCore import pyqtSignal, QObject
from PyQt5.QtWidgets import QApplication, QWidget, QPushButton, QVBoxLayout, QLabel
class Communicator(QObject):
# 定义信号
my_signal = pyqtSignal(str)
def __init__(self):
super().__init__()
class SlotExample(QWidget):
def __init__(self):
super().__init__()
self.initUI()
def initUI(self):
self.setWindowTitle('Signal and Slot Example')
self.setGeometry(100, 100, 300, 200)
self.label = QLabel('Signal not emitted yet', self)
self.button = QPushButton('Emit Signal', self)
self.button.clicked.connect(self.emit_signal)
layout = QVBoxLayout()
layout.addWidget(self.label)
layout.addWidget(self.button)
self.setLayout(layout)
self.comm = Communicator()
self.comm.my_signal.connect(self.handle_signal)
def emit_signal(self):
self.comm.my_signal.emit('Signal emitted!')
def handle_signal(self, message):
self.label.setText(message)
def main():
app = QApplication(sys.argv)
ex = SlotExample()
ex.show()
sys.exit(app.exec_())
if __name__ == '__main__':
main()
常见问题
为什么使用线程或定时器时可能导致界面卡顿?
- 线程与 UI 组件的交互: 虽然 Python 的线程可以用来执行耗时任务,但 UI 组件的更新和操作必须在主线程中进行。如果后台线程尝试直接操作 UI 组件,可能会导致界面卡顿或不稳定。正确的方法是使用线程来执行后台任务,并通过信号槽机制将结果传回主线程,主线程再更新 UI。
- 线程与事件循环: 如果后台线程中执行的任务过于耗时,可能会导致主线程的事件循环无法及时处理用户输入和其他事件,从而导致界面卡顿。确保后台任务的执行时间尽可能短,或者将任务分解为多个较小的部分,以减少对 UI 响应的影响。
- 不当的定时器使用: 定时器用于定期触发某些操作,如果定时器触发的操作很耗时,也会影响 UI 的流畅性。应确保定时器的回调函数执行迅速,或将耗时操作移至后台线程。
如果可能,考虑使用异步编程模型(如 asyncio)来处理一些任务,这样可以更好地与 UI 线程协调。
应用示例
接下来通过实现一个简单的工具,熟悉界面设计中常用的组件(pushButton、comboBox、lineEdit、progressBar等)。该工具是一款通信模块类产品的配置工具,实现了通过串口对该产品进行菜单页面配置、通信参数配置以及模块升级等功能。
- 使用窗体设计器设计并布局界面
- 将 UI 编译为 Python 源码后在应用源码中调用
#! /usr/bin/env python3 # -*- coding: utf-8 -*- # # Syrius Robotics Ltd. Co. CONFIDENTIAL # # Unpublished Copyright (c) 2024 - 2024 [Syrius Robotics Ltd. Co.], # All Rights Reserved. # from ui.Ui_main import Ui_MainWindow import time import logging import serial.tools.list_ports import queue import time import sys from PyQt5.QtCore import pyqtSlot, pyqtSignal, QThread, Qt from PyQt5.QtWidgets import QApplication, QMainWindow, QTableWidget, QTableWidgetItem from PyQt5.QtWidgets import QFileDialog, QLabel, QMessageBox from uart_host import UartHost, CommandList, StatusList # 设计配置表类用于配置参数 class ConfigTable(): def __init__(self, table_widget: QTableWidget): self._table_widget = table_widget self._table_widget.setColumnCount(2) self._table_widget.setHorizontalHeaderLabels(['参数', '值']) self._table_widget.verticalHeader().setVisible(False) self._table_widget.setColumnWidth(0, 115) self._table_widget.setColumnWidth(1, 200) self._table_index = {} def add_param(self, param: str, val: str) -> None: if param in self._table_index.keys(): self._table_widget.item(self._table_index[param], 1).setText(val) return current_row_count = self._table_widget.rowCount() self._table_widget.setRowCount(current_row_count + 1) item = QTableWidgetItem(param) item.setFlags(item.flags() & ~Qt.ItemIsEditable) item.setTextAlignment(Qt.AlignCenter) item.setToolTip(item.text()) self._table_widget.setItem(current_row_count, 0, item) item2 = QTableWidgetItem(val) item2.setTextAlignment(Qt.AlignCenter) self._table_widget.setItem(current_row_count, 1, item2) self._table_index[param] = current_row_count def delete_param(self, param: str) -> None: if param in self._table_index.keys(): self._table_widget.removeRow(self._table_index[param]) del self._table_index[param] def clear_table(self) -> None: self._table_widget.setRowCount(0) self._table_index = {} def get_items(self) -> dict: pairs = {} for row in range(self._table_widget.rowCount()): item1 = self._table_widget.item(row, 0) item2 = self._table_widget.item(row, 1) if item1 is not None and item2 is not None: text1 = item1.text() text2 = item2.text() pairs[text1] = text2 return pairs class Tool(QThread): STATE_OPEN = 0 STATE_CLOSE = 1 VALUE_TYPE_INT = 0 VALUE_TYPE_STRING = 1 sig_menu_info = pyqtSignal(dict) sig_config_param = pyqtSignal(dict) def __init__(self, win: QMainWindow): super(QThread, self).__init__() self._ui = win._ui self._window = win self._host = UartHost('pager') self._serial_devices = [] self._state = self.STATE_CLOSE self._config_table = ConfigTable(self._ui.tableWidget) self.refresh_serial_ports() # 将各种组件事件连接到对应槽函数 self._ui.progressBar_ota.setVisible(False) self._ui.pushButton_open_close.clicked.connect(self.on_button_open_close_click) self._ui.pushButton_ota_select_file.clicked.connect(self.on_button_ota_select_file_click) self._ui.pushButton_ota_update.clicked.connect(self.on_button_ota_update_click) self._ui.comboBox_menu.currentIndexChanged.connect(self.on_combobox_menu_changed) self._ui.pushButton_menu_read.clicked.connect(self.on_button_menu_read_click) self._ui.pushButton_menu_update.clicked.connect(self.on_button_menu_update_click) self._ui.pushButton_menu_save.clicked.connect(self.on_button_menu_save_click) self._ui.pushButton_config_read.clicked.connect(self.on_button_config_read_click) self._ui.pushButton_config_update.clicked.connect(self.on_button_config_update_click) self._ui.pushButton_config_save.clicked.connect(self.on_button_config_save_click) self.default_display() self.start() def refresh_status_bar_sn(self) -> None: message = {'cmd': CommandList.COMMAND_GET_SN.value, 'data': bytearray([])} response = self._host.send_sync(message) if response and response['status'] == StatusList.STATUS_SUCCESS.value: data: bytearray = response['data'] self.label_sn.setText(f" SN:{data.decode()}") self.label_sn.setVisible(True) def refresh_status_bar_version(self) -> None: message = {'cmd': CommandList.COMMAND_GET_VERSION.value, 'data': bytearray([])} response = self._host.send_sync(message) if response and response['status'] == StatusList.STATUS_SUCCESS.value: data: bytearray = response['data'] self.label_firmware_version.setVisible(True) self.label_firmware_version.setText(f"VERSION:{data[0]}.{data[1]}.{data[2]}") def default_display(self) -> None: self.label_sn = QLabel() self.label_firmware_version = QLabel() self._ui.statusBar.addWidget(self.label_sn) self._ui.statusBar.addPermanentWidget(self.label_firmware_version) self.label_sn.setVisible(False) self.label_firmware_version.setVisible(False) self._ui.groupBox_menu.setEnabled(False) self._ui.groupBox_config.setEnabled(False) self._ui.groupBox_ota.setEnabled(False) self._ui.lineEdit_tab1_index2.setEnabled(False) self._ui.lineEdit_tab2_index2.setEnabled(False) def refresh_serial_ports(self) -> None: ports = serial.tools.list_ports.comports() port_devices = [] for port in ports: port_devices.append(port.device) set1 = set(self._serial_devices) set2 = set(port_devices) if set1 != set2 and self._state == self.STATE_CLOSE: self._ui.comboBox_devices.clear() self._serial_devices = [] sorted_ports = sorted(port_devices, key=lambda x: (x[:11], int(x[11:]))) for port in sorted_ports: self._serial_devices.append(port) self._ui.comboBox_devices.addItem(port) def refresh_menu(self) -> None: message = {'cmd': CommandList.COMMAND_GET_MENU_INFO.value, 'data': bytearray([])} response = self._host.send_sync(message) if response and response['status'] == StatusList.STATUS_SUCCESS.value: data: bytearray = response['data'] self._ui.comboBox_menu.clear() for group in range(1, data[0] + 1): self._ui.comboBox_menu.addItem(f'{group}') self._ui.comboBox_menu.setCurrentIndex(0) self.on_button_menu_read_click() @pyqtSlot() def on_button_open_close_click(self) -> None: try: if self._state == self.STATE_CLOSE: self._host.open_link(self._ui.comboBox_devices.currentText()) self._ui.pushButton_open_close.setText('Close') self._state = self.STATE_OPEN self._ui.comboBox_devices.setEnabled(False) self.refresh_status_bar_sn() self.refresh_status_bar_version() self.refresh_menu() self.on_button_config_read_click() self._ui.groupBox_menu.setEnabled(True) self._ui.groupBox_config.setEnabled(True) self._ui.groupBox_ota.setEnabled(True) else: self._ui.pushButton_open_close.setText('Open') self._host.close_link() self._state = self.STATE_CLOSE self._ui.comboBox_devices.setEnabled(True) self.label_sn.setVisible(False) self.label_firmware_version.setVisible(False) self._ui.groupBox_menu.setEnabled(False) self._ui.groupBox_config.setEnabled(False) self._ui.groupBox_ota.setEnabled(False) self._config_table.clear_table() except serial.serialutil.SerialException as e: QMessageBox.warning(self._window, 'Warning', 'Device or resource busy') except Exception as e: logging.error("on_button_open_close_click: {}".format(e)) @pyqtSlot() def on_combobox_menu_changed(self) -> None: if self._ui.comboBox_menu.currentText(): self.on_button_menu_read_click() @pyqtSlot() def on_button_menu_read_click(self) -> None: pass @pyqtSlot() def on_button_menu_update_click(self) -> None: pass @pyqtSlot() def on_button_menu_save_click(self) -> None: try: message = {'cmd': CommandList.COMMAND_SAVE_MENU.value, 'data': bytearray([])} response = self._host.send_sync(message) if response and response['status'] != StatusList.STATUS_SUCCESS.value: QMessageBox.warning(self._window, 'Warning', 'retry please') except Exception as e: logging.debug(e) @pyqtSlot() def on_button_config_read_click(self) -> None: pass @pyqtSlot() def on_button_config_update_click(self) -> None: pass @pyqtSlot() def on_button_config_save_click(self) -> None: try: message = {'cmd': CommandList.COMMAND_SAVE_CONFIG_PARAMETERS.value, 'data': bytearray([])} response = self._host.send_sync(message) if response and response['status'] != StatusList.STATUS_SUCCESS.value: QMessageBox.warning(self._window, 'Warning', 'retry please') except Exception as e: logging.debug(e) @pyqtSlot() def on_button_ota_select_file_click(self) -> None: options = QFileDialog.Options() fileName, _ = QFileDialog.getOpenFileName( self._window, 'Select File', '', 'Bin Files (*.bin)', options=options) if fileName: self._ui.lineEdit_ota_file.setText(fileName) self._ui.progressBar_ota.setValue(0) self._ui.progressBar_ota.setVisible(False) @pyqtSlot() def on_button_ota_update_click(self) -> None: pass def run(self): while True: try: self.refresh_serial_ports() time.sleep(1) except queue.Empty: continue class MainWindow(QMainWindow): def __init__(self): super().__init__() self._ui = Ui_MainWindow() self._ui.setupUi(self) if __name__ == "__main__": logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(levelname)s - %(filename)s:%(lineno)d - %(message)s') app = QApplication(sys.argv) window = MainWindow() window.show() tool = Tool(window) sys.exit(app.exec_())