第24篇:PyInstaller打包实战 — 从Python脚本到Windows EXE

第24篇:PyInstaller打包实战 --- 从Python脚本到Windows EXE

一、为什么需要打包?

1.1 第一性原理思考

Python 是一门解释型语言,它的运行依赖于 Python 解释器和各种第三方库。这在开发阶段非常方便------写好代码,直接 python xxx.py 就能跑。但当你要把软件交付给客户时,问题就来了:

  • 客户机器上没有 Python 环境怎么办? 总不能让客户先装 Python、再装十几个依赖库吧?
  • 版本不一致怎么办? 你用的是 Python 3.9,客户机器上是 Python 3.7,代码跑不起来谁的锅?
  • 依赖库缺失怎么办? OpenCV、NumPy、ONNX Runtime......缺一个都不行。
  • 源码暴露怎么办? 商业软件肯定不想把源代码直接给客户。

这就是"打包"要解决的核心问题:把 Python 脚本及其所有依赖,打包成一个独立的、可在目标平台上直接运行的可执行文件。客户拿到手,双击就能用,不需要装任何环境。

1.2 为什么选择 PyInstaller?

Python 打包工具有不少,比如 PyInstaller、cx_Freeze、py2exe、Nuitka 等等。GrainServer 选择了 PyInstaller,这是为什么呢?

我们从几个维度来比较:

工具 原理 单文件打包 兼容性 社区活跃度
PyInstaller 打包解释器 + 字节码 + 依赖 支持 好(Windows/Mac/Linux) 最高
cx_Freeze 打包解释器 + 源码 + 依赖 不支持 一般 一般
py2exe 仅 Windows 支持 仅 Windows 低(年久失修)
Nuitka 编译为 C 再编译为机器码 支持 上升期

PyInstaller 的优势很明显:

第一,成熟稳定。 PyInstaller 是目前最流行的 Python 打包工具,已经发展了十几年,社区活跃,遇到问题很容易找到解决方案。

第二,跨平台。 同一个 spec 文件,稍作修改就能在 Windows、Mac、Linux 上打包。

第三,功能强大。 支持单文件打包、多目录打包、图标自定义、隐藏导入、资源文件打包......几乎你能想到的需求它都有。

第四,对科学计算库支持好。 NumPy、SciPy、OpenCV、PyTorch 这些常用库,PyInstaller 都有对应的 hook 来处理,不需要自己折腾。

对于 GrainServer 这样的工业级项目来说,"稳定可靠"是第一位的。PyInstaller 虽然不是性能最好的,但一定是最稳、坑最少的。


二、PyInstaller 工作原理简介

在深入 spec 文件之前,我们先简单了解一下 PyInstaller 的工作原理。这有助于理解后面的配置项。

PyInstaller 的打包过程大致分为三个阶段:

第一阶段:分析(Analysis)

  • 扫描你的主脚本,找出所有 import 的模块
  • 递归追踪所有依赖,形成一个依赖树
  • 区分哪些是 Python 标准库、哪些是第三方库、哪些是你自己的代码
  • 收集所有需要的文件:.pyc 字节码、.dll/.so 动态库、数据文件等

第二阶段:打包(PYZ)

  • 把所有 Python 模块的字节码(.pyc)打包成一个 PYZ 归档文件
  • PYZ 本质上是一个 zip 压缩包,里面是所有依赖的模块

第三阶段:生成 EXE(EXE)

  • 生成一个可执行文件(exe)
  • 这个 exe 包含一个小型的"引导加载程序"(bootloader)
  • 运行时,bootloader 会解压 PYZ,加载 Python 解释器,然后执行你的主脚本

简单说就是:PyInstaller 把 Python 解释器、你的代码、所有依赖库,都打包进了一个 exe 里。运行时,先解压到临时目录,然后在内存中启动 Python 解释器来执行代码。

这也是为什么 PyInstaller 打包的程序启动会慢一点------它需要先解压。但换来的是"单文件、无依赖"的便利性,这个 trade-off 通常是值得的。


三、server.spec 文件详解

