关于 building-qt-apps
building-qt-apps 是面向 Python 开发者的技能,介绍如何使用 PySide6 构建 Qt 应用。内容涵盖异步处理、分层架构、QML、桌面集成和测试,并强调避免阻塞事件循环及保持逻辑可测试。
主要功能
- 01PySide6 与 qasync 模式
- 02Manager–Service–Wrapper 分层
- 03类型化信号与槽
- 04QML ViewModel 架构
- 05使用 pytest-qt 测试 Qt 组件
适用场景
- 01规划 Python Qt 应用结构
- 02集成异步任务且不阻塞事件循环
- 03分离 QML 视图与应用逻辑
- 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)
关键规则
- 优先使用 PySide6(LGPL,无系统依赖),而非 PyQt
- 绝不阻塞事件循环:不使用
subprocess.run()、time.sleep()或同步 HTTP - qasync 用于桥接 asyncio 和 Qt 事件循环
- ThreadPoolExecutor 用于封装阻塞式第三方 API
- 在无类型库外封装 类型化 wrapper,并通过 ruff
banned-api强制执行 - 在类级别定义信号,不要在
__init__中定义 - 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 编码规则(类型系统、错误处理、异步模式)。