第21章 PyQt GUI开发

学习目标

完成本章学习后,读者应能够:

  1. 理解Qt框架架构:掌握Qt对象模型、事件循环与父子对象树
  2. 精通信号与槽机制:理解Qt的核心通信模式,实现自定义信号与跨线程通信
  3. 掌握布局与样式:灵活运用布局管理器与QSS样式表构建专业级界面
  4. 实现MVC架构:使用Model/View架构处理数据展示
  5. 掌握多文档界面:实现MDI、SDI与停靠窗口
  6. 运用多线程:使用QThread与QThreadPool构建响应式GUI
  7. 集成Qt Designer:通过UI文件与资源系统加速开发

21.1 Qt框架基础

21.1.1 Qt对象模型

Qt框架的核心是QObject对象模型,提供对象树、信号槽和事件过滤等机制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
┌─────────────────────────────────────────┐
│ QApplication │
│ ┌─────────────────────────────────┐ │
│ │ QMainWindow │ │
│ │ ┌──────────┐ ┌──────────────┐ │ │
│ │ │ QMenuBar │ │ QToolBar │ │ │
│ │ └──────────┘ └──────────────┘ │ │
│ │ ┌────────────────────────────┐ │ │
│ │ │ QWidget (central) │ │ │
│ │ │ ┌──────┐ ┌─────────────┐ │ │ │
│ │ │ │Label │ │ LineEdit │ │ │ │
│ │ │ └──────┘ └─────────────┘ │ │ │
│ │ └────────────────────────────┘ │ │
│ │ ┌────────────────────────────┐ │ │
│ │ │ QStatusBar │ │ │
│ │ └────────────────────────────┘ │ │
│ └─────────────────────────────────┘ │
└─────────────────────────────────────────┘

21.1.2 应用程序架构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
import sys
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout,
QLabel, QPushButton, QStatusBar, QMenuBar,
)
from PyQt6.QtCore import Qt, QSize
from PyQt6.QtGui import QAction, QIcon


class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("PyQt6 应用程序")
self.setMinimumSize(QSize(800, 600))

self._init_menubar()
self._init_toolbar()
self._init_central_widget()
self._init_statusbar()

def _init_menubar(self):
file_menu = self.menuBar().addMenu("文件(&F)")

new_action = QAction("新建(&N)", self)
new_action.setShortcut("Ctrl+N")
new_action.setStatusTip("新建文件")
new_action.triggered.connect(self.on_new)
file_menu.addAction(new_action)

open_action = QAction("打开(&O)", self)
open_action.setShortcut("Ctrl+O")
open_action.triggered.connect(self.on_open)
file_menu.addAction(open_action)

file_menu.addSeparator()

exit_action = QAction("退出(&Q)", self)
exit_action.setShortcut("Ctrl+Q")
exit_action.triggered.connect(self.close)
file_menu.addAction(exit_action)

def _init_toolbar(self):
toolbar = self.addToolBar("主工具栏")
toolbar.setMovable(False)

new_btn = QAction("新建", self)
new_btn.triggered.connect(self.on_new)
toolbar.addAction(new_btn)

open_btn = QAction("打开", self)
open_btn.triggered.connect(self.on_open)
toolbar.addAction(open_btn)

def _init_central_widget(self):
central = QWidget()
self.setCentralWidget(central)

layout = QVBoxLayout(central)
layout.setContentsMargins(20, 20, 20, 20)
layout.setSpacing(15)

label = QLabel("欢迎使用 PyQt6")
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
label.setStyleSheet("font-size: 24px; font-weight: bold;")
layout.addWidget(label)

btn = QPushButton("开始使用")
btn.setFixedSize(200, 45)
btn.clicked.connect(self.on_start)
layout.addWidget(btn, alignment=Qt.AlignmentFlag.AlignCenter)

def _init_statusbar(self):
self.statusBar().showMessage("就绪")

def on_new(self):
self.statusBar().showMessage("新建文件", 3000)

def on_open(self):
self.statusBar().showMessage("打开文件", 3000)

def on_start(self):
self.statusBar().showMessage("欢迎使用!", 3000)


