
上周同事拿着我发过去的 exe 来问我为什么这工具在他电脑上双击就弹 FileNotFoundError报错路径还指向一个他从没见过的C:\Users\xxx\AppData\Local\Temp\_MEI154832\config\default.yaml。同一个 exe在我机器上跑得好好的。这类问题我前后遇到过不下十次根因几乎都指向同一件事PyInstaller 打包时资源文件没进去或者进去了但代码用相对路径找不到它。这篇就把「PyInstaller 打包多个资源文件」这件事从头到尾讲透。内容包括为什么打包后资源会消失、--add-data的正确写法与分隔符坑、.spec里datas的可维护写法、运行时资源定位的标准函数、只读资源与可写数据的分离方案以及体积优化和报错排查。适合已经能用 PyInstaller 打出单文件 exe、但一遇到图片、字体、配置、模板、图标这类附属文件就翻车的人完全没用过 PyInstaller 的也能跟着走我会把每一步的理由说清楚。1. 先搞懂 PyInstaller 的运行目录模型不然改了也是瞎改资源找不到这件事十个里有八个不是 PyInstaller 的锅是作者对程序运行时到底在哪个目录这件事的认知错了。所以动手改命令之前先花五分钟把模型建立起来。1.1 单文件模式其实是个自解压包用-F--onefile打出来的 exe本质是一个自解压归档里面塞了 Python 解释器、你 import 过的所有第三方库、以及你显式声明要带上的资源文件。每次运行它会先把这些内容解压到一个临时目录再把控制权交给你的代码程序退出后这个临时目录一般会被清掉。这个临时目录的路径就存在sys._MEIPASS里。想亲眼看到的话在你的入口文件第一行加一句import sys print(MEIPASS , getattr(sys, _MEIPASS, not frozen)) input(按回车继续)用--console打成 exe 双击运行你会看到一个类似C:\Users\你的用户名\AppData\Local\Temp\_MEI154832的路径。你所有通过--add-data塞进去的资源都被放在这个目录下面。注意两个后果第一这个路径每次启动都可能不一样任何把它硬编码进代码或写进配置的做法都会翻车第二程序退出后目录被删往里面写文件等于扔进垃圾桶。1.2 开发机上跑得通只是因为当前工作目录恰好对了绝大多数翻车代码长这样with open(assets/config.json, r, encodingutf-8) as f: cfg json.load(f)open收到相对路径时是相对**当前工作目录cwd**去找的不是相对脚本文件所在目录。你在 PyCharm 或 VS Code 里跑cwd 通常是项目根目录assets/config.json恰好就在那儿一切正常。打包之后情况变成双重错位cwd 可能是桌面、可能是某个快捷方式里配置的起始位置、也可能就是 exe 所在目录而资源文件被解压到了_MEIPASS那个临时目录里——两者大概率不是同一个地方于是必然找不到。顺带提一个容易连带出错的地方__file__在打包后的行为也和你想的不一样。单文件模式下它指向_MEIPASS内的某个临时路径而如果你用了os.getcwd()那就更没谱了。所以结论很硬读任何随程序分发的资源都必须走一个统一的、基于_MEIPASS的定位函数第 4 节我会给出完整实现。1.3 目录模式和单文件模式先选对再谈资源很多人一上手就用-F觉得一个文件发出去最体面然后在资源、启动速度、杀软误报上连踩三个坑。先把两种模式的差异看清楚对比维度单文件-F / onefile目录模式-D / onedir分发形态单个 exeexe 依赖目录PyInstaller 6 为_internal启动速度慢每次启动都要解压快直接加载资源体积大时启动明显卡顿临时目录占用大无额外开销资源写入临时目录可写退出即删目录可能只读装在 Program Files 时杀软误报概率偏高自解压行为偏低调试便利性差看不到内部文件好可直接翻目录运行期资源根路径sys._MEIPASS临时目录sys._MEIPASS指向_internal我的实际选择习惯是调试期一律用目录模式因为可以直接打开_internal目录确认资源到底有没有进去、层级对不对排查效率差好几倍只有正式对外分发、且资源总量控制在很小的时候才考虑单文件。如果你的资源里有几十 MB 的字体或模型文件别犹豫直接目录模式单文件每次启动解压几十 MB 的体验很难让人接受。2. --add-data 的三种写法与两个必须记住的坑确认了模型之后接下来就是把资源交出去。命令行方式最直接适合资源少、变动频繁的场景但有两个坑每年都要劝退一批人。2.1 分隔符Windows 用分号macOS 和 Linux 用冒号--add-data的语法是源路径分隔符目标路径这个分隔符在 Windows 上是分号;在 macOS 和 Linux 上是冒号:因为 PyInstaller 用的是系统的os.pathsep。写成这样# Windows 命令行或 PowerShell pyinstaller --noconfirm --clean --windowed --name renamer ^ --add-data assets;assets ^ --add-data config\default.yaml;config ^ main.py# macOS / Linux pyinstaller --noconfirm --clean --windowed --name renamer \ --add-data assets:assets \ --add-data config/default.yaml:config \ main.py分隔符写反了会怎样PyInstaller 通常不报错而是把整串当成一个源路径去找找不到就静默跳过最后你得到一个能跑但少资源的包。这类沉默失败最坑所以每加一条--add-data都建议在打包日志里搜一下这行路径有没有被处理或者干脆打完包用pyi-archive_viewer看一眼内容第 5 节讲。注意源路径建议用绝对路径或明确的项目相对路径别依赖运行 pyinstaller 时的 cwd。在脚本里调用时更要把路径拼全否则 CI 上跑一次、本地跑一次结果可能完全不同。2.2 目标路径的含义它决定资源在包里的层级第二列那个目标路径容易理解错。它指的是资源被打进包后所处的相对目录相对于包的根单文件模式就是_MEIPASS。几个典型写法--add-data assets;assets把本地assets整个目录放进包的assets/下assets/fonts/a.ttf会变成包根/assets/fonts/a.ttf。--add-data config/default.yaml;config单个文件放进包根/config/default.yaml不会自动带上config/之外的任何东西。--add-data templates;.把templates目录的内容铺到包根注意这与上一条不同用.的时候目录本身不会成为一个层级不同版本对目录 .的处理略有差异实测最稳的是显式写目录名。我一般遵循一条规则包内层级和项目里的层级保持一致也就是assets对应的目标也写assets。这样开发时的相对位置和打包后的相对位置完全对齐定位代码只需要一个偏移量_MEIPASS心智负担最低。2.3 资源一多就别硬堆命令行三个以内资源命令行最省事超过五个命令行会变成一坨没法维护的字符串而且每加一个资源就要重新跑一遍完整打包。我踩过的具体麻烦有两个一是在 Windows 批处理里换行要写^漏一个就整行断掉二是团队里每个人本地路径不一样有人用 PowerShell 有人用 cmd引号规则还不一样。超过五个资源的正确姿势是直接进.spec。命令行能不能批量传能但没必要——.spec天生就是为这种场景设计的而且它可进版本库、可 review、可代码化生成。这也是下一节的主题。3. 用 .spec 把资源声明变成可维护的配置.spec文件其实就是一段 Python 代码PyInstaller 会执行它并收集里面定义的对象。它对资源的支持远比命令行灵活。3.1 先生成 spec再改它然后只用它第一次打包时不要手写 spec让 PyInstaller 生成pyi-makespec --windowed --name renamer --icon assets/app.ico main.py这会产出renamer.spec。之后所有资源声明都改在这个文件里用下面这条命令打包pyinstaller --noconfirm --clean renamer.spec这里有个高频坑一旦用 spec 打包命令行里的--add-data、--hidden-import、--exclude-module等参数基本会被忽略。很多人改了半天命令行没生效就是因为还在跑 spec。要么全用命令行要么全用 spec不要混着来。--distpath、--workpath、--clean、--noconfirm这几个还是有效的。3.2 datas 里到底写几元组版本差异要说清Analysis的datas参数是一个列表每个元素描述一条资源映射。历史上有两种写法# 老写法三元组第三个字段是类型码 datas [(assets/app.ico, assets, DATA)] # PyInstaller 6.x 推荐两元组 datas [(assets/app.ico, assets)]从 6.0 开始类型码字段被标记为废弃还写三元组会看到 warning。同一条 spec 在老版本和新版本之间的兼容性主要就差在这儿所以别在新项目里抄五年前的老教程。另外一个细节datas里的源路径是相对于执行打包命令时的 cwd这很不牢靠下面用SPECPATH解决。3.3 用 SPECPATH 和 glob 收集整棵目录树SPECPATH是 PyInstaller 执行 spec 时注入的全局变量值就是 spec 文件所在目录。拿它做基准路径就稳了# -*- mode: python ; coding: utf-8 -*- import os from pathlib import Path root Path(SPECPATH) def collect_dir(src_dir, dest_prefix, skip_suffix(), skip_names()): 把 src_dir 整棵树收集成 datas 列表包内层级保持不变。 out [] base root / src_dir for p in sorted(base.rglob(*)): if p.is_dir(): continue if p.suffix.lower() in skip_suffix or p.name in skip_names: continue rel_parent p.relative_to(base).parent.as_posix() dest dest_prefix if rel_parent . else f{dest_prefix}/{rel_parent} out.append((str(p), dest)) return out datas [] datas collect_dir(assets, assets, skip_suffix{.pyc, .pyo, .map, .psd}, skip_names{.DS_Store, Thumbs.db}) datas collect_dir(templates, templates) datas [(str(root / config / default.yaml), config)]这段代码解决三件事路径不依赖 cwd整目录递归收集且包内层级与磁盘一致顺手过滤掉__pycache__、.DS_Store、设计稿源文件这类不该进包的垃圾。直接写(assets, assets)也能整目录带上但过滤器没有实测经常把几百 KB 的临时文件、编辑器备份一起打进去包体积莫名其妙变大。版本提示老教程里常出现的Tree(assets)属于 PyInstaller 内部结构跨版本位置和签名变过好几次PyInstaller.building.datastruct里确实有但官方并不鼓励长期依赖。用上面这种手写 glob 的方式更可控也更好读。3.4 第三方库自带的数据文件别自己一个个抄有些资源不在你的项目目录里而在第三方包内部比如某些库的证书文件、字体、模板、图标。手动枚举它们的路径是噩梦用官方工具函数from PyInstaller.utils.hooks import collect_data_files, collect_all # 只收数据文件返回 (src, dest) 列表可以直接并进 datas datas collect_data_files(mypkg) # 数据 二进制 隐藏导入一次收齐 d, b, h collect_all(mypkg) datas d binaries b hiddenimports h命令行等价物是--collect-data mypkg和--collect-all mypkg。什么时候必须用它们当库在运行时动态拼路径去读自己的资源而 hooks 没覆盖到的时候。举几个常见例子带根证书的库需要把cacert.pem带进去绘图类库需要mpl-data目录下的字体和样式表多媒体类库需要自带字体一些 UI 框架需要一整个 web 资源目录。判断方法很简单——先在打包后的 exe 上跑一遍完整功能哪里报找不到某文件就去那个第三方包的安装目录里看有没有这个文件有就说明是资源没收集用上面的函数补上。binaries是给.dll、.so、.pyd这类动态库用的对应命令行参数--add-binary分隔符规则和--add-data完全一致。Qt 类的插件platforms、imageformats通常由官方 hooks 处理只有在插件是动态加载且 hooks 漏掉时才需要手动--add-binary。4. 代码侧的 resource_path一个函数解决九成定位问题资源进包了还得让代码找得到。这一节是全文最值得抄的部分。4.1 标准实现直接复制import os import sys def resource_path(rel: str) - str: 返回随程序分发的只读资源的绝对路径。 base getattr(sys, _MEIPASS, None) if base is None: base os.path.dirname(os.path.abspath(__file__)) return os.path.normpath(os.path.join(base, rel))三行核心逻辑解释一下getattr(sys, _MEIPASS, None)打包运行时有这个属性开发环境没有。用getattr带默认值是为了让同一份代码在 IDE 里也能跑省掉两套路径逻辑。开发环境退回__file__所在目录这是脚本文件位置等价于把脚本所在目录当作资源根和打包后_MEIPASS作为资源根的结构对齐。os.path.normpath把assets/../assets/a.png这类路径规整掉避免在 Windows 上出现混合分隔符导致的比较失败。配合使用的时候一律走这个函数绝不写裸相对路径from pathlib import Path cfg_file Path(resource_path(config/default.yaml)) with cfg_file.open(r, encodingutf-8) as f: cfg yaml.safe_load(f) font resource_path(assets/fonts/SourceHanSans.ttf) icon resource_path(assets/app.ico)一个容易忽略的点如果你的程序会 fork 子进程或把路径塞进环境变量传给别的进程注意别在模块导入阶段把它固化成一个全局常量再去序列化。函数式调用每次算一遍成本可以忽略却避免了各种缓存不一致。4.2 只读资源和可写数据必须分开这是我在实际项目里踩得最狠的一个坑程序需要保存用户配置作者图省事直接写resource_path(config/default.yaml)。开发环境没问题打包成单文件后写进了_MEIPASS临时目录用户改完设置一关程序全没了更糟的是目录模式装在Program Files下时那个位置根本不可写直接抛PermissionError。正确的划分方式类别典型内容存放位置访问方式只读资源图标、字体、模板、默认配置、内置词表打包进包内resource_path()可写数据用户配置、日志、缓存、下载产物用户数据目录user_dir()临时文件处理中间结果、解压产物系统临时目录tempfile模块用户数据目录的取法按平台分支from pathlib import Path def user_dir(app: str renamer) - Path: if sys.platform.startswith(win): base Path(os.environ.get(APPDATA) or Path.home()) elif sys.platform darwin: base Path.home() / Library / Application Support else: base Path(os.environ.get(XDG_DATA_HOME) or (Path.home() / .local / share)) d base / app d.mkdir(parentsTrue, exist_okTrue) return d然后是首次运行时把默认配置搬过去的标准动作缺少这一步用户第一次打开就是空配置import shutil cfg_dir user_dir() / config cfg_dir.mkdir(parentsTrue, exist_okTrue) user_cfg cfg_dir / config.yaml if not user_cfg.exists(): shutil.copy2(resource_path(config/default.yaml), user_cfg)这么一改升级版本也不会覆盖用户配置卸载时数据还在符合大家的预期。4.3 打包后异常静默消失先给自己留条日志用--windowed或-w打包的程序没有控制台print没人看得到未捕获的异常会让程序直接消失用户只会说双击没反应。我现在的习惯是入口处做三件事import logging, sys, traceback from pathlib import Path log_file user_dir() / app.log logging.basicConfig( filenamestr(log_file), levellogging.INFO, format%(asctime)s %(levelname)s %(name)s: %(message)s, encodingutf-8, ) def main(): ... if __name__ __main__: try: main() except Exception: logging.error(未捕获异常\n%s, traceback.format_exc()) raise调试阶段则反过来先加--console打包把 traceback 直接打在窗口里看定位完再切回--windowed。这一来一回十分钟能省掉好几小时的瞎猜。5. 完整案例带模板、字体、图标和默认配置的小工具光讲概念容易飘我拿一个真做过的批量重命名小工具走一遍结构可以直接套到你的项目上。5.1 目录结构与资源清单renamer/ ├── main.py ├── renamer.spec ├── assets/ │ ├── app.ico │ └── fonts/ │ └── SourceHanSans.ttf ├── templates/ │ └── default.txt ├── config/ │ └── default.yaml └── requirements.txtassets/app.ico是 exe 图标assets/fonts是界面用字体templates/default.txt是命名模板config/default.yaml是默认配置。四类资源正好覆盖单文件、整目录、多层目录三种形态。5.2 代码里怎么读它们from pathlib import Path import yaml class Config: def __init__(self): self.template self._load_template() self.font_path resource_path(assets/fonts/SourceHanSans.ttf) self.icon_path resource_path(assets/app.ico) def _load_template(self): p Path(resource_path(templates/default.txt)) return p.read_text(encodingutf-8).strip() def load_config(): user_cfg user_dir() / config / config.yaml if not user_cfg.exists(): user_cfg.parent.mkdir(parentsTrue, exist_okTrue) shutil.copy2(resource_path(config/default.yaml), user_cfg) return yaml.safe_load(user_cfg.read_text(encodingutf-8))5.3 spec 全文单文件版# -*- mode: python ; coding: utf-8 -*- from pathlib import Path root Path(SPECPATH) def collect_dir(src_dir, dest_prefix, skip_suffix(), skip_names()): out [] base root / src_dir for p in sorted(base.rglob(*)): if p.is_dir(): continue if p.suffix.lower() in skip_suffix or p.name in skip_names: continue rel_parent p.relative_to(base).parent.as_posix() dest dest_prefix if rel_parent . else f{dest_prefix}/{rel_parent} out.append((str(p), dest)) return out datas [] datas collect_dir(assets, assets, skip_suffix{.pyc, .pyo, .psd}, skip_names{.DS_Store, Thumbs.db}) datas collect_dir(templates, templates) datas [(str(root / config / default.yaml), config)] a Analysis( [main.py], pathex[str(root)], binaries[], datasdatas, hiddenimports[], hookspath[], excludes[tkinter, unittest, pydoc_data], noarchiveFalse, ) pyz PYZ(a.pure) exe EXE( pyz, a.scripts, a.binaries, a.datas, [], namerenamer, debugFalse, stripFalse, upxFalse, consoleFalse, iconstr(root / assets / app.ico), )想改成目录模式把EXE那段的a.binaries, a.datas换成[]并加exclude_binariesTrue后面再补一个COLLECTexe EXE(pyz, a.scripts, [], exclude_binariesTrue, namerenamer, consoleFalse, iconstr(root / assets / app.ico)) coll COLLECT(exe, a.binaries, a.datas, stripFalse, upxFalse, namerenamer)5.4 打包、验证、确认资源真的进去了pyinstaller --noconfirm --clean renamer.spec打完先别急着发做两个动作。第一用pyi-archive_viewer打开产物列内容pyi-archive_viewer dist/renamer.exe在交互界面里按L列出顶层目录看看有没有assets、templates、config三个入口进assets再看fonts在不在。少了任何一项问题一定出在 spec 的datas上跟代码无关。目录模式更简单直接打开dist/renamer/_internal/用文件管理器看。第二故意把resource_path的返回值打印到日志里跑一次看路径是否落在_MEIPASS下、文件是否真实存在。我一般会在启动日志里固定输出一行logging.info(resource root %s, getattr(sys, _MEIPASS, dev)) logging.info(font exists %s, Path(resource_path(assets/fonts/SourceHanSans.ttf)).exists())有了这一行再有人报字体没生效看一眼日志就定了。6. 资源带上之后体积和报错的两个战场资源一多包会变大包一变大新的问题就来了。这一节是我踩坑密度最高的部分。6.1 把体积从两百多兆压到四十兆手段具体做法注意点排除无用模块excludes加tkinter、unittest、pydoc_data、test别无脑排email、urllib很多库间接依赖换轻量依赖图形处理换opencv-python-headless无窗口环境本来也用不上 GUI 部分关闭 UPXupxFalseUPX 压缩后杀软误报概率上升部分 dll 还会加载失败剥离符号Linux 下stripTrueWindows 上收益很小精简资源字体做子集化图片压缩删掉设计稿这一步收益常常最大目录模式替代单文件避免重复解压带来的磁盘占用分发形态会变我最近一个项目从 230MB 降到 42MB最大的贡献其实来自第三行和第五行换掉了一个带完整 GUI 依赖的库再把 12MB 的中文字体子集化成 2MB。6.2 报错对照表看到这些现象先查这几处现象大概率根因处理方式FileNotFoundError且路径含_MEI资源没进包或代码用了相对路径查datas改用resource_pathFileNotFoundError且路径是桌面或 exe 同级用了os.getcwd()或裸相对路径同上ModuleNotFoundError只在打包后出现动态导入未被分析到hiddenimports或--collect-all双击完全没反应windowed 模式吞掉了异常写日志或临时改consoleTrueFailed to execute script main启动阶段抛异常看控制台 traceback构建日志里资源路径带乱码构建路径含中文或空格构建目录换成纯英文短路径PermissionError写配置失败往包内或 Program Files 写数据改写到用户数据目录启动卡十几秒单文件模式下的大资源解压换目录模式或压缩资源杀软直接删掉 exe自解压行为触发误报换目录模式或做代码签名排查顺序建议固定成三步先用--console复现拿到真实 traceback再核对datas和包内实际内容最后确认代码里的路径来源是不是resource_path。九成问题在前两步就暴露了很少需要动到第三步。6.3 打包完成的最后一公里换台干净机器跑一遍我见过太多在我这没问题的发布。有三个坑只有换机器才暴露一是运行库缺失目标机没装对应的 VC 运行库二是权限差异装在C:\Program Files下才暴露写目录不可写三是杀软策略差异自己机器上早已加白。我的发布前自检固定是这几条在干净虚拟机或容器里从零跑一遍不装 Python 环境。检查资源目录是否完整用pyi-archive_viewer或直接翻_internal。故意触发一次写配置确认落到了用户目录而不是包内。用无控制台模式跑一次确认异常会写进日志文件并且日志目录可创建。记录 Python 版本、PyInstaller 版本、依赖版本写进发布说明。7. 多平台和流水线里资源打包还有几个额外讲究最后聊几个跨平台场景下的实际问题主要给需要出多平台包或者接 CI 的人。PyInstaller 不支持交叉编译。在 Windows 上只能打 Windows 包Linux 包必须在 Linux 上打macOS 包必须在 macOS 上打。做多平台就得配多平台 runner或者用容器打 Linux 包。用容器的时候有个细节值得注意基础镜像的 glibc 版本决定了产物的向下兼容范围在较老的发行版镜像里构建能覆盖的目标系统更广反过来在最新版镜像里构建拿到旧机器上可能直接报版本不满足。CI 脚本里分隔符要按平台分支。同一份构建脚本Linux runner 用冒号、Windows runner 用分号最省心的做法是干脆不用命令行--add-data统一走.spec让平台差异只体现在 runner 上不出现在构建参数里。构建缓存和--clean的取舍。--clean会清掉缓存和临时文件打包更慢但结果更可靠。我的经验是代码改动走增量资源改动一律加--clean。因为资源文件的增量判断在某些版本组合下不够可靠出现过明明加了文件但包里没有的情况加一次--clean就对了为省那点时间不值当。把 PyInstaller 版本钉死。5.x 到 6.x 在目录布局多了_internal、datas元组长度、Tree用法上都有变化。你的构建文档写得再好也挡不住 runner 上自动升级到新版本。在requirements.txt里写死pyinstaller6.x.y是成本最低的稳定性保障。如果你的分发目录结构不能变6.0 之后可以用contents_directory参数把_internal的名字改回去保持和旧版本一致的目录形态。产物的冒烟测试要脚本化。我给每个工具都保留一个--version或--selftest参数启动后加载一遍所有内置资源、打印资源根路径、写一次日志再退出返回码非零就判定失败。CI 里跑一次这个能挡住绝大多数打出来的包缺资源的低级事故比人工点开界面检查靠谱得多。回头看我这些年跟 PyInstaller 资源问题打交道的过程真正要记住的其实就三条资源声明方式选一种并且只选一种要么命令行要么 spec别混资源定位统一走resource_path这类函数别用相对路径和getcwd只读资源和可写数据分开放包内写不了、写了也会丢。把这三条落实到位那些_MEIPASS带来的诡异现象基本就绝迹了。剩下唯一会让我多花时间的是第三方库自带资源没被 hooks 收全——遇到这种情况别急着自己抄路径先去翻那个库的安装目录看清楚它运行时到底在找哪个文件再用collect_data_files把整个目录收过来通常一次就解决。