SELECTED PROJECTS

launcher_qt:PyQt6 插件化桌面平台

围绕插件接口、动态加载和界面生命周期,记录桌面平台原型的结构。

阅读设置

项目文稿说明:本文按现有笔记整理,代码是节选或设计示意;仓库实现、线上地址和测试结果尚未逐项核验。后续计划不代表已经完成。

用 PyQt6 手撸一个插件化桌面工具平台

菜牙爱吃菜 · 2026-10-20

前言

最近折腾了一个小项目 —— launcher_qt,一个基于 PyQt6 的插件化桌面工具平台。说白了,就是一个「壳子」,各种功能以插件的形式插进来,按约定加载并管理界面。做这个项目的初衷很简单:我之前用 Go + Wails 写过一些桌面工具,Python 这边也想搞一个类似的玩具,顺便学学 PyQt6。

项目地址:launcher_qt(私有仓库,代码不公开,但思路可以聊聊)。

项目简介

launcher_qt 是一个插件化的桌面工具平台,核心理念是「平台提供框架,插件实现功能」。平台负责插件的加载、生命周期管理、UI 集成,而每个插件只需要关注自己的业务逻辑。

目前内置了两个核心模块:

模块 功能
browser_manager 浏览器隔离与代理管理,基于 gologin 实现多开浏览器指纹隔离
email_service 基于 IMAP 的邮件服务

技术选型:

  • UI 框架:PyQt6(Qt 的 Python 绑定)
  • 浏览器自动化:selenium + gologin
  • 数据库:SQLite(data/platform.db)
  • 网络请求:requests
  • 部署:PowerShell 脚本(setup_env.ps1 一键配环境,start.ps1 启动)

架构设计

整体架构分三层:

┌─────────────────────────────────────┐
│           host_window.py            │  ← 主窗口 / UI 壳
├─────────────────────────────────────┤
│          plugin_manager.py           │  ← 插件管理器(核心)
├──────────┬──────────┬───────────────┤
│ plugin_a │ plugin_b │  plugin_api   │  ← 插件接口层
└──────────┴──────────┴───────────────┘

插件系统怎么设计

插件系统是整个项目的灵魂。我用了一个非常朴素但实用的方式:基于约定的插件加载。

1. 插件接口定义(plugin_api.py)

所有插件必须继承 BasePlugin 接口:

## plugin_api.py
from abc import ABC, abstractmethod
from typing import Optional, Dict, Any
from PyQt6.QtWidgets import QWidget

class PluginMetadata:
    """插件元数据"""
    def __init__(self, name: str, version: str, description: str,
                 author: str = "", icon: str = ""):
        self.name = name
        self.version = version
        self.description = description
        self.author = author
        self.icon = icon

class BasePlugin(ABC):
    """插件基类,所有插件必须继承"""

    def __init__(self):
        self._metadata: Optional[PluginMetadata] = None
        self._config: Dict[str, Any] = {}
        self._widget: Optional[QWidget] = None

    @property
    def metadata(self) -> PluginMetadata:
        return self._metadata

    @abstractmethod
    def initialize(self, config: Dict[str, Any]) -> bool:
        """初始化插件,返回是否成功"""
        pass

    @abstractmethod
    def get_widget(self) -> QWidget:
        """返回插件的主界面"""
        pass

    @abstractmethod
    def on_activate(self):
        """插件被激活时调用"""
        pass

    @abstractmethod
    def on_deactivate(self):
        """插件被停用时调用"""
        pass

    def shutdown(self):
        """插件卸载时的清理工作"""
        if self._widget:
            self._widget.close()
            self._widget = None

2. 插件管理器(plugin_manager.py)

管理器负责扫描插件目录、动态加载、生命周期管理:

## plugin_manager.py
import os
import sys
import importlib
import importlib.util
from pathlib import Path
from typing import Dict, List, Optional, Type

from plugin_api import BasePlugin, PluginMetadata
from PyQt6.QtWidgets import QWidget

