mcpField
B
building-qt-apps

building-qt-apps

github.com/quick-brown-foxxx开发1104 次安装8 周前更新
代码仓库

关于 building-qt-apps

building-qt-apps 是面向 Python 开发者的技能,介绍如何使用 PySide6 构建 Qt 应用。内容涵盖异步处理、分层架构、QML、桌面集成和测试,并强调避免阻塞事件循环及保持逻辑可测试。

主要功能

  1. 01PySide6 与 qasync 模式
  2. 02Manager–Service–Wrapper 分层
  3. 03类型化信号与槽
  4. 04QML ViewModel 架构
  5. 05使用 pytest-qt 测试 Qt 组件

适用场景

  1. 01规划 Python Qt 应用结构
  2. 02集成异步任务且不阻塞事件循环
  3. 03分离 QML 视图与应用逻辑
  4. 04测试 Qt 控件和组件

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/quick-brown-foxxx/coding_rules_python --skill building-qt-apps

安装后在 Claude Code 中输入/building-qt-apps确认已加载。

详细介绍

构建 Qt 应用

前提条件

此技能扩展了 myai 的 architecting-changes 和 engineering-principles。请先加载它们。假定 using-my-skills 和 engineering-principles 已通过 myai 引导程序加载。

如需了解通用架构原则和工程理念,请参阅 myai 的 architecting-changes 和 engineering-principles。此技能仅涵盖 Python 专属的 Qt 模式:PySide6 + qasync、Manager→Service→Wrapper 分层、信号/槽、QML 集成、XDG 桌面门户,以及使用 pytest-qt 进行 Qt 专属测试。

Qt 应用使用 PySide6,并通过 qasync 集成异步功能。架构遵循 Manager → Service → Wrapper 分层。绝不要阻塞事件循环。


为什么选择 PySide6

  • LGPL 许可证(无额外限制)
  • 无额外系统依赖(随 wheel 一同提供)
  • 与 PyQt6 API 相同,但可自由再分发

架构:Manager → Service → Wrapper

有关依赖连接模式(组合根),请参阅 building-multi-ui-apps 技能。

UI Layer (MainWindow, Dialogs, TrayIcon)
    |  Qt signals/slots
    v
Manager Layer (AudioManager, TranscriptionManager)
    |  orchestrates, emits signals
    v
Service Layer (TranscriptionService, RecordingService)
    |  async operations
    v
Wrapper Layer (WhisperWrapper, SoundcardWrapper)
    |  typed interfaces to third-party libs
    v
Third-Party Libraries
Manager 模式

Manager 协调操作并发出 Qt 信号:

class TranscriptionManager(QObject):
    transcription_finished = Signal(str)
    transcription_error = Signal(str)
    model_changed = Signal(str)

    def __init__(self, settings: Settings) -> None:
        super().__init__()
        self._service: TranscriptionService | None = None
        self._bridge = QAsyncSignalBridge()

    def transcribe(self, audio_data: np.ndarray) -> bool:
        if not self._service:
            self.transcription_error.emit("Service not initialized")
            return False

        self._bridge.run_async(
            self._service.transcribe(audio_data),
            on_success=self._on_finished,
            on_error=self._on_error,
        )
        return True

    def _on_finished(self, text: str) -> None:
        self.transcription_finished.emit(text)

    def _on_error(self, error: str) -> None:
        self.transcription_error.emit(error)
Wrapper 模式

类型化 wrapper 可隔离无类型的第三方 API:

class WhisperModelWrapper:
    """Typed wrapper for faster-whisper."""

    def __init__(self, model_size: str, device: str = "auto") -> None:
        from faster_whisper import WhisperModel as _WhisperModel
        self._model = _WhisperModel(model_size, device=device)

    def transcribe(self, audio: np.ndarray, language: str | None = None) -> TranscriptionResult:
        segments_gen, info = self._model.transcribe(audio, language=language)
        return TranscriptionResult(
            text="".join(s.text for s in segments_gen),
            language=str(info.language),
        )

使用 qasync 进行异步集成(相比之下,QtAsyncio 仍处于技术预览阶段)

设置

这种启动结构适用于纯 GUI 应用。如果应用还支持 CLI 命令,不要根据 len(sys.argv) > 1 进行切换;请使用 building-multi-ui-apps 中的精简顶层路由器模式,并且仅在 GUI 入口点中启动 Qt。

import asyncio
import signal
import qasync
from PySide6.QtWidgets import QApplication

def main() -> int:
    app = QApplication(sys.argv)
    loop = qasync.QEventLoop(app)
    asyncio.set_event_loop(loop)
    signal.signal(signal.SIGINT, signal.SIG_DFL)  # Make Ctrl+C work (Qt blocks it)
    with loop:
        window = MainWindow()
        window.show()
        loop.run_forever()
    return 0