def main():
app = QApplication(sys.argv)
app.setStyle("Fusion")

window = MainWindow()
window.show()
sys.exit(app.exec())


if __name__ == "__main__":
main()

21.2 信号与槽机制

21.2.1 信号槽原理

信号与槽(Signals and Slots)是Qt的核心通信机制,实现了对象间的松耦合通信:

1
2
3
4
5
6
7
8
9
发送者(Sender)          接收者(Receiver)
┌──────────┐ ┌──────────┐
│ │ 信号(Signal)│ │
│ Button │───────────▶│ Handler │
│ │ │ │
└──────────┘ └──────────┘
│ ▲
│ clicked │ on_click()
└───────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
from PyQt6.QtCore import QObject, pyqtSignal, pyqtSlot
from PyQt6.QtWidgets import QApplication, QMainWindow, QWidget, QVBoxLayout, QPushButton, QLabel


class DataModel(QObject):
data_changed = pyqtSignal(str)
progress_updated = pyqtSignal(int)
error_occurred = pyqtSignal(str)

def __init__(self):
super().__init__()
self._data = ""

def update_data(self, new_data: str):
self._data = new_data
self.data_changed.emit(new_data)

def set_progress(self, value: int):
self.progress_updated.emit(value)

def report_error(self, message: str):
self.error_occurred.emit(message)


class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("信号与槽演示")
self.setGeometry(100, 100, 500, 300)

self.model = DataModel()
self.model.data_changed.connect(self.on_data_changed)
self.model.progress_updated.connect(self.on_progress)
self.model.error_occurred.connect(self.on_error)

central = QWidget()
self.setCentralWidget(central)
layout = QVBoxLayout(central)

self.info_label = QLabel("等待操作...")
layout.addWidget(self.info_label)

self.progress_label = QLabel("进度: 0%")
layout.addWidget(self.progress_label)

btn1 = QPushButton("更新数据")
btn1.clicked.connect(lambda: self.model.update_data("新数据内容"))
layout.addWidget(btn1)

btn2 = QPushButton("更新进度")
btn2.clicked.connect(lambda: self.model.set_progress(75))
layout.addWidget(btn2)

btn3 = QPushButton("触发错误")
btn3.clicked.connect(lambda: self.model.report_error("操作失败"))
layout.addWidget(btn3)

@pyqtSlot(str)
def on_data_changed(self, data: str):
self.info_label.setText(f"数据已更新: {data}")

@pyqtSlot(int)
def on_progress(self, value: int):
self.progress_label.setText(f"进度: {value}%")

@pyqtSlot(str)
def on_error(self, message: str):
self.info_label.setText(f"错误: {message}")
self.info_label.setStyleSheet("color: red;")


if __name__ == "__main__":
app = QApplication(sys.argv)
window = MainWindow()
window.show()
sys.exit(app.exec())

21.2.2 自定义信号与跨对象通信

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
from PyQt6.QtCore import QObject, pyqtSignal
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout,
QHBoxLayout, QPushButton, QLineEdit, QTextEdit, QLabel,
)


class LoginService(QObject):
login_success = pyqtSignal(str)
login_failed = pyqtSignal(str)

def login(self, username: str, password: str):
if username == "admin" and password == "123456":
self.login_success.emit(username)
else:
self.login_failed.emit("用户名或密码错误")


class LoginWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("用户登录")
self.setFixedSize(400, 250)

self.service = LoginService()
self.service.login_success.connect(self.on_login_success)
self.service.login_failed.connect(self.on_login_failed)

central = QWidget()
self.setCentralWidget(central)
layout = QVBoxLayout(central)
layout.setSpacing(15)

layout.addWidget(QLabel("用户登录系统"))

self.username_input = QLineEdit()
self.username_input.setPlaceholderText("请输入用户名")
layout.addWidget(self.username_input)

self.password_input = QLineEdit()
self.password_input.setPlaceholderText("请输入密码")
self.password_input.setEchoMode(QLineEdit.EchoMode.Password)
layout.addWidget(self.password_input)

