Python Desktop Application Packaging Guide

Python Desktop Application Packaging Guide

Astrid Stark Lv. ∞

写完 Python 脚本,还想要脚本的功能在无 Python 环境下运行,就需要通过将 Python 解释器、第三方库依赖和源代码封装进一个独立的可执行环境(也就是打包)来实现。

一、创建虚拟环境并确定依赖

如果想要打包出来的文件尽可能的小,就需要精准地确定脚本所依赖的第三方库。如果在全局环境下直接打包,就会导致“幽灵依赖”现象:脚本没有用到,但开发其他项目用到的、安装在全局的 Python 库也被打包进了应用。

首先,在项目文件夹下创建并激活一个虚拟环境:

1
2
3
python -m venv .venv # 创建虚拟环境
.venv\Scripts\activate # 激活虚拟环境
deactivate # 推出虚拟环境

激活虚拟环境后,如果清楚自己写代码时依赖了哪些库,可以直接在项目文件夹下运行 pip install 指令将第三方库下载到虚拟环境中。如果不清楚,则可以使用 pipreqs 库来确定依赖。

安装 pipreqs 库:

1
pip install pipreqs

在全局环境下切到项目目录,然后使用 pipreqs 将项目依赖输出为 requirements.txt

1
pipreqs ./ --encoding=utf8 --force

然后再返回虚拟环境,执行:

1
pip install -r requirements.txt

注意事项:

  • 不推荐直接用 pip freeze > requirements.txt 来导出依赖,因为有可能会连带装入很多非直接依赖。
  • pipreqs 不能被安装到创建的虚拟环境中,必须安装在全局环境中,因为如果将 pipreqs 按照在虚拟环境中,后续也会将其打包进去。

二、几种常见打包工具介绍

常见的打包工具包括 PyInstaller,Nuitka,Auto-py-to-exe 和 cx_Freeze。以下是它们的具体特点介绍:

打包工具 核心原理 优势 劣势
PyInstaller 自解压打包 (将Python解释器、源码字节码和DLL 压成一个包) 生态最好,几乎所有报错都能在 Stack Overflow 搜到 启动慢(运行时需要临时解压);极易被反编译(用 pyinstxtractor 即可反编译源码)
Nuitka 把 Python 翻译成 C/C++,再调用 gcc/msvc 编译成机器码 启动速度极快;安全性好(纯机器码,几乎无法反编译);运行效率有一定提升 需要 C 语言编译环境(但在打包过程中会自动智能提示并下载 MinGW64);打包耗时长
Auto-py-to-exe 底层基于 PyInstaller,外层套 Web UI 壳 提供友好的 Web 交互界面 继承了 PyInstaller 的优缺点
cx_Freeze 结构分离打包 在处理 PyQt/PySide 等大型跨平台 GUI 框架时,有时表现更稳定,若使用其他工具持续报错可作为备选方案 相对小众,生态不如其他工具

打包时一般会选择 PyInstaller 或 Nuitka 作为打包工具。以下是这两种工具的常用指令参数:

PyInstaller 指令参数:

指令参数 作用
-F (或 --onefile)
-D (或 --onedir)
打包形态:
-F:单文件模式。将所有内容打包成一个独立的 .exe。优点是便于分发;缺点是启动慢(运行时需在后台临时解压整个环境)。
-D:单目录模式(默认)。打包成一个包含 .exe 和所有依赖库的文件夹。启动极快,推荐桌面软件使用。
-w (或 --windowed)
-c (或 --console)
控制台显示:
-w:隐藏黑色命令行窗口。开发 Tkinter/PyQt 等 GUI 桌面应用时必备,否则用户会看到一个黑框一直在后面。
-c:显示命令行窗口(默认)。适合纯后端代码或需要查看 print 报错信息的调试阶段。
-i icon.ico 设置图标:为生成的软件设置自定义的 .ico 图标。
--hidden-import MODULENAME 隐式依赖打包:强制打包某些代码里没有直接 import,但底层动态调用的库。解决打包后报“找不到某某模块”的核心参数。
--add-data "src;dest" 添加静态资源:将外部文件(如图片、配置文件)打包进去。注意:Windows 路径分隔符用分号 ;,Mac/Linux 用冒号 :
--add-binary "src;dest" 添加动态库:强行打包第三方缺失的 .dll.so 动态链接库文件。
--exclude-module NAME 排除无用模块:主动排除掉不需要的库,降低打包体积。
-n NAME 自定义名称:自定义生成的 .exe.dist 产物文件夹的名称。如果不填,默认使用脚本的文件名。
--clean 清理缓存:运行前清理 PyInstaller 上一次打包留下的临时缓存文件。