class PluginManager:
    """插件管理器 —— 扫描、加载、卸载"""

    def __init__(self, plugin_dir: str = "plugins"):
        self.plugin_dir = Path(plugin_dir)
        self._plugins: Dict[str, BasePlugin] = {}       # name -> instance
        self._plugin_classes: Dict[str, Type[BasePlugin]] = {}  # name -> class
        self._active_plugin: Optional[str] = None

    def discover_plugins(self) -> List[PluginMetadata]:
        """扫描插件目录,发现所有可用插件"""
        discovered = []
        if not self.plugin_dir.exists():
            return discovered

        for entry in self.plugin_dir.iterdir():
            if not entry.is_dir():
                continue
            manifest = entry / "plugin.json"
            if not manifest.exists():
                continue
            # 读取元数据
            import json
            with open(manifest, "r", encoding="utf-8") as f:
                data = json.load(f)
            discovered.append(PluginMetadata(**data))

        return discovered

    def load_plugin(self, name: str, config: dict = None) -> bool:
        """动态加载指定插件"""
        if name in self._plugins:
            return True  # 已加载

        plugin_path = self.plugin_dir / name / "main.py"
        if not plugin_path.exists():
            print(f"[PluginManager] 插件 {name} 不存在")
            return False

        # 动态导入
        spec = importlib.util.spec_from_file_location(
            f"plugins.{name}", str(plugin_path)
        )
        module = importlib.util.module_from_spec(spec)
        sys.modules[f"plugins.{name}"] = module
        spec.loader.exec_module(module)

        # 查找插件类(约定:模块中有一个 Plugin 类)
        plugin_class = getattr(module, "Plugin", None)
        if not plugin_class or not issubclass(plugin_class, BasePlugin):
            print(f"[PluginManager] {name} 中没有有效的 Plugin 类")
            return False

        # 实例化
        instance = plugin_class()
        instance._config = config or {}

        if not instance.initialize(instance._config):
            print(f"[PluginManager] {name} 初始化失败")
            return False

        instance._metadata = PluginMetadata(
            name=name,
            version=getattr(module, "__version__", "0.1.0"),
            description=getattr(module, "__description__", ""),
        )

        self._plugins[name] = instance
        self._plugin_classes[name] = plugin_class
        print(f"[PluginManager] ✅ 插件 {name} 加载成功")
        return True

    def activate_plugin(self, name: str) -> Optional[QWidget]:
        """激活插件,返回其 UI 组件"""
        if name not in self._plugins:
            if not self.load_plugin(name):
                return None

        # 停用当前插件
        if self._active_plugin and self._active_plugin != name:
            self._plugins[self._active_plugin].on_deactivate()

        plugin = self._plugins[name]
        plugin.on_activate()
        self._active_plugin = name
        return plugin.get_widget()

    def unload_plugin(self, name: str):
        """卸载插件"""
        if name not in self._plugins:
            return
        self._plugins[name].on_deactivate()
        self._plugins[name].shutdown()
        del self._plugins[name]
        print(f"[PluginManager] 插件 {name} 已卸载")

    def get_loaded_plugins(self) -> List[str]:
        return list(self._plugins.keys())

3. 插件目录结构

每个插件是一个独立目录,包含 plugin.json 元数据和 main.py 入口:

plugins/
├── browser_manager/
│   ├── plugin.json
│   ├── main.py
│   └── ... (其他资源文件)
└── email_service/
    ├── plugin.json
    ├── main.py
    └── ...

plugin.json 示例:

{
  "name": "browser_manager",
  "version": "1.0.0",
  "description": "浏览器隔离与代理管理",
  "author": "菜牙爱吃菜",
  "icon": "icons/browser.svg"
}

主窗口(host_window.py)

主窗口负责整体 UI 布局,把插件的 widget 嵌入到主界面中:

## host_window.py
from PyQt6.QtWidgets import (
    QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QListWidget,
    QListWidgetItem, QStackedWidget, QLabel
)
from PyQt6.QtCore import Qt
from plugin_manager import PluginManager

class HostWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("Launcher Qt")
        self.resize(900, 600)

        # 核心组件
        self.plugin_manager = PluginManager(plugin_dir="plugins")

        # 布局:左侧导航 + 右侧内容区
        central = QWidget()
        self.setCentralWidget(central)
        layout = QHBoxLayout(central)

        # 左侧导航栏
        self.nav_list = QListWidget()
        self.nav_list.setFixedWidth(160)
        # 先初始化堆叠窗口,再构造会使用它的导航
        self.stack = QStackedWidget()
        self._build_nav()

        layout.addWidget(self.nav_list)
        layout.addWidget(self.stack, stretch=1)

        # 信号连接
        self.nav_list.currentRowChanged.connect(self._on_nav_changed)

    def _build_nav(self):
        """构建导航栏"""
        metas = self.plugin_manager.discover_plugins()
        for meta in metas:
            item = QListWidgetItem(f"  {meta.description}")
            item.setData(Qt.ItemDataRole.UserRole, meta.name)
            self.nav_list.addItem(item)

        # 首页占位
        self.stack.addWidget(QLabel("👈 选择一个插件开始使用"))

    def _on_nav_changed(self, index: int):
        """切换插件"""
        if index < 0:
            return
        item = self.nav_list.item(index)
        if not item:
            return
        plugin_name = item.data(Qt.ItemDataRole.UserRole)

        widget = self.plugin_manager.activate_plugin(plugin_name)
        if widget:
            # 插件缓存自己的 widget,切换时保留并复用
            if self.stack.indexOf(widget) < 0:
                self.stack.addWidget(widget)
            self.stack.setCurrentWidget(widget)

核心模块

browser_manager —— 浏览器隔离与代理

这个模块解决的是「多账号多开浏览器」的问题。基于 gologin 的浏览器指纹隔离能力,配合 selenium 做自动化:

## plugins/browser_manager/main.py
from plugin_api import BasePlugin
from PyQt6.QtWidgets import QWidget, QVBoxLayout, QPushButton, QLabel

class Plugin(BasePlugin):
    def initialize(self, config: dict) -> bool:
        self._gologin_api = None
        self._profiles = {}
        return True

    def get_widget(self) -> QWidget:
        if self._widget:
            return self._widget

        w = QWidget()
        layout = QVBoxLayout(w)

        self._label = QLabel("浏览器管理器就绪")
        self._btn_launch = QPushButton("启动新浏览器")
        self._btn_launch.clicked.connect(self._launch_browser)

        layout.addWidget(self._label)
        layout.addWidget(self._btn_launch)

        self._widget = w
        return w

    def _launch_browser(self):
        # 调用 gologin 启动一个隔离的浏览器实例
        profile_id = "xxx"
        self._label.setText(f"启动浏览器 profile={profile_id}")

    def on_activate(self):
        pass

    def on_deactivate(self):
        pass

email_service —— IMAP 邮件服务

## plugins/email_service/main.py
import imaplib
from plugin_api import BasePlugin
from PyQt6.QtWidgets import QWidget, QVBoxLayout, QLineEdit, QTextEdit, QPushButton

class Plugin(BasePlugin):
    def initialize(self, config: dict) -> bool:
        self.host = config.get("host", "")
        self.port = config.get("port", 993)
        self.username = config.get("username", "")
        self.password = config.get("password", "")
        self._connection = None
        return True

    def get_widget(self) -> QWidget:
        if self._widget:
            return self._widget
        # ... UI 构建省略
        self._widget = QWidget()
        return self._widget

    def on_activate(self):
        pass

    def on_deactivate(self):
        if self._connection:
            try:
                self._connection.logout()
            except Exception:
                pass

踩坑记录与经验教训

1. PyQt6 信号槽:类型安全是个坑

PyQt6 对信号槽的类型检查比 PyQt5 严格得多。比如 currentRowChanged 传的是 int,如果你声明成 str 就会报错。踩过几次之后我学乖了:connect 之前先看文档确认参数类型。