self.message_label = QLabel("")
layout.addWidget(self.message_label)

btn_layout = QHBoxLayout()
login_btn = QPushButton("登录")
login_btn.clicked.connect(self.attempt_login)
btn_layout.addWidget(login_btn)

cancel_btn = QPushButton("取消")
cancel_btn.clicked.connect(self.close)
btn_layout.addWidget(cancel_btn)
layout.addLayout(btn_layout)

self.username_input.returnPressed.connect(self.attempt_login)
self.password_input.returnPressed.connect(self.attempt_login)

def attempt_login(self):
username = self.username_input.text().strip()
password = self.password_input.text().strip()
if not username or not password:
self.message_label.setText("请输入用户名和密码")
self.message_label.setStyleSheet("color: orange;")
return
self.service.login(username, password)

def on_login_success(self, username: str):
self.message_label.setText(f"欢迎, {username}!")
self.message_label.setStyleSheet("color: green;")

def on_login_failed(self, reason: str):
self.message_label.setText(reason)
self.message_label.setStyleSheet("color: red;")


if __name__ == "__main__":
app = QApplication(sys.argv)
window = LoginWindow()
window.show()
sys.exit(app.exec())

21.3 布局与样式

21.3.1 布局管理器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QGridLayout, QFormLayout, QGroupBox, QLabel, QLineEdit,
QPushButton, QSplitter, QTextEdit, QFrame,
)
from PyQt6.QtCore import Qt