PyInstaller 有两种使用方式:命令行参数和 spec 文件。简单的项目用命令行就行,但复杂项目(比如要打包很多资源文件、要处理特殊依赖)就需要用 spec 文件了。

GrainServer 使用的就是 spec 文件方式。我们来逐段解析 server.spec

3.1 Analysis 阶段:分析与收集

python 复制代码
a = Analysis(
    ['server.py',
     'ImgProcessor/__init__.py',
     'ImgProcessor/DataProcessor.py',
     'ImgProcessor/ImgUtils.py',
     'Model/__init__.py',
     'Model/ModelInfer.py',
     'Model/ModelTools.py',
     'SocketServ/__init__.py',
     'SocketServ/SocketServer.py',
     'SocketServ/TaskHandle.py',
     'Utils/__init__.py',
     'Utils/FileHandle.py',
     'Utils/Logger.py',
     'Utils/utils.py'],
    pathex=['D:\\work\\workspace\\grain\\server\\grainserver_0.6',
            'D:\\anaconda3\\envs\\grain_cpu\\Lib\\site-packages\\scipy\\special',
            'D:\\anaconda3\\envs\\grain_cpu\\Lib\\site-packages\\'],
    binaries=[],
    datas=[("D:\\anaconda3\\envs\\grain_cpu\\Lib\\site-packages\\onnxruntime\\capi\\onnxruntime_providers_shared.dll", "onnxruntime\\capi"),
           ("D:\\anaconda3\\envs\\grain_cpu\\Lib\\site-packages\\openvino\\libs\\", "openvino\\libs"),
           ("img_predict_out", "img_predict_out"),
           ("img_processed", "img_processed"),
           ("img_seleton_out", "img_seleton_out")],
    hiddenimports=['scipy.special._cdflib'],
    hookspath=[],
    hooksconfig={},
    runtime_hooks=[],
    excludes=[],
    win_no_prefer_redirects=False,
    win_private_assemblies=False,
    cipher=block_cipher,
    noarchive=False,
)

