building-qt-apps

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Building Qt Apps

构建Qt应用

Qt apps use PySide6 with qasync for async integration. Architecture follows Manager → Service → Wrapper layering. Never block the event loop.

Qt应用使用PySide6搭配qasync实现异步集成,架构遵循Manager → Service → Wrapper分层原则,绝对不能阻塞事件循环。

Why PySide6

为什么选择PySide6

  • LGPL license (no additional restrictions)
  • No extra system dependencies (ships with wheels)
  • Same API as PyQt6, but freely redistributable

  • LGPL许可证(无额外使用限制)
  • 无额外系统依赖(通过wheel包分发)
  • 与PyQt6 API一致,但支持自由分发

Architecture: Manager → Service → Wrapper

架构:Manager → Service → Wrapper

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
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 Pattern

Manager模式

Managers coordinate operations and emit Qt signals:
python
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)
Manager负责协调操作并发送Qt信号:
python
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 Pattern

Wrapper模式

Typed wrappers isolate untyped third-party APIs:
python
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),
        )

带类型的Wrapper可以隔离无类型的第三方API:
python
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),
        )

Async Integration with qasync (over QtAsyncio, which is still in technical preview)

使用qasync实现异步集成(优于仍处于技术预览阶段的QtAsyncio)

Setup

环境配置

python
import asyncio
import qasync
from PySide6.QtWidgets import QApplication

def main() -> int:
    app = QApplication(sys.argv)
    loop = qasync.QEventLoop(app)
    asyncio.set_event_loop(loop)
    with loop:
        window = MainWindow()
        window.show()
        loop.run_forever()
    return 0
python
import asyncio
import qasync
from PySide6.QtWidgets import QApplication

def main() -> int:
    app = QApplication(sys.argv)
    loop = qasync.QEventLoop(app)
    asyncio.set_event_loop(loop)
    with loop:
        window = MainWindow()
        window.show()
        loop.run_forever()
    return 0

QAsyncSignalBridge

QAsyncSignalBridge

Bridge async coroutines to Qt signals:
python
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_event_loop()
        self._task = loop.create_task(_wrapped())
将异步协程桥接到Qt信号:
python
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_event_loop()
        self._task = loop.create_task(_wrapped())

ThreadPoolExecutor for Blocking Libraries

针对阻塞库使用ThreadPoolExecutor

When a library only provides sync API:
python
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_event_loop()
        result = await loop.run_in_executor(self._executor, self._sync_record)
        self.recording_completed.emit(result)

当第三方库仅提供同步API时使用:
python
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_event_loop()
        result = await loop.run_in_executor(self._executor, self._sync_record)
        self.recording_completed.emit(result)

Key Rules

核心规则

  1. PySide6 (LGPL, no system deps) over PyQt
  2. Never block event loop: no
    subprocess.run()
    , no
    time.sleep()
    , no sync HTTP
  3. qasync bridges asyncio and Qt event loops
  4. ThreadPoolExecutor wraps blocking third-party APIs
  5. Typed wrappers around untyped libraries, enforced via ruff
    banned-api
  6. Signals at class level, not in
    __init__
  7. camelCase for Qt event handlers (ignore ruff N802), snake_case for our slots

  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命名

Signal/Slot Conventions

信号/槽约定

  • Define signals at class level (not in
    __init__
    )
  • Connect signals in the component that owns the relationship
  • Use typed signals:
    Signal(str)
    ,
    Signal(float)
    ,
    Signal(object)
python
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)

  • 在类级别定义信号(不要放在
    __init__
    中)
  • 在持有对应关系的组件中连接信号
  • 使用带类型的信号:
    Signal(str)
    Signal(float)
    Signal(object)
python
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)

Naming Convention Exception

命名约定例外情况

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

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

Qt事件处理器遵循Qt的约定使用
camelCase
命名:
toml
[tool.ruff.lint]
ignore = ["N802"]  # Qt event handlers use camelCase
python
class CustomWidget(QWidget):
    def mousePressEvent(self, event: QMouseEvent) -> None:  # Qt convention
        ...

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

Declarative Label → Callback Pattern

声明式标签→回调模式

Whenever bootstrapping a fixed set of labeled actions — tray menus, button bars, context menus, toolbar items — avoid imperative
addAction
/
addButton
chains. Instead, declare all entries as data at the top of the setup method (where
self
is in scope for type-safe bound-method references) and drive the construction with a generic loop at the bottom.
"SEPARATOR"
is a
Literal
sentinel: basedpyright rejects any other string in that position, so both the sentinel and the callbacks are fully type-checked.
python
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
is the single place to add, remove, or reorder items. The loop is generic boilerplate that never changes. Mistyping
self._poen_settings
is caught by basedpyright at check time — no runtime surprises. The same pattern applies to button bars, context menus, or any other label → callback mapping.