class LayoutDemo(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("布局演示")
self.setGeometry(100, 100, 900, 600)

central = QWidget()
self.setCentralWidget(central)
main_layout = QHBoxLayout(central)

left_panel = self._create_form_panel()
main_layout.addWidget(left_panel)

splitter = QSplitter(Qt.Orientation.Horizontal)
splitter.addWidget(self._create_editor())
splitter.addWidget(self._create_preview())
splitter.setStretchFactor(0, 2)
splitter.setStretchFactor(1, 1)
main_layout.addWidget(splitter, stretch=1)

def _create_form_panel(self) -> QGroupBox:
group = QGroupBox("表单")
form_layout = QFormLayout(group)
form_layout.setSpacing(10)

form_layout.addRow("姓名:", QLineEdit())
form_layout.addRow("邮箱:", QLineEdit())

btn_layout = QHBoxLayout()
btn_layout.addWidget(QPushButton("提交"))
btn_layout.addWidget(QPushButton("重置"))
form_layout.addRow(btn_layout)

group.setFixedWidth(250)
return group

def _create_editor(self) -> QWidget:
widget = QWidget()
layout = QVBoxLayout(widget)

toolbar = QHBoxLayout()
toolbar.addWidget(QPushButton("B"))
toolbar.addWidget(QPushButton("I"))
toolbar.addWidget(QPushButton("U"))
toolbar.addStretch()
layout.addLayout(toolbar)

editor = QTextEdit()
editor.setPlaceholderText("在此输入内容...")
layout.addWidget(editor)

return widget

def _create_preview(self) -> QWidget:
widget = QWidget()
layout = QVBoxLayout(widget)
layout.addWidget(QLabel("预览区域"))
preview = QTextEdit()
preview.setReadOnly(True)
layout.addWidget(preview)
return widget


if __name__ == "__main__":
app = QApplication(sys.argv)
window = LayoutDemo()
window.show()
sys.exit(app.exec())

21.3.2 QSS样式表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
DARK_STYLE = """
QMainWindow {
background-color: #1e1e2e;
}

QWidget {
color: #cdd6f4;
font-family: "Microsoft YaHei", "Segoe UI", sans-serif;
font-size: 13px;
}

QGroupBox {
border: 1px solid #45475a;
border-radius: 6px;
margin-top: 12px;
padding-top: 16px;
font-weight: bold;
}

QGroupBox::title {
subcontrol-origin: margin;
left: 12px;
padding: 0 6px;
}

QLineEdit, QTextEdit {
background-color: #313244;
border: 1px solid #45475a;
border-radius: 4px;
padding: 6px 10px;
selection-background-color: #585b70;
}

QLineEdit:focus, QTextEdit:focus {
border-color: #89b4fa;
}

QPushButton {
background-color: #45475a;
border: 1px solid #585b70;
border-radius: 4px;
padding: 8px 20px;
min-width: 80px;
}

QPushButton:hover {
background-color: #585b70;
}

QPushButton:pressed {
background-color: #313244;
}

QLabel {
background: transparent;
border: none;
}

QMenuBar {
background-color: #181825;
border-bottom: 1px solid #313244;
}

QMenuBar::item:selected {
background-color: #45475a;
}

QMenu {
background-color: #1e1e2e;
border: 1px solid #45475a;
}

QMenu::item:selected {
background-color: #45475a;
}

QStatusBar {
background-color: #181825;
border-top: 1px solid #313244;
}

QSplitter::handle {
background-color: #45475a;
width: 2px;
}

QScrollBar:vertical {
background: #1e1e2e;
width: 10px;
border: none;
}

QScrollBar::handle:vertical {
background: #45475a;
border-radius: 5px;
min-height: 30px;
}

QScrollBar::handle:vertical:hover {
background: #585b70;
}
"""


class StyledApp(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("QSS样式演示")
self.setGeometry(100, 100, 700, 500)

central = QWidget()
self.setCentralWidget(central)
layout = QVBoxLayout(central)
layout.setSpacing(15)
layout.setContentsMargins(30, 30, 30, 30)

title = QLabel("暗色主题应用")
title.setStyleSheet("font-size: 28px; font-weight: bold; color: #89b4fa;")
title.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(title)

form = QGroupBox("用户信息")
form_layout = QFormLayout(form)
form_layout.addRow("用户名:", QLineEdit())
form_layout.addRow("邮箱:", QLineEdit())
layout.addWidget(form)

btn_layout = QHBoxLayout()
btn_layout.addStretch()
btn_layout.addWidget(QPushButton("提交"))
btn_layout.addWidget(QPushButton("取消"))
layout.addLayout(btn_layout)

self.statusBar().showMessage("就绪")


if __name__ == "__main__":
app = QApplication(sys.argv)
app.setStyleSheet(DARK_STYLE)
window = StyledApp()
window.show()
sys.exit(app.exec())

21.4 Model/View架构

21.4.1 自定义模型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
from PyQt6.QtCore import Qt, QAbstractTableModel, QModelIndex
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QTableView, QVBoxLayout, QWidget,
QPushButton, QHBoxLayout, QInputDialog, QMessageBox,
)
from dataclasses import dataclass


@dataclass
class Student:
id: int
name: str
age: int
score: float


class StudentTableModel(QAbstractTableModel):
HEADERS = ["ID", "姓名", "年龄", "成绩", "等级"]

def __init__(self, students: list[Student] = None):
super().__init__()
self._students = students or []

def rowCount(self, parent=QModelIndex()):
return len(self._students)

def columnCount(self, parent=QModelIndex()):
return len(self.HEADERS)

def data(self, index: QModelIndex, role=Qt.ItemDataRole.DisplayRole):
if not index.isValid() or not (0 <= index.row() < len(self._students)):
return None

student = self._students[index.row()]
col = index.column()

if role == Qt.ItemDataRole.DisplayRole:
if col == 0: return student.id
if col == 1: return student.name
if col == 2: return student.age
if col == 3: return f"{student.score:.1f}"
if col == 4:
if student.score >= 90: return "优秀"
if student.score >= 80: return "良好"
if student.score >= 60: return "及格"
return "不及格"

elif role == Qt.ItemDataRole.TextAlignmentRole:
if col in (0, 2, 3, 4):
return Qt.AlignmentFlag.AlignCenter
return Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter

elif role == Qt.ItemDataRole.ForegroundRole:
if col == 4:
if student.score < 60:
from PyQt6.QtGui import QColor
return QColor("#f38ba8")
if student.score >= 90:
from PyQt6.QtGui import QColor
return QColor("#a6e3a1")

return None

def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole):
if role == Qt.ItemDataRole.DisplayRole and orientation == Qt.Orientation.Horizontal:
return self.HEADERS[section]
return None