Nuitka 指令参数:

指令参数 作用
--standalone
--onefile
打包形态:
--standalone:独立文件夹模式。生成包含所有运行环境(解释器、DLLs)的文件夹。GUI 桌面软件的首选,能够发挥 Nuitka 的秒开优势。
--onefile:单文件模式。将独立文件夹用算法压成单文件 .exe。致命代价是双击时会在后台临时解压几十 MB 的 DLL,导致肉眼可见的启动延迟,损失了 Nuitka 的核心性能优势。
--windows-disable-console 隐藏控制台:在 Windows 系统下隐藏黑色命令行控制台(作用等同于 PyInstaller 的 -w)。
--windows-icon-from-ico=X 设置图标:为生成的软件设置自定义的 .ico 图标。
--output-dir=DIR_NAME 指定输出目录:指定打包产物(.build.dist 文件夹)的存放路径,保持项目根目录整洁。
--enable-plugin=tk-inter Tkinter 专项:自动抓取并打包 Tkinter / CustomTkinter 底层必需的 Tcl/Tk 动态库。
--enable-plugin=pyside6 PySide6 专项:完美处理 PySide6 依赖。其死代码消除能力能够自动剔除未被使用的 Qt 模块,大大降低打包体积。
--enable-plugin=numpy 科学计算专项:自动处理 NumPy / Scipy 等科学计算库底层的 C/Fortran 复杂依赖。
--enable-plugin=anti-bloat 依赖优化:自动拦截并剔除很多库(如 requests, pandas)中为了跨平台兼容性而引入的无用庞大依赖。
--include-package-data=X 强行打包包内数据:将某个 Python 包内自带的数据文件(如 json, txt)强行打包进去。处理 CustomTkinter 报错“找不到 theme.json”时必用。
--include-data-dir=A=B 引入整个文件夹:将项目中的整个文件夹 A 复制到打包结果的 B 路径下。
--show-progress 显示编译进度:在控制台显示详细的 C 语言代码转换和 GCC/MSVC 编译进度条。

如果选择使用 Nuitka 进行打包,且进行了多次打包(如调试 Nuitka 打包参数),不需要删除第一次打包时生成的 x.build 文件夹。这里存放的是翻译出的 C 源码和编译好的中间目标文件(.o / .obj)。如果不删除这个文件夹,再次运行编译会触发 Nuitka 的增量编译机制,复用之前编译时的 Cache,从而极大加快打包速度。

三、基于 GUI 框架考虑的打包工具选择

Python 本身不具备画窗口的能力。所有的 GUI 框架,本质上都是在“套壳”调用底层的 C/C++ 动态链接库。打包工具无法确定该把哪些 .dll 打包进去,因此可能会导致生成的可执行文件在其他环境下因缺少依赖库无法运行,或者为了保险起见,强行塞入大量无关的库,导致应用体积过大。

以下是基于三种主流 GUI 框架的打包问题及解决办法:

GUI 框架 框架特点 问题 PyInstaller 解决办法 Nuitka 解决办法
Tkinter / CustomTkinter 轻量级库,底层依赖 Tcl/Tk,打包后的整体体积控制得较好,适合中小型工具。 打包工具容易遗漏框架自带的静态资源,必须强制声明包含。 必须在命令中加复杂的 --add-data 参数手动指明皮肤文件路径。 --enable-plugin=tk-inter 抓取底层 DLL,加 --include-package-data=customtkinter 强行把 JSON 皮肤文件包进去。
PyQt5 / PySide6 功能极强,生态完善,但由于包含大量底层 C++ 库,非常笨重。 会把整个 Qt 框架全部打包,打包后应用体积巨大。 必须手动写大量 --exclude-module 排除无用依赖。 使用 --enable-plugin=pyside6。其死代码消除能力能够自动剔除未使用的模块,极大提高启动速度并减小体积。
PyWebview / Eel / NiceGUI 前端(HTML / CSS / JS)写界面,Python 写后端逻辑。 打包成 .exe 后,运行时的解压路径变化,如果在代码里使用绝对路径或相对路径,运行必定报错。 必须利用 sys._MEIPASS 等内置变量动态切换、定位静态资源的绝对路径。 需要处理 Nuitka 的临时路径寻址问题,或使用 --include-data-dir 强行打包整个前端文件夹。