2. 插件 UI 的生命周期管理

插件缓存 widget 时,切换页面应复用已有对象。不能 deleteLater 后仍保留插件的 _widget 引用,否则切回时可能访问已删除的 Qt 对象。真正卸载时统一移出堆叠窗口、释放资源并清空引用。

3. 动态导入的坑

importlib 动态加载插件时,如果插件里有相对导入(from .utils import xxx),需要特别注意模块的 __package__ 设置。我的解决方案是:插件目录都作为顶层包处理,插件内部用绝对导入。

## 错误示范:插件里这样写会报错
## from .utils import helper  # ❌ relative import fails

## 正确做法
## from plugins.browser_manager.utils import helper  # ✅ absolute import

4. SQLite 多线程访问

SQLite 在多线程环境下容易报 database is locked。我的做法是:所有数据库操作都通过一个带锁的队列统一处理,或者简单粗暴地给连接加 timeout 参数:

import sqlite3
conn = sqlite3.connect("data/platform.db", timeout=10)
conn.execute("PRAGMA journal_mode=WAL")  # 启用 WAL 模式,改善读写并发,但仍只有一个写事务

WAL 模式对读多写少的场景效果立竿见影。

5. Windows 下 PyQt6 的 DPI 适配

高分屏下 PyQt6 界面模糊是个经典问题。解决方案:

## main.py 入口处
import os
os.environ["QT_AUTO_SCREEN_SCALE_FACTOR"] = "1"

from PyQt6.QtWidgets import QApplication
from PyQt6.QtCore import Qt
## Qt6 默认启用 HighDpiScaleFactorRoundingPolicy
## 如果还模糊,手动设置:
QApplication.setHighDpiScaleFactorRoundingPolicy(
    Qt.HighDpiScaleFactorRoundingPolicy.PassThrough
)

6. PowerShell 部署脚本的坑

Windows 上 setup_env.ps1 配环境,最大的坑是 Python 路径问题。PowerShell 的 python 命令可能指向 Microsoft Store 的占位符(如果你没装 Python 的话)。建议用 py 启动器:

## setup_env.ps1
py -m venv .venv
.\.venv\Scripts\Activate.ps1
pip install -r requirements.txt

一些不成熟的想法

  1. 插件热重载:目前改插件代码需要重启应用。理论上可以用 importlib.reload() 实现热重载,但 Qt widget 的资源释放是个大坑,没敢碰。

  2. 插件间通信:现在插件之间是完全隔离的。如果要做插件间通信(比如 browser_manager 通知 email_service 发邮件),需要一个事件总线机制。Qt 自带的信号槽其实可以实现,但目前没做。

  3. 插件商店:理想情况下可以做一个插件市场,用户按需下载。目前插件都是本地手装的,没搞远程分发。

  4. 对比 Wails:如果用 Go + Wails 做类似的事,好处是编译成单文件、体积小、性能好;坏处是 UI 定制不如 Qt 灵活,而且 Go 写桌面 UI 的生态远不如 Qt 成熟。Python + PyQt6 的优势是开发快、生态好、UI 漂亮,代价是打包体积大(用 PyInstaller 打出来至少 50MB+)。

总结

launcher_qt 目前还是个玩具级别的项目,但插件化架构的思路是通用的。核心就三件事:

  1. 定义接口(plugin_api.py)—— 约定大于配置
  2. 动态加载(plugin_manager.py)—— importlib + 反射
  3. 生命周期管理 —— 初始化、激活、停用、卸载

如果你也在折腾类似的桌面工具框架,欢迎交流。项目虽小,踩坑不少,希望对你有参考价值。


我是菜牙爱吃菜,一个在 Go/Python 之间反复横跳的开发者。

插件信任边界

importlib 会执行插件代码,插件与宿主同进程不形成安全沙箱。只加载可信目录并校验插件名,初始化失败和卸载时仍需补齐模块、后台任务及 UI 资源清理。

PythonPyQt6SQLite