def add_student(self, student: Student):
self.beginInsertRows(QModelIndex(), len(self._students), len(self._students))
self._students.append(student)
self.endInsertRows()

def remove_student(self, row: int):
if 0 <= row < len(self._students):
self.beginRemoveRows(QModelIndex(), row, row)
self._students.pop(row)
self.endRemoveRows()

def get_student(self, row: int) -> Student | None:
if 0 <= row < len(self._students):
return self._students[row]
return None


class StudentManager(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("学生成绩管理")
self.setGeometry(100, 100, 700, 500)

self.model = StudentTableModel([
Student(1, "张三", 20, 92.5),
Student(2, "李四", 21, 78.0),
Student(3, "王五", 19, 55.0),
])

central = QWidget()
self.setCentralWidget(central)
layout = QVBoxLayout(central)

toolbar = QHBoxLayout()
add_btn = QPushButton("添加")
add_btn.clicked.connect(self.add_student)
toolbar.addWidget(add_btn)

del_btn = QPushButton("删除")
del_btn.clicked.connect(self.delete_student)
toolbar.addWidget(del_btn)

toolbar.addStretch()
layout.addLayout(toolbar)

self.table = QTableView()
self.table.setModel(self.model)
self.table.setSelectionBehavior(QTableView.SelectionBehavior.SelectRows)
self.table.setSelectionMode(QTableView.SelectionMode.SingleSelection)
self.table.setAlternatingRowColors(True)
self.table.horizontalHeader().setStretchLastSection(True)
layout.addWidget(self.table)

self.statusBar().showMessage(f"共 {self.model.rowCount()} 条记录")

def add_student(self):
name, ok = QInputDialog.getText(self, "添加学生", "姓名:")
if ok and name:
from random import randint
student = Student(
id=self.model.rowCount() + 1,
name=name,
age=randint(18, 25),
score=round(randint(40, 100) + randint(0, 9) / 10, 1),
)
self.model.add_student(student)
self.statusBar().showMessage(f"已添加: {name}")

def delete_student(self):
indexes = self.table.selectionModel().selectedRows()
if not indexes:
return
row = indexes[0].row()
student = self.model.get_student(row)
if student and QMessageBox.question(
self, "确认", f"确定删除 {student.name}?",
) == QMessageBox.StandardButton.Yes:
self.model.remove_student(row)
self.statusBar().showMessage("已删除")


if __name__ == "__main__":
app = QApplication(sys.argv)
window = StudentManager()
window.show()
sys.exit(app.exec())

21.5 多线程GUI

21.5.1 QThread与工作对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
from PyQt6.QtCore import QThread, pyqtSignal, QObject, Qt
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout,
QPushButton, QProgressBar, QLabel, QTextEdit,
)
import time


class Worker(QObject):
progress = pyqtSignal(int)
finished = pyqtSignal(str)
error = pyqtSignal(str)

def __init__(self, total: int = 100):
super().__init__()
self._total = total
self._is_cancelled = False

def run(self):
try:
for i in range(self._total + 1):
if self._is_cancelled:
self.finished.emit("任务已取消")
return
time.sleep(0.03)
self.progress.emit(i)
self.finished.emit(f"任务完成!处理了 {self._total} 个项目")
except Exception as e:
self.error.emit(str(e))

def cancel(self):
self._is_cancelled = True


class AsyncTaskWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("异步任务演示")
self.setGeometry(100, 100, 500, 350)

self._thread = None
self._worker = None

central = QWidget()
self.setCentralWidget(central)
layout = QVBoxLayout(central)
layout.setSpacing(15)

self.progress_bar = QProgressBar()
self.progress_bar.setRange(0, 100)
layout.addWidget(self.progress_bar)

self.status_label = QLabel("就绪")
self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(self.status_label)

self.log_edit = QTextEdit()
self.log_edit.setReadOnly(True)
self.log_edit.setMaximumHeight(120)
layout.addWidget(self.log_edit)

btn_layout = QVBoxLayout()
self.start_btn = QPushButton("开始任务")
self.start_btn.clicked.connect(self.start_task)
btn_layout.addWidget(self.start_btn)

