Skip to content

第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对象模型,提供对象树、信号槽和事件过滤等机制:

┌─────────────────────────────────────────┐
│              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.py

21.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 替代框架对比

特性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章 测试驱动开发

青少年创意编程 - 高中Python组 - 江苏省宿城中等专业学校