3. PyInstaller 实战

说实话,PyInstaller 是我用过最顺手的 Python 打包工具。没有之一。

当年我第一次用它打包一个爬虫工具,命令行一敲就出 exe 了,那种感觉——嗯,真香。但后来项目复杂了,光靠命令行就不够用了。今天我就带你从命令行入门,一路走到 Spec 文件定制,把隐藏控制台、加图标、排除模块、打包数据文件这些实战技能全拿下。

3.1 命令行打包:快速上手

先来个最简单的。假设你写了个脚本叫 hello.py:

print("Hello, 跨平台打包!")
input("按回车退出...")

打包命令就一行:

pyinstaller hello.py

跑完之后,dist/hello/ 目录下就会出现 hello.exe。双击就能跑。

但这里有个坑——默认是带控制台的。如果你写的是 GUI 程序,比如用 PyQt 或 Tkinter,那个黑框框会一直跟着,特别碍眼。

注意:默认打包出来的 exe 会显示控制台窗口。GUI 程序必须用 --noconsole 或 -w 参数隐藏它。

3.2 隐藏控制台:让 GUI 程序干干净净

我个人习惯,只要确定是 GUI 程序,第一件事就是加 -w:

pyinstaller -w my_gui.py

这样打包出来的 exe 就不会弹出黑框了。用户双击后直接看到你的界面,体验好很多。

我在项目中遇到过一件事:有个同事忘了加 -w,结果交付给客户后,客户说「你这程序怎么还带个命令行窗口?是不是病毒?」——你看,细节决定成败。

3.3 添加图标:给程序穿上衣服

默认的 exe 图标是 PyInstaller 的默认图标,说实话挺丑的。给程序加个图标,用 -i 参数:

pyinstaller -w -i app.ico my_gui.py

注意:图标必须是 .ico 格式。如果你只有 PNG 图片,可以用在线转换工具转一下。

小技巧:图标文件最好包含多种尺寸(16x16、32x32、48x48、256x256),这样在不同分辨率下显示都清晰。

3.4 排除模块:给 exe 瘦身

默认情况下,PyInstaller 会分析你的代码,把用到的模块都打包进去。但它有时候会「过度分析」——把一些根本用不到的模块也塞进来。

比如你用了 requests,它可能把 urllib、http 等一堆相关模块都打包了。这时候可以用 --exclude-module 来排除:

pyinstaller -w --exclude-module tkinter --exclude-module unittest my_app.py

我曾经把一个 50MB 的 exe 通过排除模块瘦身到 18MB。你想想看,用户下载一个 50MB 的工具和 18MB 的工具,体验完全不一样。

怎么知道哪些模块可以排除?我建议你先打包一次,看看 dist/ 目录下的文件列表,然后对照你的代码,把明显没用的模块排除掉。

3.5 数据文件打包:让资源跟着 exe 走

很多程序需要附带数据文件,比如配置文件、图片、音频、数据库文件等。默认情况下,PyInstaller 只打包 Python 代码,数据文件需要手动指定。

命令行方式用 --add-data 参数:

pyinstaller -w --add-data "config.json;." --add-data "images/*.png;images" my_app.py

格式是 源路径;目标路径。注意分号在 Windows 下是分隔符,Linux/macOS 下用冒号。

但这里有个大坑——打包后的路径和开发时的路径不一样。代码里写死的相对路径会找不到文件。

解决方案:用 sys._MEIPASS 获取打包后的临时目录路径。写一个通用函数来获取资源路径:
import sys
import os

def resource_path(relative_path):
    """获取资源的绝对路径,兼容开发环境和打包后的 exe"""
    try:
        # PyInstaller 打包后的临时目录
        base_path = sys._MEIPASS
    except AttributeError:
        # 开发环境下的当前目录
        base_path = os.path.abspath(".")
    return os.path.join(base_path, relative_path)

# 使用示例
config_path = resource_path("config.json")

这个函数我几乎每个项目都会用。你把它放到工具模块里,所有需要读取数据文件的地方都调用它,一劳永逸。

3.6 Spec 文件:打包的终极武器

命令行参数多了之后,每次打包都要敲一长串命令,容易出错。这时候就该 Spec 文件出场了。

生成 Spec 文件很简单:

pyi-makespec my_app.py

或者从已有的打包命令生成:

pyinstaller -w -i app.ico --add-data "config.json;." my_app.py --specpath ./spec

Spec 文件其实就是一个 Python 脚本,里面定义了打包的所有配置。打开看看:

# -*- mode: python ; coding: utf-8 -*-

