Python 应用要发给”不懂 Python 的人”用,绕不开打包这道坎。三种主流选择:
- PyInstaller:最常见,资料多,但产物体积大、启动慢,杀软老把它当成病毒
- cx_Freeze:老牌,稳,但生态比 PyInstaller 还小
- Nuitka:把 Python 翻译成 C 再编译,启动快、产物小、反混淆难度高
我们最后选了 Nuitka。选了之后才发现入坑容易出坑难——跨三个平台、要支持便携版和合并版两种分发形态、还要兼顾用户体验,光打包脚本就写到了 35K 行。
这篇说说几个真坑。
为什么不用 PyInstaller
短话长说一下选型背景。
我们这个工具是给玩家用的桌面应用,主流程是抓屏 + 模型推理 + UI 控制。两个硬性诉求:
- 启动要快——用户双击 exe 转半天圈圈,体验立马劝退
- 杀软误报要少——PyInstaller 打的 Python 程序在 Windows Defender 那简直是常客
第一坑:portable 还是 merged
桌面应用的”数据放哪儿”在三个平台习惯完全不同:
- Windows 用户习惯”软件目录里啥都有”——portable 风格
- macOS 用户习惯
.app包 + 数据在用户目录~/Library/...——合并风格 - Linux 用户分裂,两种都有
我们最后给打包脚本加了两个开关:
1 | |
代码里所有路径都不能写死,统一过一层路径解析:
1 | |
is_portable_build() 在打包时通过环境变量或者打包文件标记决定。所有读写数据、缓存、配置、日志的地方都走这一层——任何一个地方写死路径,跨模式就崩。
这条规矩在项目规范文档里也写死了:
禁止在代码中写死本机绝对路径、用户名、设备路径、端口或私有环境信息。
不是教条,是被坑出来的。
第二坑:macOS .app 包的 WebView
macOS 上桌面 UI 我们用 pywebview——简单说就是把本地 Web 页面嵌进原生窗口里。开发期完全无感,跨平台一致。
打包成 .app 的时候开始出妖了。
问题一:内置 WebView(WKWebView)需要一堆 macOS 框架,打到 .app 里体积膨胀几百 MB。
问题二:某些 macOS 版本上 .app 启动 WebView 会卡白屏,原因深挖到 PyObjC 绑定层,修起来代价巨大。
问题三:用户把 .app 拖到非标准位置(比如外置 U 盘),权限系统直接拒绝。
折腾几轮之后我们做了个分流决策:
1 | |
--portable 模式下产物只是个文件夹,体积小、启动快、绕开所有 WebView 坑,缺点是用户得用浏览器看 UI。--merged 模式给追求”原生体验”的用户,体积大但和系统融合更深。
让用户选,比强求一种方案要平衡得多。
第三坑:Windows 控制台窗口
Windows 上的 GUI 程序默认带一个黑色控制台窗口。开发者觉得没啥,普通用户看着像中毒:
我打开你的软件,黑框框弹出来一个,是不是在偷我密码?
Nuitka 提供了 --windows-console-mode=disable 关掉控制台。但完全关掉又有新问题——程序崩溃时的 stderr 没地方看,用户没法提供错误信息。
最终的策略:
1 | |
--merged 的版本里 stderr 全部重定向到日志文件,崩溃信息照样能拿到——就是用户得自己翻日志目录。
为了让用户能找到日志,我们在 UI 里加了个”打开日志目录”按钮。这种小工程才是真用心。
第四坑:ONNX Runtime 的 EP 依赖
模型推理用 ONNX Runtime。问题是 ORT 的”执行提供者(Execution Provider, EP)”在不同平台依赖完全不同:
- Windows 想用 DirectML,得带 DirectML.dll
- macOS 想用 CoreML,要链接 CoreML.framework
- Linux 多半只能 CPU
Nuitka 默认不会把这些原生依赖全部识别并打包。手动指定:
1 | |
每次 ORT 升版本,依赖文件路径可能微调,打包脚本要跟着更新——这种”打包脚本耦合三方库结构”的痛苦无法根除,只能记好测试矩阵:每次升级 ORT 都跑一遍三平台打包验证。
第五坑:UPX 不是万能的
打包产物大,本能反应是”加 UPX 压缩”。Nuitka 自己也支持 --include-data-dir 配合 UPX。
实际上:
- Windows 上压完更容易被杀软误报(UPX 是恶意软件的常用工具)
- macOS 上 UPX 压完代码签名会失效,分发立马报错
- Linux 上勉强能用
我们的做法是条件启用——只在 Linux 上开 UPX,其他平台不动。打包脚本里检测一下:
1 | |
没装 UPX 就跳过。开发者无感。
第六坑:模型文件 vs 资源文件
项目里两类大文件:
- 模型文件:YOLO 的 .onnx、CLIP 的 .onnx、OCR 模型
- 资源文件:图标、UI 模板、游戏术语数据库
Nuitka 把这些当 data file 打进去,产物体积爆炸(解压后 1 GB+)。
后来分了两条路:
- 必需运行的小文件(图标、术语库 JSON):打进去
- 大模型文件:不打进去,首次启动从用户缓存目录加载,找不到就从内置 CDN 下载
判断逻辑:
1 | |
代价是用户首次启动需要联网下载几百 MB。换来的是发布包体积从 1 GB+ 降到 80 MB 左右,下载更新更轻。
几条经验
写完 35K 行打包脚本之后,沉淀的几条规矩:
一切路径走解析层,不允许散点写死。 否则一切跨平台、跨模式都翻车。
给打包脚本写真实的 CI 矩阵。 Windows / macOS / Linux × portable / merged,至少六种组合,每次发版前都跑一遍。靠人记忆是记不住的。
大文件不要打进去,用 lazy download。 既减小发布体积,又方便模型独立升级。
杀软友好性提前考虑。 用 Nuitka 而不是 PyInstaller、不加 UPX(除非必要)、可选代码签名——这些不是优化,是发布质量的硬指标。
打包脚本本身要带 --dry-run。 真正打一次几十分钟,dry-run 帮你验证配置正确性,几秒搞定。
收个尾
跨平台打包这事儿,最难的不是技术——技术随便查随便有。难的是每个平台都有自己的”用户期待”和”系统约束”,强行用一套方案套三个平台,要么牺牲体验,要么牺牲简洁。
我们最后的 build_app.py 看起来很臃肿,但每一段都对应一个真实踩过的坑。把这种”为什么这么写”的注释留好,下个维护者才不会一上来就想推倒重写。
下次见到只支持单平台打包的 Python 项目,可以善意地理解——多平台不是抠两行参数就能搞定的,背后都是血泪。