2026/6/16

Python 桌面应用开发实战:我的工具如何从 200 行脚本长成 3000 行产品

PythonPyQt5架构设计桌面应用重构

半年前我写了一个 Python 脚本:调用 TinyPNG API,压缩图片,完事。大概 200 行,够用。

然后用户开始提需求——“能不能加水印?""能不能批量重命名?""能同时用多个 API Key 吗?”

每个需求单独看都不大。但当它们堆在一起,那个 200 行的脚本开始变得难以维护。一个功能改了,另一个功能崩了。全局变量到处飞,没有模块边界,没有状态管理。

于是我开始重构。半年后,它长成了一个 3000 行的 PyQt5 桌面应用,3 个独立功能模块,双语国际化,自动构建和发布。

这篇文章不是教程,而是一次真实的重构复盘——我做了什么选择、为什么做、代价是什么。

01. 第一阶段:单一脚本,能跑就行

最初的架构非常简单:

组件实现
GUI无(命令行参数)
压缩逻辑一个函数:compress(file_path, api_key)
API Key硬编码在脚本里
状态管理
错误处理简单的 try/except

它用 requests 调用 TinyPNG API,读取图片文件,上传,下载压缩结果,保存。

最大问题:每次要压缩不同目录的图片都得改脚本。

这在只有我自己用时没问题。当我把工具分享给朋友后,问题来了——朋友不会改 Python 脚本。

02. 第二阶段:加 GUI,但架构没跟上

为了让朋友也能用,我加了 PyQt5 GUI。这一阶段犯了一个典型错误:直接把逻辑和 UI 塞在一起

压缩按钮的点击回调里,写着完整的 API 调用逻辑:

def on_compress_clicked(self):
    for file in self.file_list:
        response = requests.post("https://api.tinypng.com/shrink",
                                 auth=(self.api_key, ""),
                                 data=open(file, "rb"))
        # 50 行处理逻辑...
        # 错误处理...
        # 结果展示...
        # UI 更新...

一个函数做了 5 件事。加个进度条都要小心翼翼。

更糟的是,PyQt 的 GUI 线程不能阻塞。我被迫引入 QThread,但没有明确的设计——每个新功能都自己开线程,状态同步全靠信号,出 bug 时根本不知道是哪个 Worker 出了问题。

问题表现
职责混杂UI 里夹杂 API 调用、文件处理、错误上报
线程失控每个功能各自造 QThread,无统一管理
状态混乱API Key 在 3 个不同地方被读取
扩展困难每加一个新功能,MainWindow 膨胀一大截

三个月后,MainWindow 接近 800 行,已经没人敢动了。

03. 第三阶段:模块化重构

重构的核心思路是 按职责分模块,用 Worker 模式解耦线程

3.1 统一 Worker 模式

每个耗时操作都封装为独立的 QThread Worker:

Worker职责信号
CompressWorker调用 TinyPNG API,处理压缩/转换/缩放progress, file_done, all_done
WatermarkWorker读取图片、合成水印、保存输出progress, file_done, all_done
RenameWorker模板替换、文件名冲突检测、重命名progress, file_done, all_done

它们继承同一个基类模式,接口一致,主窗口只需要连接信号,不需要关心内部逻辑。

3.2 KeyManager 独立管理

API Key 从各个散落的地方抽出来,统一由 KeyManager 管理:

class KeyManager:
    def __init__(self):
        self.keys = []       # 所有 Key
        self.lock = Lock()   # 线程安全
        self.index = 0       # 轮询指针

    def acquire_key(self):
        with self.lock:
            for i in range(len(self.keys)):
                idx = (self.index + i) % len(self.keys)
                key = self.keys[idx]
                if not key.in_use and not key.disabled:
                    key.in_use = True
                    self.index = (idx + 1) % len(self.keys)
                    return key
            return None

    def release_key(self, key):
        with self.lock:
            key.in_use = False

有了这个抽象层,并发压缩变得简单:Worker 从 KeyManager 拿 Key,用完归还,KeyManager 自动做轮询、配额检查和失效禁用。

3.3 i18n 系统

加上双语支持时,我不想给每个窗口写两套 UI 代码。于是做了一个简单的 Translator 类:

  • 用 JSON 存翻译,点号语法查找
  • 支持 {变量} 替换
  • 语言切换时触发回调,UI 自动刷新
class Translator:
    def __init__(self, lang="zh"):
        self.lang = lang
        self._listeners = []

    def t(self, key, **kwargs):
        text = self._data.get(self.lang, {}).get(key, key)
        return text.format(**kwargs)

    def on_language_change(self, callback):
        self._listeners.append(callback)

    def switch(self, lang):
        self.lang = lang
        for cb in self._listeners:
            cb()

窗口在初始化时注册回调,语言切换后所有 label、button、table header 自动刷新,不需要重启。

04. 第四阶段:CI/CD 与自动发布

模块化之后,发布成了一个新瓶颈——每次改完代码要手动跑 PyInstaller、上传到网盘、通知用户。

GitHub Actions 解决了这个问题:

git tag v1.5.0
git push --tags

自动触发构建流程:

  1. 读取 tag 版本号,注入 .spec 文件
  2. pip install 依赖
  3. PyInstaller 打包成 .exe
  4. 上传到 GitHub Releases
指标手动发布CI/CD 自动发布
一次发布耗时~15 分钟~3 分钟
发布频率想发但嫌麻烦修完 bug 就发
镜像版本遗漏偶尔从不
回滚找旧文件Git tag 直接切

05. 我学到的 5 条经验

如果让我重新来一次,我会告诉自己这些事:

① 小工具也要考虑架构 200 行脚本可以没有架构。但当它要加 GUI 时,先花一天设计模块边界,能省后面两个月的重构时间。

② Worker 模式是 PyQt 的最佳实践 QThread 本身不可怕,可怕的是每个功能都自己造轮子。统一 Worker 基类,定义清晰的信号接口,所有并发问题在一个地方解决。

③ 全局变量是万恶之源 API Key 在 3 个地方读取、状态在 5 个地方修改——这种耦合让改一个功能变得不可能。用单一管理类收拢状态,代价最小,收益最大。

④ i18n 要尽早做 我的应用是中文界面。当用户问”有没有英文版”时,我花了 3 天把所有硬编码的字符串从代码里抽出来。如果在第一天就用 JSON 存字符串,只需要 3 小时。

⑤ CI/CD 不是大项目的专利 手动发布 3 次之后,人就会犯错——忘记注入版本号、忘记更新依赖、忘记上传。花半天配置 GitHub Actions,之后每次发布按一个按钮就行。

06. 现在的架构总览

组件职责
UIMainWindow + 5 Tab用户交互、文件拖拽、配置管理
WorkerCompress / Watermark / Rename后台任务、进度上报
核心KeyManager / TranslatorAPI Key 调度、国际化
数据JSON 配置文件持久化配置和压缩历史
构建PyInstaller + GitHub Actions打包、发布、分发

3000 行代码,5 个模块,每个模块只做一件事。

👉 如果你也在做桌面应用下载 TinyOpt 看看效果,源码也在本地可以翻开看看。架构不一定完美,但每一步重构都有当时的理由——那些理由比代码本身更有价值。