Python:PyQt5 全栈开发教程,构建跨平台桌面应用
在当今的软件开发世界中,桌面应用程序依然占据着重要地位。虽然Web应用和移动应用发展迅猛,但桌面应用在性能、用户体验和功能丰富性方面仍有其独特优势。PyQt5作为Python生态系统中最强大的GUI开发框架之一,为开发者提供了构建专业级跨平台桌面应用的完整解决方案。
本教程将带您从零开始,系统性地掌握PyQt5开发技术,最终能够独立构建功能完整的桌面应用程序。
一、PyQt5 概览与环境搭建
1.1 PyQt5 简介
什么是PyQt5
PyQt5是一个功能强大的Python GUI工具包,它是Qt框架的Python绑定。Qt是由挪威Trolltech公司开发的跨平台C++图形用户界面应用程序开发框架,被广泛应用于开发GUI程序。
PyQt5的主要特点:
- 跨平台:支持Windows、macOS、Linux等主流操作系统
- 功能丰富:提供完整的GUI控件集合
- 高性能:基于C++的Qt框架,性能优异
- 专业外观:原生外观,用户体验佳
- 开源免费:GPL和商业双重许可
PyQt5 vs 其他GUI框架对比
| 特性 | PyQt5 | Tkinter | wxPython |
|---|---|---|---|
| 学习曲线 | 中等 | 简单 | 中等 |
| 控件丰富度 | 非常丰富 | 基础 | 丰富 |
| 外观美观度 | 优秀 | 一般 | 良好 |
| 跨平台性 | 优秀 | 良好 | 优秀 |
| 文档完整度 | 优秀 | 良好 | 一般 |
| 社区活跃度 | 高 | 高 | 中等 |
1.2 开发环境准备
Python环境要求
PyQt5支持Python 3.5及以上版本,推荐使用Python 3.7或更高版本:
# 检查Python版本
python --version
# 或
python3 --version
PyQt5安装方法
使用pip安装PyQt5是最简单的方法:
# 安装PyQt5
pip install PyQt5
# 安装PyQt5开发工具(包含Qt Designer)
pip install PyQt5-tools
# 验证安装
python -c "import PyQt5; print(PyQt5.Qt.PYQT_VERSION_STR)"
Qt Designer安装与配置
Qt Designer是PyQt5的可视化界面设计工具:
# 查找Qt Designer位置
python -c "from PyQt5 import Qt; print(Qt.QLibraryInfo.location(Qt.QLibraryInfo.BinariesPath))"
# Windows系统通常在:
# Python安装目录/Lib/site-packages/qt5_applications/Qt/bin/designer.exe
IDE推荐与配置
PyCharm配置:
- 打开File → Settings → Tools → External Tools
- 添加Qt Designer工具:
- Name: Qt Designer
- Program: designer.exe的完整路径
- Working directory: ProjectFileDirProjectFileDirProjectFileDir
VS Code配置:
安装Python扩展和Qt for Python扩展,配置tasks.json文件以便快速启动Qt Designer。
1.3 第一个PyQt5程序
让我们从一个简单的"Hello World"程序开始:
import sys
from PyQt5.QtWidgets import QApplication, QWidget, QLabel, QVBoxLayout
from PyQt5.QtCore import Qt
class HelloWorldWindow(QWidget):
def __init__(self):
super().__init__()
self.initUI()
def initUI(self):
# 设置窗口属性
self.setWindowTitle('我的第一个PyQt5程序')
self.setGeometry(300, 300, 400, 200)
# 创建标签
label = QLabel('Hello, PyQt5!')
label.setAlignment(Qt.AlignCenter)
label.setStyleSheet("font-size: 18px; color: blue;")
# 创建布局
layout = QVBoxLayout()
layout.addWidget(label)
# 设置布局
self.setLayout(layout)
def main():
# 创建应用程序对象
app = QApplication(sys.argv)
# 创建窗口
window = HelloWorldWindow()
window.show()
# 进入应用程序主循环
sys.exit(app.exec_())
if __name__ == '__main__':
main()
程序结构分析
- 导入模块:导入必要的PyQt5模块
- 创建应用程序对象:QApplication管理整个应用程序
- 创建窗口类:继承自QWidget,定义窗口行为
- 初始化界面:设置窗口属性和控件
- 显示窗口:调用show()方法显示窗口
- 进入主循环:app.exec_()启动事件循环
二、PyQt5 核心概念与基础组件
2.1 核心架构理解
信号与槽机制
信号与槽是PyQt5最重要的特性之一,它提供了对象间通信的机制:
import sys
from PyQt5.QtWidgets import QApplication, QWidget, QPushButton, QVBoxLayout, QLabel
class SignalSlotDemo(QWidget):
def __init__(self):
super().__init__()
self.initUI()
def initUI(self):
self.setWindowTitle('信号与槽示例')
self.setGeometry(300, 300, 300, 150)
# 创建控件
self.label = QLabel('点击次数: 0')
self.button = QPushButton('点击我')
self.counter = 0
# 连接信号与槽
self.button.clicked.connect(self.on_button_clicked)
# 布局
layout = QVBoxLayout()
layout.addWidget(self.label)
layout.addWidget(self.button)
self.setLayout(layout)
def on_button_clicked(self):
"""按钮点击事件处理函数(槽函数)"""
self.counter += 1
self.label.setText(f'点击次数: {self.counter}')
def main():
app = QApplication(sys.argv)
window = SignalSlotDemo()
window.show()
sys.exit(app.exec_())
if __name__ == '__main__':
main()
自定义信号
您也可以创建自定义信号:
from PyQt5.QtCore import pyqtSignal, QObject
class CustomSignalDemo(QWidget):
# 定义自定义信号
custom_signal = pyqtSignal(str)
def __init__(self):
super().__init__()
self.initUI()
# 连接自定义信号
self.custom_signal.connect(self.handle_custom_signal)
def initUI(self):
self.setWindowTitle('自定义信号示例')
self.button = QPushButton('发送自定义信号')
self.button.clicked.connect(self.emit_custom_signal)
self.label = QLabel('等待信号...')
layout = QVBoxLayout()
layout.addWidget(self.button)
layout.addWidget(self.label)
self.setLayout(layout)
def emit_custom_signal(self):
"""发送自定义信号"""
self.custom_signal.emit("自定义信号被触发!")
def handle_custom_signal(self, message):
"""处理自定义信号"""
self.label.setText(message)
2.2 基础窗口组件
QMainWindow主窗口
QMainWindow是最常用的顶级窗口类,提供了菜单栏、工具栏、状态栏等:
from PyQt5.QtWidgets import (QMainWindow, QMenuBar, QStatusBar,
QToolBar, QAction, QTextEdit)
from PyQt5.QtCore import Qt
class MainWindowDemo(QMainWindow):
def __init__(self):
super().__init__()
self.initUI()
def initUI(self):
self.setWindowTitle('主窗口示例')
self.setGeometry(100, 100, 800, 600)
# 创建中央控件
central_widget = QTextEdit()
central_widget.setPlainText('这是主窗口的中央区域')
self.setCentralWidget(central_widget)
# 创建菜单栏
self.create_menu_bar()
# 创建工具栏
self.create_tool_bar()
# 创建状态栏
self.create_status_bar()
def create_menu_bar(self):
"""创建菜单栏"""
menubar = self.menuBar()
# 文件菜单
file_menu = menubar.addMenu('文件')
new_action = QAction('新建', self)
new_action.setShortcut('Ctrl+N')
new_action.triggered.connect(self.new_file)
file_menu.addAction(new_action)
open_action = QAction('打开', self)
open_action.setShortcut('Ctrl+O')
open_action.triggered.connect(self.open_file)
file_menu.addAction(open_action)
file_menu.addSeparator()
exit_action = QAction('退出', self)
exit_action.setShortcut('Ctrl+Q')
exit_action.triggered.connect(self.close)
file_menu.addAction(exit_action)
def create_tool_bar(self):
"""创建工具栏"""
toolbar = self.addToolBar('主工具栏')
new_action = QAction('新建', self)
new_action.triggered.connect(self.new_file)
toolbar.addAction(new_action)
open_action = QAction('打开', self)
open_action.triggered.connect(self.open_file)
toolbar.addAction(open_action)
def create_status_bar(self):
"""创建状态栏"""
status_bar = self.statusBar()
status_bar.showMessage('就绪')
def new_file(self):
self.statusBar().showMessage('新建文件', 2000)
self.centralWidget().clear()
def open_file(self):
self.statusBar().showMessage('打开文件', 2000)
2.3 常用控件详解
按钮类控件
from PyQt5.QtWidgets import (QWidget, QPushButton, QRadioButton,
QCheckBox, QVBoxLayout, QHBoxLayout,
QButtonGroup, QLabel)
class ButtonDemo(QWidget):
def __init__(self):
super().__init__()
self.initUI()
def initUI(self):
self.setWindowTitle('按钮控件示例')
self.setGeometry(300, 300, 400, 300)
main_layout = QVBoxLayout()
# QPushButton 普通按钮
self.push_button = QPushButton('普通按钮')
self.push_button.clicked.connect(self.on_push_button_clicked)
main_layout.addWidget(self.push_button)
# QRadioButton 单选按钮
radio_layout = QHBoxLayout()
radio_layout.addWidget(QLabel('选择性别:'))
self.radio_male = QRadioButton('男')
self.radio_female = QRadioButton('女')
self.radio_male.setChecked(True) # 默认选中
# 创建按钮组确保单选
self.gender_group = QButtonGroup()
self.gender_group.addButton(self.radio_male)
self.gender_group.addButton(self.radio_female)
radio_layout.addWidget(self.radio_male)
radio_layout.addWidget(self.radio_female)
main_layout.addLayout(radio_layout)
# QCheckBox 复选框
main_layout.addWidget(QLabel('兴趣爱好:'))
self.check_reading = QCheckBox('阅读')
self.check_music = QCheckBox('音乐')
self.check_sports = QCheckBox('运动')
main_layout.addWidget(self.check_reading)
main_layout.addWidget(self.check_music)
main_layout.addWidget(self.check_sports)
# 结果显示
self.result_label = QLabel('结果显示区域')
main_layout.addWidget(self.result_label)
# 提交按钮
submit_button = QPushButton('提交')
submit_button.clicked.connect(self.on_submit)
main_layout.addWidget(submit_button)
self.setLayout(main_layout)
def on_push_button_clicked(self):
self.result_label.setText('普通按钮被点击了!')
def on_submit(self):
# 获取单选按钮选择
gender = '男' if self.radio_male.isChecked() else '女'
# 获取复选框选择
hobbies = []
if self.check_reading.isChecked():
hobbies.append('阅读')
if self.check_music.isChecked():
hobbies.append('音乐')
if self.check_sports.isChecked():
hobbies.append('运动')
result = f'性别: {gender}, 兴趣: {", ".join(hobbies) if hobbies else "无"}'
self.result_label.setText(result)
输入类控件
from PyQt5.QtWidgets import (QWidget, QLineEdit, QTextEdit, QSpinBox,
QDoubleSpinBox, QVBoxLayout, QHBoxLayout,
QLabel, QPushButton, QSlider)
from PyQt5.QtCore import Qt
class InputDemo(QWidget):
def __init__(self):
super().__init__()
self.initUI()
def initUI(self):
self.setWindowTitle('输入控件示例')
self.setGeometry(300, 300, 500, 400)
layout = QVBoxLayout()
# QLineEdit 单行文本输入
layout.addWidget(QLabel('姓名:'))
self.name_edit = QLineEdit()
self.name_edit.setPlaceholderText('请输入您的姓名')
self.name_edit.textChanged.connect(self.on_name_changed)
layout.addWidget(self.name_edit)
# QTextEdit 多行文本输入
layout.addWidget(QLabel('个人简介:'))
self.intro_edit = QTextEdit()
self.intro_edit.setPlaceholderText('请输入个人简介...')
self.intro_edit.setMaximumHeight(100)
layout.addWidget(self.intro_edit)
# QSpinBox 整数输入
age_layout = QHBoxLayout()
age_layout.addWidget(QLabel('年龄:'))
self.age_spin = QSpinBox()
self.age_spin.setRange(0, 120)
self.age_spin.setValue(25)
self.age_spin.valueChanged.connect(self.on_age_changed)
age_layout.addWidget(self.age_spin)
layout.addLayout(age_layout)
# QDoubleSpinBox 浮点数输入
salary_layout = QHBoxLayout()
salary_layout.addWidget(QLabel('薪资(K):'))
self.salary_spin = QDoubleSpinBox()
self.salary_spin.setRange(0.0, 999.9)
self.salary_spin.setDecimals(1)
self.salary_spin.setValue(10.0)
salary_layout.addWidget(self.salary_spin)
layout.addLayout(salary_layout)
# QSlider 滑块
slider_layout = QVBoxLayout()
slider_layout.addWidget(QLabel('满意度评分:'))
self.satisfaction_slider = QSlider(Qt.Horizontal)
self.satisfaction_slider.setRange(0, 10)
self.satisfaction_slider.setValue(5)
self.satisfaction_slider.valueChanged.connect(self.on_slider_changed)
self.slider_label = QLabel('5')
self.slider_label.setAlignment(Qt.AlignCenter)
slider_layout.addWidget(self.satisfaction_slider)
slider_layout.addWidget(self.slider_label)
layout.addLayout(slider_layout)
# 显示结果
self.result_label = QLabel('输入信息将在这里显示')
layout.addWidget(self.result_label)
# 提交按钮
submit_button = QPushButton('获取所有输入')
submit_button.clicked.connect(self.get_all_inputs)
layout.addWidget(submit_button)
self.setLayout(layout)
def on_name_changed(self, text):
if len(text) > 10:
self.name_edit.setText(text[:10])
def on_age_changed(self, value):
if value >= 60:
self.result_label.setText('注意:已达到退休年龄')
else:
self.result_label.setText('')
def on_slider_changed(self, value):
self.slider_label.setText(str(value))
def get_all_inputs(self):
name = self.name_edit.text()
intro = self.intro_edit.toPlainText()
age = self.age_spin.value()
salary = self.salary_spin.value()
satisfaction = self.satisfaction_slider.value()
result = f"""
输入信息汇总:
姓名: {name}
年龄: {age}
薪资: {salary}K
满意度: {satisfaction}/10
简介: {intro[:50]}{'...' if len(intro) > 50 else ''}
"""
self.result_label.setText(result)
三、布局管理与界面设计
3.1 布局管理器
布局管理器是PyQt5中控制控件位置和大小的重要工具。
QHBoxLayout水平布局
from PyQt5.QtWidgets import (QWidget, QHBoxLayout, QPushButton,
QVBoxLayout, QLabel)
class HBoxLayoutDemo(QWidget):
def __init__(self):
super().__init__()
self.initUI()
def initUI(self):
self.setWindowTitle('水平布局示例')
self.setGeometry(300, 300, 400, 100)
# 创建水平布局
hbox = QHBoxLayout()
# 添加按钮
btn1 = QPushButton('按钮1')
btn2 = QPushButton('按钮2')
btn3 = QPushButton('按钮3')
hbox.addWidget(btn1)
hbox.addWidget(btn2)
hbox.addWidget(btn3)
# 设置拉伸因子
hbox.setStretchFactor(btn1, 1) # 按钮1占1份
hbox.setStretchFactor(btn2, 2) # 按钮2占2份
hbox.setStretchFactor(btn3, 1) # 按钮3占1份
self.setLayout(hbox)
QVBoxLayout垂直布局
class VBoxLayoutDemo(QWidget):
def __init__(self):
super().__init__()
self.initUI()
def initUI(self):
self.setWindowTitle('垂直布局示例')
self.setGeometry(300, 300, 200, 300)
# 创建垂直布局
vbox = QVBoxLayout()
# 添加控件
label = QLabel('垂直布局示例')
btn1 = QPushButton('第一个按钮')
btn2 = QPushButton('第二个按钮')
btn3 = QPushButton('第三个按钮')
vbox.addWidget(label)
vbox.addWidget(btn1)
vbox.addWidget(btn2)
vbox.addWidget(btn3)
# 添加弹性空间
vbox.addStretch()
self.setLayout(vbox)
QGridLayout网格布局
from PyQt5.QtWidgets import QGridLayout
class GridLayoutDemo(QWidget):
def __init__(self):
super().__init__()
self.initUI()
def initUI(self):
self.setWindowTitle('网格布局示例 - 计算器')
self.setGeometry(300, 300, 300, 400)
# 创建网格布局
grid = QGridLayout()
# 显示屏
display = QLineEdit()
display.setReadOnly(True)
display.setStyleSheet("font-size: 18px; padding: 10px;")
grid.addWidget(display, 0, 0, 1, 4) # 跨4列
# 按钮数据
buttons = [
('C', 1, 0), ('±', 1, 1), ('%', 1, 2), ('÷', 1, 3),
('7', 2, 0), ('8', 2, 1), ('9', 2, 2), ('×', 2, 3),
('4', 3, 0), ('5', 3, 1), ('6', 3, 2), ('-', 3, 3),
('1', 4, 0), ('2', 4, 1), ('3', 4, 2), ('+', 4, 3),
('0', 5, 0, 1, 2), ('.', 5, 2), ('=', 5, 3)
]
# 创建按钮
for btn_data in buttons:
text = btn_data[0]
row = btn_data[1]
col = btn_data[2]
row_span = btn_data[3] if len(btn_data) > 3 else 1
col_span = btn_data[4] if len(btn_data) > 4 else 1
button = QPushButton(text)
button.setMinimumHeight(50)
button.setStyleSheet("font-size: 16px;")
grid.addWidget(button, row, col, row_span, col_span)
self.setLayout(grid)
嵌套布局技巧
class NestedLayoutDemo(QWidget):
def __init__(self):
super().__init__()
self.initUI()
def initUI(self):
self.setWindowTitle('嵌套布局示例')
self.setGeometry(300, 300, 500, 400)
# 主垂直布局
main_layout = QVBoxLayout()
# 标题
title = QLabel('用户注册表单')
title.setStyleSheet("font-size: 18px; font-weight: bold; padding: 10px;")
title.setAlignment(Qt.AlignCenter)
main_layout.addWidget(title)
# 表单区域(网格布局)
form_layout = QGridLayout()
# 基本信息
form_layout.addWidget(QLabel('姓名:'), 0, 0)
form_layout.addWidget(QLineEdit(), 0, 1)
form_layout.addWidget(QLabel('邮箱:'), 1, 0)
form_layout.addWidget(QLineEdit(), 1, 1)
form_layout.addWidget(QLabel('电话:'), 2, 0)
form_layout.addWidget(QLineEdit(), 2, 1)
# 性别选择(水平布局)
gender_layout = QHBoxLayout()
gender_layout.addWidget(QRadioButton('男'))
gender_layout.addWidget(QRadioButton('女'))
gender_layout.addStretch()
form_layout.addWidget(QLabel('性别:'), 3, 0)
form_layout.addLayout(gender_layout, 3, 1)
main_layout.addLayout(form_layout)
# 按钮区域(水平布局)
button_layout = QHBoxLayout()
button_layout.addStretch()
button_layout.addWidget(QPushButton('取消'))
button_layout.addWidget(QPushButton('注册'))
main_layout.addLayout(button_layout)
self.setLayout(main_layout)
3.2 Qt Designer可视化设计
Qt Designer是PyQt5提供的可视化界面设计工具,可以大大提高开发效率。
Qt Designer基本使用
-
启动Qt Designer:
# Windows designer.exe # 或通过Python启动 python -m PyQt5.uic.pyuic -
创建新窗体:
- File → New → Widget/MainWindow/Dialog
- 选择合适的窗体类型
-
设计界面:
- 从左侧控件面板拖拽控件到窗体
- 使用右侧属性面板设置控件属性
- 使用布局管理器组织控件
.ui文件转换与使用
方法一:使用pyuic5工具转换
# 将.ui文件转换为.py文件
pyuic5 -o main_window.py main_window.ui
方法二:动态加载.ui文件
import sys
from PyQt5.QtWidgets import QApplication, QMainWindow
from PyQt5.uic import loadUi
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
# 动态加载.ui文件
loadUi('main_window.ui', self)
# 连接信号与槽
self.pushButton.clicked.connect(self.on_button_clicked)
def on_button_clicked(self):
self.label.setText('按钮被点击了!')
def main():
app = QApplication(sys.argv)
window = MainWindow()
window.show()
sys.exit(app.exec_())
3.3 样式表与美化
QSS样式表语法
QSS(Qt Style Sheets)类似于CSS,用于美化PyQt5应用程序:
class StyledWidgetDemo(QWidget):
def __init__(self):
super().__init__()
self.initUI()
self.setStyleSheet(self.get_style())
def initUI(self):
self.setWindowTitle('样式表示例')
self.setGeometry(300, 300, 400, 300)
layout = QVBoxLayout()
# 标题标签
title = QLabel('现代化界面设计')
title.setObjectName('title')
layout.addWidget(title)
# 主要按钮
primary_btn = QPushButton('主要按钮')
primary_btn.setObjectName('primary-btn')
layout.addWidget(primary_btn)
# 次要按钮
secondary_btn = QPushButton('次要按钮')
secondary_btn.setObjectName('secondary-btn')
layout.addWidget(secondary_btn)
# 危险按钮
danger_btn = QPushButton('危险按钮')
danger_btn.setObjectName('danger-btn')
layout.addWidget(danger_btn)
# 输入框
input_field = QLineEdit()
input_field.setPlaceholderText('请输入内容...')
input_field.setObjectName('input-field')
layout.addWidget(input_field)
self.setLayout(layout)
def get_style(self):
return """
QWidget {
background-color: #f0f0f0;
font-family: 'Microsoft YaHei', Arial, sans-serif;
}
#title {
font-size: 24px;
font-weight: bold;
color: #333;
padding: 20px;
text-align: center;
}
QPushButton {
padding: 10px 20px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: bold;
cursor: pointer;
margin: 5px;
}
#primary-btn {
background-color: #007bff;
color: white;
}
#primary-btn:hover {
background-color: #0056b3;
}
#primary-btn:pressed {
background-color: #004085;
}
#secondary-btn {
background-color: #6c757d;
color: white;
}
#secondary-btn:hover {
background-color: #545b62;
}
#danger-btn {
background-color: #dc3545;
color: white;
}
#danger-btn:hover {
background-color: #c82333;
}
#input-field {
padding: 12px;
border: 2px solid #ddd;
border-radius: 6px;
font-size: 14px;
margin: 5px;
}
#input-field:focus {
border-color: #007bff;
outline: none;
}
"""
主题切换实现
from PyQt5.QtWidgets import QComboBox
class ThemeDemo(QWidget):
def __init__(self):
super().__init__()
self.themes = {
'默认主题': self.get_default_theme(),
'深色主题': self.get_dark_theme(),
'绿色主题': self.get_green_theme()
}
self.initUI()
def initUI(self):
self.setWindowTitle('主题切换示例')
self.setGeometry(300, 300, 400, 300)
layout = QVBoxLayout()
# 主题选择器
theme_layout = QHBoxLayout()
theme_layout.addWidget(QLabel('选择主题:'))
self.theme_combo = QComboBox()
self.theme_combo.addItems(self.themes.keys())
self.theme_combo.currentTextChanged.connect(self.change_theme)
theme_layout.addWidget(self.theme_combo)
layout.addLayout(theme_layout)
# 示例控件
layout.addWidget(QLabel('这是一个标签'))
layout.addWidget(QPushButton('这是一个按钮'))
layout.addWidget(QLineEdit('这是一个输入框'))
self.setLayout(layout)
# 应用默认主题
self.change_theme('默认主题')
def change_theme(self, theme_name):
if theme_name in self.themes:
self.setStyleSheet(self.themes[theme_name])
def get_default_theme(self):
return """
QWidget {
background-color: white;
color: black;
}
QPushButton {
background-color: #e7e7e7;
border: 1px solid #ccc;
padding: 8px;
border-radius: 4px;
}
QPushButton:hover {
background-color: #d4edda;
}
"""
def get_dark_theme(self):
return """
QWidget {
background-color: #2b2b2b;
color: white;
}
QPushButton {
background-color: #404040;
border: 1px solid #555;
padding: 8px;
border-radius: 4px;
color: white;
}
QPushButton:hover {
background-color: #505050;
}
QLineEdit {
background-color: #404040;
border: 1px solid #555;
padding: 8px;
border-radius: 4px;
color: white;
}
"""
def get_green_theme(self):
return """
QWidget {
background-color: #f0f8f0;
color: #2d5a2d;
}
QPushButton {
background-color: #28a745;
border: none;
padding: 8px;
border-radius: 4px;
color: white;
font-weight: bold;
}
QPushButton:hover {
background-color: #218838;
}
QLineEdit {
border: 2px solid #28a745;
padding: 8px;
border-radius: 4px;
background-color: white;
}
"""
四、事件处理与交互逻辑
4.1 信号与槽深入
连接方式详解
PyQt5提供了多种信号槽连接方式:
from PyQt5.QtCore import QObject, pyqtSignal
from PyQt5.QtWidgets import QWidget, QPushButton, QVBoxLayout, QLabel
class AdvancedSignalSlotDemo(QWidget):
def __init__(self):
super().__init__()
self.initUI()
self.setup_connections()
def initUI(self):
self.setWindowTitle('高级信号槽示例')
layout = QVBoxLayout()
self.button1 = QPushButton('按钮1')
self.button2 = QPushButton('按钮2')
self.label = QLabel('状态显示')
layout.addWidget(self.button1)
layout.addWidget(self.button2)
layout.addWidget(self.label)
self.setLayout(layout)
def setup_connections(self):
# 方式1: 连接到成员函数
self.button1.clicked.connect(self.on_button1_clicked)
# 方式2: 连接到lambda函数
self.button2.clicked.connect(lambda: self.label.setText('按钮2被点击'))
# 方式3: 带参数的连接
self.button1.clicked.connect(lambda: self.show_message('来自按钮1'))
# 方式4: 一个信号连接多个槽
self.button1.clicked.connect(self.update_status)
# 方式5: 信号链接(信号连接信号)
# self.button2.clicked.connect(self.button1.clicked)
def on_button1_clicked(self):
self.label.setText('按钮1被点击了')
def show_message(self, message):
print(f"消息: {message}")
def update_status(self):
import datetime
now = datetime.datetime.now().strftime("%H:%M:%S")
self.label.setText(f"最后点击时间: {now}")
自定义信号高级用法
from PyQt5.QtCore import QObject, pyqtSignal, QTimer
import random
class DataProcessor(QObject):
"""数据处理器,演示自定义信号"""
# 定义各种类型的信号
progress_updated = pyqtSignal(int) # 进度更新信号
data_processed = pyqtSignal(str, dict) # 数据处理完成信号
error_occurred = pyqtSignal(str) # 错误信号
def __init__(self):
super().__init__()
self.timer = QTimer()
self.timer.timeout.connect(self.process_data)
self.progress = 0
def start_processing(self):
"""开始数据处理"""
self.progress = 0
self.timer.start(100) # 每100ms更新一次
def process_data(self):
"""模拟数据处理"""
self.progress += random.randint(1, 10)
self.progress_updated.emit(self.progress)
if self.progress >= 100:
self.timer.stop()
# 发送处理完成信号
result_data = {
'records_processed': 1000,
'time_taken': '5.2s',
'success_rate': '98.5%'
}
self.data_processed.emit("处理完成", result_data)
# 随机模拟错误
if random.random() < 0.05: # 5%概率出错
self.error_occurred.emit("随机处理错误")
class CustomSignalDemo(QWidget):
def __init__(self):
super().__init__()
self.processor = DataProcessor()
self.initUI()
self.setup_connections()
def initUI(self):
self.setWindowTitle('自定义信号高级示例')
layout = QVBoxLayout()
self.start_button = QPushButton('开始处理数据')
self.progress_label = QLabel('进度: 0%')
self.status_label = QLabel('状态: 待机')
self.result_label = QLabel('结果: 无')
layout.addWidget(self.start_button)
layout.addWidget(self.progress_label)
layout.addWidget(self.status_label)
layout.addWidget(self.result_label)
self.setLayout(layout)
def setup_connections(self):
# 连接按钮信号
self.start_button.clicked.connect(self.start_processing)
# 连接自定义信号
self.processor.progress_updated.connect(self.update_progress)
self.processor.data_processed.connect(self.on_data_processed)
self.processor.error_occurred.connect(self.on_error)
def start_processing(self):
self.start_button.setEnabled(False)
self.status_label.setText('状态: 处理中...')
self.processor.start_processing()
def update_progress(self, progress):
self.progress_label.setText(f'进度: {progress}%')
def on_data_processed(self, message, data):
self.status_label.setText(f'状态: {message}')
result_text = f"结果: 处理了{data['records_processed']}条记录," \
f"耗时{data['time_taken']},成功率{data['success_rate']}"
self.result_label.setText(result_text)
self.start_button.setEnabled(True)
def on_error(self, error_message):
self.status_label.setText(f'错误: {error_message}')
4.2 事件处理机制
鼠标事件处理
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QPainter, QPen
from PyQt5.QtWidgets import QWidget
class MouseEventDemo(QWidget):
def __init__(self):
super().__init__()
self.initUI()
self.drawing = False
self.brush_size = 3
self.brush_color = Qt.black
self.last_point = None
self.points = []
def initUI(self):
self.setWindowTitle('鼠标事件示例 - 简单画板')
self.setGeometry(300, 300, 600, 400)
self.setMouseTracking(True) # 启用鼠标跟踪
def mousePressEvent(self, event):
"""鼠标按下事件"""
if event.button() == Qt.LeftButton:
self.drawing = True
self.last_point = event.pos()
self.points = [event.pos()]
def mouseMoveEvent(self, event):
"""鼠标移动事件"""
if event.buttons() & Qt.LeftButton and self.drawing:
self.points.append(event.pos())
self.update() # 触发重绘
def mouseReleaseEvent(self, event):
"""鼠标释放事件"""
if event.button() == Qt.LeftButton:
self.drawing = False
def mouseDoubleClickEvent(self, event):
"""鼠标双击事件"""
if event.button() == Qt.LeftButton:
self.points.clear()
self.update()
def wheelEvent(self, event):
"""鼠标滚轮事件"""
# 改变画笔大小
delta = event.angleDelta().y()
if delta > 0:
self.brush_size = min(20, self.brush_size + 1)
else:
self.brush_size = max(1, self.brush_size - 1)
self.setWindowTitle(f'画板 - 画笔大小: {self.brush_size}')
def paintEvent(self, event):
"""绘制事件"""
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
pen = QPen(self.brush_color, self.brush_size,
Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)
painter.setPen(pen)
# 绘制线条
for i in range(1, len(self.points)):
painter.drawLine(self.points[i-1], self.points[i])
键盘事件处理
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import QTextEdit, QVBoxLayout, QLabel
class KeyboardEventDemo(QWidget):
def __init__(self):
super().__init__()
self.initUI()
self.key_combinations = []
def initUI(self):
self.setWindowTitle('键盘事件示例')
self.setGeometry(300, 300, 500, 400)
layout = QVBoxLayout()
self.info_label = QLabel('按下任意键或组合键试试...')
layout.addWidget(self.info_label)
self.text_edit = QTextEdit()
self.text_edit.setPlaceholderText('在这里输入文本,支持快捷键操作')
layout.addWidget(self.text_edit)
self.status_label = QLabel('状态: 就绪')
layout.addWidget(self.status_label)
self.setLayout(layout)
# 设置焦点策略
self.setFocusPolicy(Qt.StrongFocus)
def keyPressEvent(self, event):
"""键盘按下事件"""
key = event.key()
modifiers = event.modifiers()
# 检测修饰键
ctrl_pressed = modifiers & Qt.ControlModifier
shift_pressed = modifiers & Qt.ShiftModifier
alt_pressed = modifiers & Qt.AltModifier
# 构建按键信息
key_info = []
if ctrl_pressed:
key_info.append('Ctrl')
if shift_pressed:
key_info.append('Shift')
if alt_pressed:
key_info.append('Alt')
# 获取按键名称
key_name = self.get_key_name(key)
if key_name:
key_info.append(key_name)
key_combination = '+'.join(key_info)
self.info_label.setText(f'按键: {key_combination}')
# 处理特殊快捷键
if ctrl_pressed:
if key == Qt.Key_S:
self.save_text()
event.accept()
return
elif key == Qt.Key_O:
self.open_text()
event.accept()
return
elif key == Qt.Key_N:
self.new_text()
event.accept()
return
# 处理功能键
if key == Qt.Key_F1:
self.show_help()
event.accept()
return
elif key == Qt.Key_Escape:
self.text_edit.clear()
event.accept()
return
# 传递事件给父类处理
super().keyPressEvent(event)
def get_key_name(self, key):
"""获取按键名称"""
key_map = {
Qt.Key_A: 'A', Qt.Key_B: 'B', Qt.Key_C: 'C', Qt.Key_D: 'D',
Qt.Key_E: 'E', Qt.Key_F: 'F', Qt.Key_G: 'G', Qt.Key_H: 'H',
Qt.Key_I: 'I', Qt.Key_J: 'J', Qt.Key_K: 'K', Qt.Key_L: 'L',
Qt.Key_M: 'M', Qt.Key_N: 'N', Qt.Key_O: 'O', Qt.Key_P: 'P',
Qt.Key_Q: 'Q', Qt.Key_R: 'R', Qt.Key_S: 'S', Qt.Key_T: 'T',
Qt.Key_U: 'U', Qt.Key_V: 'V', Qt.Key_W: 'W', Qt.Key_X: 'X',
Qt.Key_Y: 'Y', Qt.Key_Z: 'Z',
Qt.Key_Return: 'Enter', Qt.Key_Space: 'Space',
Qt.Key_Backspace: 'Backspace', Qt.Key_Delete: 'Delete',
Qt.Key_F1: 'F1', Qt.Key_F2: 'F2', Qt.Key_F3: 'F3',
Qt.Key_Escape: 'Esc', Qt.Key_Tab: 'Tab'
}
return key_map.get(key, f'Key_{key}')
def save_text(self):
self.status_label.setText('状态: 保存文本 (Ctrl+S)')
def open_text(self):
self.status_label.setText('状态: 打开文本 (Ctrl+O)')
def new_text(self):
self.text_edit.clear()
self.status_label.setText('状态: 新建文本 (Ctrl+N)')
def show_help(self):
help_text = """
快捷键帮助:
Ctrl+S - 保存
Ctrl+O - 打开
Ctrl+N - 新建
F1 - 显示帮助
Esc - 清空文本
"""
self.text_edit.setPlainText(help_text)
self.status_label.setText('状态: 显示帮助 (F1)')
4.3 用户交互实现
菜单栏与工具栏
from PyQt5.QtWidgets import (QMainWindow, QMenuBar, QToolBar, QAction,
QTextEdit, QFileDialog, QMessageBox, QStatusBar)
from PyQt5.QtGui import QIcon, QKeySequence
from PyQt5.QtCore import Qt
class MenuToolbarDemo(QMainWindow):
def __init__(self):
super().__init__()
self.initUI()
self.create_actions()
self.create_menus()
self.create_toolbars()
self.create_statusbar()
def initUI(self):
self.setWindowTitle('菜单栏与工具栏示例')
self.setGeometry(100, 100, 800, 600)
# 创建中央控件
self.text_edit = QTextEdit()
self.setCentralWidget(self.text_edit)
# 文件状态
self.current_file = None
self.is_modified = False
# 监听文本变化
self.text_edit.textChanged.connect(self.text_changed)
def create_actions(self):
"""创建动作"""
# 文件操作
self.new_action = QAction('新建(&N)', self)
self.new_action.setShortcut(QKeySequence.New)
self.new_action.setStatusTip('创建新文档')
self.new_action.triggered.connect(self.new_file)
self.open_action = QAction('打开(&O)', self)
self.open_action.setShortcut(QKeySequence.Open)
self.open_action.setStatusTip('打开文档')
self.open_action.triggered.connect(self.open_file)
self.save_action = QAction('保存(&S)', self)
self.save_action.setShortcut(QKeySequence.Save)
self.save_action.setStatusTip('保存文档')
self.save_action.triggered.connect(self.save_file)
self.save_as_action = QAction('另存为(&A)', self)
self.save_as_action.setShortcut(QKeySequence.SaveAs)
self.save_as_action.setStatusTip('另存为文档')
self.save_as_action.triggered.connect(self.save_as_file)
self.exit_action = QAction('退出(&X)', self)
self.exit_action.setShortcut(QKeySequence.Quit)
self.exit_action.setStatusTip('退出应用程序')
self.exit_action.triggered.connect(self.close)
# 编辑操作
self.undo_action = QAction('撤销(&U)', self)
self.undo_action.setShortcut(QKeySequence.Undo)
self.undo_action.setStatusTip('撤销上一步操作')
self.undo_action.triggered.connect(self.text_edit.undo)
self.redo_action = QAction('重做(&R)', self)
self.redo_action.setShortcut(QKeySequence.Redo)
self.redo_action.setStatusTip('重做操作')
self.redo_action.triggered.connect(self.text_edit.redo)
self.cut_action = QAction('剪切(&T)', self)
self.cut_action.setShortcut(QKeySequence.Cut)
self.cut_action.setStatusTip('剪切选中文本')
self.cut_action.triggered.connect(self.text_edit.cut)
self.copy_action = QAction('复制(&C)', self)
self.copy_action.setShortcut(QKeySequence.Copy)
self.copy_action.setStatusTip('复制选中文本')
self.copy_action.triggered.connect(self.text_edit.copy)
self.paste_action = QAction('粘贴(&P)', self)
self.paste_action.setShortcut(QKeySequence.Paste)
self.paste_action.setStatusTip('粘贴文本')
self.paste_action.triggered.connect(self.text_edit.paste)
# 帮助操作
self.about_action = QAction('关于(&A)', self)
self.about_action.setStatusTip('关于此应用程序')
self.about_action.triggered.connect(self.about)
def create_menus(self):
"""创建菜单栏"""
menubar = self.menuBar()
# 文件菜单
file_menu = menubar.addMenu('文件(&F)')
file_menu.addAction(self.new_action)
file_menu.addAction(self.open_action)
file_menu.addSeparator()
file_menu.addAction(self.save_action)
file_menu.addAction(self.save_as_action)
file_menu.addSeparator()
file_menu.addAction(self.exit_action)
# 编辑菜单
edit_menu = menubar.addMenu('编辑(&E)')
edit_menu.addAction(self.undo_action)
edit_menu.addAction(self.redo_action)
edit_menu.addSeparator()
edit_menu.addAction(self.cut_action)
edit_menu.addAction(self.copy_action)
edit_menu.addAction(self.paste_action)
# 帮助菜单
help_menu = menubar.addMenu('帮助(&H)')
help_menu.addAction(self.about_action)
def create_toolbars(self):
"""创建工具栏"""
# 主工具栏
main_toolbar = self.addToolBar('主工具栏')
main_toolbar.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
main_toolbar.addAction(self.new_action)
main_toolbar.addAction(self.open_action)
main_toolbar.addAction(self.save_action)
main_toolbar.addSeparator()
main_toolbar.addAction(self.cut_action)
main_toolbar.addAction(self.copy_action)
main_toolbar.addAction(self.paste_action)
# 编辑工具栏
edit_toolbar = self.addToolBar('编辑工具栏')
edit_toolbar.addAction(self.undo_action)
edit_toolbar.addAction(self.redo_action)
def create_statusbar(self):
"""创建状态栏"""
self.status_bar = self.statusBar()
self.status_bar.showMessage('就绪')
# 添加永久组件
self.char_count_label = QLabel('字符数: 0')
self.status_bar.addPermanentWidget(self.char_count_label)
def new_file(self):
"""新建文件"""
if self.check_save():
self.text_edit.clear()
self.current_file = None
self.is_modified = False
self.update_title()
self.status_bar.showMessage('新建文档', 2000)
def open_file(self):
"""打开文件"""
if self.check_save():
file_path, _ = QFileDialog.getOpenFileName(
self, '打开文件', '', 'Text Files (*.txt);;All Files (*)')
if file_path:
try:
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
self.text_edit.setPlainText(content)
self.current_file = file_path
self.is_modified = False
self.update_title()
self.status_bar.showMessage(f'打开文件: {file_path}', 2000)
except Exception as e:
QMessageBox.warning(self, '错误', f'无法打开文件:\n{str(e)}')
def save_file(self):
"""保存文件"""
if self.current_file:
self.save_to_file(self.current_file)
else:
self.save_as_file()
def save_as_file(self):
"""另存为文件"""
file_path, _ = QFileDialog.getSaveFileName(
self, '保存文件', '', 'Text Files (*.txt);;All Files (*)')
if file_path:
self.save_to_file(file_path)
def save_to_file(self, file_path):
"""保存到指定文件"""
try:
with open(file_path, 'w', encoding='utf-8') as f:
f.write(self.text_edit.toPlainText())
self.current_file = file_path
self.is_modified = False
self.update_title()
self.status_bar.showMessage(f'保存文件: {file_path}', 2000)
except Exception as e:
QMessageBox.warning(self, '错误', f'无法保存文件:\n{str(e)}')
def check_save(self):
"""检查是否需要保存"""
if self.is_modified:
reply = QMessageBox.question(
self, '保存确认',
'文档已修改,是否保存?',
QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel)
if reply == QMessageBox.Yes:
self.save_file()
return not self.is_modified # 如果保存失败,返回False
elif reply == QMessageBox.Cancel:
return False
return True
def text_changed(self):
"""文本变化处理"""
self.is_modified = True
self.update_title()
# 更新字符计数
char_count = len(self.text_edit.toPlainText())
self.char_count_label.setText(f'字符数: {char_count}')
def update_title(self):
"""更新窗口标题"""
title = '文本编辑器'
if self.current_file:
title += f' - {self.current_file}'
if self.is_modified:
title += ' *'
self.setWindowTitle(title)
def about(self):
"""关于对话框"""
QMessageBox.about(self, '关于',
'这是一个使用PyQt5开发的简单文本编辑器\n'
'演示了菜单栏、工具栏和状态栏的使用')
def closeEvent(self, event):
"""窗口关闭事件"""
if self.check_save():
event.accept()
else:
event.ignore()
五、高级控件与复杂界面
5.1 表格与树形控件
QTableWidget表格操作
from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout,
QTableWidget, QTableWidgetItem, QPushButton,
QHeaderView, QAbstractItemView, QMessageBox,
QInputDialog, QLineEdit)
from PyQt5.QtCore import Qt
import random
class TableDemo(QWidget):
def __init__(self):
super().__init__()
self.initUI()
self.setup_table()
self.load_sample_data()
def initUI(self):
self.setWindowTitle('表格控件示例')
self.setGeometry(200, 200, 800, 600)
layout = QVBoxLayout()
# 按钮区域
button_layout = QHBoxLayout()
self.add_btn = QPushButton('添加行')
self.add_btn.clicked.connect(self.add_row)
button_layout.addWidget(self.add_btn)
self.delete_btn = QPushButton('删除行')
self.delete_btn.clicked.connect(self.delete_row)
button_layout.addWidget(self.delete_btn)
self.edit_btn = QPushButton('编辑')
self.edit_btn.clicked.connect(self.edit_item)
button_layout.addWidget(self.edit_btn)
self.search_btn = QPushButton('搜索')
self.search_btn.clicked.connect(self.search_items)
button_layout.addWidget(self.search_btn)
button_layout.addStretch()
layout.addLayout(button_layout)
# 表格
self.table = QTableWidget()
layout.addWidget(self.table)
self.setLayout(layout)
def setup_table(self):
"""设置表格"""
# 设置列数和列标题
columns = ['ID', '姓名', '年龄', '部门', '薪资', '入职日期']
self.table.setColumnCount(len(columns))
self.table.setHorizontalHeaderLabels(columns)
# 设置表格属性
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.table.setAlternatingRowColors(True)
# 设置列宽
header = self.table.horizontalHeader()
header.setSectionResizeMode(0, QHeaderView.ResizeToContents) # ID列
header.setSectionResizeMode(1, QHeaderView.Stretch) # 姓名列
header.setSectionResizeMode(2, QHeaderView.ResizeToContents) # 年龄列
header.setSectionResizeMode(3, QHeaderView.Stretch) # 部门列
header.setSectionResizeMode(4, QHeaderView.ResizeToContents) # 薪资列
header.setSectionResizeMode(5, QHeaderView.ResizeToContents) # 入职日期列
# 连接信号
self.table.cellDoubleClicked.connect(self.on_cell_double_clicked)
self.table.itemSelectionChanged.connect(self.on_selection_changed)
def load_sample_data(self):
"""加载示例数据"""
sample_data = [
[1, '张三', 28, '技术部', 15000, '2020-01-15'],
[2, '李四', 32, '销售部', 12000, '2019-05-20'],
[3, '王五', 26, '技术部', 13000, '2021-03-10'],
[4, '赵六', 35, '人事部', 11000, '2018-08-05'],
[5, '钱七', 29, '财务部', 14000, '2020-11-12'],
[6, '孙八', 31, '技术部', 16000, '2019-02-28'],
[7, '周九', 27, '销售部', 11500, '2021-06-15'],
[8, '吴十', 33, '技术部', 17000, '2018-12-01']
]
self.table.setRowCount(len(sample_data))
for row, data in enumerate(sample_data):
for col, value in enumerate(data):
item = QTableWidgetItem(str(value))
# 设置ID列不可编辑
if col == 0:
item.setFlags(item.flags() & ~Qt.ItemIsEditable)
# 设置数值列对齐方式
if col in [0, 2, 4]: # ID、年龄、薪资列
item.setTextAlignment(Qt.AlignCenter)
self.table.setItem(row, col, item)
def add_row(self):
"""添加行"""
dialog_data = self.get_employee_data()
if dialog_data:
row_count = self.table.rowCount()
self.table.insertRow(row_count)
# 生成新ID
new_id = self.get_next_id()
# 设置数据
data = [new_id] + dialog_data
for col, value in enumerate(data):
item = QTableWidgetItem(str(value))
if col == 0: # ID列不可编辑
item.setFlags(item.flags() & ~Qt.ItemIsEditable)
if col in [0, 2, 4]: # 数值列居中
item.setTextAlignment(Qt.AlignCenter)
self.table.setItem(row_count, col, item)
def delete_row(self):
"""删除选中行"""
current_row = self.table.currentRow()
if current_row >= 0:
reply = QMessageBox.question(
self, '确认删除',
f'确定要删除第{current_row + 1}行数据吗?',
QMessageBox.Yes | QMessageBox.No)
if reply == QMessageBox.Yes:
self.table.removeRow(current_row)
def edit_item(self):
"""编辑选中项"""
current_row = self.table.currentRow()
if current_row >= 0:
# 获取当前行数据
current_data = []
for col in range(1, self.table.columnCount()): # 跳过ID列
item = self.table.item(current_row, col)
current_data.append(item.text() if item else '')
# 显示编辑对话框
dialog_data = self.get_employee_data(current_data)
if dialog_data:
# 更新数据
for col, value in enumerate(dialog_data, 1):
item = QTableWidgetItem(str(value))
if col in [2, 4]: # 年龄、薪资列居中
item.setTextAlignment(Qt.AlignCenter)
self.table.setItem(current_row, col, item)
def search_items(self):
"""搜索功能"""
search_text, ok = QInputDialog.getText(self, '搜索', '请输入搜索关键词:')
if ok and search_text:
# 清除之前的选择
self.table.clearSelection()
# 搜索匹配项
found_items = self.table.findItems(search_text, Qt.MatchContains)
if found_items:
# 选中找到的项
for item in found_items:
item.setSelected(True)
# 滚动到第一个匹配项
self.table.scrollToItem(found_items[0])
QMessageBox.information(self, '搜索结果',
f'找到 {len(found_items)} 个匹配项')
else:
QMessageBox.information(self, '搜索结果', '未找到匹配项')
def get_employee_data(self, current_data=None):
"""获取员工数据对话框"""
from PyQt5.QtWidgets import QDialog, QFormLayout, QDialogButtonBox
dialog = QDialog(self)
dialog.setWindowTitle('员工信息')
dialog.setModal(True)
layout = QFormLayout()
# 创建输入控件
name_edit = QLineEdit()
age_edit = QLineEdit()
dept_edit = QLineEdit()
salary_edit = QLineEdit()
date_edit = QLineEdit()
# 如果有当前数据,填充到输入框
if current_data:
name_edit.setText(current_data[0])
age_edit.setText(current_data[1])
dept_edit.setText(current_data[2])
salary_edit.setText(current_data[3])
date_edit.setText(current_data[4])
# 添加到布局
layout.addRow('姓名:', name_edit)
layout.addRow('年龄:', age_edit)
layout.addRow('部门:', dept_edit)
layout.addRow('薪资:', salary_edit)
layout.addRow('入职日期:', date_edit)
# 按钮
buttons = QDialogButtonBox(
QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
buttons.accepted.connect(dialog.accept)
buttons.rejected.connect(dialog.reject)
layout.addRow(buttons)
dialog.setLayout(layout)
if dialog.exec_() == QDialog.Accepted:
return [
name_edit.text(),
age_edit.text(),
dept_edit.text(),
salary_edit.text(),
date_edit.text()
]
return None
def get_next_id(self):
"""获取下一个ID"""
max_id = 0
for row in range(self.table.rowCount()):
item = self.table.item(row, 0)
if item:
try:
id_value = int(item.text())
max_id = max(max_id, id_value)
except ValueError:
pass
return max_id + 1
def on_cell_double_clicked(self, row, column):
"""单元格双击事件"""
if column != 0: # 非ID列才能编辑
self.edit_item()
def on_selection_changed(self):
"""选择变化事件"""
has_selection = len(self.table.selectedItems()) > 0
self.delete_btn.setEnabled(has_selection)
self.edit_btn.setEnabled(has_selection)
QTreeWidget树形结构
from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout,
QTreeWidget, QTreeWidgetItem, QPushButton,
QInputDialog, QMessageBox, QMenu, QAction)
from PyQt5.QtCore import Qt
class TreeDemo(QWidget):
def __init__(self):
super().__init__()
self.initUI()
self.setup_tree()
self.load_sample_data()
def initUI(self):
self.setWindowTitle('树形控件示例 - 组织结构')
self.setGeometry(200, 200, 600, 500)
layout = QVBoxLayout()
# 按钮区域
button_layout = QHBoxLayout()
self.add_root_btn = QPushButton('添加根节点')
self.add_root_btn.clicked.connect(self.add_root_node)
button_layout.addWidget(self.add_root_btn)
self.add_child_btn = QPushButton('添加子节点')
self.add_child_btn.clicked.connect(self.add_child_node)
button_layout.addWidget(self.add_child_btn)
self.delete_btn = QPushButton('删除节点')
self.delete_btn.clicked.connect(self.delete_node)
button_layout.addWidget(self.delete_btn)
self.expand_all_btn = QPushButton('展开所有')
self.expand_all_btn.clicked.connect(self.tree.expandAll)
button_layout.addWidget(self.expand_all_btn)
self.collapse_all_btn = QPushButton('收起所有')
self.collapse_all_btn.clicked.connect(self.tree.collapseAll)
button_layout.addWidget(self.collapse_all_btn)
button_layout.addStretch()
layout.addLayout(button_layout)
# 树形控件
self.tree = QTreeWidget()
layout.addWidget(self.tree)
self.setLayout(layout)
def setup_tree(self):
"""设置树形控件"""
# 设置列标题
self.tree.setHeaderLabels(['部门/员工', '职位', '人数/工号'])
# 设置列宽
self.tree.setColumnWidth(0, 200)
self.tree.setColumnWidth(1, 150)
self.tree.setColumnWidth(2, 100)
# 连接信号
self.tree.itemClicked.connect(self.on_item_clicked)
self.tree.itemDoubleClicked.connect(self.on_item_double_clicked)
self.tree.itemSelectionChanged.connect(self.on_selection_changed)
# 设置右键菜单
self.tree.setContextMenuPolicy(Qt.CustomContextMenu)
self.tree.customContextMenuRequested.connect(self.show_context_menu)
def load_sample_data(self):
"""加载示例数据"""
# 创建公司根节点
company = QTreeWidgetItem(self.tree)
company.setText(0, 'ABC科技公司')
company.setText(1, '公司')
company.setText(2, '156人')
company.setExpanded(True)
# 技术部
tech_dept = QTreeWidgetItem(company)
tech_dept.setText(0, '技术部')
tech_dept.setText(1, '部门')
tech_dept.setText(2, '45人')
tech_dept.setExpanded(True)
# 技术部员工
tech_employees = [
('张三', '技术总监', 'T001'),
('李四', '高级工程师', 'T002'),
('王五', '前端工程师', 'T003'),
('赵六', '后端工程师', 'T004'),
('钱七', '测试工程师', 'T005')
]
for name, position, emp_id in tech_employees:
employee = QTreeWidgetItem(tech_dept)
employee.setText(0, name)
employee.setText(1, position)
employee.setText(2, emp_id)
# 销售部
sales_dept = QTreeWidgetItem(company)
sales_dept.setText(0, '销售部')
sales_dept.setText(1, '部门')
sales_dept.setText(2, '32人')
sales_employees = [
('孙八', '销售经理', 'S001'),
('周九', '销售代表', 'S002'),
('吴十', '客户经理', 'S003')
]
for name, position, emp_id in sales_employees:
employee = QTreeWidgetItem(sales_dept)
employee.setText(0, name)
employee.setText(1, position)
employee.setText(2, emp_id)
# 人事部
hr_dept = QTreeWidgetItem(company)
hr_dept.setText(0, '人事部')
hr_dept.setText(1, '部门')
hr_dept.setText(2, '8人')
hr_employees = [
('郑十一', '人事经理', 'H001'),
('王十二', '招聘专员', 'H002')
]
for name, position, emp_id in hr_employees:
employee = QTreeWidgetItem(hr_dept)
employee.setText(0, name)
employee.setText(1, position)
employee.setText(2, emp_id)
def add_root_node(self):
"""添加根节点"""
name, ok = QInputDialog.getText(self, '添加根节点', '请输入节点名称:')
if ok and name:
root_item = QTreeWidgetItem(self.tree)
root_item.setText(0, name)
root_item.setText(1, '根节点')
root_item.setText(2, '0')
self.tree.setCurrentItem(root_item)
def add_child_node(self):
"""添加子节点"""
current_item = self.tree.currentItem()
if current_item is None:
QMessageBox.warning(self, '警告', '请先选择一个父节点')
return
name, ok = QInputDialog.getText(self, '添加子节点', '请输入节点名称:')
if ok and name:
child_item = QTreeWidgetItem(current_item)
child_item.setText(0, name)
child_item.setText(1, '子节点')
child_item.setText(2, '1')
# 展开父节点
current_item.setExpanded(True)
self.tree.setCurrentItem(child_item)
def delete_node(self):
"""删除节点"""
current_item = self.tree.currentItem()
if current_item is None:
QMessageBox.warning(self, '警告', '请先选择要删除的节点')
return
reply = QMessageBox.question(
self, '确认删除',
f'确定要删除节点 "{current_item.text(0)}" 及其所有子节点吗?',
QMessageBox.Yes | QMessageBox.No)
if reply == QMessageBox.Yes:
parent = current_item.parent()
if parent:
parent.removeChild(current_item)
else:
index = self.tree.indexOfTopLevelItem(current_item)
self.tree.takeTopLevelItem(index)
def show_context_menu(self, position):
"""显示右键菜单"""
item = self.tree.itemAt(position)
if item is None:
return
menu = QMenu()
add_child_action = QAction('添加子节点', self)
add_child_action.triggered.connect(self.add_child_node)
menu.addAction(add_child_action)
if item.parent() is not None: # 不是根节点
delete_action = QAction('删除节点', self)
delete_action.triggered.connect(self.delete_node)
menu.addAction(delete_action)
menu.addSeparator()
expand_action = QAction('展开节点', self)
expand_action.triggered.connect(lambda: item.setExpanded(True))
menu.addAction(expand_action)
collapse_action = QAction('收起节点', self)
collapse_action.triggered.connect(lambda: item.setExpanded(False))
menu.addAction(collapse_action)
menu.exec_(self.tree.mapToGlobal(position))
def on_item_clicked(self, item, column):
"""项目点击事件"""
print(f"点击了: {item.text(0)}")
def on_item_double_clicked(self, item, column):
"""项目双击事件"""
if column == 0: # 只允许编辑第一列
# 启用编辑模式
self.tree.editItem(item, column)
def on_selection_changed(self):
"""选择变化事件"""
current_item = self.tree.currentItem()
has_selection = current_item is not None
self.add_child_btn.setEnabled(has_selection)
self.delete_btn.setEnabled(has_selection and current_item.parent() is not None)
六、文件处理与数据管理
6.1 文件操作
import os
import json
from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QPushButton,
QTextEdit, QFileDialog, QMessageBox, QLabel,
QListWidget, QSplitter)
from PyQt5.QtCore import Qt, QSettings
class FileManagerDemo(QWidget):
def __init__(self):
super().__init__()
self.current_file = None
self.recent_files = []
self.settings = QSettings('FileManager', 'Demo')
self.initUI()
self.load_recent_files()
def initUI(self):
self.setWindowTitle('文件管理器')
self.setGeometry(200, 200, 800, 600)
# 创建分割器
splitter = QSplitter(Qt.Horizontal)
# 左侧:最近文件列表
left_widget = QWidget()
left_layout = QVBoxLayout()
left_layout.addWidget(QLabel('最近文件:'))
self.recent_list = QListWidget()
self.recent_list.itemDoubleClicked.connect(self.open_recent_file)
left_layout.addWidget(self.recent_list)
clear_recent_btn = QPushButton('清空最近文件')
clear_recent_btn.clicked.connect(self.clear_recent_files)
left_layout.addWidget(clear_recent_btn)
left_widget.setLayout(left_layout)
left_widget.setMaximumWidth(250)
# 右侧:主编辑区域
right_widget = QWidget()
right_layout = QVBoxLayout()
# 工具栏
toolbar_layout = QHBoxLayout()
new_btn = QPushButton('新建')
new_btn.clicked.connect(self.new_file)
toolbar_layout.addWidget(new_btn)
open_btn = QPushButton('打开')
open_btn.clicked.connect(self.open_file)
toolbar_layout.addWidget(open_btn)
save_btn = QPushButton('保存')
save_btn.clicked.connect(self.save_file)
toolbar_layout.addWidget(save_btn)
save_as_btn = QPushButton('另存为')
save_as_btn.clicked.connect(self.save_as_file)
toolbar_layout.addWidget(save_as_btn)
toolbar_layout.addStretch()
# 文件信息
self.file_info_label = QLabel('无文件打开')
toolbar_layout.addWidget(self.file_info_label)
right_layout.addLayout(toolbar_layout)
# 文本编辑器
self.text_edit = QTextEdit()
self.text_edit.textChanged.connect(self.on_text_changed)
right_layout.addWidget(self.text_edit)
right_widget.setLayout(right_layout)
# 添加到分割器
splitter.addWidget(left_widget)
splitter.addWidget(right_widget)
splitter.setSizes([200, 600])
# 主布局
main_layout = QVBoxLayout()
main_layout.addWidget(splitter)
self.setLayout(main_layout)
def new_file(self):
"""新建文件"""
if self.check_save_changes():
self.text_edit.clear()
self.current_file = None
self.update_file_info()
def open_file(self):
"""打开文件"""
if self.check_save_changes():
file_path, _ = QFileDialog.getOpenFileName(
self, '打开文件', '',
'Text Files (*.txt);;Python Files (*.py);;All Files (*)')
if file_path:
self.load_file(file_path)
def load_file(self, file_path):
"""加载文件"""
try:
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
self.text_edit.setPlainText(content)
self.current_file = file_path
self.add_to_recent_files(file_path)
self.update_file_info()
except Exception as e:
QMessageBox.warning(self, '错误', f'无法打开文件:\n{str(e)}')
def save_file(self):
"""保存文件"""
if self.current_file:
self.save_to_file(self.current_file)
else:
self.save_as_file()
def save_as_file(self):
"""另存为文件"""
file_path, _ = QFileDialog.getSaveFileName(
self, '保存文件', '',
'Text Files (*.txt);;Python Files (*.py);;All Files (*)')
if file_path:
self.save_to_file(file_path)
def save_to_file(self, file_path):
"""保存到指定文件"""
try:
with open(file_path, 'w', encoding='utf-8') as f:
f.write(self.text_edit.toPlainText())
self.current_file = file_path
self.add_to_recent_files(file_path)
self.update_file_info()
QMessageBox.information(self, '成功', '文件保存成功!')
except Exception as e:
QMessageBox.warning(self, '错误', f'无法保存文件:\n{str(e)}')
def add_to_recent_files(self, file_path):
"""添加到最近文件列表"""
if file_path in self.recent_files:
self.recent_files.remove(file_path)
self.recent_files.insert(0, file_path)
# 最多保存10个最近文件
if len(self.recent_files) > 10:
self.recent_files = self.recent_files[:10]
self.update_recent_list()
self.save_recent_files()
def update_recent_list(self):
"""更新最近文件列表显示"""
self.recent_list.clear()
for file_path in self.recent_files:
if os.path.exists(file_path):
self.recent_list.addItem(os.path.basename(file_path))
def open_recent_file(self, item):
"""打开最近文件"""
index = self.recent_list.row(item)
if 0 <= index < len(self.recent_files):
file_path = self.recent_files[index]
if os.path.exists(file_path):
if self.check_save_changes():
self.load_file(file_path)
else:
QMessageBox.warning(self, '错误', '文件不存在')
self.recent_files.remove(file_path)
self.update_recent_list()
self.save_recent_files()
def clear_recent_files(self):
"""清空最近文件"""
self.recent_files.clear()
self.update_recent_list()
self.save_recent_files()
def load_recent_files(self):
"""从设置中加载最近文件"""
self.recent_files = self.settings.value('recent_files', [])
if not isinstance(self.recent_files, list):
self.recent_files = []
self.update_recent_list()
def save_recent_files(self):
"""保存最近文件到设置"""
self.settings.setValue('recent_files', self.recent_files)
def update_file_info(self):
"""更新文件信息显示"""
if self.current_file:
file_name = os.path.basename(self.current_file)
file_size = os.path.getsize(self.current_file)
self.file_info_label.setText(f'{file_name} ({file_size} bytes)')
else:
self.file_info_label.setText('无文件打开')
def on_text_changed(self):
"""文本变化事件"""
# 可以在这里添加自动保存等功能
pass
def check_save_changes(self):
"""检查是否需要保存更改"""
# 简化版本,实际应用中应该检查文本是否已修改
if self.text_edit.toPlainText().strip():
reply = QMessageBox.question(
self, '保存确认',
'当前文档可能有未保存的更改,是否继续?',
QMessageBox.Yes | QMessageBox.No)
return reply == QMessageBox.Yes
return True
def closeEvent(self, event):
"""窗口关闭事件"""
if self.check_save_changes():
self.save_recent_files()
event.accept()
else:
event.ignore()
6.2 配置文件管理
import json
import configparser
from PyQt5.QtCore import QSettings
from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QFormLayout,
QLineEdit, QSpinBox, QCheckBox, QComboBox,
QPushButton, QTabWidget, QGroupBox, QMessageBox)
class ConfigManagerDemo(QWidget):
def __init__(self):
super().__init__()
self.config_methods = {
'QSettings': self.load_qsettings,
'JSON': self.load_json_config,
'INI': self.load_ini_config
}
self.initUI()
self.load_config()
def initUI(self):
self.setWindowTitle('配置管理示例')
self.setGeometry(300, 300, 500, 600)
layout = QVBoxLayout()
# 配置方式选择
method_layout = QHBoxLayout()
method_layout.addWidget(QLabel('配置方式:'))
self.method_combo = QComboBox()
self.method_combo.addItems(list(self.config_methods.keys()))
self.method_combo.currentTextChanged.connect(self.on_method_changed)
method_layout.addWidget(self.method_combo)
method_layout.addStretch()
layout.addLayout(method_layout)
# 创建选项卡
self.tab_widget = QTabWidget()
# 通用设置选项卡
self.create_general_tab()
# 界面设置选项卡
self.create_ui_tab()
# 高级设置选项卡
self.create_advanced_tab()
layout.addWidget(self.tab_widget)
# 按钮区域
button_layout = QHBoxLayout()
load_btn = QPushButton('加载配置')
load_btn.clicked.connect(self.load_config)
button_layout.addWidget(load_btn)
save_btn = QPushButton('保存配置')
save_btn.clicked.connect(self.save_config)
button_layout.addWidget(save_btn)
reset_btn = QPushButton('重置默认')
reset_btn.clicked.connect(self.reset_to_defaults)
button_layout.addWidget(reset_btn)
button_layout.addStretch()
layout.addLayout(button_layout)
self.setLayout(layout)
def create_general_tab(self):
"""创建通用设置选项卡"""
general_widget = QWidget()
layout = QVBoxLayout()
# 基本信息组
basic_group = QGroupBox('基本信息')
basic_layout = QFormLayout()
self.username_edit = QLineEdit()
basic_layout.addRow('用户名:', self.username_edit)
self.email_edit = QLineEdit()
basic_layout.addRow('邮箱:', self.email_edit)
self.auto_save_check = QCheckBox('自动保存')
basic_layout.addRow('', self.auto_save_check)
basic_group.setLayout(basic_layout)
layout.addWidget(basic_group)
# 文件设置组
file_group = QGroupBox('文件设置')
file_layout = QFormLayout()
self.max_recent_spin = QSpinBox()
self.max_recent_spin.setRange(1, 20)
file_layout.addRow('最近文件数量:', self.max_recent_spin)
self.default_encoding_combo = QComboBox()
self.default_encoding_combo.addItems(['UTF-8', 'GBK', 'ASCII'])
file_layout.addRow('默认编码:', self.default_encoding_combo)
file_group.setLayout(file_layout)
layout.addWidget(file_group)
layout.addStretch()
general_widget.setLayout(layout)
self.tab_widget.addTab(general_widget, '通用')
def create_ui_tab(self):
"""创建界面设置选项卡"""
ui_widget = QWidget()
layout = QVBoxLayout()
# 外观设置组
appearance_group = QGroupBox('外观设置')
appearance_layout = QFormLayout()
self.theme_combo = QComboBox()
self.theme_combo.addItems(['默认', '深色', '浅色'])
appearance_layout.addRow('主题:', self.theme_combo)
self.font_size_spin = QSpinBox()
self.font_size_spin.setRange(8, 24)
appearance_layout.addRow('字体大小:', self.font_size_spin)
self.show_toolbar_check = QCheckBox('显示工具栏')
appearance_layout.addRow('', self.show_toolbar_check)
self.show_statusbar_check = QCheckBox('显示状态栏')
appearance_layout.addRow('', self.show_statusbar_check)
appearance_group.setLayout(appearance_layout)
layout.addWidget(appearance_group)
# 窗口设置组
window_group = QGroupBox('窗口设置')
window_layout = QFormLayout()
self.window_width_spin = QSpinBox()
self.window_width_spin.setRange(400, 2000)
window_layout.addRow('窗口宽度:', self.window_width_spin)
self.window_height_spin = QSpinBox()
self.window_height_spin.setRange(300, 1500)
window_layout.addRow('窗口高度:', self.window_height_spin)
self.remember_size_check = QCheckBox('记住窗口大小')
window_layout.addRow('', self.remember_size_check)
window_group.setLayout(window_layout)
layout.addWidget(window_group)
layout.addStretch()
ui_widget.setLayout(layout)
self.tab_widget.addTab(ui_widget, '界面')
def create_advanced_tab(self):
"""创建高级设置选项卡"""
advanced_widget = QWidget()
layout = QVBoxLayout()
# 性能设置组
performance_group = QGroupBox('性能设置')
performance_layout = QFormLayout()
self.cache_size_spin = QSpinBox()
self.cache_size_spin.setRange(10, 1000)
self.cache_size_spin.setSuffix(' MB')
performance_layout.addRow('缓存大小:', self.cache_size_spin)
self.thread_count_spin = QSpinBox()
self.thread_count_spin.setRange(1, 16)
performance_layout.addRow('线程数量:', self.thread_count_spin)
self.enable_logging_check = QCheckBox('启用日志')
performance_layout.addRow('', self.enable_logging_check)
performance_group.setLayout(performance_layout)
layout.addWidget(performance_group)
# 网络设置组
network_group = QGroupBox('网络设置')
network_layout = QFormLayout()
self.proxy_host_edit = QLineEdit()
network_layout.addRow('代理主机:', self.proxy_host_edit)
self.proxy_port_spin = QSpinBox()
self.proxy_port_spin.setRange(1, 65535)
network_layout.addRow('代理端口:', self.proxy_port_spin)
self.timeout_spin = QSpinBox()
self.timeout_spin.setRange(1, 300)
self.timeout_spin.setSuffix(' 秒')
network_layout.addRow('超时时间:', self.timeout_spin)
network_group.setLayout(network_layout)
layout.addWidget(network_group)
layout.addStretch()
advanced_widget.setLayout(layout)
self.tab_widget.addTab(advanced_widget, '高级')
def get_default_config(self):
"""获取默认配置"""
return {
# 通用设置
'username': 'User',
'email': 'user@example.com',
'auto_save': True,
'max_recent_files': 10,
'default_encoding': 'UTF-8',
# 界面设置
'theme': '默认',
'font_size': 12,
'show_toolbar': True,
'show_statusbar': True,
'window_width': 800,
'window_height': 600,
'remember_window_size': True,
# 高级设置
'cache_size': 100,
'thread_count': 4,
'enable_logging': False,
'proxy_host': '',
'proxy_port': 8080,
'timeout': 30
}
def load_config(self):
"""加载配置"""
method = self.method_combo.currentText()
if method in self.config_methods:
config = self.config_methods[method]()
self.apply_config(config)
def load_qsettings(self):
"""使用QSettings加载配置"""
settings = QSettings('ConfigDemo', 'Application')
config = {}
defaults = self.get_default_config()
for key, default_value in defaults.items():
config[key] = settings.value(key, default_value)
# QSettings的类型转换
if isinstance(default_value, bool):
config[key] = str(config[key]).lower() == 'true'
elif isinstance(default_value, int):
config[key] = int(config[key])
return config
def load_json_config(self):
"""从JSON文件加载配置"""
try:
with open('config.json', 'r', encoding='utf-8') as f:
config = json.load(f)
return {**self.get_default_config(), **config}
except FileNotFoundError:
return self.get_default_config()
except Exception as e:
QMessageBox.warning(self, '错误', f'加载JSON配置失败:\n{str(e)}')
return self.get_default_config()
def load_ini_config(self):
"""从INI文件加载配置"""
config = configparser.ConfigParser()
defaults = self.get_default_config()
try:
config.read('config.ini', encoding='utf-8')
result = {}
for key, default_value in defaults.items():
section = 'General'
if key in ['theme', 'font_size', 'show_toolbar', 'show_statusbar',
'window_width', 'window_height', 'remember_window_size']:
section = 'UI'
elif key in ['cache_size', 'thread_count', 'enable_logging',
'proxy_host', 'proxy_port', 'timeout']:
section = 'Advanced'
if config.has_option(section, key):
value = config.get(section, key)
if isinstance(default_value, bool):
result[key] = value.lower() == 'true'
elif isinstance(default_value, int):
result[key] = int(value)
else:
result[key] = value
else:
result[key] = default_value
return result
except Exception as e:
QMessageBox.warning(self, '错误', f'加载INI配置失败:\n{str(e)}')
return defaults
def apply_config(self, config):
"""应用配置到界面"""
# 通用设置
self.username_edit.setText(config.get('username', ''))
self.email_edit.setText(config.get('email', ''))
self.auto_save_check.setChecked(config.get('auto_save', True))
self.max_recent_spin.setValue(config.get('max_recent_files', 10))
encoding = config.get('default_encoding', 'UTF-8')
index = self.default_encoding_combo.findText(encoding)
if index >= 0:
self.default_encoding_combo.setCurrentIndex(index)
# 界面设置
theme = config.get('theme', '默认')
index = self.theme_combo.findText(theme)
if index >= 0:
self.theme_combo.setCurrentIndex(index)
self.font_size_spin.setValue(config.get('font_size', 12))
self.show_toolbar_check.setChecked(config.get('show_toolbar', True))
self.show_statusbar_check.setChecked(config.get('show_statusbar', True))
self.window_width_spin.setValue(config.get('window_width', 800))
self.window_height_spin.setValue(config.get('window_height', 600))
self.remember_size_check.setChecked(config.get('remember_window_size', True))
# 高级设置
self.cache_size_spin.setValue(config.get('cache_size', 100))
self.thread_count_spin.setValue(config.get('thread_count', 4))
self.enable_logging_check.setChecked(config.get('enable_logging', False))
self.proxy_host_edit.setText(config.get('proxy_host', ''))
self.proxy_port_spin.setValue(config.get('proxy_port', 8080))
self.timeout_spin.setValue(config.get('timeout', 30))
def get_current_config(self):
"""获取当前界面配置"""
return {
# 通用设置
'username': self.username_edit.text(),
'email': self.email_edit.text(),
'auto_save': self.auto_save_check.isChecked(),
'max_recent_files': self.max_recent_spin.value(),
'default_encoding': self.default_encoding_combo.currentText(),
# 界面设置
'theme': self.theme_combo.currentText(),
'font_size': self.font_size_spin.value(),
'show_toolbar': self.show_toolbar_check.isChecked(),
'show_statusbar': self.show_statusbar_check.isChecked(),
'window_width': self.window_width_spin.value(),
'window_height': self.window_height_spin.value(),
'remember_window_size': self.remember_size_check.isChecked(),
# 高级设置
'cache_size': self.cache_size_spin.value(),
'thread_count': self.thread_count_spin.value(),
'enable_logging': self.enable_logging_check.isChecked(),
'proxy_host': self.proxy_host_edit.text(),
'proxy_port': self.proxy_port_spin.value(),
'timeout': self.timeout_spin.value()
}
def save_config(self):
"""保存配置"""
method = self.method_combo.currentText()
config = self.get_current_config()
try:
if method == 'QSettings':
self.save_qsettings(config)
elif method == 'JSON':
self.save_json_config(config)
elif method == 'INI':
self.save_ini_config(config)
QMessageBox.information(self, '成功', '配置保存成功!')
except Exception as e:
QMessageBox.warning(self, '错误', f'保存配置失败:\n{str(e)}')
def save_qsettings(self, config):
"""使用QSettings保存配置"""
settings = QSettings('ConfigDemo', 'Application')
for key, value in config.items():
settings.setValue(key, value)
settings.sync()
def save_json_config(self, config):
"""保存到JSON文件"""
with open('config.json', 'w', encoding='utf-8') as f:
json.dump(config, f, indent=2, ensure_ascii=False)
def save_ini_config(self, config):
"""保存到INI文件"""
ini_config = configparser.ConfigParser()
# 分组保存
ini_config.add_section('General')
ini_config.add_section('UI')
ini_config.add_section('Advanced')
# 通用设置
general_keys = ['username', 'email', 'auto_save', 'max_recent_files', 'default_encoding']
for key in general_keys:
ini_config.set('General', key, str(config[key]))
# 界面设置
ui_keys = ['theme', 'font_size', 'show_toolbar', 'show_statusbar',
'window_width', 'window_height', 'remember_window_size']
for key in ui_keys:
ini_config.set('UI', key, str(config[key]))
# 高级设置
advanced_keys = ['cache_size', 'thread_count', 'enable_logging',
'proxy_host', 'proxy_port', 'timeout']
for key in advanced_keys:
ini_config.set('Advanced', key, str(config[key]))
with open('config.ini', 'w', encoding='utf-8') as f:
ini_config.write(f)
def reset_to_defaults(self):
"""重置为默认配置"""
reply = QMessageBox.question(
self, '确认重置',
'确定要重置为默认配置吗?',
QMessageBox.Yes | QMessageBox.No)
if reply == QMessageBox.Yes:
defaults = self.get_default_config()
self.apply_config(defaults)
def on_method_changed(self, method):
"""配置方式改变"""
self.load_config()
七、多线程与性能优化
7.1 多线程编程
import time
import requests
from PyQt5.QtCore import QThread, pyqtSignal, QMutex, QWaitCondition
from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QPushButton,
QProgressBar, QTextEdit, QLabel, QSpinBox,
QListWidget, QGroupBox)
class WorkerThread(QThread):
"""工作线程示例"""
# 定义信号
progress_updated = pyqtSignal(int)
status_updated = pyqtSignal(str)
result_ready = pyqtSignal(str)
error_occurred = pyqtSignal(str)
def __init__(self, task_type='download', urls=None):
super().__init__()
self.task_type = task_type
self.urls = urls or []
self.is_running = False
self.is_paused = False
self.mutex = QMutex()
self.condition = QWaitCondition()
def run(self):
"""线程主函数"""
self.is_running = True
try:
if self.task_type == 'download':
self.download_files()
elif self.task_type == 'process':
self.process_data()
elif self.task_type == 'calculate':
self.heavy_calculation()
except Exception as e:
self.error_occurred.emit(str(e))
finally:
self.is_running = False
def download_files(self):
"""下载文件示例"""
total_files = len(self.urls)
for i, url in enumerate(self.urls):
if not self.is_running:
break
# 检查暂停状态
self.check_pause()
self.status_updated.emit(f"正在下载: {url}")
try:
# 模拟下载
response = requests.get(url, timeout=10)
if response.status_code == 200:
self.result_ready.emit(f"下载成功: {url}")
else:
self.result_ready.emit(f"下载失败: {url} (状态码: {response.status_code})")
except Exception as e:
self.result_ready.emit(f"下载错误: {url} ({str(e)})")
# 更新进度
progress = int((i + 1) / total_files * 100)
self.progress_updated.emit(progress)
# 模拟下载时间
self.msleep(1000)
def process_data(self):
"""数据处理示例"""
total_steps = 100
for i in range(total_steps):
if not self.is_running:
break
self.check_pause()
self.status_updated.emit(f"处理步骤 {i + 1}/{total_steps}")
# 模拟耗时操作
self.msleep(50)
# 更新进度
progress = int((i + 1) / total_steps * 100)
self.progress_updated.emit(progress)
if self.is_running:
self.result_ready.emit("数据处理完成")
def heavy_calculation(self):
"""重计算示例"""
n = 1000000
total = 0
for i in range(n):
if not self.is_running:
break
if i % 10000 == 0:
self.check_pause()
progress = int(i / n * 100)
self.progress_updated.emit(progress)
self.status_updated.emit(f"计算进度: {i}/{n}")
# 模拟重计算
total += i * i
if self.is_running:
self.result_ready.emit(f"计算完成,结果: {total}")
def check_pause(self):
"""检查暂停状态"""
self.mutex.lock()
try:
while self.is_paused and self.is_running:
self.condition.wait(self.mutex)
finally:
self.mutex.unlock()
def pause(self):
"""暂停线程"""
self.is_paused = True
def resume(self):
"""恢复线程"""
self.mutex.lock()
try:
self.is_paused = False
self.condition.wakeAll()
finally:
self.mutex.unlock()
def stop(self):
"""停止线程"""
self.is_running = False
self.resume() # 确保线程能够退出
class DownloadManagerThread(QThread):
"""下载管理器线程"""
download_started = pyqtSignal(str)
download_finished = pyqtSignal(str, bool)
progress_updated = pyqtSignal(str, int)
def __init__(self):
super().__init__()
self.download_queue = []
self.current_downloads = {}
self.max_concurrent = 3
self.mutex = QMutex()
def add_download(self, url):
"""添加下载任务"""
self.mutex.lock()
try:
self.download_queue.append(url)
finally:
self.mutex.unlock()
def run(self):
"""运行下载管理器"""
while True:
self.mutex.lock()
try:
# 检查是否有新任务且当前下载数未达到上限
if (self.download_queue and
len(self.current_downloads) < self.max_concurrent):
url = self.download_queue.pop(0)
self.start_download(url)
# 检查已完成的下载
completed = []
for url, thread in self.current_downloads.items():
if not thread.isRunning():
completed.append(url)
for url in completed:
thread = self.current_downloads.pop(url)
self.download_finished.emit(url, True)
finally:
self.mutex.unlock()
self.msleep(100) # 避免过度消耗CPU
def start_download(self, url):
"""开始单个下载"""
thread = SingleDownloadThread(url)
thread.progress_updated.connect(
lambda progress: self.progress_updated.emit(url, progress))
self.current_downloads[url] = thread
self.download_started.emit(url)
thread.start()
class SingleDownloadThread(QThread):
"""单个下载线程"""
progress_updated = pyqtSignal(int)
def __init__(self, url):
super().__init__()
self.url = url
def run(self):
"""执行下载"""
try:
# 模拟下载过程
for i in range(101):
self.progress_updated.emit(i)
self.msleep(50)
except Exception:
pass
class ThreadDemo(QWidget):
def __init__(self):
super().__init__()
self.worker_thread = None
self.download_manager = None
self.initUI()
def initUI(self):
self.setWindowTitle('多线程示例')
self.setGeometry(200, 200, 700, 600)
layout = QVBoxLayout()
# 基本线程操作组
basic_group = QGroupBox('基本线程操作')
basic_layout = QVBoxLayout()
# 任务类型选择
task_layout = QHBoxLayout()
task_layout.addWidget(QLabel('任务类型:'))
self.download_btn = QPushButton('文件下载')
self.download_btn.clicked.connect(lambda: self.start_task('download'))
task_layout.addWidget(self.download_btn)
self.process_btn = QPushButton('数据处理')
self.process_btn.clicked.connect(lambda: self.start_task('process'))
task_layout.addWidget(self.process_btn)
self.calc_btn = QPushButton('重计算')
self.calc_btn.clicked.connect(lambda: self.start_task('calculate'))
task_layout.addWidget(self.calc_btn)
task_layout.addStretch()
basic_layout.addLayout(task_layout)
# 控制按钮
control_layout = QHBoxLayout()
self.pause_btn = QPushButton('暂停')
self.pause_btn.clicked.connect(self.pause_task)
self.pause_btn.setEnabled(False)
control_layout.addWidget(self.pause_btn)
self.resume_btn = QPushButton('继续')
self.resume_btn.clicked.connect(self.resume_task)
self.resume_btn.setEnabled(False)
control_layout.addWidget(self.resume_btn)
self.stop_btn = QPushButton('停止')
self.stop_btn.clicked.connect(self.stop_task)
self.stop_btn.setEnabled(False)
control_layout.addWidget(self.stop_btn)
control_layout.addStretch()
basic_layout.addLayout(control_layout)
# 进度显示
self.progress_bar = QProgressBar()
basic_layout.addWidget(self.progress_bar)
self.status_label = QLabel('就绪')
basic_layout.addWidget(self.status_label)
basic_group.setLayout(basic_layout)
layout.addWidget(basic_group)
# 下载管理器组
download_group = QGroupBox('下载管理器')
download_layout = QVBoxLayout()
# 添加下载
add_layout = QHBoxLayout()
add_layout.addWidget(QLabel('并发数
self.concurrent_spin = QSpinBox()
self.concurrent_spin.setRange(1, 10)
self.concurrent_spin.setValue(3)
add_layout.addWidget(self.concurrent_spin)
self.add_download_btn = QPushButton('添加下载任务')
self.add_download_btn.clicked.connect(self.add_download_task)
add_layout.addWidget(self.add_download_btn)
self.start_manager_btn = QPushButton('启动管理器')
self.start_manager_btn.clicked.connect(self.start_download_manager)
add_layout.addWidget(self.start_manager_btn)
add_layout.addStretch()
download_layout.addLayout(add_layout)
# 下载列表
self.download_list = QListWidget()
download_layout.addWidget(self.download_list)
download_group.setLayout(download_layout)
layout.addWidget(download_group)
# 结果显示
result_group = QGroupBox('执行结果')
result_layout = QVBoxLayout()
self.result_text = QTextEdit()
self.result_text.setMaximumHeight(150)
result_layout.addWidget(self.result_text)
result_group.setLayout(result_layout)
layout.addWidget(result_group)
self.setLayout(layout)
def start_task(self, task_type):
"""启动任务"""
if self.worker_thread and self.worker_thread.isRunning():
self.result_text.append("任务正在运行中...")
return
# 准备测试URL
test_urls = [
'https://httpbin.org/delay/1',
'https://httpbin.org/delay/2',
'https://httpbin.org/delay/1',
'https://httpbin.org/status/200',
'https://httpbin.org/json'
]
self.worker_thread = WorkerThread(task_type, test_urls)
# 连接信号
self.worker_thread.progress_updated.connect(self.update_progress)
self.worker_thread.status_updated.connect(self.update_status)
self.worker_thread.result_ready.connect(self.show_result)
self.worker_thread.error_occurred.connect(self.show_error)
self.worker_thread.finished.connect(self.task_finished)
# 更新UI状态
self.set_task_running(True)
# 启动线程
self.worker_thread.start()
self.result_text.append(f"开始执行{task_type}任务...")
def pause_task(self):
"""暂停任务"""
if self.worker_thread:
self.worker_thread.pause()
self.pause_btn.setEnabled(False)
self.resume_btn.setEnabled(True)
self.status_label.setText("任务已暂停")
def resume_task(self):
"""恢复任务"""
if self.worker_thread:
self.worker_thread.resume()
self.pause_btn.setEnabled(True)
self.resume_btn.setEnabled(False)
self.status_label.setText("任务已恢复")
def stop_task(self):
"""停止任务"""
if self.worker_thread:
self.worker_thread.stop()
self.worker_thread.wait() # 等待线程结束
self.task_finished()
self.status_label.setText("任务已停止")
def task_finished(self):
"""任务完成"""
self.set_task_running(False)
self.progress_bar.setValue(0)
self.status_label.setText("任务完成")
def set_task_running(self, running):
"""设置任务运行状态"""
self.download_btn.setEnabled(not running)
self.process_btn.setEnabled(not running)
self.calc_btn.setEnabled(not running)
self.pause_btn.setEnabled(running)
self.stop_btn.setEnabled(running)
self.resume_btn.setEnabled(False)
def update_progress(self, value):
"""更新进度"""
self.progress_bar.setValue(value)
def update_status(self, status):
"""更新状态"""
self.status_label.setText(status)
def show_result(self, result):
"""显示结果"""
self.result_text.append(result)
def show_error(self, error):
"""显示错误"""
self.result_text.append(f"错误: {error}")
def start_download_manager(self):
"""启动下载管理器"""
if self.download_manager and self.download_manager.isRunning():
return
self.download_manager = DownloadManagerThread()
self.download_manager.max_concurrent = self.concurrent_spin.value()
# 连接信号
self.download_manager.download_started.connect(self.on_download_started)
self.download_manager.download_finished.connect(self.on_download_finished)
self.download_manager.progress_updated.connect(self.on_download_progress)
self.download_manager.start()
self.start_manager_btn.setText('管理器运行中')
self.start_manager_btn.setEnabled(False)
def add_download_task(self):
"""添加下载任务"""
test_urls = [
'https://httpbin.org/delay/2',
'https://httpbin.org/delay/3',
'https://httpbin.org/delay/1',
'https://httpbin.org/json',
'https://httpbin.org/status/200'
]
if not self.download_manager:
self.start_download_manager()
for url in test_urls[:2]: # 添加前两个URL
self.download_manager.add_download(url)
self.download_list.addItem(f"队列中: {url}")
def on_download_started(self, url):
"""下载开始"""
for i in range(self.download_list.count()):
item = self.download_list.item(i)
if url in item.text():
item.setText(f"下载中: {url} (0%)")
break
def on_download_finished(self, url, success):
"""下载完成"""
for i in range(self.download_list.count()):
item = self.download_list.item(i)
if url in item.text():
status = "完成" if success else "失败"
item.setText(f"{status}: {url}")
break
def on_download_progress(self, url, progress):
"""下载进度更新"""
for i in range(self.download_list.count()):
item = self.download_list.item(i)
if url in item.text() and "下载中" in item.text():
item.setText(f"下载中: {url} ({progress}%)")
break
def closeEvent(self, event):
"""窗口关闭事件"""
# 停止所有线程
if self.worker_thread and self.worker_thread.isRunning():
self.worker_thread.stop()
self.worker_thread.wait()
if self.download_manager and self.download_manager.isRunning():
self.download_manager.terminate()
self.download_manager.wait()
event.accept()
7.2 性能优化技巧
import sys
import time
import psutil
import gc
from PyQt5.QtCore import QTimer, QThread, pyqtSignal
from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel,
QPushButton, QProgressBar, QTextEdit, QGroupBox,
QTableWidget, QTableWidgetItem, QHeaderView)
from PyQt5.QtGui import QPixmap, QPainter
class PerformanceMonitor(QThread):
"""性能监控线程"""
stats_updated = pyqtSignal(dict)
def __init__(self):
super().__init__()
self.is_running = False
self.process = psutil.Process()
def run(self):
"""监控主循环"""
self.is_running = True
while self.is_running:
try:
# 获取CPU使用率
cpu_percent = self.process.cpu_percent()
# 获取内存信息
memory_info = self.process.memory_info()
memory_mb = memory_info.rss / 1024 / 1024
# 获取系统总内存
virtual_memory = psutil.virtual_memory()
total_memory_mb = virtual_memory.total / 1024 / 1024
memory_percent = (memory_mb / total_memory_mb) * 100
# 获取线程数
thread_count = self.process.num_threads()
# 获取文件描述符数量(Linux/Mac)
try:
fd_count = self.process.num_fds()
except AttributeError:
fd_count = 0 # Windows不支持
stats = {
'cpu_percent': cpu_percent,
'memory_mb': memory_mb,
'memory_percent': memory_percent,
'thread_count': thread_count,
'fd_count': fd_count
}
self.stats_updated.emit(stats)
except Exception as e:
print(f"性能监控错误: {e}")
self.msleep(1000) # 每秒更新一次
def stop(self):
"""停止监控"""
self.is_running = False
class MemoryLeakDemo(QWidget):
"""内存泄漏演示和优化"""
def __init__(self):
super().__init__()
self.large_data = []
self.widgets_cache = {}
self.image_cache = {}
self.initUI()
def initUI(self):
self.setWindowTitle('内存泄漏演示')
layout = QVBoxLayout()
# 操作按钮
button_layout = QHBoxLayout()
leak_btn = QPushButton('制造内存泄漏')
leak_btn.clicked.connect(self.create_memory_leak)
button_layout.addWidget(leak_btn)
clean_btn = QPushButton('清理内存')
clean_btn.clicked.connect(self.clean_memory)
button_layout.addWidget(clean_btn)
gc_btn = QPushButton('强制垃圾回收')
gc_btn.clicked.connect(self.force_gc)
button_layout.addWidget(gc_btn)
button_layout.addStretch()
layout.addLayout(button_layout)
# 信息显示
self.info_label = QLabel('内存使用情况将在这里显示')
layout.addWidget(self.info_label)
self.setLayout(layout)
def create_memory_leak(self):
"""制造内存泄漏"""
# 创建大量数据但不释放
for i in range(1000):
data = [j for j in range(1000)] # 创建大列表
self.large_data.append(data)
self.info_label.setText(f'已创建 {len(self.large_data)} 个大对象')
def clean_memory(self):
"""清理内存"""
self.large_data.clear()
self.widgets_cache.clear()
self.image_cache.clear()
self.info_label.setText('内存已清理')
def force_gc(self):
"""强制垃圾回收"""
collected = gc.collect()
self.info_label.setText(f'垃圾回收完成,回收了 {collected} 个对象')
class OptimizedTableWidget(QTableWidget):
"""优化的表格控件"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.item_cache = {}
self.setup_optimization()
def setup_optimization(self):
"""设置优化选项"""
# 启用虚拟化(仅绘制可见项)
self.setVerticalScrollMode(self.ScrollPerPixel)
self.setHorizontalScrollMode(self.ScrollPerPixel)
# 设置选择模式
self.setSelectionBehavior(self.SelectRows)
# 禁用不必要的功能
self.setAlternatingRowColors(True)
def setItem(self, row, column, item):
"""重写setItem方法,添加缓存"""
cache_key = f"{row}_{column}"
# 如果项目相同,不重复设置
if cache_key in self.item_cache:
old_item = self.item_cache[cache_key]
if old_item.text() == item.text():
return
self.item_cache[cache_key] = item
super().setItem(row, column, item)
def clear_cache(self):
"""清理缓存"""
self.item_cache.clear()
class PerformanceDemo(QWidget):
def __init__(self):
super().__init__()
self.monitor = PerformanceMonitor()
self.timer = QTimer()
self.large_table = None
self.initUI()
self.start_monitoring()
def initUI(self):
self.setWindowTitle('性能优化示例')
self.setGeometry(200, 200, 800, 700)
layout = QVBoxLayout()
# 性能监控组
monitor_group = QGroupBox('性能监控')
monitor_layout = QVBoxLayout()
self.cpu_label = QLabel('CPU: 0%')
self.memory_label = QLabel('内存: 0 MB')
self.thread_label = QLabel('线程: 0')
monitor_layout.addWidget(self.cpu_label)
monitor_layout.addWidget(self.memory_label)
monitor_layout.addWidget(self.thread_label)
monitor_group.setLayout(monitor_layout)
layout.addWidget(monitor_group)
# 优化测试组
test_group = QGroupBox('性能测试')
test_layout = QVBoxLayout()
# 按钮行1
button_layout1 = QHBoxLayout()
create_table_btn = QPushButton('创建大表格(未优化)')
create_table_btn.clicked.connect(self.create_large_table)
button_layout1.addWidget(create_table_btn)
create_optimized_table_btn = QPushButton('创建大表格(优化)')
create_optimized_table_btn.clicked.connect(self.create_optimized_table)
button_layout1.addWidget(create_optimized_table_btn)
button_layout1.addStretch()
test_layout.addLayout(button_layout1)
# 按钮行2
button_layout2 = QHBoxLayout()
heavy_calc_btn = QPushButton('重计算任务')
heavy_calc_btn.clicked.connect(self.heavy_calculation)
button_layout2.addWidget(heavy_calc_btn)
optimized_calc_btn = QPushButton('优化计算任务')
optimized_calc_btn.clicked.connect(self.optimized_calculation)
button_layout2.addWidget(optimized_calc_btn)
button_layout2.addStretch()
test_layout.addLayout(button_layout2)
# 进度条
self.progress_bar = QProgressBar()
test_layout.addWidget(self.progress_bar)
# 结果显示
self.result_text = QTextEdit()
self.result_text.setMaximumHeight(150)
test_layout.addWidget(self.result_text)
test_group.setLayout(test_layout)
layout.addWidget(test_group)
# 内存泄漏演示
leak_demo = MemoryLeakDemo()
layout.addWidget(leak_demo)
self.setLayout(layout)
def start_monitoring(self):
"""开始性能监控"""
self.monitor.stats_updated.connect(self.update_stats)
self.monitor.start()
def update_stats(self, stats):
"""更新性能统计"""
self.cpu_label.setText(f"CPU: {stats['cpu_percent']:.1f}%")
self.memory_label.setText(f"内存: {stats['memory_mb']:.1f} MB "
f"({stats['memory_percent']:.1f}%)")
self.thread_label.setText(f"线程: {stats['thread_count']}")
def create_large_table(self):
"""创建大表格(未优化)"""
start_time = time.time()
if self.large_table:
self.large_table.close()
self.large_table = QTableWidget(1000, 10)
self.large_table.setWindowTitle('大表格(未优化)')
self.large_table.resize(800, 600)
# 设置表头
headers = [f'列{i+1}' for i in range(10)]
self.large_table.setHorizontalHeaderLabels(headers)
# 填充数据(未优化方式)
for row in range(1000):
for col in range(10):
item = QTableWidgetItem(f'数据_{row}_{col}')
self.large_table.setItem(row, col, item)
if row % 100 == 0:
self.progress_bar.setValue(int(row / 1000 * 100))
QApplication.processEvents() # 处理UI事件
self.large_table.show()
end_time = time.time()
self.result_text.append(f'未优化表格创建耗时: {end_time - start_time:.2f}秒')
self.progress_bar.setValue(0)
def create_optimized_table(self):
"""创建大表格(优化版)"""
start_time = time.time()
if self.large_table:
self.large_table.close()
self.large_table = OptimizedTableWidget(1000, 10)
self.large_table.setWindowTitle('大表格(优化版)')
self.large_table.resize(800, 600)
# 设置表头
headers = [f'列{i+1}' for i in range(10)]
self.large_table.setHorizontalHeaderLabels(headers)
# 批量创建项目
items_to_create = []
for row in range(1000):
for col in range(10):
items_to_create.append((row, col, f'数据_{row}_{col}'))
# 分批处理,避免UI阻塞
batch_size = 100
for i in range(0, len(items_to_create), batch_size):
batch = items_to_create[i:i + batch_size]
for row, col, text in batch:
item = QTableWidgetItem(text)
self.large_table.setItem(row, col, item)
progress = int((i + batch_size) / len(items_to_create) * 100)
self.progress_bar.setValue(min(progress, 100))
QApplication.processEvents()
self.large_table.show()
end_time = time.time()
self.result_text.append(f'优化表格创建耗时: {end_time - start_time:.2f}秒')
self.progress_bar.setValue(0)
def heavy_calculation(self):
"""重计算任务(阻塞UI)"""
start_time = time.time()
result = 0
for i in range(1000000):
result += i * i
if i % 50000 == 0:
progress = int(i / 1000000 * 100)
self.progress_bar.setValue(progress)
# 注意:这里没有processEvents,会阻塞UI
end_time = time.time()
self.result_text.append(f'重计算完成,耗时: {end_time - start_time:.2f}秒,结果: {result}')
self.progress_bar.setValue(0)
def optimized_calculation(self):
"""优化的计算任务(不阻塞UI)"""
start_time = time.time()
result = 0
for i in range(1000000):
result += i * i
if i % 10000 == 0: # 更频繁的更新
progress = int(i / 1000000 * 100)
self.progress_bar.setValue(progress)
QApplication.processEvents() # 处理UI事件,保持响应
end_time = time.time()
self.result_text.append(f'优化计算完成,耗时: {end_time - start_time:.2f}秒,结果: {result}')
self.progress_bar.setValue(0)
def closeEvent(self, event):
"""窗口关闭事件"""
if self.monitor.isRunning():
self.monitor.stop()
self.monitor.wait()
if self.large_table:
self.large_table.close()
event.accept()
八、打包发布与部署
8.1 应用程序打包
# setup.py - PyInstaller配置文件
import sys
import os
from PyQt5.QtWidgets import QApplication
from PyInstaller.__main__ import run
class PackagingDemo:
"""打包演示类"""
def __init__(self):
self.app_name = "PyQt5Demo"
self.main_script = "main.py"
self.icon_file = "icon.ico"
self.version = "1.0.0"
def create_spec_file(self):
"""创建.spec文件"""
spec_content = f'''
# -*- mode: python ; coding: utf-8 -*-
block_cipher = None
a = Analysis(
['{self.main_script}'],
pathex=[],
binaries=[],
datas=[
('resources/', 'resources/'),
('config/', 'config/'),
('README.md', '.'),
],
hiddenimports=[
'PyQt5.QtCore',
'PyQt5.QtGui',
'PyQt5.QtWidgets',
'PyQt5.QtNetwork',
'requests',
'json',
],
hookspath=[],
hooksconfig={{}},
runtime_hooks=[],
excludes=[
'tkinter',
'matplotlib',
'numpy',
'pandas',
],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False,
)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.zipfiles,
a.datas,
[],
name='{self.app_name}',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon='{self.icon_file}',
version='version_info.txt'
)
'''
with open(f'{self.app_name}.spec', 'w', encoding='utf-8') as f:
f.write(spec_content)
print(f"已创建 {self.app_name}.spec 文件")
def create_version_info(self):
"""创建版本信息文件"""
version_info = f'''
# UTF-8
VSVersionInfo(
ffi=FixedFileInfo(
filevers=(1, 0, 0, 0),
prodvers=(1, 0, 0, 0),
mask=0x3f,
flags=0x0,
OS=0x40004,
fileType=0x1,
subtype=0x0,
date=(0, 0)
),
kids=[
StringFileInfo(
[
StringTable(
u'080404B0',
[StringStruct(u'CompanyName', u'Your Company'),
StringStruct(u'FileDescription', u'PyQt5 Demo Application'),
StringStruct(u'FileVersion', u'{self.version}'),
StringStruct(u'InternalName', u'{self.app_name}'),
StringStruct(u'LegalCopyright', u'Copyright © 2024 Your Company'),
StringStruct(u'OriginalFilename', u'{self.app_name}.exe'),
StringStruct(u'ProductName', u'PyQt5 Demo'),
StringStruct(u'ProductVersion', u'{self.version}')])
]),
VarFileInfo([VarStruct(u'Translation', [2052, 1200])])
]
)
'''
with open('version_info.txt', 'w', encoding='utf-8') as f:
f.write(version_info)
print("已创建 version_info.txt 文件")
def build_onedir(self):
"""构建目录版本"""
cmd = [
'--name=' + self.app_name,
'--windowed', # 无控制台窗口
'--onedir', # 打包成目录
'--clean', # 清理临时文件
'--noconfirm', # 不确认覆盖
f'--icon={self.icon_file}',
'--add-data=resources;resources',
'--add-data=config;config',
'--hidden-import=PyQt5.QtCore',
'--hidden-import=PyQt5.QtGui',
'--hidden-import=PyQt5.QtWidgets',
self.main_script
]
print("开始构建目录版本...")
run(cmd)
print("目录版本构建完成!")
def build_onefile(self):
"""构建单文件版本"""
cmd = [
'--name=' + self.app_name,
'--windowed',
'--onefile', # 打包成单文件
'--clean',
'--noconfirm',
f'--icon={self.icon_file}',
'--add-data=resources;resources',
'--add-data=config;config',
'--hidden-import=PyQt5.QtCore',
'--hidden-import=PyQt5.QtGui',
'--hidden-import=PyQt5.QtWidgets',
self.main_script
]
print("开始构建单文件版本...")
run(cmd)
print("单文件版本构建完成!")
def optimize_build(self):
"""优化构建"""
cmd = [
'--name=' + self.app_name,
'--windowed',
'--onefile',
'--clean',
'--noconfirm',
f'--icon={self.icon_file}',
'--upx-dir=upx', # UPX压缩目录
'--exclude-module=tkinter',
'--exclude-module=matplotlib',
'--exclude-module=numpy',
'--exclude-module=pandas',
'--strip', # 去除符号信息
'--optimize=2', # Python优化级别
self.main_script
]
print("开始优化构建...")
run(cmd)
print("优化构建完成!")
# build_script.py - 自动构建脚本
import os
import shutil
import subprocess
import zipfile
from datetime import datetime
class BuildManager:
"""构建管理器"""
def __init__(self):
self.project_name = "PyQt5Demo"
self.version = "1.0.0"
self.build_dir = "build"
self.dist_dir = "dist"
self.output_dir = "release"
def clean_build(self):
"""清理构建目录"""
dirs_to_clean = [self.build_dir, self.dist_dir, '__pycache__']
for dir_name in dirs_to_clean:
if os.path.exists(dir_name):
shutil.rmtree(dir_name)
print(f"已清理 {dir_name}")
def create_directories(self):
"""创建必要目录"""
os.makedirs(self.output_dir, exist_ok=True)
os.makedirs('resources', exist_ok=True)
os.makedirs('config', exist_ok=True)
def copy_resources(self):
"""复制资源文件"""
resource_files = [
('README.md', 'README.md'),
('LICENSE', 'LICENSE'),
('requirements.txt', 'requirements.txt'),
]
for src, dst in resource_files:
if os.path.exists(src):
dst_path = os.path.join(self.output_dir, dst)
shutil.copy2(src, dst_path)
print(f"已复制 {src} -> {dst_path}")
def build_executable(self):
"""构建可执行文件"""
packager = PackagingDemo()
# 创建配置文件
packager.create_spec_file()
packager.create_version_info()
# 构建
packager.build_onefile()
# 移动到发布目录
exe_name = f"{self.project_name}.exe"
src_path = os.path.join(self.dist_dir, exe_name)
dst_path = os.path.join(self.output_dir, exe_name)
if os.path.exists(src_path):
shutil.move(src_path, dst_path)
print(f"可执行文件已移动到 {dst_path}")
def create_installer(self):
"""创建安装程序 (需要NSIS)"""
nsis_script = f'''
; {self.project_name} 安装脚本
!define APP_NAME "{self.project_name}"
!define APP_VERSION "{self.version}"
!define APP_PUBLISHER "Your Company"
!define APP_URL "https://your-website.com"
!define APP_EXE "{self.project_name}.exe"
; 包含现代UI
!include "MUI2.nsh"
; 常规设置
Name "${{APP_NAME}}"
OutFile "{self.project_name}_Setup_${{APP_VERSION}}.exe"
InstallDir "$PROGRAMFILES\\${{APP_NAME}}"
InstallDirRegKey HKCU "Software\\${{APP_NAME}}" ""
RequestExecutionLevel admin
; 界面设置
!define MUI_ABORTWARNING
!define MUI_ICON "icon.ico"
!define MUI_UNICON "icon.ico"
; 页面
!insertmacro MUI_PAGE_WELCOME
!insertmacro MUI_PAGE_LICENSE "LICENSE"
!insertmacro MUI_PAGE_DIRECTORY
!insertmacro MUI_PAGE_INSTFILES
!insertmacro MUI_PAGE_FINISH
!insertmacro MUI_UNPAGE_WELCOME
!insertmacro MUI_UNPAGE_CONFIRM
!insertmacro MUI_UNPAGE_INSTFILES
!insertmacro MUI_UNPAGE_FINISH
; 语言
!insertmacro MUI_LANGUAGE "SimpChinese"
; 安装部分
Section "主程序" SecMain
SetOutPath "$INSTDIR"
File "{self.output_dir}\\{self.project_name}.exe"
File "{self.output_dir}\\README.md"
File "{self.output_dir}\\LICENSE"
; 创建快捷方式
CreateDirectory "$SMPROGRAMS\\${{APP_NAME}}"
CreateShortCut "$SMPROGRAMS\\${{APP_NAME}}\\${{APP_NAME}}.lnk" "$INSTDIR\\${{APP_EXE}}"
CreateShortCut "$DESKTOP\\${{APP_NAME}}.lnk" "$INSTDIR\\${{APP_EXE}}"
; 写入注册表
WriteRegStr HKCU "Software\\${{APP_NAME}}" "" $INSTDIR
WriteUninstaller "$INSTDIR\\Uninstall.exe"
SectionEnd
; 卸载部分
Section "Uninstall"
Delete "$INSTDIR\\{self.project_name}.exe"
Delete "$INSTDIR\\README.md"
Delete "$INSTDIR\\LICENSE"
Delete "$INSTDIR\\Uninstall.exe"
Delete "$SMPROGRAMS\\${{APP_NAME}}\\${{APP_NAME}}.lnk"
Delete "$DESKTOP\\${{APP_NAME}}.lnk"
RMDir "$SMPROGRAMS\\${{APP_NAME}}"
RMDir "$INSTDIR"
DeleteRegKey HKCU "Software\\${{APP_NAME}}"
SectionEnd
'''
with open(f'{self.project_name}.nsi', 'w', encoding='utf-8') as f:
f.write(nsis_script)
print(f"已创建 NSIS 安装脚本: {self.project_name}.nsi")
def create_release_package(self):
"""创建发布包"""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
zip_name = f"{self.project_name}_v{self.version}_{timestamp}.zip"
zip_path = os.path.join(self.output_dir, zip_name)
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
for root, dirs, files in os.walk(self.output_dir):
for file in files:
if file.endswith('.zip'):
continue
file_path = os.path.join(root, file)
arc_path = os.path.relpath(file_path, self.output_dir)
zipf.write(file_path, arc_path)
print(f"已创建发布包: {zip_path}")
def full_build(self):
"""完整构建流程"""
print("=== 开始完整构建流程 ===")
# 1. 清理
print("\n1. 清理构建目录...")
self.clean_build()
# 2. 创建目录
print("\n2. 创建必要目录...")
self.create_directories()
# 3. 构建可执行文件
print("\n3. 构建可执行文件...")
self.build_executable()
# 4. 复制资源
print("\n4. 复制资源文件...")
self.copy_resources()
# 5. 创建安装程序脚本
print("\n5. 创建安装程序脚本...")
self.create_installer()
# 6. 创建发布包
print("\n6. 创建发布包...")
self.create_release_package()
print("\n=== 构建完成 ===")
print(f"发布文件位于: {os.path.abspath(self.output_dir)}")
if __name__ == "__main__":
builder = BuildManager()
builder.full_build()
8.2 跨平台部署
# deploy_config.py - 跨平台部署配置
import os
import platform
import subprocess
import shutil
from pathlib import Path
class CrossPlatformDeployer:
"""跨平台部署工具"""
def __init__(self):
self.system = platform.system().lower()
self.project_name = "PyQt5Demo"
self.version = "1.0.0"
self.python_version = "3.9"
def detect_platform(self):
"""检测平台信息"""
info = {
'system': self.system,
'machine': platform.machine(),
'python_version': platform.python_version(),
'architecture': platform.architecture()[0]
}
print("平台信息:")
for key, value in info.items():
print(f" {key}: {value}")
return info
def setup_windows_env(self):
"""设置Windows环境"""
print("配置Windows部署环境...")
# 检查必要工具
tools = {
'pyinstaller': 'pip install pyinstaller',
'upx': '下载 UPX 并添加到 PATH',
'nsis': '安装 NSIS (Nullsoft Scriptable Install System)'
}
for tool, install_cmd in tools.items():
try:
subprocess.run([tool, '--version'],
capture_output=True, check=True)
print(f"✓ {tool} 已安装")
except (subprocess.CalledProcessError, FileNotFoundError):
print(f"✗ {tool} 未安装: {install_cmd}")
def setup_macos_env(self):
"""设置macOS环境"""
print("配置macOS部署环境...")
# 检查Xcode命令行工具
try:
subprocess.run(['xcode-select', '--version'],
capture_output=True, check=True)
print("✓ Xcode命令行工具已安装")
except subprocess.CalledProcessError:
print("✗ 请安装Xcode命令行工具: xcode-select --install")
# 检查必要工具
tools = {
'pyinstaller': 'pip install pyinstaller',
'create-dmg': 'brew install create-dmg'
}
for tool, install_cmd in tools.items():
try:
if tool == 'create-dmg':
subprocess.run(['which', 'create-dmg'],
capture_output=True, check=True)
else:
subprocess.run([tool, '--version'],
capture_output=True, check=True)
print(f"✓ {tool} 已安装")
except (subprocess.CalledProcessError, FileNotFoundError):
print(f"✗ {tool} 未安装: {install_cmd}")
def setup_linux_env(self):
"""设置Linux环境"""
print("配置Linux部署环境...")
# 检查包管理器
package_managers = ['apt', 'yum', 'dnf', 'pacman']
pm = None
for manager in package_managers:
try:
subprocess.run(['which', manager],
capture_output=True, check=True)
pm = manager
break
except subprocess.CalledProcessError:
continue
if pm:
print(f"✓ 包管理器: {pm}")
else:
print("✗ 未找到支持的包管理器")
# 检查必要工具
tools = {
'pyinstaller': 'pip install pyinstaller',
'fpm': 'gem install fpm (用于创建deb/rpm包)'
}
for tool, install_cmd in tools.items():
try:
subprocess.run([tool, '--version'],
capture_output=True, check=True)
print(f"✓ {tool} 已安装")
except (subprocess.CalledProcessError, FileNotFoundError):
print(f"✗ {tool} 未安装: {install_cmd}")
def build_windows(self):
"""Windows平台构建"""
print("构建Windows版本...")
spec_content = f'''
# Windows PyInstaller spec
a = Analysis(
['main.py'],
pathex=[],
binaries=[],
datas=[('resources', 'resources')],
hiddenimports=['PyQt5.QtCore', 'PyQt5.QtGui', 'PyQt5.QtWidgets'],
hookspath=[],
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=None,
noarchive=False,
)
pyz = PYZ(a.pure, a.zipped_data, cipher=None)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.zipfiles,
a.datas,
[],
name='{self.project_name}',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=False,
icon='icon.ico',
version='version_info.txt'
)
'''
with open('windows.spec', 'w') as f:
f.write(spec_content)
# 构建
subprocess.run(['pyinstaller', 'windows.spec', '--clean'])
# 创建安装程序
self.create_windows_installer()
def build_macos(self):
"""macOS平台构建"""
print("构建macOS版本...")
# PyInstaller构建
cmd = [
'pyinstaller',
'--name=' + self.project_name,
'--windowed',
'--onefile',
'--icon=icon.icns',
'--add-data=resources:resources',
'--osx-bundle-identifier=com.yourcompany.' + self.project_name.lower(),
'main.py'
]
subprocess.run(cmd)
# 创建DMG包
self.create_macos_dmg()
def build_linux(self):
"""Linux平台构建"""
print("构建Linux版本...")
# PyInstaller构建
cmd = [
'pyinstaller',
'--name=' + self.project_name,
'--windowed',
'--onefile',
'--add-data=resources:resources',
'main.py'
]
subprocess.run(cmd)
# 创建AppImage
self.create_linux_appimage()
# 创建DEB包
self.create_linux_deb()
def create_windows_installer(self):
"""创建Windows安装程序"""
print("创建Windows安装程序...")
nsis_script = f'''
!define APP_NAME "{self.project_name}"
!define APP_VERSION "{self.version}"
!define APP_PUBLISHER "Your Company"
!define APP_EXE "{self.project_name}.exe"
!include "MUI2.nsh"
Name "${{APP_NAME}} ${{APP_VERSION}}"
OutFile "{self.project_name}_Setup_${{APP_VERSION}}.exe"
InstallDir "$PROGRAMFILES64\\${{APP_NAME}}"
RequestExecutionLevel admin
!define MUI_ABORTWARNING
!define MUI_ICON "icon.ico"
!insertmacro MUI_PAGE_WELCOME
!insertmacro MUI_PAGE_LICENSE "LICENSE"
!insertmacro MUI_PAGE_DIRECTORY
!insertmacro MUI_PAGE_INSTFILES
!insertmacro MUI_PAGE_FINISH
!insertmacro MUI_UNPAGE_WELCOME
!insertmacro MUI_UNPAGE_CONFIRM
!insertmacro MUI_UNPAGE_INSTFILES
!insertmacro MUI_UNPAGE_FINISH
!insertmacro MUI_LANGUAGE "English"
Section "MainSection" SEC01
SetOutPath "$INSTDIR"
File "dist\\{self.project_name}.exe"
File "README.md"
File "LICENSE"
CreateDirectory "$SMPROGRAMS\\${{APP_NAME}}"
CreateShortCut "$SMPROGRAMS\\${{APP_NAME}}\\${{APP_NAME}}.lnk" "$INSTDIR\\${{APP_EXE}}"
CreateShortCut "$DESKTOP\\${{APP_NAME}}.lnk" "$INSTDIR\\${{APP_EXE}}"
WriteRegStr HKLM "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\${{APP_NAME}}" "DisplayName" "${{APP_NAME}}"
WriteRegStr HKLM "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\${{APP_NAME}}" "UninstallString" "$INSTDIR\\Uninstall.exe"
WriteUninstaller "$INSTDIR\\Uninstall.exe"
SectionEnd
Section "Uninstall"
Delete "$INSTDIR\\{self.project_name}.exe"
Delete "$INSTDIR\\README.md"
Delete "$INSTDIR\\LICENSE"
Delete "$INSTDIR\\Uninstall.exe"
Delete "$SMPROGRAMS\\${{APP_NAME}}\\${{APP_NAME}}.lnk"
Delete "$DESKTOP\\${{APP_NAME}}.lnk"
RMDir "$SMPROGRAMS\\${{APP_NAME}}"
RMDir "$INSTDIR"
DeleteRegKey HKLM "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\${{APP_NAME}}"
SectionEnd
'''
with open('installer.nsi', 'w') as f:
f.write(nsis_script)
try:
subprocess.run(['makensis', 'installer.nsi'], check=True)
print("✓ Windows安装程序创建成功")
except subprocess.CalledProcessError:
print("✗ 创建Windows安装程序失败")
def create_macos_dmg(self):
"""创建macOS DMG包"""
print("创建macOS DMG包...")
app_path = f"dist/{self.project_name}.app"
dmg_path = f"dist/{self.project_name}_{self.version}.dmg"
cmd = [
'create-dmg',
'--volname', f"{self.project_name} {self.version}",
'--window-pos', '200', '120',
'--window-size', '600', '300',
'--icon-size', '100',
'--icon', f"{self.project_name}.app", '175', '120',
'--hide-extension', f"{self.project_name}.app",
'--app-drop-link', '425', '120',
dmg_path,
app_path
]
try:
subprocess.run(cmd, check=True)
print("✓ macOS DMG包创建成功")
except subprocess.CalledProcessError:
print("✗ 创建macOS DMG包失败")
def create_linux_appimage(self):
"""创建Linux AppImage"""
print("创建Linux AppImage...")
# 创建AppDir结构
appdir = f"{self.project_name}.AppDir"
os.makedirs(f"{appdir}/usr/bin", exist_ok=True)
os.makedirs(f"{appdir}/usr/share/applications", exist_ok=True)
os.makedirs(f"{appdir}/usr/share/icons/hicolor/256x256/apps", exist_ok=True)
# 复制可执行文件
shutil.copy(f"dist/{self.project_name}", f"{appdir}/usr/bin/")
# 创建desktop文件
desktop_content = f'''[Desktop Entry]
Type=Application
Name={self.project_name}
Exec={self.project_name}
Icon={self.project_name}
Comment=PyQt5 Demo Application
Categories=Utility;
'''
with open(f"{appdir}/{self.project_name}.desktop", 'w') as f:
f.write(desktop_content)
shutil.copy(f"{appdir}/{self.project_name}.desktop",
f"{appdir}/usr/share/applications/")
# 复制图标
if os.path.exists("icon.png"):
shutil.copy("icon.png",
f"{appdir}/usr/share/icons/hicolor/256x256/apps/{self.project_name}.png")
# 创建AppRun脚本
apprun_content = f'''#!/bin/bash
SELF=$(readlink -f "$0")
HERE=${{SELF%/*}}
export PATH="${{HERE}}/usr/bin:${{PATH}}"
export LD_LIBRARY_PATH="${{HERE}}/usr/lib:${{LD_LIBRARY_PATH}}"
exec "${{HERE}}/usr/bin/{self.project_name}" "$@"
'''
with open(f"{appdir}/AppRun", 'w') as f:
f.write(apprun_content)
os.chmod(f"{appdir}/AppRun", 0o755)
print("✓ AppImage目录结构创建完成")
def create_linux_deb(self):
"""创建Linux DEB包"""
print("创建Linux DEB包...")
if not shutil.which('fpm'):
print("✗ 需要安装fpm: gem install fpm")
return
cmd = [
'fpm',
'-s', 'dir',
'-t', 'deb',
'-n', self.project_name.lower(),
'-v', self.version,
'--description', 'PyQt5 Demo Application',
'--maintainer', 'your-email@example.com',
'--license', 'MIT',
'--category', 'utils',
f'dist/{self.project_name}=/usr/bin/{self.project_name}'
]
try:
subprocess.run(cmd, check=True)
print("✓ DEB包创建成功")
except subprocess.CalledProcessError:
print("✗ 创建DEB包失败")
def deploy(self):
"""执行部署"""
print(f"开始{self.system}平台部署...")
# 检测平台
self.detect_platform()
# 设置环境
if self.system == 'windows':
self.setup_windows_env()
self.build_windows()
elif self.system == 'darwin':
self.setup_macos_env()
self.build_macos()
elif self.system == 'linux':
self.setup_linux_env()
self.build_linux()
else:
print(f"不支持的平台: {self.system}")
return
print("部署完成!")
if __name__ == "__main__":
deployer = CrossPlatformDeployer()
deployer.deploy()
九、实战项目案例
9.1 文本编辑器项目
# text_editor.py - 完整的文本编辑器项目
import sys
import os
from PyQt5.QtWidgets import *
from PyQt5.QtCore import *
from PyQt5.QtGui import *
import json
import re
class FindReplaceDialog(QDialog):
"""查找替换对话框"""
def __init__(self, parent=None):
super().__init__(parent)
self.parent = parent
self.initUI()
def initUI(self):
self.setWindowTitle('查找和替换')
self.setModal(True)
self.resize(400, 200)
layout = QVBoxLayout()
# 查找部分
find_layout = QHBoxLayout()
find_layout.addWidget(QLabel('查找:'))
self.find_edit = QLineEdit()
find_layout.addWidget(self.find_edit)
layout.addLayout(find_layout)
# 替换部分
replace_layout = QHBoxLayout()
replace_layout.addWidget(QLabel('替换:'))
self.replace_edit = QLineEdit()
replace_layout.addWidget(self.replace_edit)
layout.addLayout(replace_layout)
# 选项
options_layout = QHBoxLayout()
self.case_sensitive = QCheckBox('区分大小写')
self.whole_word = QCheckBox('全词匹配')
self.regex = QCheckBox('正则表达式')
options_layout.addWidget(self.case_sensitive)
options_layout.addWidget(self.whole_word)
options_layout.addWidget(self.regex)
layout.addLayout(options_layout)
# 按钮
button_layout = QHBoxLayout()
self.find_next_btn = QPushButton('查找下一个')
self.find_next_btn.clicked.connect(self.find_next)
button_layout.addWidget(self.find_next_btn)
self.replace_btn = QPushButton('替换')
self.replace_btn.clicked.connect(self.replace)
button_layout.addWidget(self.replace_btn)
self.replace_all_btn = QPushButton('全部替换')
self.replace_all_btn.clicked.connect(self.replace_all)
button_layout.addWidget(self.replace_all_btn)
close_btn = QPushButton('关闭')
close_btn.clicked.connect(self.close)
button_layout.addWidget(close_btn)
layout.addLayout(button_layout)
self.setLayout(layout)
def find_next(self):
"""查找下一个"""
if self.parent:
self.parent.find_text(
self.find_edit.text(),
self.case_sensitive.isChecked(),
self.whole_word.isChecked(),
self.regex.isChecked()
)
def replace(self):
"""替换当前"""
if self.parent:
self.parent.replace_text(
self.find_edit.text(),
self.replace_edit.text(),
self.case_sensitive.isChecked(),
self.whole_word.isChecked(),
self.regex.isChecked()
)
def replace_all(self):
"""全部替换"""
if self.parent:
count = self.parent.replace_all_text(
self.find_edit.text(),
self.replace_edit.text(),
self.case_sensitive.isChecked(),
self.whole_word.isChecked(),
self.regex.isChecked()
)
QMessageBox.information(self, '替换完成', f'共替换了 {count} 处')
class HighlightingTextEdit(QTextEdit):
"""支持语法高亮的文本编辑器"""
def __init__(self, parent=None):
super().__init__(parent)
self.highlighter = None
self.setup_editor()
def setup_editor(self):
"""设置编辑器"""
# 设置字体
font = QFont("Consolas", 12)
font.setFixedPitch(True)
self.setFont(font)
# 设置制表符宽度
metrics = QFontMetrics(font)
self.setTabStopWidth(4 * metrics.width(' '))
# 行号和当前行高亮将在主窗口中实现
def set_language(self, language):
"""设置语言高亮"""
if language.lower() == 'python':
self.highlighter = PythonHighlighter(self.document())
else:
self.highlighter = None
class PythonHighlighter(QSyntaxHighlighter):
"""Python语法高亮器"""
def __init__(self, parent=None):
super().__init__(parent)
self.highlighting_rules = []
# 关键字格式
keyword_format = QTextCharFormat()
keyword_format.setColor(QColor(85, 85, 255))
keyword_format.setFontWeight(QFont.Bold)
keywords = [
'and', 'as', 'assert', 'break', 'class', 'continue', 'def',
'del', 'elif', 'else', 'except', 'exec', 'finally', 'for',
'from', 'global', 'if', 'import', 'in', 'is', 'lambda',
'not', 'or', 'pass', 'print', 'raise', 'return', 'try',
'while', 'with', 'yield'
]
for word in keywords:
pattern = QRegExp(f'\\b{word}\\b')
rule = (pattern, keyword_format)
self.highlighting_rules.append(rule)
# 字符串格式
string_format = QTextCharFormat()
string_format.setColor(QColor(255, 85, 85))
self.highlighting_rules.append((QRegExp('".*"'), string_format))
self.highlighting_rules.append((QRegExp("'.*'"), string_format))
# 注释格式
comment_format = QTextCharFormat()
comment_format.setColor(QColor(85, 170, 85))
comment_format.setFontItalic(True)
self.highlighting_rules.append((QRegExp('#.*'), comment_format))
# 函数格式
function_format = QTextCharFormat()
function_format.setColor(QColor(255, 170, 0))
function_format.setFontWeight(QFont.Bold)
self.highlighting_rules.append((
QRegExp('\\bdef\\s+([A-Za-z_][A-Za-z0-9_]*)'),
function_format
))
def highlightBlock(self, text):
"""高亮文本块"""
for pattern, format in self.highlighting_rules:
expression = QRegExp(pattern)
index = expression.indexIn(text)
while index >= 0:
length = expression.matchedLength()
self.setFormat(index, length, format)
index = expression.indexIn(text, index + length)
class TextEditor(QMainWindow):
"""文本编辑器主窗口"""
def __init__(self):
super().__init__()
self.current_file = None
self.is_modified = False
self.settings = QSettings('TextEditor', 'PyQt5Demo')
self.recent_files = []
self.initUI()
self.load_settings()
self.new_file()
def initUI(self):
"""初始化界面"""
self.setWindowTitle('PyQt5 文本编辑器')
self.setGeometry(100, 100, 1000, 700)
# 创建中央控件
self.text_edit = HighlightingTextEdit()
self.text_edit.textChanged.connect(self.text_changed)
self.text_edit.cursorPositionChanged.connect(self.cursor_position_changed)
self.setCentralWidget(self.text_edit)
# 创建菜单栏
self.create_menus()
# 创建工具栏
self.create_toolbars()
# 创建状态栏
self.create_statusbar()
# 设置样式
self.set_style()
def create_menus(self):
"""创建菜单栏"""
menubar = self.menuBar()
# 文件菜单
file_menu = menubar.addMenu('文件(&F)')
new_action = QAction('新建(&N)', self)
new_action.setShortcut('Ctrl+N')
new_action.triggered.connect(self.new_file)
file_menu.addAction(new_action)
open_action = QAction('打开(&O)', self)
open_action.setShortcut('Ctrl+O')
open_action.triggered.connect(self.open_file)
file_menu.addAction(open_action)
# 最近文件子菜单
self.recent_menu = file_menu.addMenu('最近文件(&R)')
self.update_recent_menu()
file_menu.addSeparator()
save_action = QAction('保存(&S)', self)
save_action.setShortcut('Ctrl+S')
save_action.triggered.connect(self.save_file)
file_menu.addAction(save_action)
save_as_action = QAction('另存为(&A)', self)
save_as_action.setShortcut('Ctrl+Shift+S')
save_as_action.triggered.connect(self.save_as_file)
file_menu.addAction(save_as_action)
file_menu.addSeparator()
exit_action = QAction('退出(&X)', self)
exit_action.setShortcut('Alt+F4')
exit_action.triggered.connect(self.close)
file_menu.addAction(exit_action)
# 编辑菜单
edit_menu = menubar.addMenu('编辑(&E)')
undo_action = QAction('撤销(&U)', self)
undo_action.setShortcut('Ctrl+Z')
undo_action.triggered.connect(self.text_edit.undo)
edit_menu.addAction(undo_action)
redo_action = QAction('重做(&R)', self)
redo_action.setShortcut('Ctrl+Y')
redo_action.triggered.connect(self.text_edit.redo)
edit_menu.addAction(redo_action)
edit_menu.addSeparator()
cut_action = QAction('剪切(&T)', self)
cut_action.setShortcut('Ctrl+X')
cut_action.triggered.connect(self.text_edit.cut)
edit_menu.addAction(cut_action)
copy_action = QAction('复制(&C)', self)
copy_action.setShortcut('Ctrl+C')
copy_action.triggered.connect(self.text_edit.copy)
edit_menu.addAction(copy_action)
paste_action = QAction('粘贴(&P)', self)
paste_action.setShortcut('Ctrl+V')
paste_action.triggered.connect(self.text_edit.paste)
edit_menu.addAction(paste_action)
edit_menu.addSeparator()
select_all_action = QAction('全选(&A)', self)
select_all_action.setShortcut('Ctrl+A')
select_all_action.triggered.connect(self.text_edit.selectAll)
edit_menu.addAction(select_all_action)
edit_menu.addSeparator()
find_action = QAction('查找替换(&F)', self)
find_action.setShortcut('Ctrl+F')
find_action.triggered.connect(self.show_find_dialog)
edit_menu.addAction(find_action)
# 格式菜单
format_menu = menubar.addMenu('格式(&O)')
font_action = QAction('字体(&F)', self)
font_action.triggered.connect(self.choose_font)
format_menu.addAction(font_action)
# 语言子菜单
language_menu = format_menu.addMenu('语法高亮(&S)')
plain_action = QAction('纯文本', self)
plain_action.triggered.connect(lambda: self.set_language('plain'))
language_menu.addAction(plain_action)
python_action = QAction('Python', self)
python_action.triggered.connect(lambda: self.set_language('python'))
language_menu.addAction(python_action)
# 视图菜单
view_menu = menubar.addMenu('视图(&V)')
self.word_wrap_action = QAction('自动换行(&W)', self)
self.word_wrap_action.setCheckable(True)
self.word_wrap_action.setChecked(True)
self.word_wrap_action.triggered.connect(self.toggle_word_wrap)
view_menu.addAction(self.word_wrap_action)
zoom_in_action = QAction('放大(&I)', self)
zoom_in_action.setShortcut('Ctrl++')
zoom_in_action.triggered.connect(self.zoom_in)
view_menu.addAction(zoom_in_action)
zoom_out_action = QAction('缩小(&O)', self)
zoom_out_action.setShortcut('Ctrl+-')
zoom_out_action.triggered.connect(self.zoom_out)
view_menu.addAction(zoom_out_action)
reset_zoom_action = QAction('重置缩放(&R)', self)
reset_zoom_action.setShortcut('Ctrl+0')
reset_zoom_action.triggered.connect(self.reset_zoom)
view_menu.addAction(reset_zoom_action)
# 帮助菜单
help_menu = menubar.addMenu('帮助(&H)')
about_action = QAction('关于(&A)', self)
about_action.triggered.connect(self.about)
help_menu.addAction(about_action)
def create_toolbars(self):
"""创建工具栏"""
toolbar = self.addToolBar('主工具栏')
# 文件操作
toolbar.addAction('新建', self.new_file)
toolbar.addAction('打开', self.open_file)
toolbar.addAction('保存', self.save_file)
toolbar.addSeparator()
# 编辑操作
toolbar.addAction('撤销', self.text_edit.undo)
toolbar.addAction('重做', self.text_edit.redo)
toolbar.addSeparator()
toolbar.addAction('剪切', self.text_edit.cut)
toolbar.addAction('复制', self.text_edit.copy)
toolbar.addAction('粘贴', self.text_edit.paste)
toolbar.addSeparator()
# 查找
toolbar.addAction('查找', self.show_find_dialog)
def create_statusbar(self):
"""创建状态栏"""
self.status_bar = self.statusBar()
# 文件状态
self.file_label = QLabel('新文档')
self.status_bar.addWidget(self.file_label)
# 编码
self.encoding_label = QLabel('UTF-8')
self.status_bar.addPermanentWidget(self.encoding_label)
# 行列信息
self.cursor_label = QLabel('第 1 行,第 1 列')
self.status_bar.addPermanentWidget(self.cursor_label)
# 字符统计
self.char_count_label = QLabel('字符数: 0')
self.status_bar.addPermanentWidget(self.char_count_label)
def set_style(self):
"""设置样式"""
self.setStyleSheet("""
QMainWindow {
background-color: #f0f0f0;
}
QTextEdit {
background-color: white;
border: 1px solid #ccc;
selection-background-color: #3399ff;
selection-color: white;
}
QMenuBar {
background-color: #f0f0f0;
border-bottom: 1px solid #ccc;
}
QMenuBar::item:selected {
background-color: #e0e0e0;
}
QToolBar {
background-color: #f8f8f8;
border: 1px solid #ccc;
spacing: 3px;
}
QStatusBar {
background-color: #f0f0f0;
border-top: 1px solid #ccc;
}
""")
def new_file(self):
"""新建文件"""
if self.check_save():
self.text_edit.clear()
self.current_file = None
self.is_modified = False
self.update_title()
self.update_status()
def open_file(self):
"""打开文件"""
if self.check_save():
file_path, _ = QFileDialog.getOpenFileName(
self, '打开文件', '',
'Text Files (*.txt);;Python Files (*.py);;All Files (*)')
if file_path:
self.load_file(file_path)
def load_file(self, file_path):
"""加载文件"""
try:
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
self.text_edit.setPlainText(content)
self.current_file = file_path
self.is_modified = False
self.add_recent_file(file_path)
self.update_title()
self.update_status()
# 根据文件扩展名设置语法高亮
if file_path.endswith('.py'):
self.set_language('python')
except Exception as e:
QMessageBox.warning(self, '错误', f'无法打开文件:\n{str(e)}')
def save_file(self):
"""保存文件"""
if self.current_file:
self.save_to_file(self.current_file)
else:
self.save_as_file()
def save_as_file(self):
"""另存为文件"""
file_path, _ = QFileDialog.getSaveFileName(
self, '保存文件', '',
'Text Files (*.txt);;Python Files (*.py);;All Files (*)')
if file_path:
self.save_to_file(file_path)
def save_to_file(self, file_path):
"""保存到指定文件"""
try:
with open(file_path, 'w', encoding='utf-8') as f:
f.write(self.text_edit.toPlainText())
self.current_file = file_path
self.is_modified = False
self.add_recent_file(file_path)
self.update_title()
self.update_status()
self.status_bar.showMessage('文件已保存', 2000)
except Exception as e:
QMessageBox.warning(self, '错误', f'无法保存文件:\n{str(e)}')
def add_recent_file(self, file_path):
"""添加到最近文件"""
if file_path in self.recent_files:
self.recent_files.remove(file_path)
self.recent_files.insert(0, file_path)
# 最多保存10个最近文件
if len(self.recent_files) > 10:
self.recent_files = self.recent_files[:10]
self.update_recent_menu()
self.save_settings()
def update_recent_menu(self):
"""更新最近文件菜单"""
self.recent_menu.clear()
for i, file_path in enumerate(self.recent_files):
if os.path.exists(file_path):
action = QAction(f"&{i+1} {os.path.basename(file_path)}", self)
action.setData(file_path)
action.triggered.connect(self.open_recent_file)
self.recent_menu.addAction(action)
if self.recent_files:
self.recent_menu.addSeparator()
clear_action = QAction('清空最近文件', self)
clear_action.triggered.connect(self.clear_recent_files)
self.recent_menu.addAction(clear_action)
def open_recent_file(self):
"""打开最近文件"""
action = self.sender()
if action:
file_path = action.data()
if os.path.exists(file_path):
if self.check_save():
self.load_file(file_path)
else:
QMessageBox.warning(self, '错误', '文件不存在')
self.recent_files.remove(file_path)
self.update_recent_menu()
def clear_recent_files(self):
"""清空最近文件"""
self.recent_files.clear()
self.update_recent_menu()
self.save_settings()
def show_find_dialog(self):
"""显示查找替换对话框"""
dialog = FindReplaceDialog(self)
dialog.show()
def find_text(self, text, case_sensitive, whole_word, regex):
"""查找文本"""
if not text:
return
flags = QTextDocument.FindFlag(0)
if case_sensitive:
flags |= QTextDocument.FindCaseSensitively
if whole_word:
flags |= QTextDocument.FindWholeWords
cursor = self.text_edit.textCursor()
if regex:
# 正则表达式查找
pattern = QRegExp(text)
if not case_sensitive:
pattern.setCaseSensitivity(Qt.CaseInsensitive)
found_cursor = self.text_edit.document().find(pattern, cursor)
else:
found_cursor = self.text_edit.document().find(text, cursor, flags)
if not found_cursor.isNull():
self.text_edit.setTextCursor(found_cursor)
else:
QMessageBox.information(self, '查找', '未找到指定文本')
def replace_text(self, find_text, replace_text, case_sensitive, whole_word, regex):
"""替换文本"""
cursor = self.text_edit.textCursor()
if cursor.hasSelection():
cursor.insertText(replace_text)
# 查找下一个
self.find_text(find_text, case_sensitive, whole_word, regex)
def replace_all_text(self, find_text, replace_text, case_sensitive, whole_word, regex):
"""全部替换"""
if not find_text:
return 0
count = 0
content = self.text_edit.toPlainText()
if regex:
# 正则表达式替换
flags = 0 if case_sensitive else re.IGNORECASE
pattern = re.compile(find_text, flags)
new_content, count = pattern.subn(replace_text, content)
else:
# 普通文本替换
if not case_sensitive:
# 不区分大小写的替换比较复杂,这里简化处理
find_text = find_text.lower()
temp_content = content.lower()
parts = temp_content.split(find_text)
if len(parts) > 1:
count = len(parts) - 1
# 这里需要更复杂的逻辑来保持原始大小写
new_content = content.replace(find_text, replace_text)
else:
new_content = content
else:
parts = content.split(find_text)
count = len(parts) - 1
new_content = replace_text.join(parts)
if count > 0:
self.text_edit.setPlainText(new_content)
return count
def choose_font(self):
"""选择字体"""
current_font = self.text_edit.font()
font, ok = QFontDialog.getFont(current_font, self)
if ok:
self.text_edit.setFont(font)
def set_language(self, language):
"""设置语言"""
self.text_edit.set_language(language)
def toggle_word_wrap(self):
"""切换自动换行"""
if self.word_wrap_action.isChecked():
self.text_edit.setLineWrapMode(QTextEdit.WidgetWidth)
else:
self.text_edit.setLineWrapMode(QTextEdit.NoWrap)
def zoom_in(self):
"""放大"""
self.text_edit.zoomIn()
def zoom_out(self):
"""缩小"""
self.text_edit.zoomOut()
def reset_zoom(self):
"""重置缩放"""
font = self.text_edit.font()
font.setPointSize(12) # 默认字体大小
self.text_edit.setFont(font)
def text_changed(self):
"""文本变化事件"""
self.is_modified = True
self.update_title()
self.update_char_count()
def cursor_position_changed(self):
"""光标位置变化"""
cursor = self.text_edit.textCursor()
line = cursor.blockNumber() + 1
column = cursor.columnNumber() + 1
self.cursor_label.setText(f'第 {line} 行,第 {column} 列')
def update_title(self):
"""更新窗口标题"""
title = 'PyQt5 文本编辑器'
if self.current_file:
filename = os.path.basename(self.current_file)
title += f' - {filename}'
else:
title += ' - 新文档'
if self.is_modified:
title += ' *'
self.setWindowTitle(title)
def update_status(self):
"""更新状态栏"""
if self.current_file:
self.file_label.setText(os.path.basename(self.current_file))
else:
self.file_label.setText('新文档')
def update_char_count(self):
"""更新字符计数"""
text = self.text_edit.toPlainText()
char_count = len(text)
word_count = len(text.split())
line_count = text.count('\n') + 1
self.char_count_label.setText(
f'字符: {char_count}, 单词: {word_count}, 行: {line_count}')
def check_save(self):
"""检查是否需要保存"""
if self.is_modified:
reply = QMessageBox.question(
self, '保存确认',
'文档已修改,是否保存?',
QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel)
if reply == QMessageBox.Yes:
self.save_file()
return not self.is_modified
elif reply == QMessageBox.Cancel:
return False
return True
def load_settings(self):
"""加载设置"""
# 窗口几何
geometry = self.settings.value('geometry')
if geometry:
self.restoreGeometry(geometry)
# 最近文件
self.recent_files = self.settings.value('recent_files', [])
if not isinstance(self.recent_files, list):
self.recent_files = []
self.update_recent_menu()
# 字体设置
font_family = self.settings.value('font_family', 'Consolas')
font_size = int(self.settings.value('font_size', 12))
font = QFont(font_family, font_size)
self.text_edit.setFont(font)
# 自动换行
word_wrap = self.settings.value('word_wrap', True, type=bool)
self.word_wrap_action.setChecked(word_wrap)
self.toggle_word_wrap()
def save_settings(self):
"""保存设置"""
self.settings.setValue('geometry', self.saveGeometry())
self.settings.setValue('recent_files', self.recent_files)
font = self.text_edit.font()
self.settings.setValue('font_family', font.family())
self.settings.setValue('font_size', font.pointSize())
self.settings.setValue('word_wrap', self.word_wrap_action.isChecked())
def about(self):
"""关于对话框"""
QMessageBox.about(self, '关于',
'这是一个使用PyQt5开发的文本编辑器\n\n'
'功能特性:\n'
'• 语法高亮\n'
'• 查找替换\n'
'• 最近文件\n'
'• 自定义字体\n'
'• 自动换行\n'
'• 缩放功能\n\n'
'版本: 1.0.0')
def closeEvent(self, event):
"""窗口关闭事件"""
if self.check_save():
self.save_settings()
event.accept()
else:
event.ignore()
def main():
app = QApplication(sys.argv)
app.setApplicationName('PyQt5 文本编辑器')
app.setOrganizationName('PyQt5 Demo')
editor = TextEditor()
editor.show()
sys.exit(app.exec_())
if __name__ == '__main__':
main()
这个完整的文本编辑器项目展示了PyQt5的许多高级特性,包括:
- 完整的菜单系统:文件、编辑、格式、视图、帮助菜单
- 工具栏和状态栏:提供快速访问和状态信息
- 语法高亮:支持Python语法高亮
- 查找替换功能:支持正则表达式
- 最近文件管理:自动记录和管理最近打开的文件
- 设置持久化:使用QSettings保存用户偏好
- 字体和缩放:可自定义编辑器外观
- 完整的文件操作:新建、打开、保存、另存为
- 状态信息显示:光标位置、字符统计等
该项目可以作为学习PyQt5的完整示例,展示了如何构建一个功能完整的桌面应用程序。
更多推荐

所有评论(0)