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
一些不成熟的想法
-
插件热重载:目前改插件代码需要重启应用。理论上可以用
importlib.reload()实现热重载,但 Qt widget 的资源释放是个大坑,没敢碰。 -
插件间通信:现在插件之间是完全隔离的。如果要做插件间通信(比如 browser_manager 通知 email_service 发邮件),需要一个事件总线机制。Qt 自带的信号槽其实可以实现,但目前没做。
-
插件商店:理想情况下可以做一个插件市场,用户按需下载。目前插件都是本地手装的,没搞远程分发。
-
对比 Wails:如果用 Go + Wails 做类似的事,好处是编译成单文件、体积小、性能好;坏处是 UI 定制不如 Qt 灵活,而且 Go 写桌面 UI 的生态远不如 Qt 成熟。Python + PyQt6 的优势是开发快、生态好、UI 漂亮,代价是打包体积大(用 PyInstaller 打出来至少 50MB+)。
总结
launcher_qt 目前还是个玩具级别的项目,但插件化架构的思路是通用的。核心就三件事:
- 定义接口(
plugin_api.py)—— 约定大于配置 - 动态加载(
plugin_manager.py)——importlib+ 反射 - 生命周期管理 —— 初始化、激活、停用、卸载
如果你也在折腾类似的桌面工具框架,欢迎交流。项目虽小,踩坑不少,希望对你有参考价值。
我是菜牙爱吃菜,一个在 Go/Python 之间反复横跳的开发者。
插件信任边界
importlib 会执行插件代码,插件与宿主同进程不形成安全沙箱。只加载可信目录并校验插件名,初始化失败和卸载时仍需补齐模块、后台任务及 UI 资源清理。