← 代码课堂

第 07 课:类与信号槽 —— 桌面应用骨架

难度:★★★(需要会 Python 的类和对象)
教材:guigubahuang_mod_manager/(鬼谷八荒 Mod 管理器)
预计时间:讲解 70 分钟 + 练习 50 分钟
学完你能:看懂 PyQt/PySide 桌面程序的结构,理解「信号与槽」,并独立给界面加一个按钮功能


课前须知:桌面程序的特别之处

网页(第 05 课)跑在浏览器里;桌面程序直接跑在操作系统上,好处是:

Python 做桌面界面最主流的库是 Qt。它有两套 Python 绑定:

PyQt6(本项目用的)PySide6
出品方第三方 RiverbankQt 官方
许可证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 之后:

  1. 如果电脑装了游戏,它会自动找到游戏目录(怎么找到的?本课会讲)
  2. 没装游戏也没关系:会提示「未检测到游戏路径,请在设置中配置」,

在「设置」里随便选一个文件夹,也能体验界面

  1. 玩一玩:工具栏按钮、列表双击启用/禁用、标签页切换、状态栏提示

观察重点:你点每一个按钮,界面都有反应——但代码里并没有「一直盯着按钮」的循环。
这是怎么做到的?答案就是本课核心:信号与槽。


第二步:模块地图

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 课「分层」在桌面程序里的落地。


第三步:逐块精讲

块 1: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()

命令行程序从头跑到尾就结束;桌面程序会一直等:
等你点按钮、等你拖窗口、等你关掉它。所有「点击响应」都由这个循环分发。

这样 from gui.main_window import ... 才能找到(和第 01、05 课讲过的入口技巧同理)

块 2:主窗口的初始化顺序(第 24-35 行)

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 行)的逻辑很产品化:

读配置里的游戏路径 → 无效就自动检测 → 检测到就写回配置 → 创建三个管理器对象 → 刷新列表

这就是「打开软件就能用」的背后流程。

块 3:信号与槽 —— 本课核心(第 47-87 行、105-106 行)

看这些按钮的创建:

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 的「信号与槽」:

和普通函数调用比一比:

写法谁主动
普通调用self.refresh_mods()你主动叫它
信号与槽clicked.connect(self.refresh_mods)事件发生时自动叫它

生活比喻:普通调用是「你打电话叫外卖」;信号与槽是「你设了闹钟,时间到它自己响」。
桌面程序不可能用 while 循环盯着几百个按钮,所以用「事件发生 → 自动通知」的模式。

和第 06 课的 EventBus 对比:那 20 行手写的「发布/订阅」,
和 Qt 的信号槽是同一个思想——只不过 Qt 把它做进了框架里,控件自带信号。
理解了一个,就理解了两个。

块 4:界面和数据怎么对应(第 212-234 行)

列表里每一行都要对应一个 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)

可以塞任意 Python 对象

这是个非常实用的技巧:界面控件负责显示,业务对象负责数据,两者用暗格绑定。

块 5:分层的铁律(对照第 06 课)

打开 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,绝不反过来。好处:

  1. core 可以脱离界面单独测试(练习 4 会让你体验)
  2. 以后想换成别的界面库(PySide6、网页、命令行),core 一行都不用改
  3. 界面代码通常又长又琐碎,逻辑代码干净独立,才好维护

块 6: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)

两个亮点:

这样程序装在只读目录也能正常保存设置。这是桌面软件的常见约定。

这样程序升级新增了配置项,老用户的配置文件也不会出错。非常实用的小技巧。

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

这段代码用正则表达式把这个文件里的路径抠出来

找不到就扫 C: 到 F: 盘符;自动检测 + 手动兜底,这就是产品思维

取第一个括号里捕获的部分")。不会正则也没关系,知道它在"从一堆文本里提取路径"即可

mod_manager.py(161 行)—— 管理 Mod 的增删启停

用 seen 集合去重,最后按"启用优先 + 名字"排序

有 Villain.json 就是 Villain,有 .dll 就是 MELoader……**又是一串 if 判断,
等价于一张「特征 → 框架」的规则表**

放在 Mods/ 就是启用,移到 Mods/.disabled/ 就是禁用。游戏只读 Mods/,
所以这个办法简单又可靠。好方案往往很朴素。

已存在就抛 FileExistsError(界面层捕获后弹警告)

backup_manager.py(128 行)—— 备份与恢复

里面放一份清单 backup_manifest.json(记录时间、描述、备份了哪些目录)

生成器表达式 + 递归遍历,是 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)})

省掉"先判断存在再 append"的样板代码

gui/install_dialog.py / settings_dialog.py:
弹出式对话框。核心用法是 dialog.exec()——它会"卡住"主窗口直到用户点了确定/取消,
然后返回 Accepted 或 Rejected(主窗口第 272、414 行都这么用)。


第四步:对照开源,别人怎么写桌面程序

对照 PyQt6 vs PySide6

两套库的 API 几乎一模一样,项目里的代码换成 PySide6 只需要改 import:

# PyQt6(教材项目)
from PyQt6.QtWidgets import QApplication, QPushButton
# PySide6
from PySide6.QtWidgets import QApplication, QPushButton

差别主要在许可证:PyQt6 是 GPL(闭源商用要买授权),PySide6 是 LGPL(商用更宽松)。
自己学习和开源项目用哪个都行;将来做商业软件可以优先考虑 PySide6。

对照其他 GUI 方案

方案特点适合
PyQt6 / PySide6控件丰富、跨平台、生态大正经桌面软件
tkinterPython 自带、零安装、界面朴素临时小工具
Electron / Tauri用网页技术做桌面前端团队、界面要求高
命令行 CLI最轻、能自动化开发者工具(第 06 课的 cli/)

「鬼谷 Mod 管理器」如果换成 tkinter,界面会难看得多;
换成 Electron,又要多学一整套网页技术。选 Qt 是这类工具软件的合理选择。

对照:事件循环 vs 网页

网页的「事件驱动」由浏览器负责(第 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 课说的"项目完整度"。


本课小结

"移动文件夹"实现启用/禁用

术语表

术语人话解释
Qt一个跨平台 GUI 框架
PyQt6 / PySide6Qt 的两套 Python 绑定,API 几乎相同
QApplicationQt 程序中唯一的「总管家」对象
事件循环(exec)桌面程序的主循环:一直等待并分发事件
信号 / 槽事件(信号)和响应函数(槽)的自动连接机制
QMainWindow / QWidget主窗口 / 基础控件容器
布局(Layout)自动排布控件的规则(不用手算坐标)
模态对话框弹出后必须先处理它(比如确认框),exec() 实现
shutilPython 标准库,负责复制/移动/删除文件目录
defaultdict字典的自动初始化版本,省样板代码

下节预告

第 08 课:FastAPI 入门:从零到接口。
我们回到 Web 世界,看 worry_debate_game/app.py 怎么用现代框架
几行代码就搭出 API——并和你在「查资料」里手写的 HTTP 服务器对比,
你会真切感受到"框架帮你省掉了哪些活"。这是 AI 应用技术链的第一课。