难度:★★★(需要会 Python 的类和对象)
教材:guigubahuang_mod_manager/(鬼谷八荒 Mod 管理器)
预计时间:讲解 70 分钟 + 练习 50 分钟
学完你能:看懂 PyQt/PySide 桌面程序的结构,理解「信号与槽」,并独立给界面加一个按钮功能
网页(第 05 课)跑在浏览器里;桌面程序直接跑在操作系统上,好处是:
Python 做桌面界面最主流的库是 Qt。它有两套 Python 绑定:
| PyQt6(本项目用的) | PySide6 | |
|---|---|---|
| 出品方 | 第三方 Riverbank | Qt 官方 |
| 许可证 | GPL / 商业 | LGPL(商用更友好) |
| API | 几乎完全一样 | 几乎完全一样 |
两套代码基本可以互换,只是 import 的名字不同。 你项目里用的是 PyQt6
(from PyQt6.QtWidgets import ...),以后看到 PySide6 的教程也不用慌,把名字换掉即可。
安装依赖并运行:
cd guigubahuang_mod_manager
py -3 -m pip install PyQt6
py -3 main.py
小提示:这个项目目前没有
requirements.txt和 README。
可以试着补上(利用第 06 课学的 requirements.txt 或 pyproject 都行),这是项目完整度的体现。
运行 py -3 main.py 之后:
在「设置」里随便选一个文件夹,也能体验界面
观察重点:你点每一个按钮,界面都有反应——但代码里并没有「一直盯着按钮」的循环。
这是怎么做到的?答案就是本课核心:信号与槽。
guigubahuang_mod_manager/
├── main.py 入口:创建 Qt 应用 + 显示主窗口
│
├── gui/ 界面层(认识 Qt 控件)
│ ├── main_window.py 主窗口(418 行):工具栏/列表/详情/状态栏
│ ├── install_dialog.py 安装 Mod 的弹窗
│ └── settings_dialog.py 设置弹窗
│
└── core/ 逻辑层(一行 Qt 代码都没有)
├── config.py 读写用户配置(~/.guigubahuang_mod_manager/)
├── game_path.py 自动检测游戏安装位置
├── mod_manager.py 扫描 / 安装 / 启用 / 禁用 / 卸载 Mod
├── backup_manager.py 备份与恢复(复制整个目录)
└── conflict_detector.py 检测 Mod 之间的文件冲突
先记住这条铁律:gui/ 可以 import core/,但 core/ 绝不 import PyQt。
这就是第 06 课「分层」在桌面程序里的落地。
main.py —— Qt 程序的启动四步(第 6-22 行)from PyQt6.QtWidgets import QApplication
from gui.main_window import ModManagerWindow
def main():
app = QApplication(sys.argv) # 1. 创建「应用对象」(全局唯一)
app.setApplicationName("鬼谷八荒 Mod管理器")
window = ModManagerWindow() # 2. 创建主窗口
window.show() # 3. 显示出来
sys.exit(app.exec()) # 4. 进入「事件循环」,直到窗口关闭
if __name__ == "__main__":
main()
QApplication:每个 Qt 程序有且只有一个,管理所有窗口、事件、剪贴板等app.exec():进入事件循环——这是桌面程序和命令行程序最大的区别。命令行程序从头跑到尾就结束;桌面程序会一直等:
等你点按钮、等你拖窗口、等你关掉它。所有「点击响应」都由这个循环分发。
sys.path.insert(...)(第 4 行)把项目根目录加进模块搜索路径,这样 from gui.main_window import ... 才能找到(和第 01、05 课讲过的入口技巧同理)
def __init__(self):
super().__init__()
self.config = load_config() # 1. 读配置
self.setWindowTitle("鬼谷八荒 Mod管理器")
self._setup_ui() # 2. 搭界面
self._setup_menu() # 3. 搭菜单
self._init_managers() # 4. 建逻辑对象、加载数据
对比第 03 课关系图的「先加载数据 → 再画界面 → 再绑事件」,
桌面程序常常是「先搭界面 → 再加载数据填进去」,
因为控件必须先存在,数据才有地方显示。本质都是「先有容器,再填内容」。
_init_managers()(第 178-200 行)的逻辑很产品化:
读配置里的游戏路径 → 无效就自动检测 → 检测到就写回配置 → 创建三个管理器对象 → 刷新列表
这就是「打开软件就能用」的背后流程。
看这些按钮的创建:
self.btn_refresh = QPushButton("刷新")
self.btn_refresh.clicked.connect(self.refresh_mods) # ← 关键就在这一行
self.btn_install = QPushButton("安装Mod")
self.btn_install.clicked.connect(self.install_mod)
self.mod_tree.itemSelectionChanged.connect(self._on_mod_selected)
self.mod_tree.itemDoubleClicked.connect(self._on_mod_double_clicked)
控件.信号.connect(函数) 就是 Qt 的「信号与槽」:
connect 的意思:「以后只要发生这件事,就自动调用这个函数」和普通函数调用比一比:
| 写法 | 谁主动 | |
|---|---|---|
| 普通调用 | self.refresh_mods() | 你主动叫它 |
| 信号与槽 | clicked.connect(self.refresh_mods) | 事件发生时自动叫它 |
生活比喻:普通调用是「你打电话叫外卖」;信号与槽是「你设了闹钟,时间到它自己响」。
桌面程序不可能用 while 循环盯着几百个按钮,所以用「事件发生 → 自动通知」的模式。
和第 06 课的 EventBus 对比:那 20 行手写的「发布/订阅」,
和 Qt 的信号槽是同一个思想——只不过 Qt 把它做进了框架里,控件自带信号。
理解了一个,就理解了两个。
列表里每一行都要对应一个 Python 对象(ModInfo),怎么挂上去?
def _populate_tree(self):
self.mod_tree.clear()
for mod in self.mods:
item = QTreeWidgetItem()
item.setText(COL_NAME, mod.name)
item.setText(COL_VERSION, mod.version)
...
item.setData(0, Qt.ItemDataRole.UserRole, mod) # 把对象存进这一行
def _on_mod_selected(self):
items = self.mod_tree.selectedItems()
mod = items[0].data(0, Qt.ItemDataRole.UserRole) # 再取出来
self._show_mod_details(mod)
setData(位置, 角色, 值) / data(...) 是 Qt 给每个列表项准备的「暗格」,可以塞任意 Python 对象
UserRole 是一个约定的「角色编号」,表示「这是我自己的数据」这是个非常实用的技巧:界面控件负责显示,业务对象负责数据,两者用暗格绑定。
打开 core/ 里的任意一个文件,你会发现没有一行 import PyQt6。
而 gui/main_window.py 第 11-15 行 import 了一堆 core 的东西:
from core.config import load_config, save_config
from core.game_path import detect_game_path
from core.mod_manager import ModManager, ModInfo
from core.backup_manager import BackupManager
from core.conflict_detector import ConflictDetector
依赖方向是单向的:gui → core,绝不反过来。好处:
config.py(29 行)—— 配置存哪里
CONFIG_DIR = Path.home() / ".guigubahuang_mod_manager"
CONFIG_FILE = CONFIG_DIR / "config.json"
def load_config():
if CONFIG_FILE.exists():
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
return {**DEFAULT_CONFIG, **json.load(f)} # 默认值 + 用户值 合并
return dict(DEFAULT_CONFIG)
两个亮点:
~/.xxx),而不是程序目录——这样程序装在只读目录也能正常保存设置。这是桌面软件的常见约定。
{**DEFAULT_CONFIG, **json.load(f)} 用字典合并:用户配置缺哪个字段,就自动用默认值补。这样程序升级新增了配置项,老用户的配置文件也不会出错。非常实用的小技巧。
game_path.py(66 行)—— 自动找游戏
def find_steam_library_folders():
...
for match in re.finditer(r'"path"\s*"([^"]+)"', content):
path = match.group(1).replace("\\\\", "/")
folders.append(Path(path))
libraryfolders.vdf 文件里,这段代码用正则表达式把这个文件里的路径抠出来
detect_game_path() 的逻辑:先查 Steam 库里的常见文件夹名,找不到就扫 C: 到 F: 盘符;自动检测 + 手动兜底,这就是产品思维
re.finditer / match.group(1):正则的常见用法("找到所有符合模式的内容,取第一个括号里捕获的部分")。不会正则也没关系,知道它在"从一堆文本里提取路径"即可
mod_manager.py(161 行)—— 管理 Mod 的增删启停
@dataclass ModInfo:定义"一个 Mod 有哪些信息"(名字、版本、作者、是否启用……)scan_mods():扫描两个目录——正常的 Mods/ 和禁用的 Mods/.disabled/;用 seen 集合去重,最后按"启用优先 + 名字"排序
_detect_framework()(第 90-101 行):靠特征文件判断 Mod 用的框架——有 Villain.json 就是 Villain,有 .dll 就是 MELoader……**又是一串 if 判断,
等价于一张「特征 → 框架」的规则表**
toggle_mod()(第 138-151 行):启用/禁用的实现居然是"把文件夹在两个目录之间移动"——放在 Mods/ 就是启用,移到 Mods/.disabled/ 就是禁用。游戏只读 Mods/,
所以这个办法简单又可靠。好方案往往很朴素。
install_mod():shutil.copy2(复制文件)、shutil.copytree(复制整个文件夹)、已存在就抛 FileExistsError(界面层捕获后弹警告)
backup_manager.py(128 行)—— 备份与恢复
backup_20260913_103000),里面放一份清单 backup_manifest.json(记录时间、描述、备份了哪些目录)
create_backup() 复制游戏的关键目录(Mods、MelonLoader 等)restore_backup():先删掉现有的,再从备份复制回去_get_dir_size():用 sum(f.stat().st_size for f in path.rglob("*")) 算大小——生成器表达式 + 递归遍历,是 Python 的常用组合
conflict_detector.py(49 行)—— 冲突检测(一点算法味)
file_map = defaultdict(list)
for mod in mods:
if not mod.enabled:
continue
for file_path in mod.files:
file_map[file_path].append(mod) # 文件 → 哪些 Mod 改了它
for file_path, mod_list in file_map.items():
if len(mod_list) > 1: # 超过一个 Mod 改同一文件 = 冲突
conflicts.append({"file": file_path, "mods": mod_list, "count": len(mod_list)})
defaultdict(list):字典的"自动初始化"版本——用没见过的键时自动建空列表,省掉"先判断存在再 append"的样板代码
gui/install_dialog.py / settings_dialog.py:
弹出式对话框。核心用法是 dialog.exec()——它会"卡住"主窗口直到用户点了确定/取消,
然后返回 Accepted 或 Rejected(主窗口第 272、414 行都这么用)。
两套库的 API 几乎一模一样,项目里的代码换成 PySide6 只需要改 import:
# PyQt6(教材项目)
from PyQt6.QtWidgets import QApplication, QPushButton
# PySide6
from PySide6.QtWidgets import QApplication, QPushButton
差别主要在许可证:PyQt6 是 GPL(闭源商用要买授权),PySide6 是 LGPL(商用更宽松)。
自己学习和开源项目用哪个都行;将来做商业软件可以优先考虑 PySide6。
| 方案 | 特点 | 适合 |
|---|---|---|
| PyQt6 / PySide6 | 控件丰富、跨平台、生态大 | 正经桌面软件 |
| tkinter | Python 自带、零安装、界面朴素 | 临时小工具 |
| Electron / Tauri | 用网页技术做桌面 | 前端团队、界面要求高 |
| 命令行 CLI | 最轻、能自动化 | 开发者工具(第 06 课的 cli/) |
「鬼谷 Mod 管理器」如果换成 tkinter,界面会难看得多;
换成 Electron,又要多学一整套网页技术。选 Qt 是这类工具软件的合理选择。
网页的「事件驱动」由浏览器负责(第 05 课写过 addEventListener);
桌面程序的「事件驱动」由 app.exec() 事件循环负责。同一个思想:
不要主动轮询,而是注册回调,等事件来找你。
练习 1(热身,练信号槽):加一个「关于」按钮
在工具栏加一个按钮,点击后弹出 QMessageBox.information(self, "关于", "...")。
需要三步:创建按钮 → clicked.connect(你的函数) → 写那个函数。
这一套就是你以后给任何界面加功能的标准动作。
练习 2(必做):状态栏显示选中数量
让状态栏在选中变化时显示「已选中 N 个」。
提示:self.mod_tree.itemSelectionChanged 信号已经有了(第 105 行绑到 _on_mod_selected),
在它触发的函数里用 len(self.mod_tree.selectedItems()) 算数量更新 self.status_label。
练习 3(必做):给列表加一列「文件数」
提示:顶部第 20 行有 COL_NAME, COL_VERSION, ... = range(5) 这行常量定义;_setup_ui() 里 setHeaderLabels 加表头;_populate_tree() 里多 setText 一列。
改完想一想:为什么用常量 COL_XXX 而不是直接写数字 0、1、2?
以后插入一列时,只改常量定义,不用满代码找数字
练习 4(体会分层):不用界面,直接用 core
新建一个 test_core.py,写:
from core.mod_manager import ModManager
from core.conflict_detector import ConflictDetector
mm = ModManager(".") # 用一个临时目录当游戏目录
mods = mm.scan_mods()
print("找到的 Mod:", [m.name for m in mods])
print("冲突:", ConflictDetector(mm).scan_conflicts(mods))
运行它,观察输出。然后回答:为什么这段代码不启动界面也能工作?
(答得出,说明你真正理解了分层。)
练习 5(思考题):
为什么 core 里不允许 import PyQt6?用自己的话写 2-3 行解释。
从"测试"和"换界面"两个角度想。
练习 6(选做,补完整度):
给这个项目补一个 requirements.txt(内容一行:PyQt6),
再补一个简短 README(项目是干嘛的、怎么装依赖、怎么运行)。
这就是第 06 课说的"项目完整度"。
app.exec() 事件循环控件.信号.connect(函数) 就是全部语法setData/data(UserRole) 绑定,避免全局变量gui → core 单向依赖,core 一行 Qt 都不碰,所以能独立测试和复用"移动文件夹"实现启用/禁用
Path、shutil、rglob 是桌面工具的三件常用兵器| 术语 | 人话解释 |
|---|---|
| Qt | 一个跨平台 GUI 框架 |
| PyQt6 / PySide6 | Qt 的两套 Python 绑定,API 几乎相同 |
| QApplication | Qt 程序中唯一的「总管家」对象 |
| 事件循环(exec) | 桌面程序的主循环:一直等待并分发事件 |
| 信号 / 槽 | 事件(信号)和响应函数(槽)的自动连接机制 |
| QMainWindow / QWidget | 主窗口 / 基础控件容器 |
| 布局(Layout) | 自动排布控件的规则(不用手算坐标) |
| 模态对话框 | 弹出后必须先处理它(比如确认框),exec() 实现 |
| shutil | Python 标准库,负责复制/移动/删除文件目录 |
| defaultdict | 字典的自动初始化版本,省样板代码 |
第 08 课:FastAPI 入门:从零到接口。
我们回到 Web 世界,看 worry_debate_game/app.py 怎么用现代框架
几行代码就搭出 API——并和你在「查资料」里手写的 HTTP 服务器对比,
你会真切感受到"框架帮你省掉了哪些活"。这是 AI 应用技术链的第一课。