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_())
    

预览效果

在这里插入图片描述

  • 13
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值