一、概念
PyInstaller 是一个将 Python 应用程序打包成独立可执行文件的工具,支持 Windows、Linux、macOS 等多平台。它能将 Python 脚本及其依赖项打包成单一的可执行文件,无需目标机器安装 Python 环境。它的特点:
跨平台支持:Windows (.exe)、Linux、macOS (.app)
自动依赖分析:自动检测并打包所需的库和模块
单文件模式:可打包成单个 exe 文件
隐藏源码:保护 Python 源代码
支持第三方库:NumPy、Pandas、PyQt、Tkinter 等
自定义图标:支持自定义应用程序图标
无需修改代码:原生 Python 代码无需改动
1.2 工作原理
Python源码 → PyInstaller分析 → 收集依赖 → 生成引导程序 → 打包资源 → 生成EXE
打包流程:
分析阶段:扫描 Python 脚本,识别所有导入的模块
收集阶段:收集 Python 解释器、依赖库、资源文件
打包阶段:将所有文件打包成可执行文件
引导阶段:创建引导程序,在运行时解压并执行
1.3 打包模式对比
| 特性 | 单文件模式 (--onefile) | 单目录模式 (--onedir) |
|---|---|---|
| 文件数量 | 1个exe文件 | 1个文件夹(含多个文件) |
| 启动速度 | 较慢(需解压) | 快速 |
| 体积 | 较大 | 相对较小 |
| 分发 | 方便 | 需打包整个文件夹 |
| 调试 | 困难 | 容易 |
| 推荐场景 | 简单工具、单机应用 | 复杂应用、企业软件 |
二、PyInstaller 功能详解
2.1 核心功能
- 自动依赖检测
自动分析 import 语句
检测隐式依赖(动态导入)
支持第三方库的钩子(hooks)
- 资源文件打包
图片、音频、视频
配置文件(JSON、YAML、INI)
数据文件(CSV、Excel)
DLL 文件、字体文件
- 代码保护
将 .py 编译为 .pyc 字节码
打包进可执行文件,难以反编译
支持加密(需额外配置)
- 自定义配置
.spec 文件:高级配置
排除不需要的模块
添加隐藏导入
自定义图标和版本信息
2.2 支持的应用类型
| 应用类型 | 支持程度 | 注意事项 |
|---|---|---|
| 命令行工具 | ✅ 完美支持 | 最简单 |
| GUI应用(Tkinter) | ✅ 完美支持 | 需注意资源文件路径 |
| GUI应用(PyQt/PySide) | ✅ 完美支持 | 体积较大 |
| Web应用(Flask/Django) | ⚠️ 部分支持 | 需特殊配置 |
| 数据科学应用 | ✅ 支持 | NumPy/Pandas体积大 |
| 游戏(Pygame) | ✅ 支持 | 需打包资源文件 |
三、安装与环境配置
3.1 安装 PyInstaller
方法1:使用 pip 安装(推荐)
pip install pyinstaller
方法2:安装最新开发版
pip install github.com/pyinstaller…
方法3:从源码安装
git clone github.com/pyinstaller…
cd pyinstaller
python setup.py install
验证安装
pyinstaller --version
3.2 环境准备
创建虚拟环境(推荐)
python -m venv myenv
激活虚拟环境# Windows:
myenv\Scripts\activate# Linux/Mac:
source myenv/bin/activate
安装项目依赖
pip install -r requirements.txt
安装 PyInstaller
pip install pyinstaller
3.3 依赖检查工具
"""
检查项目依赖的工具脚本
"""
111
import pkg_resourcesimport subprocess
def check_dependencies():
"""检查已安装的包"""
installed_packages = [d.project_name for d in pkg_resources.working_set]
print("已安装的包:")
for package in sorted(installed_packages):
print(f" - {package}")
def generate_requirements():
"""生成 requirements.txt"""
result = subprocess.run(['pip', 'freeze'], capture_output=True, text=True)
with open('requirements.txt', 'w') as f:
f.write(result.stdout)
print("requirements.txt 已生成")
def check_import_errors(script_path):
"""检查脚本的导入错误"""
try:
with open(script_path, 'r', encoding='utf-8') as f:
code = f.read()
compile(code, script_path, 'exec')
print(f"✅ {script_path} 语法检查通过")
except SyntaxError as e:
print(f"❌ 语法错误: {e}")
except Exception as e:
print(f"❌ 其他错误: {e}")
if name == 'main':
check_dependencies()
generate_requirements()
四、基础打包步骤
4.1 简单示例程序
hello.py - 简单的命令行程序"""
简单的 Hello World 程序
"""
import sysimport datetime
def main():
print("=" * 50)
print("欢迎使用 Python 打包程序")
print("=" * 50)
name = input("请输入你的名字: ")
current_time = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
print(f"\n你好, {name}!")
print(f"当前时间: {current_time}")
print(f"Python 版本: {sys.version}")
input("\n按回车键退出...")
if name == 'main':
main()
4.2 基础打包命令
1. 最简单的打包(生成文件夹)
pyinstaller hello.py
2. 打包成单个exe文件
pyinstaller --onefile hello.py
3. 打包时不显示控制台窗口(GUI程序)
pyinstaller --onefile --noconsole hello.py
4. 自定义输出目录和名称
pyinstaller --onefile --name MyApp hello.py
5. 添加图标
pyinstaller --onefile --icon=app.ico hello.py
6. 完整命令示例
pyinstaller --onefile --noconsole --icon=app.ico --name MyApp hello.py
4.3 打包后的目录结构
项目目录/
├── hello.py # 源代码
├── app.ico # 图标文件
├── build/ # 构建临时文件
│ └── MyApp/
│ ├── Analysis-00.toc
│ ├── PYZ-00.pyz
│ └── ...
├── dist/ # 最终输出目录
│ └── MyApp.exe # 可执行文件
└── MyApp.spec # 配置文件
五、打包选项详解
5.1 常用选项速查表
| 选项 | 说明 | 示例 |
|---|---|---|
| --onefile / -F | 打包成单个文件 | pyinstaller -F app.py |
| --onedir / -D | 打包成文件夹(默认) | pyinstaller -D app.py |
| --noconsole / -w | 不显示控制台(GUI) | pyinstaller -w app.py |
| --console / -c | 显示控制台(默认) | pyinstaller -c app.py |
| --icon= / -i | 设置图标 | pyinstaller -i app.ico app.py |
| --name= / -n | 设置程序名称 | pyinstaller -n MyApp app.py |
| --add-data | 添加数据文件 | --add-data "data;data" |
| --add-binary | 添加二进制文件 | --add-binary "lib.dll;." |
| --hidden-import | 添加隐藏导入 | --hidden-import=requests |
| --exclude-module | 排除模块 | --exclude-module=pytest |
| --clean | 清理临时文件 | pyinstaller --clean app.py |
| --distpath | 指定输出目录 | --distpath ./output |
| --workpath | 指定工作目录 | --workpath ./build |
| --specpath | 指定spec文件路径 | --specpath ./specs |
5.2 资源文件打包
calculator.py - 带资源文件的计算器程序"""
简单计算器程序(带图标和配置文件)
"""
import tkinter as tk
from tkinter import ttk
import json
import os
import sys
def resource_path(relative_path):
"""
获取资源文件的绝对路径
PyInstaller打包后,资源文件会被解压到临时目录
"""
try:
# PyInstaller创建临时文件夹,路径存储在 _MEIPASS
base_path = sys._MEIPASS
except Exception:
base_path = os.path.abspath(".")
return os.path.join(base_path, relative_path)
class Calculator:
def init(self, root):
self.root = root
self.root.title("简单计算器")
# 加载配置文件
self.load_config()
# 设置图标
try:
icon_path = resource_path('icon.ico')
self.root.iconbitmap(icon_path)
except:
pass
self.create_widgets()
def load_config(self):
"""加载配置文件"""
try:
config_path = resource_path('config.json')
with open(config_path, 'r', encoding='utf-8') as f:
self.config = json.load(f)
except:
self.config = {'theme': 'default', 'precision': 2}
def create_widgets(self):
"""创建界面组件"""
# 显示框
self.display = tk.Entry(self.root, width=30, font=('Arial', 14),
justify='right')
self.display.grid(row=0, column=0, columnspan=4, padx=10, pady=10)
# 按钮布局
buttons = [
'7', '8', '9', '/',
'4', '5', '6', '*',
'1', '2', '3', '-',
'0', '.', '=', '+'
]
row = 1
col = 0
for button in buttons:
cmd = lambda x=button: self.click(x)
tk.Button(self.root, text=button, width=5, height=2,
command=cmd).grid(row=row, column=col, padx=5, pady=5)
col += 1
if col > 3:
col = 0
row += 1
# 清除按钮
tk.Button(self.root, text='C', width=5, height=2,
command=self.clear).grid(row=row, column=0, padx=5, pady=5)
def click(self, key):
"""按钮点击事件"""
if key == '=':
try:
result = eval(self.display.get())
self.display.delete(0, tk.END)
self.display.insert(0, str(result))
except:
self.display.delete(0, tk.END)
self.display.insert(0, "错误")
else:
self.display.insert(tk.END, key)
def clear(self):
"""清除显示"""
self.display.delete(0, tk.END)
def main():
root = tk.Tk()
app = Calculator(root)
root.mainloop()
if name == 'main':
main()
配置文件 config.json:
{
"theme": "light",
"precision": 2,
"window_size": "300x400"}
打包命令(Windows):
方式1:命令行指定资源文件
pyinstaller --onefile --noconsole ^
--icon=icon.ico ^
--add-data "config.json;." ^
--add-data "icon.ico;." ^
--name Calculator ^
calculator.py
方式2:Linux/Mac(使用冒号分隔)
pyinstaller --onefile --noconsole \
--icon=icon.ico \
--add-data "config.json:." \
--add-data "icon.ico:." \
--name Calculator \
calculator.py
5.3 使用 .spec 文件高级配置
Calculator.spec - 高级配置文件# -- mode: python ; coding: utf-8 --
block_cipher = None
分析阶段配置
a = Analysis(
['calculator.py'], # 主脚本
pathex=[], # 额外的搜索路径
binaries=[], # 二进制文件
datas=[ # 数据文件
('config.json', '.'),
('icon.ico', '.'),
('resources/*', 'resources'), # 整个文件夹
],
hiddenimports=[ # 隐藏导入
'tkinter',
'json',
],
hookspath=[], # 自定义钩子路径
hooksconfig={},
runtime_hooks=[], # 运行时钩子
excludes=[ # 排除的模块
'matplotlib',
'numpy',
'pandas',
],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False,
)
PYZ 打包
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
EXE 配置
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.zipfiles,
a.datas,
[],
name='Calculator', # 程序名称
debug=False, # 调试模式
bootloader_ignore_signals=False,
strip=False,
upx=True, # 使用UPX压缩
upx_exclude=[],
runtime_tmpdir=None,
console=False, # 不显示控制台
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon='icon.ico', # 图标
version='version.txt', # 版本信息文件
)
使用 .spec 文件打包:
生成 .spec 文件
pyinstaller --onefile --noconsole calculator.py
修改 .spec 文件后重新打包
pyinstaller Calculator.spec
清理后重新打包
pyinstaller --clean Calculator.spec
5.4 版本信息配置
version.txt - Windows版本信息# UTF-8## 用于 --version-file 参数
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'我的公司'),
StringStruct(u'FileDescription', u'简单计算器'),
StringStruct(u'FileVersion', u'1.0.0.0'),
StringStruct(u'InternalName', u'Calculator'),
StringStruct(u'LegalCopyright', u'Copyright © 2025'),
StringStruct(u'OriginalFilename', u'Calculator.exe'),
StringStruct(u'ProductName', u'计算器'),
StringStruct(u'ProductVersion', u'1.0.0.0')])
]
),
VarFileInfo([VarStruct(u'Translation', [1033, 1200])])
]
)
生成版本信息文件:
安装 pefile 库
pip install pefile
使用版本信息打包
pyinstaller --onefile --noconsole --icon=icon.ico --version-file=version.txt calculator.py
六、实战案例
6.1 案例1:命令行工具打包
file_manager.py - 文件管理工具"""
命令行文件管理工具
功能:批量重命名、文件搜索、文件统计
"""
import os
import sys
import argparse
from pathlib import Pat
himport json
class FileManager:
def init(self):
self.config_file = 'config.json'
self.load_config()
def load_config(self):
"""加载配置"""
if os.path.exists(self.config_file):
with open(self.config_file, 'r', encoding='utf-8') as f:
self.config = json.load(f)
else:
self.config = {'last_path': '.'}
def save_config(self):
"""保存配置"""
with open(self.config_file, 'w', encoding='utf-8') as f:
json.dump(self.config, f, indent=2, ensure_ascii=False)
def batch_rename(self, directory, pattern, replacement):
"""
批量重命名文件
Args:
directory: 目标目录
pattern: 要替换的模式
replacement: 替换后的文本
"""
count = 0
for file in Path(directory).iterdir():
if file.is_file() and pattern in file.name:
new_name = file.name.replace(pattern, replacement)
new_path = file.parent / new_name
file.rename(new_path)
print(f"✅ {file.name} → {new_name}")
count += 1
print(f"\n共重命名 {count} 个文件")
return count
def search_files(self, directory, keyword, extension=None):
"""
搜索文件
Args:
directory: 搜索目录
keyword: 关键词
extension: 文件扩展名(可选)
"""
results = []
for root, dirs, files in os.walk(directory):
for file in files:
if keyword.lower() in file.lower():
if extension is None or file.endswith(extension):
full_path = os.path.join(root, file)
size = os.path.getsize(full_path)
results.append({
'path': full_path,
'name': file,
'size': size
})
return results
def file_statistics(self, directory):
"""
文件统计
Args:
directory: 统计目录
"""
stats = {
'total_files': 0,
'total_size': 0,
'extensions': {}
}
for root, dirs, files in os.walk(directory):
for file in files:
stats['total_files'] += 1
full_path = os.path.join(root, file)
size = os.path.getsize(full_path)
stats['total_size'] += size
ext = Path(file).suffix.lower()
if ext:
if ext not in stats['extensions']:
stats['extensions'][ext] = {'count': 0, 'size': 0}
stats['extensions'][ext]['count'] += 1
stats['extensions'][ext]['size'] += size
return stats
def format_size(self, size):
"""格式化文件大小"""
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
if size < 1024.0:
return f"{size:.2f} {unit}"
size /= 1024.0
return f"{size:.2f} PB"
def main():
parser = argparse.ArgumentParser(description='文件管理工具')
subparsers = parser.add_subparsers(dest='command', help='子命令')
# 批量重命名命令
rename_parser = subparsers.add_parser('rename', help='批量重命名')
rename_parser.add_argument('directory', help='目标目录')
rename_parser.add_argument('pattern', help='要替换的模式')
rename_parser.add_argument('replacement', help='替换后的文本')
# 搜索命令
search_parser = subparsers.add_parser('search', help='搜索文件')
search_parser.add_argument('directory', help='搜索目录')
search_parser.add_argument('keyword', help='关键词')
search_parser.add_argument('--ext', help='文件扩展名')
# 统计命令
stats_parser = subparsers.add_parser('stats', help='文件统计')
stats_parser.add_argument('directory', help='统计目录')
args = parser.parse_args()
if not args.command:
parser.print_help()
return
fm = FileManager()
if args.command == 'rename':
fm.batch_rename(args.directory, args.pattern, args.replacement)
elif args.command == 'search':
results = fm.search_files(args.directory, args.keyword, args.ext)
print(f"\n找到 {len(results)} 个文件:\n")
for r in results:
print(f"�� {r['name']}")
print(f" 路径: {r['path']}")
print(f" 大小: {fm.format_size(r['size'])}\n")
elif args.command == 'stats':
stats = fm.file_statistics(args.directory)
print("\n文件统计结果:")
print("=" * 60)
print(f"总文件数: {stats['total_files']}")
print(f"总大小: {fm.format_size(stats['total_size'])}")
print("\n按扩展名统计:")
print("-" * 60)
sorted_exts = sorted(stats['extensions'].items(),
key=lambda x: x[1]['size'], reverse=True)
for ext, info in sorted_exts:
print(f"{ext:15} {info['count']:6} 个文件 "
f"{fm.format_size(info['size']):>12}")
if name == 'main':
main()
打包命令:
pyinstaller --onefile --name FileManager file_manager.py
使用示例:
批量重命名
FileManager.exe rename ./photos "IMG" "Photo"
搜索文件
FileManager.exe search ./documents "report" --ext .pdf
文件统计
FileManager.exe stats ./projects
6.2 案例2:PyQt5 GUI 应用打包
todo_app.py - 待办事项管理器(PyQt5)"""
待办事项管理器
功能:添加、删除、标记完成、保存到文件
"""import sysimport jsonfrom datetime import datetimefrom PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout,
QHBoxLayout, QLineEdit, QPushButton, QListWidget,
QListWidgetItem, QMessageBox, QLabel)from PyQt5.QtCore import Qtfrom PyQt5.QtGui import QIconimport os
def resource_path(relative_path):
"""获取资源文件路径"""
try:
base_path = sys._MEIPASS
except Exception:
base_path = os.path.abspath(".")
return os.path.join(base_path, relative_path)
class TodoApp(QMainWindow):
def init(self):
super().init()
self.data_file = 'todos.json'
self.todos = []
self.init_ui()
self.load_todos()
def init_ui(self):
"""初始化界面"""
self.setWindowTitle('待办事项管理器')
self.setGeometry(100, 100, 500, 600)
# 设置图标
try:
icon_path = resource_path('app.ico')
self.setWindowIcon(QIcon(icon_path))
except:
pass
# 中央部件
central_widget = QWidget()
self.setCentralWidget(central_widget)
# 主布局
layout = QVBoxLayout()
# 标题
title = QLabel('�� 我的待办事项')
title.setStyleSheet('font-size: 24px; font-weight: bold; padding: 10px;')
title.setAlignment(Qt.AlignCenter)
layout.addWidget(title)
# 输入区域
input_layout = QHBoxLayout()
self.input_field = QLineEdit()
self.input_field.setPlaceholderText('输入新的待办事项...')
self.input_field.returnPressed.connect(self.add_todo)
add_btn = QPushButton('添加')
add_btn.clicked.connect(self.add_todo)
add_btn.setStyleSheet('background-color: #4CAF50; color: white; padding: 8px;')
input_layout.addWidget(self.input_field)
input_layout.addWidget(add_btn)
layout.addLayout(input_layout)
# 列表区域
self.list_widget = QListWidget()
self.list_widget.itemDoubleClicked.connect(self.toggle_todo)
layout.addWidget(self.list_widget)
# 按钮区域
button_layout = QHBoxLayout()
delete_btn = QPushButton('删除选中')
delete_btn.clicked.connect(self.delete_todo)
delete_btn.setStyleSheet('background-color: #f44336; color: white; padding: 8px;')
clear_btn = QPushButton('清空已完成')
clear_btn.clicked.connect(self.clear_completed)
clear_btn.setStyleSheet('background-color: #FF9800; color: white; padding: 8px;')
button_layout.addWidget(delete_btn)
button_layout.addWidget(clear_btn)
layout.addLayout(button_layout)
# 统计信息
self.stats_label = QLabel()
self.stats_label.setAlignment(Qt.AlignCenter)
self.stats_label.setStyleSheet('padding: 10px; color: #666;')
layout.addWidget(self.stats_label)
central_widget.setLayout(layout)
self.update_stats()
def add_todo(self):
"""添加待办事项"""
text = self.input_field.text().strip()
if text:
todo = {
'text': text,
'completed': False,
'created_at': datetime.now().isoformat()
}
self.todos.append(todo)
self.refresh_list()
self.input_field.clear()
self.save_todos()
def delete_todo(self):
"""删除选中的待办事项"""
current_row = self.list_widget.currentRow()
if current_row >= 0:
del self.todos[current_row]
self.refresh_list()
self.save_todos()
def toggle_todo(self, item):
"""切换完成状态"""
row = self.list_widget.row(item)
self.todos[row]['completed'] = not self.todos[row]['completed']
self.refresh_list()
self.save_todos()
def clear_completed(self):
"""清空已完成的事项"""
self.todos = [todo for todo in self.todos if not todo['completed']]
self.refresh_list()
self.save_todos()
def refresh_list(self):
"""刷新列表显示"""
self.list_widget.clear()
for todo in self.todos:
item = QListWidgetItem()
if todo['completed']:
item.setText(f"✅ {todo['text']}")
item.setForeground(Qt.gray)
else:
item.setText(f"⭕ {todo['text']}")
self.list_widget.addItem(item)
self.update_stats()
def update_stats(self):
"""更新统计信息"""
total = len(self.todos)
completed = sum(1 for todo in self.todos if todo['completed'])
pending = total - completed
self.stats_label.setText(
f"总计: {total} | 已完成: {completed} | 待完成: {pending}"
)
def load_todos(self):
"""从文件加载待办事项"""
try:
if os.path.exists(self.data_file):
with open(self.data_file, 'r', encoding='utf-8') as f:
self.todos = json.load(f)
self.refresh_list()
except Exception as e:
QMessageBox.warning(self, '错误', f'加载数据失败: {str(e)}')
def save_todos(self):
"""保存待办事项到文件"""
try:
with open(self.data_file, 'w', encoding='utf-8') as f:
json.dump(self.todos, f, indent=2, ensure_ascii=False)
except Exception as e:
QMessageBox.warning(self, '错误', f'保存数据失败: {str(e)}')
def closeEvent(self, event):
"""关闭事件"""
self.save_todos()
event.accept()
def main():
app = QApplication(sys.argv)
window = TodoApp()
window.show()
sys.exit(app.exec_())
if name == 'main':
main()
打包命令:
安装依赖
pip install PyQt5
打包(单文件模式)
pyinstaller --onefile --noconsole --icon=app.ico --name TodoApp todo_app.py
或使用 .spec 文件
pyinstaller TodoApp.spec
TodoApp.spec 配置:
复制
-- mode: python ; coding: utf-8 --
block_cipher = None
a = Analysis(
['todo_app.py'],
pathex=[],
binaries=[],
datas=[('app.ico', '.')],
hiddenimports=['PyQt5'],
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,
a.binaries,
a.zipfiles,
a.datas,
[],
name='TodoApp',
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',
)
6.3 案例3:Flask Web 应用打包
web_app.py - Flask Web 应用"""
简单的 Flask Web 应用
功能:本地 Web 服务器,提供文件浏览和上传
"""from flask import Flask, render_template_string, request, send_file, jsonifyimport osimport sysimport webbrowserimport threadingfrom pathlib import Path
def resource_path(relative_path):
"""获取资源文件路径"""
try:
base_path = sys._MEIPASS
except Exception:
base_path = os.path.abspath(".")
return os.path.join(base_path, relative_path)
app = Flask(name)
app.config['UPLOAD_FOLDER'] = './uploads'
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
HTML 模板
HTML_TEMPLATE = """
文件管理器
body {
font-family: Arial, sans-serif;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
background-color: #f5f5f5;
}
.container {
background: white;
padding: 30px;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
h1 {
color: #333;
text-align: center;
}
.upload-section {
margin: 20px 0;
padding: 20px;
background: #f9f9f9;
border-radius: 5px;
}
.file-list {
margin-top: 20px;
}
.file-item {
padding: 10px;
margin: 5px 0;
background: #fff;
border: 1px solid #ddd;
border-radius: 5px;
display: flex;
justify-content: space-between;
align-items: center;
}
.file-item:hover {
background: #f0f0f0;
}
button {
padding: 10px 20px;
background: #4CAF50;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
}
button:hover {
background: #45a049;
}
.delete-btn {
background: #f44336;
}
.delete-btn:hover {
background: #da190b;
}
function deleteFile(filename) {
if (confirm('确定要删除 ' + filename + ' 吗?')) {
fetch('/delete/' + filename, {method: 'DELETE'})
.then(() => location.reload());
}
}
"""
@app.route('/')def index():
"""首页"""
files = os.listdir(app.config['UPLOAD_FOLDER'])
return render_template_string(HTML_TEMPLATE, files=files)
@app.route('/upload', methods=['POST'])def upload():
"""上传文件"""
if 'file' not in request.files:
return '没有文件', 400
file = request.files['file']
if file.filename == '':
return '文件名为空', 400
file.save(os.path.join(app.config['UPLOAD_FOLDER'], file.filename))
return '''
alert('上传成功!');
window.location.href = '/';
'''
@app.route('/download/')def download(filename):
"""下载文件"""
file_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
return send_file(file_path, as_attachment=True)
@app.route('/delete/', methods=['DELETE'])def delete(filename):
"""删除文件"""
file_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
if os.path.exists(file_path):
os.remove(file_path)
return jsonify({'success': True})
return jsonify({'success': False}), 404
def open_browser():
"""延迟打开浏览器"""
import time
time.sleep(1.5)
webbrowser.open('http://127.0.0.1:5000')
def main():
print("=" * 60)
print("文件管理器 Web 服务已启动")
print("访问地址: http://127.0.0.1:5000")
print("按 Ctrl+C 停止服务")
print("=" * 60)
# 在新线程中打开浏览器
threading.Thread(target=open_browser, daemon=True).start()
# 启动 Flask 服务
app.run(host='127.0.0.1', port=5000, debug=False)
if name == 'main':
main()
打包命令:
安装依赖
pip install flask
打包
pyinstaller --onefile --noconsole --icon=app.ico --name FileManager web_app.py
或者显示控制台(方便调试)
pyinstaller --onefile --console --icon=app.ico --name FileManager web_app.py
七、常见问题及解决方法
7.1 导入错误问题
问题1:ModuleNotFoundError
复制
问题:打包后运行提示找不到模块# 原因:动态导入或隐式导入未被检测
解决方案1:使用 --hidden-import
pyinstaller --onefile --hidden-import=requests --hidden-import=PIL app.py
解决方案2:在 .spec 文件中添加
hiddenimports=['requests', 'PIL', 'numpy.core._methods']
解决方案3:在代码中显式导入import requests # 即使不直接使用,也要导入
问题2:第三方库钩子缺失
某些库需要特殊处理,创建自定义钩子
hook-mylib.pyfrom PyInstaller.utils.hooks import collect_data_files, collect_submodules
datas = collect_data_files('mylib')
hiddenimports = collect_submodules('mylib')
使用钩子
pyinstaller --additional-hooks-dir=./hooks app.py
7.2 资源文件问题
问题3:找不到资源文件
错误的方式(打包后会失败)with open('config.json', 'r') as f:
config = json.load(f)
正确的方式import sysimport os
def resource_path(relative_path):
"""获取资源文件的绝对路径"""
try:
# PyInstaller 创建临时文件夹,路径存储在 _MEIPASS
base_path = sys._MEIPASS
except Exception:
base_path = os.path.abspath(".")
return os.path.join(base_path, relative_path)
使用
config_path = resource_path('config.json')with open(config_path, 'r') as f:
config = json.load(f)
问题4:图片/图标不显示
打包时添加资源文件
pyinstaller --onefile --add-data "images;images" --add-data "config.json;." app.py
在代码中使用
icon_path = resource_path('images/icon.png')
7.3 体积优化问题
问题5:打包文件过大
解决方案1:排除不需要的模块
pyinstaller --onefile --exclude-module=matplotlib --exclude-module=pandas app.py
解决方案2:使用虚拟环境(只安装必要的包)
python -m venv venv
venv\Scripts\activate
pip install 必要的包
pyinstaller app.py
解决方案3:使用 UPX 压缩# 下载 UPX: upx.github.io/# 将 upx.exe 放到 PATH 或 PyInstaller 目录
pyinstaller --onefile --upx-dir=./upx app.py
解决方案4:在 .spec 中精细控制
excludes=['tkinter', 'matplotlib', 'test', 'unittest']
体积对比示例:
optimization_test.py - 测试不同优化方案的效果"""
测试脚本:比较不同打包选项的文件大小
"""import subprocessimport os
def get_file_size(file_path):
"""获取文件大小(MB)"""
if os.path.exists(file_path):
size = os.path.getsize(file_path) / (1024 * 1024)
return f"{size:.2f} MB"
return "文件不存在"
测试不同的打包选项
test_cases = [
{
'name': '基础打包',
'cmd': 'pyinstaller --onefile app.py',
'output': 'dist/app.exe'
},
{
'name': '排除模块',
'cmd': 'pyinstaller --onefile --exclude-module=matplotlib app.py',
'output': 'dist/app.exe'
},
{
'name': 'UPX压缩',
'cmd': 'pyinstaller --onefile --upx-dir=./upx app.py',
'output': 'dist/app.exe'
},
]
print("打包体积优化测试")print("=" * 60)
for test in test_cases:
print(f"\n测试: {test['name']}")
print(f"命令: {test['cmd']}")
# 执行打包
# subprocess.run(test['cmd'], shell=True)
# 显示文件大小
size = get_file_size(test['output'])
print(f"文件大小: {size}")
7.4 运行时错误
问题6:打包后运行闪退
解决方案1:使用控制台模式查看错误
pyinstaller --onefile --console app.py
解决方案2:添加异常捕获import sysimport traceback
def main():
try:
# 你的主程序代码
pass
except Exception as e:
# 将错误写入日志文件
with open('error.log', 'w') as f:
f.write(traceback.format_exc())
print(f"发生错误: {e}")
input("按回车键退出...")
sys.exit(1)
if name == 'main':
main()
问题7:DLL 缺失错误
Windows 常见 DLL 缺失
解决方案1:安装 Visual C++ Redistributable# 下载地址:aka.ms/vs/17/relea…
解决方案2:手动添加 DLL
pyinstaller --onefile --add-binary "msvcp140.dll;." app.py
解决方案3:使用 --collect-all
pyinstaller --onefile --collect-all numpy app.py
7.5 特定库问题
问题8:PyQt5/PySide2 打包问题
PyQt5 打包配置
pyinstaller --onefile --noconsole \
--hidden-import=PyQt5.QtPrintSupport \
--hidden-import=PyQt5.sip \
app.py
或在 .spec 中
hiddenimports=[
'PyQt5.QtPrintSupport',
'PyQt5.sip',
'PyQt5.QtCore',
'PyQt5.QtGui',
'PyQt5.QtWidgets',
]
问题9:NumPy/Pandas 打包问题
NumPy/Pandas 打包
pyinstaller --onefile \
--hidden-import=numpy.core._methods \
--hidden-import=numpy.lib.format \
--collect-all pandas \
app.py
问题10:Matplotlib 打包问题
Matplotlib 打包
pyinstaller --onefile \
--hidden-import=matplotlib.backends.backend_tkagg \
--collect-data matplotlib \
app.py
7.6 调试技巧
debug_helper.py - 调试辅助工具"""
打包后的调试辅助工具
"""import sysimport osimport tracebackfrom datetime import datetime
class DebugHelper:
def init(self, log_file='debug.log'):
self.log_file = log_file
def log(self, message):
"""写入日志"""
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
with open(self.log_file, 'a', encoding='utf-8') as f:
f.write(f"[{timestamp}] {message}\n")
def log_exception(self, exc):
"""记录异常"""
self.log("=" * 60)
self.log("发生异常:")
self.log(traceback.format_exc())
self.log("=" * 60)
def log_system_info(self):
"""记录系统信息"""
self.log("系统信息:")
self.log(f" Python版本: {sys.version}")
self.log(f" 平台: {sys.platform}")
self.log(f" 可执行文件: {sys.executable}")
# 检查是否在打包环境中运行
if getattr(sys, 'frozen', False):
self.log(f" 运行模式: 打包后")
self.log(f" 临时目录: {sys._MEIPASS}")
else:
self.log(f" 运行模式: 开发环境")
def check_resources(self, resource_list):
"""检查资源文件是否存在"""
self.log("\n资源文件检查:")
for resource in resource_list:
exists = os.path.exists(resource)
status = "✅" if exists else "❌"
self.log(f" {status} {resource}")
使用示例def main():
debug = DebugHelper()
try:
debug.log_system_info()
debug.check_resources(['config.json', 'icon.ico', 'data/'])
# 你的主程序代码
print("程序运行中...")
except Exception as e:
debug.log_exception(e)
print(f"发生错误,详情请查看 {debug.log_file}")
input("按回车键退出...")
if name == 'main':
main()
八、高级技巧与最佳实践
8.1 自动化打包脚本
build.py - 自动化打包脚本"""
自动化打包脚本
功能:清理、打包、测试、生成安装包
"""import subprocessimport shutilimport osimport sysfrom pathlib import Path
class Builder:
def init(self, app_name, main_script):
self.app_name = app_name
self.main_script = main_script
self.dist_dir = Path('dist')
self.build_dir = Path('build')
self.spec_file = Path(f'{app_name}.spec')
def clean(self):
"""清理旧的构建文件"""
print("�� 清理旧文件...")
dirs_to_remove = [self.dist_dir, self.build_dir]
for dir_path in dirs_to_remove:
if dir_path.exists():
shutil.rmtree(dir_path)
print(f" ✅ 删除 {dir_path}")
if self.spec_file.exists():
self.spec_file.unlink()
print(f" ✅ 删除 {self.spec_file}")
def build(self, options=None):
"""执行打包"""
print(f"\n�� 开始打包 {self.app_name}...")
# 基础命令
cmd = [
'pyinstaller',
'--onefile',
'--noconsole',
f'--name={self.app_name}',
]
# 添加额外选项
if options:
cmd.extend(options)
# 添加主脚本
cmd.append(self.main_script)
# 执行打包
print(f" 命令: {' '.join(cmd)}")
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode == 0:
print(" ✅ 打包成功")
return True
else:
print(" ❌ 打包失败")
print(result.stderr)
return False
def test(self):
"""测试打包后的程序"""
print(f"\n�� 测试 {self.app_name}...")
exe_path = self.dist_dir / f'{self.app_name}.exe'
if not exe_path.exists():
print(" ❌ 可执行文件不存在")
return False
# 获取文件大小
size_mb = exe_path.stat().st_size / (1024 * 1024)
print(f" 文件大小: {size_mb:.2f} MB")
# 可以添加更多测试...
print(" ✅ 测试通过")
return True
def create_installer(self):
"""创建安装包(使用 Inno Setup)"""
print(f"\n�� 创建安装包...")
# 这里可以集成 Inno Setup 或 NSIS
# 示例:生成 Inno Setup 脚本
iss_content = f"""
[Setup]
AppName={self.app_name}
AppVersion=1.0
DefaultDirName={{pf}}\{self.app_name}
DefaultGroupName={self.app_name}
OutputDir=installer
OutputBaseFilename={self.app_name}_Setup
[Files]
Source: "dist\{self.app_name}.exe"; DestDir: "{{app}}"
[Icons]
Name: "{{group}}\{self.app_name}"; Filename: "{{app}}\{self.app_name}.exe"
Name: "{{commondesktop}}\{self.app_name}"; Filename: "{{app}}\{self.app_name}.exe"
"""
iss_file = Path(f'{self.app_name}.iss')
with open(iss_file, 'w', encoding='utf-8') as f:
f.write(iss_content)
print(f" ✅ 生成 Inno Setup 脚本: {iss_file}")
print(" 提示: 使用 Inno Setup Compiler 编译此脚本")
def package(self):
"""打包成压缩文件"""
print(f"\n�� 创建发布包...")
import zipfile
zip_name = f'{self.app_name}_v1.0.zip'
with zipfile.ZipFile(zip_name, 'w', zipfile.ZIP_DEFLATED) as zipf:
exe_path = self.dist_dir / f'{self.app_name}.exe'
if exe_path.exists():
zipf.write(exe_path, f'{self.app_name}.exe')
print(f" ✅ 创建压缩包: {zip_name}")
else:
print(" ❌ 可执行文件不存在")
def run_all(self, options=None):
"""执行完整的构建流程"""
print("=" * 60)
print(f"开始构建 {self.app_name}")
print("=" * 60)
# 1. 清理
self.clean()
# 2. 打包
if not self.build(options):
print("\n❌ 构建失败")
return False
# 3. 测试
if not self.test():
print("\n❌ 测试失败")
return False
# 4. 创建发布包
self.package()
# 5. 生成安装脚本
self.create_installer()
print("\n" + "=" * 60)
print("✅ 构建完成!")
print("=" * 60)
return True
使用示例if name == 'main':
# 配置
APP_NAME = 'MyApp'
MAIN_SCRIPT = 'app.py'
# 打包选项
OPTIONS = [
'--icon=app.ico',
'--add-data=config.json;.',
'--hidden-import=requests',
]
# 执行构建
builder = Builder(APP_NAME, MAIN_SCRIPT)
builder.run_all(OPTIONS)
8.2 多平台打包方案
multi_platform_build.py - 多平台打包"""
多平台打包脚本
支持 Windows、Linux、macOS
"""import platformimport subprocessimport sys
class MultiPlatformBuilder:
def init(self, app_name, main_script):
self.app_name = app_name
self.main_script = main_script
self.system = platform.system()
def get_platform_options(self):
"""获取平台特定的打包选项"""
options = {
'Windows': [
'--onefile',
'--noconsole',
'--icon=app.ico',
],
'Linux': [
'--onefile',
'--noconsole',
],
'Darwin': [ # macOS
'--onefile',
'--noconsole',
'--icon=app.icns', # macOS 使用 .icns 格式
]
}
return options.get(self.system, [])
def build(self):
"""执行打包"""
print(f"当前平台: {self.system}")
cmd = ['pyinstaller']
cmd.extend(self.get_platform_options())
cmd.extend([f'--name={self.app_name}', self.main_script])
print(f"执行命令: {' '.join(cmd)}")
subprocess.run(cmd)
def create_macos_app(self):
"""创建 macOS .app 包"""
if self.system != 'Darwin':
return
print("创建 macOS .app 包...")
# 使用 --windowed 选项创建 .app 包
cmd = [
'pyinstaller',
'--windowed',
'--onefile',
f'--name={self.app_name}',
'--icon=app.icns',
self.main_script
]
subprocess.run(cmd)
print(f"✅ 创建完成: dist/{self.app_name}.app")
def create_linux_appimage(self):
"""创建 Linux AppImage"""
if self.system != 'Linux':
return
print("创建 Linux AppImage...")
# 这里需要额外的工具如 appimagetool
# 示例流程:
# 1. 创建 AppDir 结构
# 2. 复制可执行文件
# 3. 创建 .desktop 文件
# 4. 使用 appimagetool 打包
print("提示: 需要安装 appimagetool")
使用示例if name == 'main':
builder = MultiPlatformBuilder('MyApp', 'app.py')
builder.build()
if platform.system() == 'Darwin':
builder.create_macos_app()
elif platform.system() == 'Linux':
builder.create_linux_appimage()
8.3 版本管理与自动更新
version_manager.py - 版本管理"""
版本管理和自动更新检查
"""
import json
import requests
from packaging import version
import sys
import os
class VersionManager:
def init(self, current_version, update_url):
self.current_version = current_version
self.update_url = update_url
def check_update(self):
"""检查更新"""
try:
response = requests.get(self.update_url, timeout=5)
data = response.json()
latest_version = data.get('version')
download_url = data.get('download_url')
changelog = data.get('changelog', '')
if version.parse(latest_version) > version.parse(self.current_version):
return {
'has_update': True,
'latest_version': latest_version,
'download_url': download_url,
'changelog': changelog
}
else:
return {'has_update': False}
except Exception as e:
print(f"检查更新失败: {e}")
return {'has_update': False, 'error': str(e)}
def download_update(self, url, save_path):
"""下载更新"""
try:
print(f"正在下载更新...")
response = requests.get(url, stream=True)
total_size = int(response.headers.get('content-length', 0))
with open(save_path, 'wb') as f:
downloaded = 0
for chunk in response.iter_content(chunk_size=8192):
if chunk:
f.write(chunk)
downloaded += len(chunk)
# 显示进度
if total_size > 0:
progress = (downloaded / total_size) * 100
print(f'\r进度: {progress:.1f}%', end='')
print(f'\n✅ 下载完成: {save_path}')
return True
except Exception as e:
print(f'❌ 下载失败: {e}')
return False
def apply_update(self, new_exe_path):
"""应用更新(重启程序)"""
import subprocess
# 创建更新脚本
if sys.platform == 'win32':
update_script = 'update.bat'
script_content = f"""
@echo off
echo 正在更新程序...
timeout /t 2 /nobreak > nul
move /y "{new_exe_path}" "{sys.executable}"
start "" "{sys.executable}"
del "%~f0"
"""
else:
update_script = 'update.sh'
script_content = f"""#!/bin/bash
echo "正在更新程序..."
sleep 2
mv -f "{new_exe_path}" "{sys.executable}"
chmod +x "{sys.executable}"
"{sys.executable}" &
rm -f "$0"
"""
with open(update_script, 'w') as f:
f.write(script_content)
# 执行更新脚本
if sys.platform == 'win32':
subprocess.Popen([update_script], shell=True)
else:
os.chmod(update_script, 0o755)
subprocess.Popen([f'./{update_script}'], shell=True)
sys.exit(0)
在主程序中使用def main():
APP_VERSION = '1.0.0'
UPDATE_URL = 'api.example.com/check_updat…'
vm = VersionManager(APP_VERSION, UPDATE_URL)
# 检查更新
update_info = vm.check_update()
if update_info.get('has_update'):
print(f"发现新版本: {update_info['latest_version']}")
print(f"更新内容:\n{update_info['changelog']}")
choice = input("是否下载更新?(y/n): ")
if choice.lower() == 'y':
new_exe = 'new_version.exe'
if vm.download_update(update_info['download_url'], new_exe):
vm.apply_update(new_exe)
else:
print("当前已是最新版本")
8.4 加密与代码保护
code_protection.py - 代码保护方案"""
代码保护和加密
"""import base64import zlibfrom cryptography.fernet import Fernet
class CodeProtector:
def init(self, key=None):
"""
初始化加密器
Args:
key: 加密密钥(如果为None则生成新密钥)
"""
if key is None:
self.key = Fernet.generate_key()
else:
self.key = key
self.cipher = Fernet(self.key)
def encrypt_string(self, text):
"""加密字符串"""
encrypted = self.cipher.encrypt(text.encode())
return base64.b64encode(encrypted).decode()
def decrypt_string(self, encrypted_text):
"""解密字符串"""
encrypted = base64.b64decode(encrypted_text.encode())
return self.cipher.decrypt(encrypted).decode()
def obfuscate_code(self, code):
"""混淆代码(简单压缩+base64)"""
compressed = zlib.compress(code.encode())
obfuscated = base64.b64encode(compressed).decode()
return obfuscated
def deobfuscate_code(self, obfuscated):
"""反混淆代码"""
compressed = base64.b64decode(obfuscated.encode())
code = zlib.decompress(compressed).decode()
return code
使用示例def protect_sensitive_data():
"""保护敏感数据"""
protector = CodeProtector()
# 保存密钥(注意:密钥需要安全存储)
with open('secret.key', 'wb') as f:
f.write(protector.key)
# 加密敏感配置
api_key = "your_secret_api_key"
encrypted_key = protector.encrypt_string(api_key)
print(f"加密后的API密钥: {encrypted_key}")
# 在程序中使用
# 读取密钥
with open('secret.key', 'rb') as f:
key = f.read()
protector = CodeProtector(key)
decrypted_key = protector.decrypt_string(encrypted_key)
print(f"解密后的API密钥: {decrypted_key}")
打包时的注意事项"""
- 使用 --key 参数加密字节码(需要 tinyaes)
pyinstaller --onefile --key=your_encryption_key app.py
- 使用 PyArmor 进行深度混淆
pip install pyarmor
pyarmor obfuscate app.py
pyinstaller --onefile app.py
- 使用 Nuitka 编译为 C 代码
pip install nuitka
nuitka --standalone --onefile app.py
"""
8.5 性能优化技巧
performance_optimizer.py - 性能优化"""
打包性能优化技巧
"""import timeimport functoolsimport sys
class PerformanceOptimizer:
"""性能优化工具"""
@staticmethod
def lazy_import(module_name):
"""延迟导入(减少启动时间)"""
def decorator(func): @functools.wraps(func)
def wrapper(*args, **kwargs):
# 只在需要时才导入模块
module = import(module_name)
return func(module, *args, **kwargs)
return wrapper
return decorator
@staticmethod
def cache_result(func):
"""缓存函数结果"""
cache = {}
@functools.wraps(func)
def wrapper(*args):
if args in cache:
return cache[args]
result = func(*args)
cache[args] = result
return result
return wrapper
@staticmethod
def measure_time(func):
"""测量函数执行时间""" @functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
end = time.time()
print(f"{func.name} 耗时: {end - start:.4f}秒")
return result
return wrapper
优化示例class OptimizedApp:
def init(self):
# 延迟导入重型库
self._numpy = None
self._pandas = None
@property
def numpy(self):
"""延迟导入 NumPy"""
if self._numpy is None:
import numpy as np
self._numpy = np
return self._numpy
@property
def pandas(self):
"""延迟导入 Pandas"""
if self._pandas is None:
import pandas as pd
self._pandas = pd
return self._pandas
@PerformanceOptimizer.measure_time
def process_data(self, data):
"""处理数据"""
# 只在需要时才导入和使用 NumPy
if isinstance(data, list):
return self.numpy.array(data).mean()
return data
启动优化def optimize_startup():
"""优化启动时间"""
# 1. 禁用不必要的导入检查
sys.dont_write_bytecode = True
# 2. 预加载常用模块
import importlib
modules_to_preload = ['json', 'os', 'sys']
for module in modules_to_preload:
importlib.import_module(module)
# 3. 设置递归限制(如果需要)
sys.setrecursionlimit(1500)
.spec 文件优化配置"""
优化的 .spec 文件示例
a = Analysis(
['app.py'],
pathex=[],
binaries=[],
datas=[],
hiddenimports=[],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[
# 排除不需要的标准库模块
'tkinter',
'unittest',
'test',
'distutils',
'setuptools',
'pip',
# 排除不需要的第三方库
'matplotlib',
'scipy',
'IPython',
],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=None,
noarchive=False,
)
移除重复的二进制文件
a.binaries = [x for x in a.binaries if not x[0].startswith('api-ms-win')]
移除不需要的数据文件
a.datas = [x for x in a.datas if not x[0].startswith('tcl')]
pyz = PYZ(a.pure, a.zipped_data, cipher=None)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.zipfiles,
a.datas,
[],
name='OptimizedApp',
debug=False,
bootloader_ignore_signals=False,
strip=True, # 移除调试符号
upx=True, # 使用 UPX 压缩
upx_exclude=[],
runtime_tmpdir=None,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)
"""
8.6 完整的生产级打包模板
production_build.py - 生产级打包模板"""
生产级打包脚本
包含:清理、构建、测试、签名、发布
"""import osimport sysimport shutilimport subprocessimport jsonimport hashlibfrom pathlib import Pathfrom datetime import datetime
class ProductionBuilder:
def init(self, config_file='build_config.json'):
"""
初始化构建器
Args:
config_file: 构建配置文件
"""
self.load_config(config_file)
self.build_time = datetime.now()
def load_config(self, config_file):
"""加载构建配置"""
with open(config_file, 'r', encoding='utf-8') as f:
self.config = json.load(f)
# 基本配置
self.app_name = self.config['app_name']
self.version = self.config['version']
self.main_script = self.config['main_script']
self.icon = self.config.get('icon', 'app.ico')
# 目录配置
self.dist_dir = Path(self.config.get('dist_dir', 'dist'))
self.build_dir = Path(self.config.get('build_dir', 'build'))
self.release_dir = Path(self.config.get('release_dir', 'release'))
def clean(self):
"""清理构建目录"""
print("\n" + "=" * 60)
print("�� 清理构建目录")
print("=" * 60)
dirs_to_clean = [self.dist_dir, self.build_dir]
for dir_path in dirs_to_clean:
if dir_path.exists():
shutil.rmtree(dir_path)
print(f"✅ 清理: {dir_path}")
# 清理 .spec 文件
spec_files = list(Path('.').glob('*.spec'))
for spec_file in spec_files:
spec_file.unlink()
print(f"✅ 清理: {spec_file}")
def validate_environment(self):
"""验证构建环境"""
print("\n" + "=" * 60)
print("�� 验证构建环境")
print("=" * 60)
checks = []
# 检查 Python 版本
python_version = sys.version_info
print(f"Python 版本: {python_version.major}.{python_version.minor}.{python_version.micro}")
checks.append(python_version.major == 3 and python_version.minor >= 7)
# 检查 PyInstaller
try:
result = subprocess.run(['pyinstaller', '--version'],
capture_output=True, text=True)
print(f"PyInstaller 版本: {result.stdout.strip()}")
checks.append(True)
except FileNotFoundError:
print("❌ PyInstaller 未安装")
checks.append(False)
# 检查主脚本
if Path(self.main_script).exists():
print(f"✅ 主脚本存在: {self.main_script}")
checks.append(True)
else:
print(f"❌ 主脚本不存在: {self.main_script}")
checks.append(False)
# 检查图标文件
if Path(self.icon).exists():
print(f"✅ 图标文件存在: {self.icon}")
checks.append(True)
else:
print(f"⚠️ 图标文件不存在: {self.icon}")
checks.append(True) # 图标不是必需的
return all(checks)
def build(self):
"""执行构建"""
print("\n" + "=" * 60)
print(f"�� 构建 {self.app_name} v{self.version}")
print("=" * 60)
# 构建命令
cmd = ['pyinstaller']
# 基本选项
cmd.extend([
'--onefile',
'--noconsole' if self.config.get('noconsole', True) else '--console',
f'--name={self.app_name}',
])
# 图标
if Path(self.icon).exists():
cmd.append(f'--icon={self.icon}')
# 添加数据文件
for data in self.config.get('add_data', []):
cmd.append(f'--add-data={data}')
# 添加二进制文件
for binary in self.config.get('add_binary', []):
cmd.append(f'--add-binary={binary}')
# 隐藏导入
for hidden in self.config.get('hidden_imports', []):
cmd.append(f'--hidden-import={hidden}')
# 排除模块
for exclude in self.config.get('excludes', []):
cmd.append(f'--exclude-module={exclude}')
# 其他选项
if self.config.get('upx', True):
cmd.append('--upx-dir=./upx')
if self.config.get('clean', True):
cmd.append('--clean')
# 主脚本
cmd.append(self.main_script)
# 执行构建
print(f"\n执行命令:")
print(' '.join(cmd))
print()
result = subprocess.run(cmd)
if result.returncode == 0:
print("\n✅ 构建成功")
return True
else:
print("\n❌ 构建失败")
return False
def test(self):
"""测试构建结果"""
print("\n" + "=" * 60)
print("�� 测试构建结果")
print("=" * 60)
exe_name = f"{self.app_name}.exe" if sys.platform == 'win32' else self.app_name
exe_path = self.dist_dir / exe_name
if not exe_path.exists():
print(f"❌ 可执行文件不存在: {exe_path}")
return False
# 文件大小
size_mb = exe_path.stat().st_size / (1024 * 1024)
print(f"�� 文件大小: {size_mb:.2f} MB")
# 计算 MD5
md5_hash = self.calculate_md5(exe_path)
print(f"�� MD5: {md5_hash}")
# 可以添加更多测试
# 例如:运行程序并检查输出
print("✅ 测试通过")
return True
def calculate_md5(self, file_path):
"""计算文件 MD5"""
md5 = hashlib.md5()
with open(file_path, 'rb') as f:
for chunk in iter(lambda: f.read(4096), b''):
md5.update(chunk)
return md5.hexdigest()
def sign(self):
"""代码签名(Windows)"""
if sys.platform != 'win32':
print("\n⚠️ 代码签名仅支持 Windows")
return True
print("\n" + "=" * 60)
print("✍️ 代码签名")
print("=" * 60)
cert_file = self.config.get('certificate')
if not cert_file or not Path(cert_file).exists():
print("⚠️ 未配置证书文件,跳过签名")
return True
exe_path = self.dist_dir / f"{self.app_name}.exe"
# 使用 signtool 签名
cmd = [
'signtool',
'sign',
'/f', cert_file,
'/p', self.config.get('certificate_password', ''),
'/t', 'timestamp.digicert.com',
'/v',
str(exe_path)
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode == 0:
print("✅ 签名成功")
return True
else:
print(f"❌ 签名失败: {result.stderr}")
return False
def package(self):
"""打包发布文件"""
print("\n" + "=" * 60)
print("�� 打包发布文件")
print("=" * 60)
# 创建发布目录
self.release_dir.mkdir(exist_ok=True)
exe_name = f"{self.app_name}.exe" if sys.platform == 'win32' else self.app_name
exe_path = self.dist_dir / exe_name
# 创建版本标识
version_tag = f"v{self.version}{self.build_time.strftime('%Y%m%d%H%M%S')}"
# 1. 复制可执行文件
release_exe = self.release_dir / f"{self.app_name}_{version_tag}.exe"
shutil.copy2(exe_path, release_exe)
print(f"✅ 复制可执行文件: {release_exe.name}")
# 2. 创建 ZIP 压缩包
import zipfile
zip_path = self.release_dir / f"{self.app_name}_{version_tag}.zip"
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
zipf.write(exe_path, exe_name)
# 添加其他文件
for extra_file in self.config.get('extra_files', []):
if Path(extra_file).exists():
zipf.write(extra_file, Path(extra_file).name)
print(f"✅ 创建压缩包: {zip_path.name}")
# 3. 生成版本信息文件
version_info = {
'app_name': self.app_name,
'version': self.version,
'build_time': self.build_time.isoformat(),
'platform': sys.platform,
'python_version': f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
'file_size_mb': round(exe_path.stat().st_size / (1024 * 1024), 2),
'md5': self.calculate_md5(exe_path),
}
version_file = self.release_dir / f"{self.app_name}_{version_tag}_info.json"
with open(version_file, 'w', encoding='utf-8') as f:
json.dump(version_info, f, indent=2, ensure_ascii=False)
print(f"✅ 生成版本信息: {version_file.name}")
# 4. 生成 README
readme_content = f"""
{self.app_name} v{self.version}
构建信息
-
构建时间: {self.build_time.strftime('%Y-%m-%d %H:%M:%S')}
-
平台: {sys.platform}
-
Python 版本: {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}
-
文件大小: {version_info['file_size_mb']} MB
-
MD5: {version_info['md5']}
安装说明
-
解压 ZIP 文件
-
运行 {exe_name}
更新日志{self.config.get('changelog', '- 初始版本')}
联系方式
-
官网: {self.config.get('website', 'N/A')}
-
邮箱: {self.config.get('email', 'N/A')}
"""
readme_file = self.release_dir / 'README.md'
with open(readme_file, 'w', encoding='utf-8') as f:
f.write(readme_content)
print(f"✅ 生成 README: {readme_file.name}")
return True
def upload_to_server(self):
"""上传到服务器(可选)"""
if not self.config.get('upload_enabled', False):
print("\n⚠️ 未启用自动上传")
return True
print("\n" + "=" * 60)
print("☁️ 上传到服务器")
print("=" * 60)
# 这里可以实现 FTP、SFTP 或云存储上传
# 示例:使用 paramiko 上传到 SFTP
print("⚠️ 上传功能需要额外实现")
return True
def generate_report(self):
"""生成构建报告"""
print("\n" + "=" * 60)
print("�� 构建报告")
print("=" * 60)
exe_name = f"{self.app_name}.exe" if sys.platform == 'win32' else self.app_name
exe_path = self.dist_dir / exe_name
report = f"""
╔══════════════════════════════════════════════════════════╗
║ 构建报告 - {self.app_name}
╚══════════════════════════════════════════════════════════╝
应用名称: {self.app_name}
版本号: {self.version}
构建时间: {self.build_time.strftime('%Y-%m-%d %H:%M:%S')}
文件信息:
- 路径: {exe_path}
- 大小: {exe_path.stat().st_size / (1024 * 1024):.2f} MB
- MD5: {self.calculate_md5(exe_path)}
构建配置:
- Python: {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}
- 平台: {sys.platform}
- 模式: {'无控制台' if self.config.get('noconsole', True) else '控制台'}
发布目录: {self.release_dir}
╔══════════════════════════════════════════════════════════╗
║ 构建完成!
╚══════════════════════════════════════════════════════════╝
"""
print(report)
# 保存报告
report_file = self.release_dir / f"build_report_{self.build_time.strftime('%Y%m%d_%H%M%S')}.txt"
with open(report_file, 'w', encoding='utf-8') as f:
f.write(report)
def run_full_build(self):
"""执行完整构建流程"""
start_time = datetime.now()
print("\n")
print("╔" + "═" * 58 + "╗")
print(f"║ {self.app_name} v{self.version} - 生产构建流程".ljust(59) + "║")
print("╚" + "═" * 58 + "╝")
steps = [
("验证环境", self.validate_environment),
("清理目录", self.clean),
("执行构建", self.build),
("测试构建", self.test),
("代码签名", self.sign),
("打包发布", self.package),
("上传服务器", self.upload_to_server),
]
for step_name, step_func in steps:
if not step_func():
print(f"\n❌ 构建失败于: {step_name}")
return False
# 生成报告
self.generate_report()
end_time = datetime.now()
duration = (end_time - start_time).total_seconds()
print(f"\n✅ 构建成功!总耗时: {duration:.2f} 秒")
return True
构建配置文件示例
BUILD_CONFIG_EXAMPLE = """
{
"app_name": "MyApp",
"version": "1.0.0",
"main_script": "app.py",
"icon": "app.ico",
"noconsole": true,
"upx": true,
"clean": true,
"add_data": [
"config.json;.",
"resources;resources"
],
"add_binary": [],
"hidden_imports": [
"requests",
"PIL"
],
"excludes": [
"matplotlib",
"pandas",
"unittest",
"test"
],
"extra_files": [
"README.md",
"LICENSE"
],
"dist_dir": "dist",
"build_dir": "build",
"release_dir": "release",
"certificate": "cert.pfx",
"certificate_password": "password",
"upload_enabled": false,
"upload_server": "ftp.example.com",
"upload_path": "/releases",
"changelog": "- 初始版本\n- 添加核心功能",
"website": "example.com",
"email": "support@example.com"
}
"""
使用示例if name == 'main':
# 创建示例配置文件
if not Path('build_config.json').exists():
with open('build_config.json', 'w', encoding='utf-8') as f:
f.write(BUILD_CONFIG_EXAMPLE)
print("✅ 已创建示例配置文件: build_config.json")
print("请根据实际情况修改配置文件,然后重新运行此脚本")
sys.exit(0)
# 执行构建
builder = ProductionBuilder('build_config.json')
success = builder.run_full_build()
sys.exit(0 if success else 1)
九、总结与最佳实践清单
9.1 打包前检查清单
✅ 代码检查
□ 所有导入语句正确
□ 没有语法错误
□ 路径使用 resource_path() 函数
□ 异常处理完善
✅ 依赖管理
□ requirements.txt 已更新
□ 使用虚拟环境
□ 第三方库版本兼容
✅ 资源文件
□ 所有资源文件已列出
□ 图标文件格式正确
□ 配置文件路径正确
✅ 测试
□ 开发环境测试通过
□ 打包后测试通过
□ 不同系统测试通过
9.2 最佳实践总结
使用虚拟环境:避免打包不必要的依赖
资源路径处理:始终使用 resource_path() 函数
异常处理:添加完善的错误处理和日志记录
版本管理:使用 .spec 文件管理复杂配置
体积优化:排除不需要的模块,使用 UPX 压缩
测试充分:在目标环境充分测试
文档完善:提供详细的安装和使用说明
自动化构建:使用脚本自动化构建流程
9.3 常用命令速查
基础打包
pyinstaller app.py
单文件打包
pyinstaller --onefile app.py
GUI应用(无控制台)
pyinstaller --onefile --noconsole app.py
完整配置
pyinstaller --onefile --noconsole --icon=app.ico --name=MyApp \
--add-data="config.json;." --hidden-import=requests app.py
使用 .spec 文件
pyinstaller MyApp.spec
清理重新打包
pyinstaller --clean MyApp.spec