前端隔离机制: 在用前端技术构建桌面 UI 时,为了避免界面样式冲突,强烈建议利用 Shadow DOM 技术来封装 UI 组件,这样能有效防止桌面应用内外样式的相互污染,保证界面的独立和稳定。

四、常见问题排解与修复

打包选项:单文件 or 文件夹?

如果是小工具或者是轻量脚本,可以选择单文件模式。此时,单文件便携性较好,且运行性能与文件夹无太大差异。

但如果是带 GUI 界面、需要快速启动的桌面软件,则应该选择文件夹模式。

以 Nuitka 为例,如果使用 --onefile 参数重新打包,Nuitka 会用 Zstandard 算法将 dist 文件夹压缩,运行应用时会在 ~AppData/Local/Temp 下建立临时文件夹并解压其包装的 DLL 文件,这将导致极高的运行时延迟。

路径问题:单文件模式下资源定位失效

在单文件模式下,程序运行时会被解压到系统的临时目录中执行。此时,如果在代码中使用相对路径或通过 os.getcwd() 获取当前工作目录,获取到的实际上是用户双击程序所在的外部目录(例如桌面),而不是程序实际运行的临时解压目录。

这种路径错位会导致程序无法找到内置的静态资源(如 UI 图标、配置文件、HTML 模板等),从而引发运行时报错。

修复方案:使用动态路径定位

为了确保无论在开发环境还是打包后的单文件环境中都能正确加载资源,必须使用脚本运行时的绝对路径。

  • 常规与 Nuitka 方案: 可以通过 Python 内置变量 __file__ 获取当前执行文件的真实绝对路径,从而精准定位临时目录。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    import os
    from PIL import Image
    import customtkinter as ctk

    def _load_icon(self, filename):
    """安全加载图标的辅助函数,兼容单文件模式的绝对路径查找"""
    # 获取当前运行代码文件所在的真实绝对路径
    base_path = os.path.dirname(os.path.abspath(__file__))
    icon_path = os.path.join(base_path, "icons", filename)

    if os.path.exists(icon_path):
    return ctk.CTkImage(light_image=Image.open(icon_path),
    dark_image=Image.open(icon_path),
    size=(22, 22))
    return None
  • PyInstaller 方案: PyInstaller 会将临时目录的路径挂载到 sys._MEIPASS 变量中,可以使用以下通用代码段进行动态切换:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    import sys
    import os

    def get_resource_path(relative_path):
    if hasattr(sys, '_MEIPASS'):
    # PyInstaller 运行时的临时目录
    return os.path.join(sys._MEIPASS, relative_path)
    # 本地开发时的目录
    return os.path.join(os.path.abspath("."), relative_path)

在使用 Nuitka 打包时,除了修改代码路径,也应该确保打包命令中已加入 --include-data-dir=icons=icons 参数,显式将静态文件夹打包进程序。

杀毒软件误报问题

由于打包工具的单文件模式行为(在后台静默释放文件到临时目录并执行)与许多恶意软件的特征高度相似,生成的 .exe 文件极易被 Windows Defender 或 360 等杀毒软件拦截或直接删除。

在这一点上,Nuitka 编译生成的原生机器码具有明显优势,其运行逻辑与常规 C/C++ 程序一致,被误报的概率显著低于 PyInstaller。如果应用受众是普通用户,强烈推荐使用 Nuitka 的文件夹模式进行分发。

高分辨率屏幕 GUI 模糊问题

在 Windows 系统中,如果用户的显示器开启了缩放(如 125%、150%),使用 Tkinter 等部分框架打包后的 GUI 界面可能会出现严重的字体发虚和图像模糊。这是因为系统默认对未声明 DPI 感知的程序进行了强制拉伸。

修复方案: 可以在 Python 主程序的入口处加入以下调用 Windows API 的代码,强制声明程序感知系统的 DPI 缩放,即可恢复界面的原生高清显示:

1
2
3
4
5
6
7
import ctypes

try:
# 设定进程的 DPI 感知级别 (2 = Per Monitor DPI Aware)
ctypes.windll.shcore.SetProcessDpiAwareness(2)
except Exception:
pass # 忽略低版本 Windows 系统的调用异常

五、打包工具选择总结

开发框架 首选打包工具 注意事项
原生 Tkinter PyInstaller (单文件)
CustomTkinter Nuitka (文件夹模式) 必须显式声明包含其自带的 theme 主题资源文件
PyQt / PySide Nuitka / cx_Freeze 体积较大;容易少打包底层的 C++ 动态库
Flet / PyWebview PyInstaller HTML / 静态资源在打包后的绝对路径寻址问题