a = Analysis(
    ['my_app.py'],
    pathex=[],
    binaries=[],
    datas=[('config.json', '.'), ('images/*.png', 'images')],
    hiddenimports=[],
    hookspath=[],
    hooksconfig={},
    runtime_hooks=[],
    excludes=['tkinter', 'unittest'],
    win_no_prefer_redirects=False,
    win_private_assemblies=False,
    cipher=None,
    noarchive=False,
)

pyz = PYZ(a.pure, a.zipped_data, cipher=None)

exe = EXE(
    pyz,
    a.scripts,
    a.binaries,
    a.zipfiles,
    a.datas,
    [],
    name='my_app',
    debug=False,
    bootloader_ignore_signals=False,
    strip=False,
    upx=True,
    upx_exclude=[],
    runtime_tmpdir=None,
    console=False,      # 隐藏控制台
    disable_windowed_traceback=False,
    argv_emulation=False,
    target_arch=None,
    codesign_identity=None,
    entitlements_file=None,
    icon='app.ico',     # 图标文件
)

看到没?之前命令行里的 -w 对应 console=False,-i app.ico 对应 icon='app.ico',--add-data 对应 datas 列表,--exclude-module 对应 excludes 列表。

修改完 Spec 文件后,打包命令就变成了:

pyinstaller my_app.spec

干净利落。而且 Spec 文件可以放到版本控制里,团队协作时大家用同一份配置,不会出现「我电脑上能打包,你电脑上就不行」的尴尬。

3.7 实战:一个完整的 Spec 文件示例

下面是我一个真实项目的 Spec 文件,你可以直接拿来改:

# -*- mode: python ; coding: utf-8 -*-

block_cipher = None

a = Analysis(
    ['main.py', 'utils.py'],           # 多个入口文件
    pathex=['C:\\my_project'],         # 项目路径
    binaries=[],                       # 动态链接库
    datas=[
        ('config/*.json', 'config'),   # 配置文件
        ('assets/*.png', 'assets'),    # 图片资源
        ('database/*.db', 'database'), # 数据库文件
    ],
    hiddenimports=[
        'PIL._tkinter_finder',         # 有时 PyInstaller 找不到的模块
        'cffi',
    ],
    hookspath=[],
    hooksconfig={},
    runtime_hooks=[],
    excludes=[
        'tkinter',                     # 没用 Tkinter 就排除
        'unittest',                    # 测试框架不需要
        'pdb',                         # 调试器不需要
        'email',                       # 没用邮件功能就排除
    ],
    win_no_prefer_redirects=False,
    win_private_assemblies=False,
    cipher=block_cipher,
    noarchive=False,
)

pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)

exe = EXE(
    pyz,
    a.scripts,
    a.binaries,
    a.zipfiles,
    a.datas,
    [],
    name='MyApp',
    debug=False,
    bootloader_ignore_signals=False,
    strip=False,
    upx=True,                          # 启用 UPX 压缩
    upx_exclude=['vcruntime140.dll'],  # 某些 DLL 不能压缩
    runtime_tmpdir=None,
    console=False,
    disable_windowed_traceback=False,
    argv_emulation=False,
    target_arch=None,
    codesign_identity=None,
    entitlements_file=None,
    icon='app.ico',
)
经验之谈:Spec 文件里的 upx=True 可以显著减小 exe 体积,但需要提前安装 UPX 工具。我一般会把它设为 True,然后手动排除那些压缩后会出问题的 DLL。

3.8 常见问题与避坑指南

打包过程中我踩过的坑,今天一次性告诉你:

  • 路径问题:代码里不要用 os.getcwd() 获取当前目录,打包后这个路径不对。用前面说的 resource_path() 函数。
  • 动态导入:如果你的代码用了 __import__() 或 importlib 动态导入模块,PyInstaller 可能分析不到。需要在 hiddenimports 里手动指定。
  • UPX 压缩失败:某些 DLL 文件压缩后会损坏。遇到这种情况,在 upx_exclude 里排除它们。
  • 杀软误报:打包后的 exe 有时会被杀毒软件误报。可以尝试用 --upx-exclude 排除某些文件,或者换个打包方式。

我曾经遇到一个项目,打包后 exe 在客户电脑上闪退。查了半天,发现是 config.json 文件没打包进去。从那以后,我每次打包完都会在 dist/ 目录下检查一遍,确保所有数据文件都在。

3.9 小结

这一章的内容,说白了就是让你从「能打包」进化到「会打包」。命令行适合快速验证,Spec 文件适合正式项目。记住几个关键点:

  • GUI 程序一定要隐藏控制台
  • 图标用 .ico 格式,包含多尺寸
  • 排除无用模块给 exe 瘦身
  • 数据文件用 resource_path() 函数处理路径
  • Spec 文件是团队协作的最佳实践