Pyinstaller 打包的文件过大,根本原因在于包含了冗余的依赖文件

前言:网上有许多减大小的方法,比如虚拟环境 (减少二进制文件的搜索路径,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)