(代码来源:server.spec:7-40

Analysis 是整个打包流程的第一步,也是配置项最多的一步。我们逐个来看关键参数:

3.1.1 源码文件列表

第一个参数是一个列表,列出了所有需要打包的 Python 源文件:

python 复制代码
['server.py',
 'ImgProcessor/__init__.py',
 'ImgProcessor/DataProcessor.py',
 'ImgProcessor/ImgUtils.py',
 'Model/__init__.py',
 'Model/ModelInfer.py',
 'Model/ModelTools.py',
 'SocketServ/__init__.py',
 'SocketServ/SocketServer.py',
 'SocketServ/TaskHandle.py',
 'Utils/__init__.py',
 'Utils/FileHandle.py',
 'Utils/Logger.py',
 'Utils/utils.py']

你可能会问:"PyInstaller 不是会自动分析 import 吗?为什么还要手动列这么多文件?"

好问题。PyInstaller 确实会自动分析依赖,但它的分析是从"入口脚本"开始的。如果你的代码用了一些"动态导入"(比如 __import__()importlib.import_module()),或者某些模块的导入路径比较特殊,PyInstaller 可能分析不到。

GrainServer 这里把所有自己写的模块都列出来,是一种"保险"的做法------确保所有源码都被包含进去,不会漏。虽然 server.py 已经 import 了这些模块,理论上 PyInstaller 能自动找到,但手动列出来更直观、更可靠。

3.1.2 pathex:搜索路径
python 复制代码
pathex=['D:\\work\\workspace\\grain\\server\\grainserver_0.6',
        'D:\\anaconda3\\envs\\grain_cpu\\Lib\\site-packages\\scipy\\special',
        'D:\\anaconda3\\envs\\grain_cpu\\Lib\\site-packages\\']

pathex 是 PyInstaller 搜索模块的路径列表,类似于 Python 的 sys.path。当 PyInstaller 找不到某个模块时,你可以把它的所在目录加到这里。

这里列了三个路径:

  1. 项目根目录:确保能找到项目自己的模块
  2. scipy.special 目录:为了让 SciPy 的特殊函数模块能被正确找到
  3. site-packages 目录:第三方库的总目录

通常情况下,PyInstaller 能自动找到 site-packages 里的包,不需要手动加。但这里加了,可能是为了处理某些特殊的导入问题。

3.1.3 binaries:二进制文件
python 复制代码
binaries=[]

binaries 用来添加额外的二进制文件(DLL、SO 等)。这里是空的------不是因为不需要二进制文件,而是因为 ONNX Runtime 和 OpenVINO 的 DLL 放在了 datas 里。

严格来说,DLL 文件应该放在 binaries 里,PyInstaller 会对它们做一些特殊处理(比如依赖分析)。但放在 datas 里也能用,只是不会做额外的依赖分析。这是一个"能用但不够规范"的做法。

3.1.4 datas:数据文件
python 复制代码
datas=[
    ("D:\\anaconda3\\envs\\grain_cpu\\Lib\\site-packages\\onnxruntime\\capi\\onnxruntime_providers_shared.dll", "onnxruntime\\capi"),
    ("D:\\anaconda3\\envs\\grain_cpu\\Lib\\site-packages\\openvino\\libs\\", "openvino\\libs"),
    ("img_predict_out", "img_predict_out"),
    ("img_processed", "img_processed"),
    ("img_seleton_out", "img_seleton_out")
]

datas 是一个列表,每个元素是一个二元组 (源路径, 目标路径),表示把源路径的文件/目录,打包后放到目标路径下。

这里打包了五类数据:

1. ONNX Runtime 的共享 DLL

python 复制代码
("D:\\anaconda3\\envs\\grain_cpu\\Lib\\site-packages\\onnxruntime\\capi\\onnxruntime_providers_shared.dll", "onnxruntime\\capi")

onnxruntime_providers_shared.dll 是 ONNX Runtime 的核心 DLL 之一。PyInstaller 的 hook 可能没有正确收集到这个 DLL,所以需要手动加进去。

打包后,这个 DLL 会被放在 onnxruntime/capi/ 目录下,和它在 site-packages 中的相对路径保持一致。这样 ONNX Runtime 运行时才能找到它。

2. OpenVINO 的运行库

python 复制代码
("D:\\anaconda3\\envs\\grain_cpu\\Lib\\site-packages\\openvino\\libs\\", "openvino\\libs")

OpenVINO 的运行库文件比较多,都在 openvino/libs/ 目录下。这里直接把整个目录打包进去。

OpenVINO 是一个比较重的推理引擎,它的运行库包含很多 DLL 文件。如果不手动打包进去,程序运行时会报"找不到 DLL"的错误。

3. 三个输出目录

python 复制代码
("img_predict_out", "img_predict_out"),
("img_processed", "img_processed"),
("img_seleton_out", "img_seleton_out")

这三个是程序运行时的临时输出目录。打包时把空目录也打进去,确保程序运行时这些目录存在,不用运行时再创建。

这是一个很实用的小技巧------提前把需要的目录结构准备好,避免运行时因为目录不存在而报错。

3.1.5 hiddenimports:隐藏导入
python 复制代码
hiddenimports=['scipy.special._cdflib']

hiddenimports 是 PyInstaller 打包中最常见的"坑点"之一。

什么是"隐藏导入"?PyInstaller 是通过静态分析代码中的 import 语句来收集依赖的。但有些库会用"动态导入"的方式加载模块------代码里没有显式的 import 语句,而是在运行时通过字符串来导入。这种导入 PyInstaller 分析不到,所以叫"隐藏导入"。

scipy.special._cdflib 就是一个典型例子。SciPy 的某些函数会在运行时动态导入 _cdflib 模块,PyInstaller 分析不出来,需要手动告诉它:"这个模块也要打包进去"。

如果不加这个配置,程序运行到用到 SciPy 统计函数的地方,就会报 ModuleNotFoundError: No module named 'scipy.special._cdflib' 的错误。

怎么找隐藏导入?通常是打包后运行程序,报什么模块缺失,就把什么模块加到 hiddenimports 里。这是一个"试错"的过程,也是 PyInstaller 打包最磨人的地方。

3.2 PYZ 阶段:字节码归档

python 复制代码
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)