QAsyncSignalBridge

将异步协程桥接到 Qt 信号:

class QAsyncSignalBridge(QObject):
    finished = Signal(object)
    error = Signal(str)

    def run_async(
        self,
        coro: Coroutine[object, None, T],
        on_success: Callable[[T], None] | None = None,
        on_error: Callable[[str], None] | None = None,
    ) -> None:
        async def _wrapped() -> None:
            try:
                result = await coro
                if on_success:
                    on_success(result)
                else:
                    self.finished.emit(result)
            except Exception as e:
                if on_error:
                    on_error(str(e))
                else:
                    self.error.emit(str(e))

        loop = asyncio.get_running_loop()
        self._task = loop.create_task(_wrapped())
用于阻塞库的 ThreadPoolExecutor

当库只提供同步 API 时:

class AsyncRecorder(QObject):
    recording_completed = Signal(np.ndarray)

    def __init__(self) -> None:
        super().__init__()
        self._executor = ThreadPoolExecutor(max_workers=1)

    async def start_recording(self) -> None:
        loop = asyncio.get_running_loop()
        result = await loop.run_in_executor(self._executor, self._sync_record)
        self.recording_completed.emit(result)

关键规则

  1. 优先使用 PySide6(LGPL,无系统依赖),而非 PyQt
  2. 绝不阻塞事件循环:不使用 subprocess.run()、time.sleep() 或同步 HTTP
  3. qasync 用于桥接 asyncio 和 Qt 事件循环
  4. ThreadPoolExecutor 用于封装阻塞式第三方 API
  5. 在无类型库外封装 类型化 wrapper,并通过 ruff banned-api 强制执行
  6. 在类级别定义信号,不要在 __init__ 中定义
  7. Qt 事件处理器使用 camelCase(忽略 ruff N802),我们的槽使用 snake_case

Ctrl+C 和关闭

Qt 的事件循环会阻塞 Python 信号处理,因此按下 Ctrl+C 时看起来毫无反应。解决方法:在 loop.run_forever() 之前调用 signal.signal(signal.SIGINT, signal.SIG_DFL),让操作系统直接处理 SIGINT(如上方设置示例所示)。

如果应用需要在 Ctrl+C 时进行清理(保存状态、释放锁、停止录制),请使用调用 QApplication.quit() 的处理器,而非 SIG_DFL,这样 Qt 的关闭流程才能运行:

def _sigint_handler(*_args: object) -> None:
    QApplication.quit()

signal.signal(signal.SIGINT, _sigint_handler)

# Timer lets Python process the signal between Qt events
timer = QTimer()
timer.start(200)
timer.timeout.connect(lambda: None)

有关子进程关闭模式,请参阅 setting-up-python-projects 技能。


信号/槽约定

  • 在类级别定义信号(不要在 __init__ 中定义)
  • 在拥有关联关系的组件中连接信号
  • 使用类型化信号:Signal(str)、Signal(float)。仅当 PySide6 不支持泛型信号时才使用 Signal(object),并添加 # PySide6 limitation: no generic signals 注释
class AudioManager(QObject):
    volume_changed = Signal(float)
    recording_completed = Signal(np.ndarray)
    recording_failed = Signal(str)

    def __init__(self) -> None:
        super().__init__()
        self._recorder = AsyncRecorder()
        self._recorder.recording_completed.connect(self.recording_completed)

命名约定例外

Qt 事件处理器按照 Qt 约定使用 camelCase:

[tool.ruff.lint]
ignore = ["N802"]  # Qt event handlers use camelCase
class CustomWidget(QWidget):
    def mousePressEvent(self, event: QMouseEvent) -> None:  # Qt convention
        ...

    def on_button_clicked(self) -> None:  # Our slots use snake_case
        ...

声明式标签 → 回调模式

每当需要初始化一组固定的带标签操作——托盘菜单、按钮栏、上下文菜单、工具栏项——都应避免使用命令式的 addAction/addButton 链式调用。应改为在设置方法顶部将所有条目声明为数据(此处 self 在作用域内,可安全引用绑定方法),并在底部用通用循环驱动构建过程。

"SEPARATOR" 是一个 Literal 哨兵值:basedpyright 会拒绝此处的任何其他字符串,因此哨兵值和回调都会经过完整的类型检查。

from typing import Callable, Final, Literal

_SEPARATOR: Final = "SEPARATOR"
_Entry = tuple[str, Callable[[], None]] | Literal["SEPARATOR"]