self.cancel_btn = QPushButton("取消任务")
self.cancel_btn.setEnabled(False)
self.cancel_btn.clicked.connect(self.cancel_task)
btn_layout.addWidget(self.cancel_btn)
layout.addLayout(btn_layout)

def start_task(self):
self._worker = Worker(total=100)
self._thread = QThread()
self._worker.moveToThread(self._thread)

self._thread.started.connect(self._worker.run)
self._worker.progress.connect(self.on_progress)
self._worker.finished.connect(self.on_finished)
self._worker.error.connect(self.on_error)
self._worker.finished.connect(self._thread.quit)
self._worker.finished.connect(self._worker.deleteLater)
self._thread.finished.connect(self._thread.deleteLater)

self.start_btn.setEnabled(False)
self.cancel_btn.setEnabled(True)
self.progress_bar.setValue(0)
self.status_label.setText("执行中...")
self._thread.start()

def cancel_task(self):
if self._worker:
self._worker.cancel()
self.cancel_btn.setEnabled(False)

def on_progress(self, value: int):
self.progress_bar.setValue(value)
self.status_label.setText(f"进度: {value}%")

def on_finished(self, message: str):
self.start_btn.setEnabled(True)
self.cancel_btn.setEnabled(False)
self.status_label.setText(message)
self.log_edit.append(message)

def on_error(self, message: str):
self.start_btn.setEnabled(True)
self.cancel_btn.setEnabled(False)
self.status_label.setText(f"错误: {message}")
self.log_edit.append(f"错误: {message}")

def closeEvent(self, event):
if self._thread and self._thread.isRunning():
self._worker.cancel()
self._thread.quit()
self._thread.wait(3000)
event.accept()


if __name__ == "__main__":
app = QApplication(sys.argv)
window = AsyncTaskWindow()
window.show()
sys.exit(app.exec())

21.6 Qt Designer集成

21.6.1 使用UI文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from PyQt6.QtWidgets import QApplication, QMainWindow
from PyQt6.uic import loadUi


class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
loadUi("mainwindow.ui", self)

self.submitButton.clicked.connect(self.on_submit)
self.cancelButton.clicked.connect(self.close)

def on_submit(self):
name = self.nameInput.text()
email = self.emailInput.text()
print(f"提交: {name}, {email}")


if __name__ == "__main__":
app = QApplication(sys.argv)
window = MainWindow()
window.show()
sys.exit(app.exec())

21.6.2 资源系统

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from PyQt6.QtGui import QIcon
from PyQt6.QtWidgets import QApplication, QMainWindow, QPushButton

import resources_rc


class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("资源系统演示")

btn = QPushButton(QIcon(":/icons/save.png"), "保存", self)
btn.clicked.connect(lambda: print("保存"))
self.setCentralWidget(btn)

资源文件 resources.qrc

1
2
3
4
5
6
7
<RCC>
<qresource prefix="/">
<file>icons/save.png</file>
<file>icons/open.png</file>
<file>icons/new.png</file>
</qresource>
</RCC>

编译资源文件:

1
pyside6-rcc resources.qrc -o resources_rc.py

21.7 综合实例:Markdown编辑器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
import sys
import markdown
from pathlib import Path

from PyQt6.QtCore import Qt, QThread, pyqtSignal
from PyQt6.QtGui import QAction, QFont, QTextCursor
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QSplitter, QTextEdit,
QFileDialog, QMessageBox, QLabel,
)


class MarkdownPreviewer(QThread):
preview_ready = pyqtSignal(str)

def __init__(self, text: str):
super().__init__()
self._text = text

def run(self):
html = markdown.markdown(
self._text,
extensions=["tables", "fenced_code", "toc", "nl2br"],
)
self.preview_ready.emit(html)


class MarkdownEditor(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("Markdown 编辑器")
self.setGeometry(100, 100, 1000, 700)
self._current_file = None
self._previewer = None

self._init_ui()
self._update_preview()

def _init_ui(self):
self._init_menubar()
self._init_toolbar()
self._init_editor()
self._init_statusbar()

def _init_menubar(self):
file_menu = self.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)

