Python 项目打包成可执行文件:PyInstaller 避坑指南

用 Python 开发完小工具后,需要交付给非技术人员使用,打包成 exe 是常见需求。PyInstaller 是最流行的打包工具,但使用中会遇到各种问题。本文整理了常见坑点和解决方案。

PyInstaller 基础用法

安装

1
pip install pyinstaller

基本命令

1
2
3
4
5
6
7
8
# 打包单个文件
pyinstaller --onefile main.py

# 打包目录(控制台程序)
pyinstaller --onedir main.py

# 打包为窗口程序(无控制台)
pyinstaller --onefile --noconsole main.py

生成文件

1
2
3
4
5
6
dist/
├── main.exe # 打包后的可执行文件
└── main/ # --onedir 模式
├── main.exe
└── ...依赖文件...
build/ # 临时构建目录

依赖路径问题处理

问题:找不到数据文件

1
2
3
4
# 问题代码
def load_config():
with open('config.json', 'r') as f: # 相对路径,打包后找不到
return json.load(f)

解决方案:使用 sys._MEIPASSos.path

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import sys
import os

def resource_path(relative_path):
"""获取资源文件的绝对路径

开发环境:返回项目根目录下的路径
打包环境:返回临时目录下的路径
"""
if hasattr(sys, '_MEIPASS'):
# PyInstaller 创建的临时目录
base_path = sys._MEIPASS
else:
# 开发环境
base_path = os.path.abspath('.')
return os.path.join(base_path, relative_path)

def load_config():
config_path = resource_path('config.json')
with open(config_path, 'r') as f:
return json.load(f)

问题:运行时路径

1
2
3
4
5
6
7
8
9
10
11
# 问题:获取 exe 所在目录
exe_dir = os.path.dirname(sys.argv[0]) # 有时为空

# 正确做法
def get_exe_dir():
if getattr(sys, 'frozen', False):
# 打包后
return os.path.dirname(sys.executable)
else:
# 开发环境
return os.path.dirname(os.path.abspath(__file__))

spec 文件配置

对于复杂项目,推荐使用 spec 文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
# main.spec
# -*- mode: python ; coding: utf-8 -*-

block_cipher = None

a = Analysis(
['main.py'],
pathex=[],
binaries=[],
datas=[
('config.json', '.'), # 打包数据文件
('assets/*.png', 'assets'), # 打包 assets 目录
],
hiddenimports=[
'sklearn.utils._cython_blas',
'sklearn.neighbors._typedefs',
],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
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,
[],
exclude_binaries=True,
name='MyApp',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=False, # 窗口程序
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon='icon.ico', # 图标
)

coll = COLLECT(
exe,
a.binaries,
a.zipfiles,
a.datas,
strip=False,
upx=True,
upx_exclude=[],
name='MyApp',
)

打包命令:

1
pyinstaller main.spec

多文件与资源打包

打包静态资源

1
2
3
4
5
6
7
8
9
10
# 项目结构
project/
├── main.py
├── ui/
│ └── main.ui
├── assets/
│ ├── logo.png
│ └── style.css
└── config/
└── settings.json
1
2
3
4
5
6
7
8
9
# main.spec
a = Analysis(
['main.py'],
datas=[
('assets', 'assets'), # 源目录 : 目标目录
('config/settings.json', 'config'),
],
# ...
)
1
2
3
4
5
6
7
8
9
# 代码中访问
def get_asset_path(filename):
if hasattr(sys, '_MEIPASS'):
return os.path.join(sys._MEIPASS, 'assets', filename)
else:
return os.path.join('assets', filename)

# 使用
logo = Image.open(get_asset_path('logo.png'))

打包 DLL/so 文件

1
2
3
4
5
6
7
8
# main.spec
a = Analysis(
['main.py'],
binaries=[
('libs/mylib.dll', '.'), # Windows
# ('libs/mylib.so', '.'), # Linux
],
)

图标设置与版本信息

设置图标

1
2
# Windows 需要 .ico 格式
pyinstaller --onefile --icon=app.ico main.py

转换图片格式(需要 Pillow):

1
2
3
4
from PIL import Image

img = Image.open('app.png')
img.save('app.ico', format='ICO', sizes=[(16,16), (32,32), (48,48), (256,256)])

Windows 版本信息

