Nuitka 跨平台打包——Windows、macOS、Linux 一套构建脚本的折腾史

Python 应用要发给”不懂 Python 的人”用,绕不开打包这道坎。三种主流选择:

  • PyInstaller:最常见,资料多,但产物体积大、启动慢,杀软老把它当成病毒
  • cx_Freeze:老牌,稳,但生态比 PyInstaller 还小
  • Nuitka:把 Python 翻译成 C 再编译,启动快、产物小、反混淆难度高

我们最后选了 Nuitka。选了之后才发现入坑容易出坑难——跨三个平台、要支持便携版和合并版两种分发形态、还要兼顾用户体验,光打包脚本就写到了 35K 行。

这篇说说几个真坑。

为什么不用 PyInstaller

短话长说一下选型背景。

我们这个工具是给玩家用的桌面应用,主流程是抓屏 + 模型推理 + UI 控制。两个硬性诉求:

  1. 启动要快——用户双击 exe 转半天圈圈,体验立马劝退
  2. 杀软误报要少——PyInstaller 打的 Python 程序在 Windows Defender 那简直是常客

第一坑:portable 还是 merged

桌面应用的”数据放哪儿”在三个平台习惯完全不同:

  • Windows 用户习惯”软件目录里啥都有”——portable 风格
  • macOS 用户习惯 .app 包 + 数据在用户目录 ~/Library/...——合并风格
  • Linux 用户分裂,两种都有

我们最后给打包脚本加了两个开关:

1
2
python build_app.py --portable    # 便携:所有数据放程序同目录
python build_app.py --merged # 合并:数据放 ~/.gakumas-assistant/

代码里所有路径都不能写死,统一过一层路径解析:

1
2
3
4
5
# src/utils/runtime_paths.py
def get_data_dir() -> Path:
if is_portable_build():
return get_exe_dir() / "data"
return Path.home() / ".gakumas-assistant" / "data"

is_portable_build() 在打包时通过环境变量或者打包文件标记决定。所有读写数据、缓存、配置、日志的地方都走这一层——任何一个地方写死路径,跨模式就崩。

这条规矩在项目规范文档里也写死了:

禁止在代码中写死本机绝对路径、用户名、设备路径、端口或私有环境信息。

不是教条,是被坑出来的。

第二坑:macOS .app 包的 WebView

macOS 上桌面 UI 我们用 pywebview——简单说就是把本地 Web 页面嵌进原生窗口里。开发期完全无感,跨平台一致。

打包成 .app 的时候开始出妖了。

问题一:内置 WebView(WKWebView)需要一堆 macOS 框架,打到 .app 里体积膨胀几百 MB。
问题二:某些 macOS 版本上 .app 启动 WebView 会卡白屏,原因深挖到 PyObjC 绑定层,修起来代价巨大。
问题三:用户把 .app 拖到非标准位置(比如外置 U 盘),权限系统直接拒绝。

折腾几轮之后我们做了个分流决策:

1
2
macOS --portable → 不内置 WebView,跑浏览器版(默认浏览器打开 localhost)
macOS --merged → 内置 WebView,正经 .app 体验

--portable 模式下产物只是个文件夹,体积小、启动快、绕开所有 WebView 坑,缺点是用户得用浏览器看 UI。
--merged 模式给追求”原生体验”的用户,体积大但和系统融合更深。

让用户选,比强求一种方案要平衡得多。

第三坑:Windows 控制台窗口

Windows 上的 GUI 程序默认带一个黑色控制台窗口。开发者觉得没啥,普通用户看着像中毒:

我打开你的软件,黑框框弹出来一个,是不是在偷我密码?

Nuitka 提供了 --windows-console-mode=disable 关掉控制台。但完全关掉又有新问题——程序崩溃时的 stderr 没地方看,用户没法提供错误信息。

最终的策略:

1
2
Windows --portable → 带控制台(默认给会调试的用户用)
Windows --merged → 不带控制台(给小白用户用)

--merged 的版本里 stderr 全部重定向到日志文件,崩溃信息照样能拿到——就是用户得自己翻日志目录。

为了让用户能找到日志,我们在 UI 里加了个”打开日志目录”按钮。这种小工程才是真用心。

第四坑:ONNX Runtime 的 EP 依赖

模型推理用 ONNX Runtime。问题是 ORT 的”执行提供者(Execution Provider, EP)”在不同平台依赖完全不同:

  • Windows 想用 DirectML,得带 DirectML.dll
  • macOS 想用 CoreML,要链接 CoreML.framework
  • Linux 多半只能 CPU

Nuitka 默认不会把这些原生依赖全部识别并打包。手动指定:

1
2
3
4
5
# build_app.py 片段
nuitka_args += [
"--include-package-data=onnxruntime",
"--include-data-dir=path/to/onnxruntime/capi=onnxruntime/capi",
]

每次 ORT 升版本,依赖文件路径可能微调,打包脚本要跟着更新——这种”打包脚本耦合三方库结构”的痛苦无法根除,只能记好测试矩阵:每次升级 ORT 都跑一遍三平台打包验证。

第五坑:UPX 不是万能的

打包产物大,本能反应是”加 UPX 压缩”。Nuitka 自己也支持 --include-data-dir 配合 UPX。

实际上:

  • Windows 上压完更容易被杀软误报(UPX 是恶意软件的常用工具)
  • macOS 上 UPX 压完代码签名会失效,分发立马报错
  • Linux 上勉强能用

我们的做法是条件启用——只在 Linux 上开 UPX,其他平台不动。打包脚本里检测一下:

1
2
if platform.system() == "Linux" and shutil.which("upx"):
nuitka_args.append("--upx-binary=" + shutil.which("upx"))

没装 UPX 就跳过。开发者无感。

第六坑:模型文件 vs 资源文件

项目里两类大文件:

  • 模型文件:YOLO 的 .onnx、CLIP 的 .onnx、OCR 模型
  • 资源文件:图标、UI 模板、游戏术语数据库

Nuitka 把这些当 data file 打进去,产物体积爆炸(解压后 1 GB+)。

后来分了两条路:

  • 必需运行的小文件(图标、术语库 JSON):打进去
  • 大模型文件:不打进去,首次启动从用户缓存目录加载,找不到就从内置 CDN 下载

判断逻辑:

1
2
3
4
5
6
def ensure_model_available(model_name: str) -> Path:
local_path = get_user_cache_dir() / model_name
if local_path.exists():
return local_path
download_from_cdn(model_name, local_path) # 带进度条
return local_path

代价是用户首次启动需要联网下载几百 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 项目,可以善意地理解——多平台不是抠两行参数就能搞定的,背后都是血泪。

欢迎关注我的其它发布渠道