save_action = QAction("保存(&S)", self)
save_action.setShortcut("Ctrl+S")
save_action.triggered.connect(self.save_file)
file_menu.addAction(save_action)

saveas_action = QAction("另存为(&A)", self)
saveas_action.setShortcut("Ctrl+Shift+S")
saveas_action.triggered.connect(self.save_file_as)
file_menu.addAction(saveas_action)

file_menu.addSeparator()
exit_action = QAction("退出(&Q)", self)
exit_action.setShortcut("Ctrl+Q")
exit_action.triggered.connect(self.close)
file_menu.addAction(exit_action)

def _init_toolbar(self):
toolbar = self.addToolBar("格式")
toolbar.setMovable(False)

for text, prefix, shortcut in [
("B", "**", "Ctrl+B"),
("I", "*", "Ctrl+I"),
("Code", "`", "Ctrl+K"),
]:
action = QAction(text, self)
action.setShortcut(shortcut)
action.triggered.connect(lambda checked, p=prefix: self._insert_format(p))
toolbar.addAction(action)

def _init_editor(self):
splitter = QSplitter(Qt.Orientation.Horizontal)

self.editor = QTextEdit()
self.editor.setFont(QFont("Consolas", 12))
self.editor.textChanged.connect(self._on_text_changed)
splitter.addWidget(self.editor)

self.preview = QTextEdit()
self.preview.setReadOnly(True)
self.preview.setFont(QFont("Microsoft YaHei", 12))
splitter.addWidget(self.preview)

splitter.setStretchFactor(0, 1)
splitter.setStretchFactor(1, 1)
self.setCentralWidget(splitter)

def _init_statusbar(self):
self._status_label = QLabel("就绪")
self.statusBar().addPermanentWidget(self._status_label)

def _on_text_changed(self):
self._update_preview()
self._update_status()

def _update_preview(self):
text = self.editor.toPlainText()
if self._previewer and self._previewer.isRunning():
return
self._previewer = MarkdownPreviewer(text)
self._previewer.preview_ready.connect(self._set_preview)
self._previewer.start()

def _set_preview(self, html: str):
self.preview.setHtml(f"""
<html>
<head>
<style>
body {{ font-family: "Microsoft YaHei", sans-serif; padding: 20px; }}
code {{ background: #f0f0f0; padding: 2px 6px; border-radius: 3px; }}
pre {{ background: #f5f5f5; padding: 12px; border-radius: 6px; overflow-x: auto; }}
table {{ border-collapse: collapse; width: 100%; }}
th, td {{ border: 1px solid #ddd; padding: 8px; text-align: left; }}
blockquote {{ border-left: 4px solid #ddd; margin: 0; padding-left: 16px; color: #666; }}
</style>
</head>
<body>{html}</body>
</html>
""")

def _update_status(self):
text = self.editor.toPlainText()
chars = len(text)
words = len(text.split())
lines = text.count("\n") + 1
filename = Path(self._current_file).name if self._current_file else "未命名"
self._status_label.setText(f"{filename} | {lines} 行 | {words} 词 | {chars} 字符")

def _insert_format(self, prefix: str):
cursor = self.editor.textCursor()
selected = cursor.selectedText()
if selected:
cursor.insertText(f"{prefix}{selected}{prefix}")
else:
cursor.insertText(f"{prefix}文本{prefix}")
cursor.movePosition(QTextCursor.MoveOperation.Left, QTextCursor.MoveMode.MoveAnchor, len(prefix) + 2)
cursor.movePosition(QTextCursor.MoveOperation.Right, QTextCursor.MoveMode.KeepAnchor, 2)
self.editor.setTextCursor(cursor)

def new_file(self):
self.editor.clear()
self._current_file = None
self._update_status()

def open_file(self):
filepath, _ = QFileDialog.getOpenFileName(
self, "打开文件", "", "Markdown (*.md);;所有文件 (*)",
)
if filepath:
try:
text = Path(filepath).read_text(encoding="utf-8")
self.editor.setPlainText(text)
self._current_file = filepath
self._update_status()
except Exception as e:
QMessageBox.critical(self, "错误", f"无法打开文件: {e}")