class ApplicationTrayIcon(QSystemTrayIcon):
    def __init__(self) -> None:
        super().__init__()
        self.setIcon(QIcon("icon.png"))
        self._setup_menu()

    def _setup_menu(self) -> None:
        entries: list[_Entry] = [
            ("Settings", self._open_settings),
            _SEPARATOR,
            ("Quit", QApplication.quit),
        ]

        menu = QMenu()
        for entry in entries:
            if entry is _SEPARATOR:
                menu.addSeparator()
            else:
                label, cb = entry
                menu.addAction(label, cb)
        self.setContextMenu(menu)

    def _open_settings(self) -> None: ...

entries 是添加、删除或重新排序条目的唯一位置。循环是通用样板代码,不需要更改。把 self._poen_settings 拼错会在检查时被 basedpyright 捕获,不会带来运行时意外。同样的模式适用于按钮栏、上下文菜单,或任何其他标签 → 回调映射。


单实例强制机制

class LockManager:
    def __init__(self, lock_path: Path) -> None:
        self._lock_path = lock_path

    def acquire(self) -> Result[None, str]:
        if self._lock_path.exists():
            pid = int(self._lock_path.read_text())
            if self._is_process_running(pid):
                return Err(f"Another instance running (PID {pid})")
            # Stale lock file
        self._lock_path.write_text(str(os.getpid()))
        return Ok(None)

    def release(self) -> None:
        self._lock_path.unlink(missing_ok=True)

键盘快捷键

可通过 TOML 配置自定义:

class ActionID(enum.Enum):
    NEW_PROFILE = "new_profile"
    START_PROFILE = "start_profile"

@dataclass
class ActionShortcut:
    id: str
    label: str
    default_key: str

DEFAULT_SHORTCUTS = (
    ActionShortcut(ActionID.NEW_PROFILE.value, "New Profile", "Ctrl+N"),
    ActionShortcut(ActionID.START_PROFILE.value, "Start Profile", "Return"),
)

用户覆盖项存储在 platformdirs.user_config_dir("appname")/shortcuts.toml 中。


设置管理

类型安全的 QSettings 封装:

class Settings:
    def __init__(self) -> None:
        self._settings = QSettings(APP_NAME, APP_NAME)
        self._init_defaults()

    def get_str(self, key: str, default: str = "") -> str:
        value = self._settings.value(key, default)
        return str(value) if value is not None else default

    def get_int(self, key: str, default: int = 0) -> int:
        value = self._settings.value(key, default)
        return int(value) if value is not None else default

    def set(self, key: str, value: str | int | bool) -> None:
        self._settings.setValue(key, value)

QML 集成:View + ViewModel(MVVM)

对于基于 QML 的 UI,应将 QML 视为纯粹、无逻辑的视图,并将所有逻辑保留在 Python 中,这样无需启动 QML 即可进行单元测试。采用 View–ViewModel(MVVM) 分离:

  • View = 仅包含 QML。QML 中没有业务逻辑:function 块中不执行实际工作,不做导航决策,绑定中不包含规则。它负责呈现状态并发出“用户意图”(调用 VM 槽)。
  • ViewModel = 暴露给 QML 的 QObject。仅包含纯状态和命令,不包含 Qt 窗口控件。它是 QML 绑定的唯一对象,也是唯一可通过两种方式测试的单元(pytest-qt)。
  • Model = VM 下层的纯数据/逻辑。可以是无 Qt 依赖的纯 Python 代码(最便于测试)。

属性取代 MVVM 数据绑定;@Slot 方法充当“命令”。

唯一桥接对象

QML 与 Qt 之间恰好只有一个桥接对象:交给引擎上下文的 QObject。在启动时(组合根)只暴露一次——不要到处调用 setContextProperty:

from PySide6.QtCore import QObject, Property, Signal, Slot

class PageViewModel(QObject):
    pageChanged = Signal(str)

    @Property(str)
    def currentPage(self) -> str: ...          # read-only state
    @currentPage.setter
    def currentPage(self, value: str) -> None: ...

    @Slot(result=str)
    def title(self) -> str: ...

    @Slot(str)                                  # user intent -> logic
    def navigate(self, page: str) -> None: ...

# composition root
engine = QQmlApplicationEngine()
engine.rootContext().setContextProperty("vm", page_vm)
engine.load("Main.qml")

让 VM 不感知 QML。 它不得访问 QML 内部内容——只暴露类型化的槽、属性和信号,并由 QML 将它们转换为 UI。

多页面 / Shell(每页使用独立 VM,不使用巨型接缝)

对于多页面应用,为每个页面配备自己的 ViewModel,并为应用级关注点单独配备一个根 / shell ViewModel(当前页面、共享状态、导航到 VM 或事件总线)。在组合根中构建所有 VM,并且只注入它们各自的依赖项,以便每个 VM 都能通过替身独立构造:

root = RootViewModel(settings, api)
root.pageA = PageAVM(settings, api)      # each page gets ONLY its deps
root.pageB = PageBVM(settings, repo)

