第21章 PyQt GUI开发
学习目标
完成本章学习后,读者应能够:
- 理解Qt框架架构:掌握Qt对象模型、事件循环与父子对象树
- 精通信号与槽机制:理解Qt的核心通信模式,实现自定义信号与跨线程通信
- 掌握布局与样式:灵活运用布局管理器与QSS样式表构建专业级界面
- 实现MVC架构:使用Model/View架构处理数据展示
- 掌握多文档界面:实现MDI、SDI与停靠窗口
- 运用多线程:使用QThread与QThreadPool构建响应式GUI
- 集成Qt Designer:通过UI文件与资源系统加速开发
21.1 Qt框架基础
21.1.1 Qt对象模型
Qt框架的核心是QObject对象模型,提供对象树、信号槽和事件过滤等机制:
┌─────────────────────────────────────────┐
│ QApplication │
│ ┌─────────────────────────────────┐ │
│ │ QMainWindow │ │
│ │ ┌──────────┐ ┌──────────────┐ │ │
│ │ │ QMenuBar │ │ QToolBar │ │ │
│ │ └──────────┘ └──────────────┘ │ │
│ │ ┌────────────────────────────┐ │ │
│ │ │ QWidget (central) │ │ │
│ │ │ ┌──────┐ ┌─────────────┐ │ │ │
│ │ │ │Label │ │ LineEdit │ │ │ │
│ │ │ └──────┘ └─────────────┘ │ │ │
│ │ └────────────────────────────┘ │ │
│ │ ┌────────────────────────────┐ │ │
│ │ │ QStatusBar │ │ │
│ │ └────────────────────────────┘ │ │
│ └─────────────────────────────────┘ │
└─────────────────────────────────────────┘21.1.2 应用程序架构
python
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的核心通信机制,实现了对象间的松耦合通信:
发送者(Sender) 接收者(Receiver)
┌──────────┐ ┌──────────┐
│ │ 信号(Signal)│ │
│ Button │───────────▶│ Handler │
│ │ │ │
└──────────┘ └──────────┘
│ ▲
│ clicked │ on_click()
└───────────────────────┘python
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 自定义信号与跨对象通信
python
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 布局管理器
python
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样式表
python
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 自定义模型
python
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与工作对象
python
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文件
python
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 资源系统
python
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:
xml
<RCC>
<qresource prefix="/">
<file>icons/save.png</file>
<file>icons/open.png</file>
<file>icons/new.png</file>
</qresource>
</RCC>编译资源文件:
bash
pyside6-rcc resources.qrc -o resources_rc.py21.7 综合实例:Markdown编辑器
python
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 替代框架对比
| 特性 | PyQt6 | PySide6 | Tkinter |
|---|---|---|---|
| 许可证 | GPL/商业 | LGPL | Python |
| 控件丰富度 | ★★★★★ | ★★★★★ | ★★★ |
| 样式定制 | QSS | QSS | 有限 |
| 设计器 | Qt Designer | Qt Designer | 无 |
| 文档 | 丰富 | 官方 | 一般 |
| 打包体积 | 大 | 大 | 小 |
| 学习曲线 | 陡 | 陡 | 平缓 |
21.9 本章小结
本章系统阐述了PyQt6 GUI开发的核心知识体系:
- Qt框架基础:QObject对象模型、事件循环与父子对象树
- 信号与槽:Qt核心通信机制、自定义信号与跨对象通信
- 布局与样式:布局管理器、QSplitter与QSS样式表
- Model/View架构:自定义表格模型与数据展示
- 多线程GUI:QThread工作对象模式与线程安全通信
- Qt Designer集成:UI文件加载与资源系统
- 综合实例:Markdown编辑器的完整实现
21.10 习题与项目练习
基础题
使用PyQt6创建一个登录窗口,包含用户名、密码输入框,实现表单验证与信号槽通信。
实现一个支持加减乘除的计算器,使用QGridLayout布局。
创建一个文本编辑器,支持新建、打开、保存功能,使用QSS设置暗色主题。
进阶题
使用QAbstractTableModel实现一个可排序、可过滤的数据表格,支持CSV导入导出。
实现一个多线程文件搜索工具,搜索过程中界面保持响应,实时显示进度和结果。
使用QSplitter实现一个双面板文件管理器,左侧目录树,右侧文件列表。
综合项目
数据库管理工具:构建一个SQLite数据库管理GUI应用,包含:
- 数据库连接管理
- 表结构浏览与编辑
- SQL查询编辑器与结果展示
- 数据导入/导出(CSV、JSON)
- 使用Model/View架构
RSS阅读器:构建一个桌面RSS阅读器,包含:
- RSS订阅管理(增删改查)
- 文章列表与内容预览
- 多线程后台刷新
- 收藏与标记已读
- 搜索与过滤功能
思考题
PyQt6的信号槽机制与回调函数相比有何优势?在大型项目中如何避免信号槽连接导致的内存泄漏?
QThread的
moveToThread模式与继承QThread重写run()方法有何区别?为什么推荐使用前者?
21.11 延伸阅读
21.11.1 Qt官方资源
- Qt官方文档 (https://doc.qt.io/qt-6/) — Qt 6权威指南
- PyQt6文档 (https://www.riverbankcomputing.com/static/Docs/PyQt6/) — Python绑定文档
- PySide6文档 (https://doc.qt.io/qtforpython/) — Qt官方Python绑定
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 工具与资源
- Qt Designer (https://doc.qt.io/qt-6/qtdesigner-manual.html) — 可视化界面设计器
- Qt Creator (https://www.qt.io/product/development-tools) — 集成开发环境
- QSS样式参考 (https://doc.qt.io/qt-6/stylesheet-reference.html) — 样式表参考
21.11.4 社区与生态
- Qt Forum (https://forum.qt.io/) — Qt社区论坛
- Qt Examples (https://doc.qt.io/qt-6/qtexamplesandtutorials.html) — 官方示例
- Qt Blog (https://www.qt.io/blog) — Qt官方博客
下一章:第22章 测试驱动开发