def save_file(self):
if self._current_file:
try:
Path(self._current_file).write_text(
self.editor.toPlainText(), encoding="utf-8",
)
self.statusBar().showMessage("文件已保存", 3000)
except Exception as e:
QMessageBox.critical(self, "错误", f"无法保存文件: {e}")
else:
self.save_file_as()

def save_file_as(self):
filepath, _ = QFileDialog.getSaveFileName(
self, "保存文件", "", "Markdown (*.md);;所有文件 (*)",
)
if filepath:
self._current_file = filepath
self.save_file()

def closeEvent(self, event):
if self._previewer and self._previewer.isRunning():
self._previewer.quit()
self._previewer.wait()
event.accept()


if __name__ == "__main__":
app = QApplication(sys.argv)
window = MarkdownEditor()
window.show()
sys.exit(app.exec())

21.8 前沿技术动态

21.8.1 PyQt6新特性

  • 高DPI支持:自动缩放,无需手动处理
  • Python类型提示:完整的类型注解支持
  • QML与Python:通过PyQt6绑定QML前端
  • Qt for WebAssembly:浏览器中运行Qt应用

21.8.2 替代框架对比

特性PyQt6PySide6Tkinter
许可证GPL/商业LGPLPython
控件丰富度★★★★★★★★★★★★★
样式定制QSSQSS有限
设计器Qt DesignerQt Designer
文档丰富官方一般
打包体积
学习曲线平缓

21.9 本章小结

本章系统阐述了PyQt6 GUI开发的核心知识体系:

  1. Qt框架基础:QObject对象模型、事件循环与父子对象树
  2. 信号与槽:Qt核心通信机制、自定义信号与跨对象通信
  3. 布局与样式:布局管理器、QSplitter与QSS样式表
  4. Model/View架构:自定义表格模型与数据展示
  5. 多线程GUI:QThread工作对象模式与线程安全通信
  6. Qt Designer集成:UI文件加载与资源系统
  7. 综合实例:Markdown编辑器的完整实现

21.10 习题与项目练习

基础题

  1. 使用PyQt6创建一个登录窗口,包含用户名、密码输入框,实现表单验证与信号槽通信。

  2. 实现一个支持加减乘除的计算器,使用QGridLayout布局。

  3. 创建一个文本编辑器,支持新建、打开、保存功能,使用QSS设置暗色主题。

进阶题

  1. 使用QAbstractTableModel实现一个可排序、可过滤的数据表格,支持CSV导入导出。

  2. 实现一个多线程文件搜索工具,搜索过程中界面保持响应,实时显示进度和结果。

  3. 使用QSplitter实现一个双面板文件管理器,左侧目录树,右侧文件列表。

综合项目

  1. 数据库管理工具:构建一个SQLite数据库管理GUI应用,包含:

    • 数据库连接管理
    • 表结构浏览与编辑
    • SQL查询编辑器与结果展示
    • 数据导入/导出(CSV、JSON)
    • 使用Model/View架构
  2. RSS阅读器:构建一个桌面RSS阅读器,包含:

    • RSS订阅管理(增删改查)
    • 文章列表与内容预览
    • 多线程后台刷新
    • 收藏与标记已读
    • 搜索与过滤功能

思考题

  1. PyQt6的信号槽机制与回调函数相比有何优势?在大型项目中如何避免信号槽连接导致的内存泄漏?

  2. QThread的moveToThread模式与继承QThread重写run()方法有何区别?为什么推荐使用前者?

21.11 延伸阅读

21.11.1 Qt官方资源

21.11.2 进阶书籍

  • 《Rapid GUI Programming with Python and Qt》 (Mark Summerfield) — Qt经典教程
  • 《Qt 6 C++ GUI Programming Cookbook》 — Qt实战技巧
  • 《Mastering GUI Programming with Python》 — PyQt6高级教程

21.11.3 工具与资源

21.11.4 社区与生态


下一章:第22章 测试驱动开发