当你需要初始化一组固定的带标签操作时(系统托盘菜单、按钮栏、右键菜单、工具栏项等),不要使用命令式的
addAction
/
addButton
链式调用。你应该在setup方法的顶部将所有条目声明为数据(此时
self
在作用域内,可以安全引用绑定方法),然后在底部通过通用循环来构建界面。
"SEPARATOR"
是一个
Literal
类型的标记值:basedpyright会拒绝该位置的其他任何字符串,因此标记值和回调都可以获得完整的类型校验。
python
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会在类型检查阶段就捕获错误,不会出现运行时意外。该模式同样适用于按钮栏、右键菜单或者其他任何标签→回调映射的场景。

Single Instance Enforcement

单实例强制校验

python
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)

python
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})")
            # 过期的锁文件
        self._lock_path.write_text(str(os.getpid()))
        return Ok(None)

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

Keyboard Shortcuts

键盘快捷键

Customizable via TOML config:
python
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"),
)
User overrides stored in
~/.config/appname/shortcuts.toml
.

通过TOML配置支持自定义:
python
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"),
)
用户自定义配置存储在
~/.config/appname/shortcuts.toml
中。

Settings Management

设置管理

Type-safe QSettings wrapper:
python
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)

带类型安全的QSettings封装:
python
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)

Testing Qt Components

Qt组件测试

Use
pytest-qt
:
python
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)
使用
pytest-qt
python
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)

Routing QML Logs to Python Logger

将QML日志路由到Python日志系统

QML
console.log/info/warn/error
calls print to stderr by default with no structure or log levels. Install a custom Qt message handler before creating the QML engine to route them through Python's
logging
module.
默认情况下QML的
console.log/info/warn/error
调用会直接打印到stderr,没有结构化格式也没有日志级别区分。在创建QML引擎前安装自定义的Qt消息处理器,可以将这些日志路由到Python的
logging
模块中。

The Handler

处理器实现

python
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)
python
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)

Install Before QML Engine

在QML引擎初始化前安装处理器

python
qInstallMessageHandler(_qt_message_handler)
engine = QQmlApplicationEngine()
Order matters — install before
QQmlApplicationEngine()
so early QML load warnings are captured.
python
qInstallMessageHandler(_qt_message_handler)
engine = QQmlApplicationEngine()
顺序很重要——必须在
QQmlApplicationEngine()
之前安装,这样才能捕获QML加载初期的警告。

QML Usage

QML使用示例

qml
Component.onCompleted: {
    console.info("Panel loaded, items: " + listModel.count)
    console.warn("Missing optional property")
    console.error("Failed to load resource")
}
qml
Component.onCompleted: {
    console.info("Panel loaded, items: " + listModel.count)
    console.warn("Missing optional property")
    console.error("Failed to load resource")
}

Gotcha:
console.log()
Is Silently Dropped

注意:
console.log()
会被静默丢弃

Qt maps
console.log()
to
QtDebugMsg
, which Qt's own message filtering suppresses before the handler is called. The handler never sees it.
QML callQt typeReaches handlerRecommendation
console.log()
QtDebugMsg
NoDon't use
console.info()
QtInfoMsg
YesUse for debug output
console.warn()
QtWarningMsg
YesRecoverable issues
console.error()
QtCriticalMsg
YesErrors
Always use
console.info()
instead of
console.log()
.
The logger name
qt.qml
lets you filter or suppress QML messages independently:
python
logging.getLogger("qt.qml").setLevel(logging.WARNING)  # silence info-level QML noise
See the
setting-up-logging
skill for colored stdout/file logging setup that works with this handler.

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消息:
python
logging.getLogger("qt.qml").setLevel(logging.WARNING)  # 关闭QML的info级别的冗余日志
你可以查看
setting-up-logging
技能,了解如何搭配该处理器实现带颜色的标准输出/文件日志配置。

Platform Integration - File Dialogs (XDG Desktop Portals)

平台集成 - 文件对话框(XDG Desktop Portals)

On Linux, file dialogs use XDG Desktop Portals for native system pickers (with favorites, bookmarks, etc.). The app sets
QT_QPA_PLATFORMTHEME=xdgdesktopportal
at startup if no platform theme is configured.
Requirements:
xdg-desktop-portal
+ a desktop backend (
xdg-desktop-portal-kde
,
xdg-desktop-portal-gnome
, etc.).
No code changes needed — standard
QFileDialog
calls automatically use portals when the platform theme is set. In Flatpak environments, portals are used transparently without any configuration.
在Linux系统上,文件对话框使用XDG Desktop Portals来调用系统原生选择器(支持收藏夹、书签等功能)。如果没有配置平台主题,应用会在启动时设置
QT_QPA_PLATFORMTHEME=xdgdesktopportal
依赖要求:
xdg-desktop-portal
+ 桌面后端(
xdg-desktop-portal-kde
xdg-desktop-portal-gnome
等)。
无需修改代码——当平台主题配置正确时,标准的
QFileDialog
调用会自动使用portals。在Flatpak环境中,无需任何配置就会透明使用portals。