(代码来源:server.spec:41

PYZ 阶段就简单多了。它把 Analysis 阶段收集到的所有 Python 模块的字节码(.pyc)打包成一个 PYZ 文件。

  • a.pure:纯 Python 模块(.py 文件编译后的字节码)
  • a.zipped_data:压缩后的数据
  • cipher:加密密钥(这里是 None,不加密)

PYZ 文件本质上是一个 zip 压缩包。你可以用解压软件打开它,能看到里面都是 .pyc 文件。

3.3 EXE 阶段:生成可执行文件

python 复制代码
exe = EXE(
    pyz,
    a.scripts,
    a.binaries,
    a.zipfiles,
    a.datas,
    [],
    name='server',
    debug=False,
    bootloader_ignore_signals=False,
    strip=False,
    upx=True,
    upx_exclude=[],
    runtime_tmpdir=None,
    console=True,
    disable_windowed_traceback=False,
    argv_emulation=False,
    target_arch=None,
    codesign_identity=None,
    entitlements_file=None,
)

(代码来源:server.spec:43-63

EXE 阶段是最后一步,生成最终的可执行文件。我们来看几个关键参数:

3.3.1 name:输出文件名
python 复制代码
name='server'

生成的 exe 文件名,最终就是 server.exe

3.3.2 upx:UPX 压缩
python 复制代码
upx=True,
upx_exclude=[],

UPX 是一个可执行文件压缩工具。开启 UPX 后,PyInstaller 会用 UPX 压缩生成的 exe 和 DLL,可以显著减小文件体积。

但 UPX 也有缺点:

  • 启动变慢:运行时需要先解压
  • 某些 DLL 压缩后会出问题:尤其是一些加壳的、或者有特殊校验的 DLL
  • 杀毒软件误报:UPX 加壳的程序有时会被杀毒软件误报为病毒

GrainServer 开启了 UPX,但没有排除任何 DLL(upx_exclude=[])。这说明它的依赖库都能和 UPX 良好兼容。

3.3.3 console:控制台窗口
python 复制代码
console=True,

这个参数决定了程序运行时是否显示控制台窗口。

  • console=True:显示控制台窗口(命令行窗口)
  • console=False:不显示控制台窗口(GUI 程序用这个)

GrainServer 是后端服务,为什么要显示控制台窗口?

因为 日志是输出到控制台的 。在 Utils/Logger.py 中,日志同时输出到文件和控制台。保留控制台窗口,用户可以直接看到程序运行状态,方便调试和排查问题。

对于工业软件来说,"可观测性"很重要。出了问题,用户能直接看到控制台里的错误信息,比只看日志文件更直观。


四、打包策略深度解析

4.1 推理引擎的打包考量

GrainServer 支持两个推理引擎:OpenVINO 和 ONNX Runtime。这两个引擎的打包策略略有不同:

ONNX Runtime :只手动加了一个 DLL(onnxruntime_providers_shared.dll)。其他的 DLL 和 Python 绑定,PyInstaller 应该能自动分析到。

OpenVINO :直接打包了整个 libs/ 目录。OpenVINO 的依赖比较复杂,有很多 DLL,而且它们之间的依赖关系 PyInstaller 不一定能全部分析到。直接打包整个目录,虽然体积大一点,但最稳妥。

这体现了一个打包原则:对于复杂的 C++ 扩展库,宁多勿漏。 多打几个 DLL 只是体积大一点,漏了一个程序就跑不起来。

4.2 为什么用目录模式而不是单文件模式?

PyInstaller 有两种打包模式:

  • 单文件模式--onefile):所有东西都打包进一个 exe,运行时解压到临时目录
  • 目录模式(默认):生成一个目录,里面有 exe 和各种依赖文件

GrainServer 用的是目录模式(spec 文件里没有 --onefile 对应的配置)。为什么不做成单文件?

单文件模式的优点是"只有一个文件,方便分发"。但缺点也很明显:

  1. 启动慢:每次运行都要解压所有文件到临时目录
  2. 占用更多磁盘空间:运行时临时目录里会有一份解压后的副本
  3. 修改资源文件不方便:想换个模型文件、改个配置,都要重新打包
  4. 调试困难:出了问题,没法直接查看某个 DLL 是否存在

对于 GrainServer 这样的后端服务,通常是安装在一台机器上长期运行,不是拷来拷去的便携软件。目录模式更合适:启动快、调试方便、可以单独替换某个文件。

4.3 模型文件的处理

你可能注意到了,spec 文件里没有打包 models/ 目录下的 ONNX 模型文件。那模型文件怎么处理?

从项目目录结构来看,dist/ 目录下有独立的 models/ 文件夹,说明模型文件是手动拷贝过去的,没有通过 PyInstaller 打包。

这是一个很合理的设计决策:

  • 模型文件体积大:一个 ONNX 模型几十 MB,十几个模型就是几百 MB。打进 exe 里,exe 会非常臃肿
  • 模型需要独立更新:换模型、加新模型,不需要重新打包程序
  • 按需加载:用户可能只用几种金属的模型,不需要把所有模型都带上

程序和模型分离,是工业软件的常见做法。


五、常见打包坑与解决方案

根据 GrainServer 的配置和常见的 Python 打包经验,我们来总结几个最容易踩的坑:

5.1 隐藏导入问题

现象 :程序运行时报 ModuleNotFoundError,说某个模块找不到。

原因:PyInstaller 静态分析没分析到这个模块的导入(动态导入、延迟导入等)。

解决方案 :把缺失的模块加到 hiddenimports 里。比如 GrainServer 里的 scipy.special._cdflib

怎么找全所有隐藏导入?没有捷径,就是打包 → 运行 → 报错 → 加 import → 重新打包 → 再运行,循环往复。

5.2 DLL 缺失问题

现象 :程序运行时报 ImportError: DLL load failed 或者 Could not find ...dll

原因:某些 C++ 扩展库依赖的 DLL 没有被打包进去。

解决方案 :用 datasbinaries 手动添加缺失的 DLL。GrainServer 里的 ONNX Runtime DLL 和 OpenVINO 运行库就是这么处理的。

排查工具推荐:

  • Dependencies(原名 Dependency Walker):查看 DLL 的依赖关系
  • Process Monitor:监视程序运行时加载了哪些文件

5.3 路径问题

现象:打包后程序找不到某些资源文件(比如模型文件、配置文件)。

原因:打包前你用的是相对路径,但打包后程序的运行目录变了(单文件模式下是临时目录),相对路径就不对了。

解决方案 :使用 sys._MEIPASS 来获取打包后的资源路径。PyInstaller 运行时会把资源路径存在这个变量里。

示例代码:

python 复制代码
import sys
import os

def resource_path(relative_path):
    if hasattr(sys, '_MEIPASS'):
        return os.path.join(sys._MEIPASS, relative_path)
    return os.path.join(os.path.abspath('.'), relative_path)

GrainServer 中模型文件是放在 exe 外部的,所以这个问题不明显。但如果你的资源文件是打进 exe 内部的,就一定要处理路径问题。

5.4 SciPy/NumPy 相关问题

现象 :各种奇怪的导入错误,尤其是 scipy.specialscipy.linalg 这些子模块。

原因:SciPy 的内部结构比较复杂,有很多 C/Fortran 扩展,PyInstaller 的 hook 不一定能覆盖所有情况。

解决方案

  1. 确保 hiddenimports 里加了必要的模块
  2. 把 scipy 的相关路径加到 pathex
  3. 有时需要手动添加一些 DLL

GrainServer 的配置里已经做了这些处理:pathex 加了 scipy.special 目录,hiddenimports 加了 scipy.special._cdflib

5.5 杀毒软件误报

现象:打包后的 exe 被杀毒软件当成病毒删了。

原因:PyInstaller 的 bootloader 被很多恶意软件滥用,所以很多杀毒软件对 PyInstaller 打包的程序比较敏感。尤其是加了 UPX 压缩的,更容易被误报。

解决方案

  1. 关闭 UPX(upx=False),虽然体积变大,但误报率会降低
  2. 对 exe 进行数字签名(需要购买代码签名证书)
  3. 告诉用户把程序加入杀毒软件白名单

对于工业软件来说,通常是部署在客户的内网环境中,和客户说明一下加白名单就行,这不是大问题。


六、打包优化建议

GrainServer 当前的打包配置是"能用"的水平,但还有一些可以优化的地方:

6.1 路径规范化

当前 spec 文件里用的都是绝对路径,比如 D:\work\workspace\grain\server\grainserver。这意味着换一台机器、换一个目录,spec 文件就用不了了。

更好的做法是用相对路径,基于 spec 文件所在目录来构造路径:

python 复制代码
import os
spec_root = os.path.abspath(os.path.dirname(__file__))

pathex=[spec_root, ...]
datas=[
    (os.path.join(spec_root, "img_predict_out"), "img_predict_out"),
    ...
]

这样 spec 文件就是"可移植"的了,在任何机器上只要在项目根目录下运行打包命令就行。

6.2 用 binaries 放 DLL

前面提到过,DLL 文件应该放在 binaries 而不是 datas 里。PyInstaller 对 binaries 里的文件会做额外的依赖分析,确保它们的依赖也被打包进去。

6.3 排除不必要的模块

PyInstaller 会把所有能分析到的模块都打进去,其中很多可能根本用不上。比如:

  • tkinter(GUI 库,后端服务用不到)
  • unittest(单元测试框架)
  • 各种没用到的第三方库

可以用 excludes 参数排除掉这些模块,减小体积:

python 复制代码
excludes=['tkinter', 'unittest', 'email', 'html', ...]

不过排除模块有风险------万一排除了某个间接依赖的模块,程序运行时会报错。需要仔细测试。

6.4 虚拟环境打包

打包时最好在一个干净的虚拟环境中进行,只安装项目真正需要的依赖。这样 PyInstaller 就不会把多余的包打进去,体积自然就小了。

GrainServer 用的是 Conda 环境(D:\anaconda3\envs\grain_cpu),这已经是一个不错的实践。


七、总结

PyInstaller 打包看似简单------一条命令的事,但实际上水很深。尤其是对于 OpenVINO、ONNX Runtime 这样有复杂 C++ 依赖的库,要让打包后的程序正常运行,需要处理很多细节。

回顾 GrainServer 的打包配置,它的设计思路是:

  • 稳字当头:宁多勿漏,确保所有依赖都打进去
  • 实用优先:目录模式、控制台窗口,方便调试和维护
  • 程序与模型分离:模型文件不打进 exe,独立更新

打包是 Python 项目交付的"最后一公里"。这一公里走不好,前面的代码写得再漂亮,客户也用不起来。通过研究 spec 文件,我们不仅学到了 PyInstaller 的具体用法,更体会到了工业软件"稳定可靠、易于维护"的设计思想。

相关推荐
一次旅行1 小时前
AI 前沿日报 | 2026年07月14日
人工智能
智慧物业老杨1 小时前
物业单盘精细化预算数智化方案:IoT采集+AI建模全链路自动化落地体系
人工智能·物联网·自动化
华研前沿标杆游学1 小时前
2026年制造企业标杆参访实操指南:3步选对适配参访路线
python
xn71331 小时前
别再把 MCP Roots 当安全沙箱:一个符号链接就能逃逸目录
人工智能·后端·mcp
Nturmoils2 小时前
不自己造轮子:在小艺开放平台搭一个真实的健康小助手
人工智能
statistican_ABin2 小时前
中国城市 PM2.5较高浓度风险识别
人工智能
茶马古道的搬运工2 小时前
AI 深度技能之-解读Multica(一)- 让Agent 变成真·队友
人工智能
YOLO数据集集合2 小时前
自动驾驶道路视觉开源数据集TT100K工程化使用文档|道路标识目标检测资源|开源数据集分享
人工智能·目标检测·自动驾驶
BerryS3N2 小时前
Cursor+GitOps:AI驱动的自动化运维新范式
运维·人工智能·自动化