让页面只接收自己的 VM(而不是整个 root 对象),并让 shell 保持为精简的导航器(StackView/Loader),只响应根 VM 的当前页面信号。不要:

  • 将所有属性 / 槽都放在一个庞大的 app 对象上。
  • 将整个根 VM 传递给每个页面。
  • 在 QML 中放置逻辑。
  • 让 shell 了解每个页面的内部实现。

对于列表 / 集合,应向 QML 暴露 QAbstractListModel/QAbstractItemModel——QML 视图(ListView、Repeater)由模型驱动,而不是到处传递 Python 可迭代对象。


测试 Qt 组件

使用 pytest-qt:

def test_main_window_creates(qtbot: QtBot) -> None:
    window = MainWindow()
    qtbot.addWidget(window)
    assert window.isVisible() is False  # Not shown until .show()

def test_button_click(qtbot: QtBot) -> None:
    widget = MyWidget()
    qtbot.addWidget(widget)
    with qtbot.waitSignal(widget.action_triggered, timeout=1000):
        qtbot.mouseClick(widget.button, Qt.LeftButton)

将 QML 日志路由到 Python Logger

QML 的 console.log/info/warn/error 调用默认会打印到 stderr,不包含结构化信息或日志级别。在创建 QML 引擎之前安装自定义 Qt 消息处理器,将这些日志路由到 Python 的 logging 模块。

处理器
import logging
from PySide6.QtCore import QMessageLogContext, QtMsgType, qInstallMessageHandler

_qt_logger = logging.getLogger("qt.qml")

def _qt_message_handler(msg_type: QtMsgType, context: QMessageLogContext, message: str) -> None:
    file: str = context.file or ""
    line: int = context.line or 0
    location = f" ({file}:{line})" if file else ""
    log_message = f"{message}{location}"

    if msg_type == QtMsgType.QtDebugMsg:
        _qt_logger.debug(log_message)
    elif msg_type == QtMsgType.QtInfoMsg:
        _qt_logger.info(log_message)
    elif msg_type == QtMsgType.QtWarningMsg:
        _qt_logger.warning(log_message)
    else:  # QtCriticalMsg, QtFatalMsg
        _qt_logger.error(log_message)
在 QML 引擎之前安装
qInstallMessageHandler(_qt_message_handler)
engine = QQmlApplicationEngine()

顺序很重要——应在 QQmlApplicationEngine() 之前安装,以捕获 QML 加载早期的警告。

QML 用法
Component.onCompleted: {
    console.info("Panel loaded, items: " + listModel.count)
    console.warn("Missing optional property")
    console.error("Failed to load resource")
}
注意事项:console.log() 会被静默丢弃

Qt 会将 console.log() 映射为 QtDebugMsg,Qt 自身的消息过滤会在调用处理器之前抑制该消息。处理器永远不会收到它。

QML 调用Qt 类型是否到达处理器建议
console.log()QtDebugMsg否不要使用
console.info()QtInfoMsg是用于输出调试信息
console.warn()QtWarningMsg是用于可恢复的问题
console.error()QtCriticalMsg是用于错误

始终使用 console.info(),而不要使用 console.log()。

日志记录器名称 qt.qml 可让你独立筛选或抑制 QML 消息:

logging.getLogger("qt.qml").setLevel(logging.WARNING)  # silence info-level QML noise

请参阅 setting-up-logging 技能,了解适用于此处理器的彩色标准输出/文件日志设置。


平台集成 - 文件对话框(XDG 桌面门户)

在 Linux 上,文件对话框使用 XDG 桌面门户来调用原生系统选择器(支持收藏夹、书签等)。如果尚未配置平台主题,应用会在启动时设置 QT_QPA_PLATFORMTHEME=xdgdesktopportal。

要求: xdg-desktop-portal + 桌面后端(xdg-desktop-portal-kde、xdg-desktop-portal-gnome 等)。

无需更改代码——设置平台主题后,标准的 QFileDialog 调用会自动使用门户。在 Flatpak 环境中,无需任何配置即可透明地使用门户。


相关 myai 技能

  • architecting-changes — 上级技能。语言无关的架构决策框架。
  • engineering-principles — 上级技能。语言无关的理念:架构分离、异步模式。
  • building-multi-ui-apps — Qt GUI 与 CLI/API 共用核心时适用的 Python 多界面模式。
  • setting-up-shortcuts — PySide6 应用的 Python 键盘快捷键设置。
  • setting-up-logging — Python 日志设置,包括 QML 日志路由。
  • testing-python — 使用 pytest-qt 进行 Python 测试。
  • writing-python-code — Python 编码规则(类型系统、错误处理、异步模式)。

评论

支持 Markdown