创建 version.txt:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
VSVersionInfo(
ffi=FixedFileInfo(
filevers=(1, 0, 0, 0),
prodvers=(1, 0, 0, 0),
mask=0x3f,
flags=0x0,
OS=0x40004,
fileType=0x1,
subtype=0x0,
date=(0, 0)
),
kids=[
StringFileInfo(
[
StringTable(
u'040904B0',
[
StringStruct(u'CompanyName', u'MyCompany'),
StringStruct(u'FileDescription', u'My Application'),
StringStruct(u'FileVersion', u'1.0.0.0'),
StringStruct(u'InternalName', u'myapp'),
StringStruct(u'OriginalFilename', u'myapp.exe'),
StringStruct(u'ProductName', u'My Application'),
StringStruct(u'ProductVersion', u'1.0.0.0'),
]
)
]
),
VarFileInfo([VarStruct(u'Translation', [1033, 1200])])
]
)

spec 文件配置:

1
2
3
4
5
6
7
8
9
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name='MyApp',
icon='app.ico',
version='version.txt', # Windows 版本信息
)

常见错误与解决

错误1:ImportError

1
ImportError: No module named 'xxx'

原因:PyInstaller 没有正确分析到某些 import。

解决

1
2
3
4
5
# 1. 添加隐藏导入
pyinstaller --hiddenimport=sklearn --hiddenimport=pandas main.py

# 2. 或者在 spec 文件中
hiddenimports=['sklearn', 'pandas']

错误2:NumPy / SciPy 打包失败

1
2
3
4
5
# NumPy 和 SciPy 需要特殊的 hidden imports
pyinstaller --onefile \
--hiddenimport=numpy.linalg._umath_linalg \
--hiddenimport=numpy.core._multiarray_umath \
main.py

错误3:tkinter 打包失败

1
2
# tkinter 需要额外的数据文件
pyinstaller --add-data "$PYTHONHOME/Lib/tkinter/;tkinter/" main.py

错误4:打包后程序启动慢

1
2
3
# spec 文件中启用 UPX 压缩
upx=True
upx_exclude=['vcruntime140.dll']

错误5:闪退(无错误信息)

添加控制台窗口调试:

1
pyinstaller --console --debug=all main.py

或者在代码中添加异常捕获:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import sys
import traceback

def main():
try:
# 你的代码
pass
except Exception as e:
with open('error.log', 'w') as f:
traceback.print_exc(file=f)
raise

if __name__ == '__main__':
main()

实战:桌面工具打包完整流程

项目结构

1
2
3
4
5
6
7
8
9
10
11
12
mytool/
├── main.py # 入口
├── ui/
│ └── main_window.py # UI 代码
├── utils/
│ └── helpers.py # 工具函数
├── config/
│ └── settings.json # 配置文件
├── assets/
│ └── icon.ico # 图标
├── requirements.txt
└── main.spec

main.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#!/usr/bin/env python3
import sys
import os
import logging
from ui.main_window import MainWindow

def setup_logging():
log_dir = os.path.dirname(os.path.abspath(__file__))
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler(os.path.join(log_dir, 'app.log')),
logging.StreamHandler()
]
)

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

def main():
setup_logging()
logging.info('程序启动')

app = MainWindow(resource_path('config/settings.json'))
app.run()

logging.info('程序退出')

if __name__ == '__main__':
main()

main.spec

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
# -*- mode: python ; coding: utf-8 -*-

block_cipher = None

a = Analysis(
['main.py'],
pathex=[],
binaries=[],
datas=[
('config/settings.json', 'config'),
('assets/icon.ico', 'assets'),
],
hiddenimports=[
'PIL._tkinter_finder', # PIL
'sklearn', # 如果用到
'numpy',
],
hookspath=[],
runtime_hooks=[],
excludes=[
'tkinter',
],
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,
[],
exclude_binaries=True,
name='MyTool',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=False,
icon='assets/icon.ico',
version='version.txt',
disable_windowed_traceback=False,
)

coll = COLLECT(
exe,
a.binaries,
a.zipfiles,
a.datas,
strip=False,
upx=True,
name='MyTool',
)

打包命令

1
2
3
4
5
6
7
8
# 清理旧构建
rm -rf build dist

# 打包
pyinstaller main.spec

# 精简(可选)
upx bin/*.dll bin/*.exe -9

目录结构(打包后)

1
2
3
4
5
6
7
dist/MyTool/
├── MyTool.exe # 主程序
├── config/
│ └── settings.json
├── assets/
│ └── icon.ico
└── [其他依赖文件]

总结

PyInstaller 常见问题与解决:

问题 解决方案
找不到数据文件 使用 sys._MEIPASSresource_path()
缺少模块 添加 --hiddenimport
打包后启动慢 启用 UPX 压缩
NumPy/SciPy 问题 添加特定 hidden imports
无图标 转换为 .ico 格式
闪退无报错 添加异常捕获和日志

最佳实践:

  1. 使用 spec 文件管理复杂配置
  2. resource_path() 统一资源访问
  3. 开发时用虚拟环境,避免依赖污染
  4. 打包后测试:exe 能否独立运行