前言:网上有许多减大小的方法,比如虚拟环境 (减少二进制文件的搜索路径,e.g.,miniconda,pipenv,virtualenv)、upx 压缩 (逐个压缩二进制文件)。但它们都没有解决 exe 文件过大的根本原因,要实现减体积、提速度的双重目标,需要删除 exe 中的冗余依赖文件。经测试发现,Pyinstaller 在打包的时候,会把 import 所导入的模块的所有依赖文件都封装到 exe 里面。如果你调用了 OpenCV 的图像处理函数,那么 OpenCV 的 cv2.pyd 和 opencv_videoio_ffmpeg480_64.dll (25.1 MB) 都会包含进 exe。但是后者是视频处理的依赖,显然是冗余的
method | size | delay |
虚拟环境 (miniconda) | 78.9 MB (100.0 %) | 11.00 s (100.0 %) |
UPX 压缩 | 59.7 MB (75.7 %) | 10.07 s (91.5 %) |
exclude.txt (本文) | 46.5 MB (58.9 %) | 7.88 s (71.6%) |
exclude.txt + UPX 压缩 | 37.4 MB (47.4 %) | 8.15 s (74.1 %) |
效果:本文的压缩思路与“虚拟环境”方法相似,都是去除冗余依赖,故使用本文方法无需使用“虚拟环境”。本文的实验程序主要使用了 PyQt5、matplotlib、numpy,经程序瘦身后 exe 文件大小由 78.9 MB 减小到 37.4 MB (仅为原来的 47.4%),启动时长由 11.00 s 减少到 8.15 s (为原来的 74.1%)
环境要求:Windows,Pyinstaller 版本 5.8.0 以上
开发环境:Windows 10,Python 3.8.0,Pyinstaller 6.3.0
声明:本文所使用代码不开源,觉得本文的思路可行的话,请加 QQ - 1398173074 购买 (¥30,注明来意)
商品包含一份 190+ 行的代码,以及相应的说明书。本文所使用的代码基于标准库 os、pathlib,包含一个名为 Installer 的类。有排查 exe 冗余依赖文件的函数 dump_exclude,有修改 *.spec 文件的函数 modify_spec,提供源代码修改指引的函数 check_src。
排查依赖文件
Pyinstaller 版本 6.0.0 以上,使用 pyinstaller -D 打包会生成一个 exe 和一个文件夹 _internal (否则则生成与 exe 同名的文件夹,exe 被包含其中)
使用 pyinstaller -F 打包则会生成一个 exe
但实际上,这个 exe 相当于 exe 和 _internal 的压缩包
在每次运行的时候,这个 exe 会解压到临时目录的一个文件夹 (这是单 exe 启动缓慢的原因,除去冗余的依赖文件可以减小文件体积,也可以提高解压速度)
而这个文件夹的内容与 _internal 文件夹是一致的,故可以研究 _internal 文件夹中的冗余内容,进而对 pyinstaller -F 打包的 exe 进行瘦身
如果会点英文的话,会发现 _internal 中其实有很多 .dll、.pyd 是不会用到的,但是一个一个删太慢了,有什么效率高的方法吗?
利用动态库加载时机 (聪明的同学自行操作,省省钱),便可以快速地得到不需要的文件列表,并写入 exclude.txt 中
接下来的难题就是,如何告诉 pyinstaller 我们不需要这些依赖文件
修改源代码
运行 pyinstaller -F 生成 .spec 文件
这个文件实际上是一个 python 文件,看看第一行就知道了
在 .spec 文件中添加以下代码
EXE 是 pyinstaller 源代码中的一个类,my_exclude 是我们自定义的类属性,通过这个方法,要排除的文件列表就成功传送到 pyinstaller 的源代码中了
接着就是修改 Pyinstaller 的源代码,首先找到 api.py 所在文件
在指定位置添加以下代码
运行 pyinstaller *.spec 即可完成打包
pyinstaller D:\Workbench\idle\pyinstaller\__exp__\install\zjqt.spec
941 INFO: PyInstaller: 6.3.0
941 INFO: Python: 3.8.0 (conda)
941 INFO: Platform: Windows-10-10.0.19041-SP0……
Skip: PyQt5\Qt5\bin\d3dcompiler_47.dll
Skip: PyQt5\Qt5\bin\libegl.dll
Skip: PyQt5\Qt5\bin\libglesv2.dll
Skip: PyQt5\Qt5\bin\opengl32sw.dll
Skip: PyQt5\Qt5\plugins\generic\qtuiotouchplugin.dll
Skip: PyQt5\Qt5\plugins\platforms\qoffscreen.dll
Skip: PyQt5\Qt5\plugins\iconengines\qsvgicon.dll
Skip: PyQt5\Qt5\plugins\platformthemes\qxdgdesktopportal.dll
Skip: PyQt5\Qt5\plugins\platforms\qwebgl.dll
Skip: PyQt5\Qt5\plugins\platforms\qminimal.dll……
105858 INFO: Appending PKG archive to EXE
105917 INFO: Fixing EXE headers
106211 INFO: Building EXE from EXE-00.toc completed successfully.
代码示例
本文所使用的代码主要含有一个 Installer 类,其框架如下 (因涉及知识产权,部分函数为空实现)
import os
import shutil
import sys
import time
from pathlib import Path
os.system("chcp 65001")
def execute(cmd, check=True):
ret = print(cmd) or os.system(cmd)
if check and ret: raise OSError(f"Fail to execute: {cmd}")
def find_exe(name):
import psutil
for p in psutil.process_iter():
if p.name() == name: return p
class Installer:
""" cite: https://blog.csdn.net/qq_55745968/article/details/135430884
:param main: 主程序文件
:param console: 是否显示控制台
:param icon: 图标文件
:param paths: 搜索路径 (非必需)
:param hiddenimports: 导入模块 (非必需)
:ivar opt_mode: 模式参数 (不适用于 spec)
:ivar opt_general: 通用的参数"""
exe = Path(sys.executable).parent / "Scripts" / "pyinstaller"
def __init__(self,
main: Path,
console: bool = True,
icon: Path = None,
paths: list = [],
hiddenimports: list = []):
# 生成工作目录
wkdir = main.parent / "install"
wkdir.mkdir(exist_ok=True)
os.chdir(wkdir)
# 记录相关文件
self.main = main.absolute()
self.spec = Path(f"{self.main.stem}.spec").absolute()
self.exclude = Path("exclude.txt").absolute()
# 模式参数 (不适用于 spec)
self.opt_mode = ["-c" if console else "-w"]
if icon:
self.opt_mode.append(f"-i {icon.absolute()}")
# 通用的参数
self.opt_general = []
for p in paths:
self.opt_general.append(f"-p {p}")
for m in hiddenimports:
self.opt_general.append(f"--hidden-import {m}")
def install(self, one_file=True, spec=False):
""" :param one_file: 单文件打包 / 多文件打包
:param spec: 使用 spec 文件打包"""
opt_mode = " ".join(self.opt_mode + ["-F" if one_file else "-D"])
opt_general = " ".join(self.opt_general)
target = self.spec if spec else (opt_mode + " " + str(self.main))
execute(f"{self.exe} {opt_general} {target}")
def clean(self, build=False, dist=True):
fs = []
if build: fs.append("build")
if dist: fs.append("dist")
fs = list(map(Path, fs))
# 尝试删除
while True:
# 关闭正在运行的程序
exe = find_exe(self.main.stem + ".exe")
if dist and exe:
print("Kill:", exe.name())
exe.kill(), time.sleep(1)
try:
for f in fs:
if f.is_dir():
shutil.rmtree(f, ignore_errors=False)
break
except PermissionError as e:
input(f"\n{e}\nPlease resolve the above error: ")
def load_exclude(self):
return self.exclude.read_text().split("\n")
def dump_exclude(self, fmts=("dll", "pyd", "so")):
raise NotImplementedError
def modify_spec(self):
raise NotImplementedError
@staticmethod
def check_src(mark):
raise NotImplementedError
def __repr__(self):
return f"{type(self).__name__}({self.main}, opt_general={self.opt_general}, opt_mode={self.opt_mode})"
if __name__ == "__main__":
# website: https://upx.github.io/
# note: 安装后务必删除缓存 C:/Users/{Yourname}/AppData/Local/pyinstaller
upx_dir = None # "D:/Software/_tool/upx"
# Step 0: 校验源代码的修改情况, 否则提供修改建议
print(Installer.__doc__, "\n")
Installer.check_src()
isl = Installer(Path("D:/Workbench/Repository/Deal/pyinstaller/__exp__/zjqt.py"),
console=False,
icon=Path("D:/Information/Source/icon/pika.ico"))
isl.clean()
# Step 1: one-dir 打包, 生成 exclude.txt
if not isl.exclude.is_file():
isl.dump_exclude()
# Step 2: one-file 打包, 生成 spec 文件
if isl.load_exclude():
isl.install(one_file=True)
# Step 3: 修改 spec 文件, 生成最终的 exe 文件
isl.clean(build=True)
if upx_dir: isl.opt_general.append(f"--upx-dir {Path(upx_dir).resolve()}")
isl.modify_spec()
isl.install(spec=True)