简介:面向需要把 Python 脚本打包成免环境可执行文件的开发者,这是一份 PyInstaller 早期发行版安装包(版本 0.1.4),可用于了解这款打包工具自身的实现与工程组织方式,也适合对照理解脚本分析、依赖收集与冻结打包的基本思路。包内共 19 个文件,以 10 个 txt 说明与测试用例文本、5 个 py 源码及测试脚本为主体,另含 2 个 pkg-info 元数据、1 个 cfg 配置文件和 1 个 regen-docs 文档生成脚本,压缩后整体约 37KB,体积虽小但结构完整:既有 setup.py 安装入口与 egg-info 目录中的顶层模块清单、依赖说明,也有 docs 下的 index、conf、news 等文档配置,以及 tests 中 basic、freeze、requirements 等主题的测试文本,可直观看到打包器早期的模块划分与验证方式。已有 590 人学习,适合希望低成本翻阅打包工具源码、了解依赖分析与非 Python 资源处理难点的读者,也可作为编写自动化打包脚本、排查打包异常时的参考素材。
1. 一个 38MB 的 exe 交付现场:pyinstall 安装包不只是敲一条命令
上周帮一个做财务的朋友看他那套内部工具,Python 写的 Excel 批量清洗脚本,本机跑得好好的,一打成 exe 发给同事,双击窗口一闪就没了。他以为是 pyinstall 安装包本身有问题,反复重装 PyInstaller、换版本、换 Python,折腾两天。最后定位到原因特别朴素:脚本里pd.read_excel('模板.xlsx')用的是相对路径,打包成单文件后运行目录变成了临时解压目录,文件根本不在那儿。
这类事在把 Python 交付给非开发者的场景里几乎每周都会重演一次。PyInstaller 这个 pyinstall 安装包,干的事情说穿了就一句话:把 CPython 解释器、你的字节码、第三方二进制依赖、资源文件,按一份依赖清单拼成一个自带运行时的可执行文件。它不编译成机器码,也不是加壳加密,所以体积天然偏大、反编译门槛也不高。适合谁?适合要把脚本交给没有 Python 环境、也不会敲命令的人用;不适合追求极致体积、或者需要真正代码保护的商业分发。下面按我实际拆解的顺序,把链路、参数和踩过的坑一条条铺开。
2. 打包链路拆开看:从 .py 到 exe 的四个阶段与 spec 文件
很多人对 PyInstaller 的认知停留在「一条命令出个 exe」,出问题就只能靠猜。真正要好用,得先知道它在 build 目录里干了什么,因为百分之八十的报错信息都藏在中间产物里。
2.1 Analysis、PYZ、PKG、COLLECT 四个阶段各自负责什么
PyInstaller 的执行流程大致分四段。第一段是 Analysis,从你指定的入口脚本出发,递归扫描所有import语句,构建模块依赖图,同时去各个 site-packages 目录里找对应的.py、.pyd、.dll、.so,输出一份Analysis-00.toc清单。这一步决定了「什么会被打进包里」,也是所有缺模块问题的源头。第二段是 PYZ,把收集到的纯 Python 模块编译成字节码,塞进一个 PYZ 归档文件。第三段是 PKG/EXE,把 PYZ、二进制依赖、数据文件按清单顺序拼成一个文件,头部接上 bootloader——那是用 C 写的启动器,负责在运行时解包、设置sys.path、拉起内嵌的解释器。第四段是 COLLECT,只在 onedir 模式下有意义,把上述内容摊平到一个目录里。
理解这个顺序很关键:Analysis 阶段没扫到的东西,后面三阶段怎么调参数都补不回来。所以遇到ModuleNotFoundError,先别急着换 PyInstaller 版本,去看build/项目名/warn-项目名.txt,那份文件会把「导入失败」和「条件导入」的模块全部列出来,是排查的第一现场。
顺带说清一件事:因为打进去的是解释器加字节码,不是机器码,所以一个空脚本打出来也有十几 MB,带 pandas 就奔着 60MB 去,带 torch 三位数起步。这是机制决定的,不是参数没调好。
2.2 环境隔离:为什么我坚持在干净 venv 里打,而不是在 base 环境
这是最容易省事也最容易翻车的一步。如果你的 Python 环境里装了几十个包——尤其是 conda base 环境里塞了 Jupyter、PyTorch、各种科学计算库——PyInstaller 的 Analysis 阶段会顺着一堆隐式依赖把能扫到的都带上,最后产物几百 MB,还容易因为某个 DLL 版本冲突在别人机器上报DLL load failed。
我一般会为每个交付项目单独建一个构建环境,只装运行必需的东西:
# 在项目根目录创建独立构建环境,避免污染主环境 python -m venv .buildenv # Windows 激活 .buildenv\Scripts\activate # Linux / macOS 激活 source .buildenv/bin/activate # 先升级打包工具本身,再装业务依赖 python -m pip install -U pip pip install "pyinstaller>=6.0,<7.0" pip install -r requirements-runtime.txt # 确认当前环境里没有多余的大包 pip list逻辑说明:requirements-runtime.txt和开发用的requirements.txt要分开写。开发环境里的 pytest、black、jupyter、matplotlib(如果只是画图调试用)都不该进构建环境,它们会让 Analysis 多扫几百个模块。参数说明:pyinstaller>=6.0是硬约束,Python 3.12 只能用 6.x 系列,3.8 以下建议锁 5.13;如果公司内部有镜像源,在这一步指定,别等到打包中途去拉包。
提示:装完依赖后跑一次
python -c "import 主模块",确认在干净环境里也能正常 import,再开始打包。这一步能提前暴露「本机靠 base 环境里的包才跑得通」的隐患。
2.3 spec 文件才是真实入口,命令行参数只是它的简写
第一次执行pyinstaller main.py时,它会在同目录生成一个main.spec。这个文件是 Python 语法,记录了 Analysis、PYZ、EXE、COLLECT 四段的全部配置。往后所有构建都应该基于 spec 文件执行pyinstaller main.spec,而不是继续堆命令行参数——因为命令行参数一长串,跨机器复现时必然有人漏抄一个。
一份我常用的最小 spec 骨架:
# app.spec —— 单目录模式的基础模板 a = Analysis( ['src/main.py'], # 入口脚本,路径相对 spec 文件所在目录 pathex=['src'], # 额外的模块搜索路径 binaries=[], # 手动补充的 .pyd/.dll,一般留空 datas=[('assets', 'assets')], # (源路径, 包内目标路径) hiddenimports=[], # 动态导入、插件式加载的模块写这里 hookspath=[], excludes=['tkinter', 'matplotlib', 'notebook', 'IPython'], noarchive=False, # False 表示字节码打进 PYZ,True 则散落 ) pyz = PYZ(a.pure) exe = EXE( pyz, a.scripts, [], exclude_binaries=True, # onedir 模式必须为 True name='DataTool', debug=False, strip=False, upx=False, console=False, # GUI 程序关控制台,调试期改成 True icon='assets/app.ico', ) coll = COLLECT( exe, a.binaries, a.datas, strip=False, upx=False, name='DataTool', )逻辑说明:Analysis决定收什么,EXE决定可执行文件长什么样,COLLECT决定目录结构。参数说明:datas里源路径可以用相对路径,目标路径是运行时的相对位置,写('assets', 'assets')意味着运行时assets/就在 exe 同级;excludes是体积控制的第一道闸,把明确用不到的 GUI 框架、测试框架、交互式环境列进去,通常能省 20-40MB;noarchive=True会让模块以散文件形式存在,启动略快但目录更乱,一般不建议。
注意:spec 文件里的路径是相对于「执行 pyinstaller 命令时的工作目录」,不是相对于 spec 文件位置。稳妥做法是每次都在项目根目录执行,或者用
--specpath明确指定。
3. 参数怎么设:单文件、隐藏导入与运行时资源定位
参数这一块,网上流传的清单大多是把官方文档抄一遍,缺的是「什么时候该用哪个、用了会带来什么代价」。这里只讲真正影响交付质量的三组选择。
3.1 单文件与单目录:启动时间、报毒率和交付形态的三角权衡
--onefile和--onedir是第一个要做的决定,没有标准答案,只有代价。
| 维度 | 单文件 onefile | 单目录 onedir |
|---|---|---|
| 交付形态 | 一个 exe,双击即用 | 一个文件夹,需整体压缩分发 |
| 冷启动 | 慢,每次启动解压到临时目录,1-3 秒起 | 快,直接加载 |
| 体积 | 略小(压缩后) | 略大(未压缩) |
| 杀软误报率 | 明显更高 | 相对低 |
| 更新维护 | 每次整包替换 | 可只替换 exe 或某个资源 |
| 调试难度 | 高,临时目录一闪而过 | 低,目录结构一目了然 |
我的一般做法是:内部工具、装机量小、追求交付省事,走 onefile;对启动速度敏感、或者需要频繁更新资源的,走 onedir 然后打 zip。有个细节值得说:onefile 每次启动都会把内容解压到%TEMP%\_MEIxxxxx目录,程序退出后由 bootloader 清理,但如果程序崩溃或者被强杀,这些临时目录会残留,跑久了磁盘上会攒一堆。可以在 spec 里设置runtime_tmpdir,指定一个固定或可控的临时位置:
exe = EXE( pyz, a.scripts, [], exclude_binaries=True, name='DataTool', console=False, runtime_tmpdir='.', # 解压到 exe 同级的临时目录,便于排查 )参数说明:runtime_tmpdir也可以写成绝对路径,但注意目标机器上该路径必须有写权限;写.表示当前工作目录,好处是残留可查、坏处是如果程序被放在只读目录(比如某些企业装机的 Program Files)会直接启动失败。这一条踩过的人不少。
3.2 隐藏导入:动态 import 是打包器的天然盲区
Analysis 靠静态扫描 import 语句工作,凡是「字符串拼出来的模块名」它都看不见。典型场景包括插件式架构用importlib.import_module(name)加载模块、用__import__动态加载、以及某些库在运行时才决定导入哪个后端(比如数据库驱动、图像编解码器)。
最小的补救方式是在 spec 里写死:
hiddenimports=[ 'pandas._libs.tslibs.base', 'sqlalchemy.dialects.sqlite', 'myapp.plugins.excel_reader', ]但插件目录会随业务增长,一个个加不现实。用钩子工具函数批量收集更稳:
# app.spec 顶部 from PyInstaller.utils.hooks import collect_submodules, collect_data_files # 把 myapp.plugins 包下所有子模块全部纳入 hidden_plugins = collect_submodules('myapp.plugins') # 把包内自带的模板、配置文件一并打进去 plugin_datas = collect_data_files('myapp.plugins') a = Analysis( ['src/main.py'], datas=[('assets', 'assets')] + plugin_datas, hiddenimports=hidden_plugins, # 其余字段省略 )逻辑说明:collect_submodules在构建时真实 import 一遍目标包并遍历其子模块,比手写清单可靠;collect_data_files会扫描包目录下的非.py文件。参数说明:这两个函数都接受include_py_files、excludes等可选参数,如果某个子模块依赖了不该进包的重型库,用excludes参数在收集阶段就排除掉,比事后在 Analysis 里排除更干净。
提示:如果打包后报的错是
ModuleNotFoundError: No module named 'xxx',先别怀疑 hiddenimports 没写全,去看build/项目名/warn-项目名.txt。有时候模块其实打进去了,只是运行时的sys.path不对,两者处理方式完全不同。
3.3 资源路径:sys._MEIPASS是绕不过去的一道坎
第 1 章那个一闪而过的窗口,根因就在这里。打包成单文件后,程序运行时的工作目录和资源实际位置是分离的,所有写死相对路径的open()、Path()、QIcon()全部失效。
标准解法是写一个统一的路径解析函数,全项目只走这一个入口:
import os import sys def resource_path(relative: str) -> str: """兼容源码运行与 PyInstaller 打包运行的资源定位。 打包后 bootloader 会把解压目录写入 sys._MEIPASS; 源码运行时退化为当前文件所在目录。 """ base = getattr(sys, '_MEIPASS', os.path.dirname(os.path.abspath(__file__))) return os.path.join(base, relative) # 调用示例 config_file = resource_path(os.path.join('assets', 'config.yaml')) with open(config_file, 'r', encoding='utf-8') as f: cfg = f.read()逻辑说明:sys._MEIPASS只在 frozen(打包)状态下存在,源码运行时用getattr给默认值,保证两套环境同一份代码都能跑。参数说明:传入的relative必须和 spec 里datas的目标路径严格对应——spec 写('assets', 'assets'),这里就必须是assets/xxx,多一层少一层都会找不到。
另一个容易忽略的点是「可写目录」。资源文件在_MEIPASS里是只读的(onedir 模式下取决于安装位置),如果程序需要写日志、写缓存、保存用户配置,千万别往资源目录写,要用os.path.expanduser('~')或者%APPDATA%这类用户目录。这个坑我在一个需要保存最近打开记录的桌面工具上踩过,打包后一切正常,用户一保存就抛PermissionError。
4. 避坑排查:闪退、报毒、缺模块的五类现场记录
这一章按「现象 → 原因 → 解决」记录五条高频问题,全部来自实际交付现场。如果你的打包结果不是这五种,先按 4.6 的调试流程定位,不要盲改参数。
4.1 双击后窗口一闪而过,没有任何报错
现象:exe 双击后黑框闪一下消失,事件查看器里也没有明显记录。
原因:用了console=False构建,而程序在启动早期就抛了未捕获异常,输出被吞掉。常见触发点是导入阶段的依赖缺失、配置文件读取失败、或者编码问题。
解决:先用调试版构建,保留控制台并打开详细日志:
pyinstaller app.spec --console --log-level DEBUG --clean --noconfirm参数说明:--console覆盖 spec 里的console=False;--log-level DEBUG会把 Analysis 收集的每一个模块打印出来,量很大但定位有效;--clean清掉 build 缓存,避免旧产物干扰;--noconfirm跳过覆盖确认,适合脚本化。跑起来看到完整 traceback 之后,再决定是补依赖还是改代码。
4.2 打包成功,但杀毒软件直接隔离或标红
现象:exe 落地后被安全软件删除,或提示「未知发布者、可能存在风险」。
原因:PyInstaller 的 bootloader 是被大量恶意样本复用的公共组件,特征库对它有天然敏感度;单文件模式的自解压行为又和某些打包型恶意软件高度相似。这不是你代码的问题。
解决:优先换成 onedir 模式,误报率会明显下降;其次给 exe 加上版本信息资源和图标,让文件看起来是正规产品;企业内分发走 IT 白名单报备。版本信息通过--version-file指定:
# version_info.txt —— 供 --version-file 使用 VSVersionInfo( ffi=FixedFileInfo(filevers=(1, 2, 0, 0), prodvers=(1, 2, 0, 0)), kids=[ StringFileInfo([StringTable('080404b0', [ StringStruct('CompanyName', '内部工具组'), StringStruct('FileDescription', '数据清洗助手'), StringStruct('FileVersion', '1.2.0.0'), StringStruct('ProductName', 'DataTool'), ])]), VarFileInfo([VarStruct('Translation', [2052, 1200])]) ] )逻辑说明:这是 PyInstaller 支持的版本资源描述格式,构建时会被编译进 exe 的 PE 资源段。参数说明:filevers是四段式数字版本,080404b0对应简体中文加 Unicode 代码页,Translation里的2052是语言 ID。填了这些之后,文件属性里能看到完整信息,杀软的白名单审核也更容易过。
4.3 本机跑得好好的,打包后报 ModuleNotFoundError
现象:ModuleNotFoundError: No module named 'xxx',但 xxx 在开发环境里明明装好了。
原因:三种可能——模块是动态导入的,Analysis 没扫到;模块在构建环境里根本没装(依赖装在 base 环境而不是 venv);模块装了但装在了非标准路径。
解决:先确认构建环境里有这个包(pip show 包名),再确认是不是动态导入。是的话按 3.2 的方式补 hiddenimports。还有一种隐蔽情况:某些库通过pkg_resources或importlib.metadata在运行时检查自身版本,这类元数据不会被自动收集,需要在 spec 里加copy_metadata:
from PyInstaller.utils.hooks import copy_metadata a = Analysis( ['src/main.py'], datas=copy_metadata('一些需要读自身版本的包'), hiddenimports=['pkg_resources'], )4.4 报 DLL load failed 或缺少 VCRUNTIME140.dll
现象:程序启动时报ImportError: DLL load failed while importing xxx,或者提示缺少某个运行库。
原因:目标机器是干净系统,没装 Microsoft Visual C++ 运行库;或者构建环境是 conda,混入了 conda 自有渠道的 DLL,和 pip 渠道的二进制不兼容。
解决:优先在纯 venv + pip 环境里重建打包环境,别用 conda base;如果确实依赖 conda 的科学计算栈,用 conda 建一个最小环境,只装必需项。目标机器侧则需要在交付说明里写明运行库要求,或者把vcruntime140.dll一并放进 onedir 目录(注意授权合规)。
注意:conda 环境打包出的产物经常带着一堆 MKL、OpenMP 相关的 DLL,体积大且容易和其他软件冲突。能用 pip 装的库,就别在 conda 里打。
4.5 打包体积失控,动辄两三百 MB
现象:一个只用 pandas 和 openpyxl 的小工具,产物 250MB。
原因:构建环境不干净,Analysis 扫到了无关的大包;或者某个依赖牵出了整套科学计算栈;再者是没做 excludes。
解决:先看build/项目名/Analysis-00.toc,按体积排序列出实际收进去的包,找出意外的大头;然后在 spec 的excludes里显式排除。实测常见可排除项包括tkinter、matplotlib、notebook、IPython、pytest、setuptools的一部分。排除之后通常能砍掉 30%-50%。
4.6 出问题时的固定调试流程
不要把调试做成碰运气。我的一般顺序是:第一步,用--onedir --console --log-level DEBUG打一版,确保能看到完整输出;第二步,进dist/项目名/目录,手动双击 exe,观察是导入阶段崩还是运行阶段崩;第三步,看build/项目名/warn-项目名.txt,重点找missing module相关条目;第四步,把dist目录整体拷到一台没装 Python 的干净机器上复测,很多问题只在这类环境暴露;第五步,再回到 spec 改配置。跳过第三步直接改参数,是我见过最多的无效操作。
5. 体积与验证:把 300MB 压到 40MB 的边界在哪
打包能出 exe 只是及格线。真正决定这份 pyinstall 安装包能不能长期用的,是体积能不能接受、以及在别人机器上会不会翻车。
5.1 体积裁剪的三板斧与 UPX 的取舍
裁剪体积有三个层次,按收益从大到小排:环境隔离、excludes 排除、UPX 压缩。
环境隔离的收益最大也最容易被忽略,前面 2.2 已经说过。excludes 是第二层,需要基于Analysis-00.toc的实际内容来定,不能凭感觉抄网上的清单——你排除了一个真正被用到的模块,程序在某个分支才会崩,测试很难覆盖到。第三层是 UPX,--upx-dir指定 UPX 可执行文件目录后,PyInstaller 会用它压缩二进制文件,通常能再省 20%-40%。
但 UPX 我现在的默认选择是关掉,原因是代价太大:压缩后的二进制特征和加壳程序高度相似,杀软误报率明显上升;某些 DLL 被 UPX 处理后会加载失败,尤其是带签名的商业库;压缩还会拖慢启动,因为每次都要在内存里解压。只有在体积是硬约束、且能接受白名单报备成本时,才考虑开它。
# 仅在内部分发、且已确认目标机器无杀软拦截时使用 pyinstaller app.spec --upx-dir "C:\tools\upx" --noconfirm参数说明:--upx-dir指向解压后的 UPX 目录,PyInstaller 会自动调用;如果只想对部分文件压缩,可以在 spec 里针对a.binaries单独设置upx=True。反过来的开关是--noupx,用于在全局配置里强制禁用。
5.2 发版前的验证清单
每次交付前我都跑一遍这张表,能挡掉绝大多数「到我这儿是好的」类问题。
| 验证项 | 具体做法 | 关注点 |
|---|---|---|
| 干净系统启动 | 找一台未装 Python 的机器或新建虚拟机 | 运行库缺失、路径依赖 |
| 中文路径 | 把 exe 放到含中文和空格的目录下运行 | 编码、路径拼接 |
| 只读目录 | 放到 Program Files 或受限目录运行 | 写权限、临时目录设置 |
| 断网运行 | 断网后启动并走一遍主流程 | 隐式网络依赖、在线资源 |
| 长路径 | 放到深层嵌套目录 | 路径长度限制 |
| 首次启动耗时 | 掐表记录冷启动时间 | onefile 解压开销 |
| 安全软件扫描 | 用企业常用安全软件全盘扫一次 | 误报、隔离 |
| 卸载残留 | 删除目录后检查临时文件夹 | _MEI残留 |
表格里我最看重的是「只读目录」和「断网运行」两项。前者能暴露所有往资源目录写文件的代码,后者能暴露那些启动时偷偷拉配置、拉字体、拉模型的实现——打包本身不会让这些代码消失,只会让它们在离线环境下变成启动失败。
5.3 交叉编译的边界:Windows 上打不出 macOS 包
这是我被问得最多的一个问题,答案很明确:PyInstaller 不支持交叉编译。它打包的是当前平台上的解释器和二进制依赖,在 Windows 上只能产出 Windows 可执行文件,在 macOS 上只能产出 macOS 的 app 或可执行文件,Linux 同理。想在三个平台发版,就得准备三套构建环境,通常是三台机器或者三个 CI runner。
同一平台内的架构差异也要注意:macOS 的 Intel 和 Apple Silicon 需要分别构建,或者用 universal2 的 Python 打通用包;Windows 的 32 位和 64 位同理,64 位系统能跑 32 位程序但反过来不行。这些不是参数能绕过的,是构建环境本身决定的。
提示:多平台发版建议把 spec 文件和构建脚本一起纳入版本管理,用同一份 spec 在不同平台上执行。差异部分(图标格式、目标平台专属依赖)用
sys.platform判断后条件写入,比维护三份 spec 文件可靠得多。
6. 一份可复用的 spec 模板与构建脚本
把前面所有内容收敛下来,实际项目里我会固定用这样一套东西。spec 文件按业务改,构建脚本基本不动。
# build/app.spec —— 单目录模式的完整模板 import sys from PyInstaller.utils.hooks import collect_submodules, collect_data_files APP_NAME = 'DataTool' ENTRY = 'src/main.py' hidden = collect_submodules('myapp.plugins') datas = [('assets', 'assets')] + collect_data_files('myapp.plugins') # 非 Windows 平台去掉 ico,避免构建报错 icon = 'assets/app.ico' if sys.platform == 'win32' else None a = Analysis( [ENTRY], pathex=['src'], binaries=[], datas=datas, hiddenimports=hidden, hookspath=[], excludes=['tkinter', 'matplotlib', 'notebook', 'IPython', 'pytest'], noarchive=False, ) pyz = PYZ(a.pure) exe = EXE( pyz, a.scripts, [], exclude_binaries=True, name=APP_NAME, debug=False, strip=False, upx=False, console=False, icon=icon, ) coll = COLLECT( exe, a.binaries, a.datas, strip=False, upx=False, name=APP_NAME, )配套的构建脚本负责环境检查和产物整理:
# build/build.py —— 调用 PyInstaller 并做产物自检 import subprocess import sys from pathlib import Path ROOT = Path(__file__).resolve().parent.parent SPEC = ROOT / 'build' / 'app.spec' DIST = ROOT / 'dist' def main() -> int: # 每次构建前清掉旧产物,避免残留文件混进交付包 cmd = [ sys.executable, '-m', 'PyInstaller', str(SPEC), '--distpath', str(DIST), '--workpath', str(ROOT / 'build' / 'work'), '--noconfirm', '--clean', ] result = subprocess.run(cmd, cwd=ROOT) if result.returncode != 0: print('构建失败,先看 build/work 下的 warn 文件') return result.returncode # 产物自检:入口文件是否生成、体积是否异常 exe = DIST / 'DataTool' / ('DataTool.exe' if sys.platform == 'win32' else 'DataTool') if not exe.exists(): print(f'未找到预期产物: {exe}') return 1 size_mb = sum(f.stat().st_size for f in (DIST / 'DataTool').rglob('*')) / 1024 / 1024 print(f'构建完成,目录体积 {size_mb:.1f} MB') if size_mb > 120: print('体积超出预期,检查 excludes 与构建环境是否干净') return 0 if __name__ == '__main__': raise SystemExit(main())逻辑说明:脚本把「清理 → 构建 → 产物存在性检查 → 体积告警」串成一条链,失败时返回非零码,方便接进 CI。参数说明:--workpath单独指定,是为了让 build 中间产物和源码目录隔离,方便整体清理;--distpath固定到根目录下的 dist;cwd=ROOT保证 spec 里的相对路径解析基准一致——这一点如果不设,在不同目录执行脚本会得到完全不同的结果,是个典型的玄学问题来源。
有一个进阶技巧值得单独说:把excludes和hiddenimports从静态列表改成按需生成。具体做法是在构建脚本里扫描src/下的插件目录,动态产出模块名列表,写进一个临时的excludes.json,spec 再读这个文件。这样新增插件不用改 spec,减少了一次「忘了加 hiddenimports 导致线上崩」的机会。
从那以后我每次发版前都会强制走一遍:干净 venv 重新构建、断网环境双击、只读目录试运行,三步全过才敢发给别人。前面那些闪退、报毒、缺模块的现场,九成都能在这三步里提前撞见。希望帮到你。
本文还有配套的精品资源,点击获取