Python 打包心得
Nuikta
-
优先 Nuikta:
python -m nuitka --mingw64 --standalone --output-dir=out --show-progress --enable-plugin=pyqt5 --nofollow-import-to=numpy,cv2,PIL,six,pytweening,pyscreeze,pyrect,pyperclip --windows-disable-console .\main1.py -
某些包使用 Nuikta 会有问题(如Plotly),这个时候有解决方法,但没必要,时间和收益不成正比。老老实实使用 pyinstaller 就行。
Pyinstaller 打包过大
多半是因为在 conda 环境下,并且程序里面有调用 numpy 包(或者其他间接调用了 numpy 的包,如 cv2 等)。进行了一些对比测试后,建议如下:
- 不要在 conda 下安装 pyinstaller,即使是
conda install pyinstaller -c conda-forge也无法解决这个问题。应该在系统原始的 python3 上安装 pyinstaller。貌似是因为 pyinstaller 会根据它自己所在环境寻找 lib 路径,判断哪些包(.so)涵盖进去,所以 conda 环境下某些 lib 会被包进去。如果经常打包,建议直接用一个虚拟机,虽然很笨但是有奇效。 - ~~conda 安装 numpy 建议使用 conda install numpy -c conda-forge,主要是因为 conda 中的 numpy 包有使用 mkl 这个矩阵计算库,打包时很有可能会把 mkl 包上。~~ 本条作废:这个问题已经解决了,现在 numpy 的安装默认行为已经不是这样了。
Pyintsaller 优先生成目录
及其不推荐 Pyinstaller 只生成一个文件,这样有更新会相当相当麻烦。而是生成目录,这样可以备份生成的目录(即 _internal),后续有更新操作时,如果包不变的话,那么 _internal 里面内容也不会变,后续直接替换 exe 即可。
这种操作方式下还有如下的几个技巧:
删除目录中没必要的文件
打开 exe 文件,然后对 _internal 目录中的文件进行无脑删除,此时会弹出某些文件被占用的窗口,这些文件跳过处理即可。这样那些没有用的文件就会被删除掉了。
PS:这种可能有风险,有些文件可能是后续操作才会用到(但我很少遇到过),所以先做好备份。
一些常见的无用文件
以下是我遇到的 _internal 中没用到的文件,即可以删除的文件:
tcl、tcl8、tk里面的文件都可以删除,但这三个文件夹要保留。PyQt中的translation不用,bin目录下有些文件不用。
后续包有更新
在更新的时候,还遇到一个问题,就是遇到用了新的包。其实最简单的做法就是只把 _internal 新加的文件放到更新的文件夹中,即:
_internal
new_file1
new_file2
new.exe
合成一个压缩包,然后用户解压把里面的文件直接拖进去即可,系统会自动把新增的文件放入到了 _internal 中了。
第一次发布时目录很大
第一次发布的时候,_internal 会有很多文件,而且子文件比较多,这样复制起来很麻烦。我觉得最直接的就是压缩目录,然后写一个 readme.txt 就行了。
如果非要钻牛角尖,想直接无脑被用户使用(不推荐)。可以在程序里面刚开始运行的时候进行检测是否有压缩包,如果有那么就在程序里先解压。此时目录结构为:
bootstrap.py (bootstrap 程序,刚开始会先解压)
main.py (项目文件入口)
...
打包的时候对 bootstrap.py 进行打包,但问题是要启动 bootstrap 也需要必须的文件呀。但是好处是这样 _internal 里面的文件只用放基础的几个就行,如下所示:
_internal (bootstrap 必须要用的文件)
_tcl_data (空)
_tk_data (空)
_socket.pyd
base_library.zip
python3.dll
python312.dll
select.pyd
_internal.zip (整个项目用到的文件)
my.exe (对 bootstrap 打包生成的 exe)
下面是一个 bootstrap.py 示例,他是会检查同目录下是否有 .notok 文件,如果有则解压,解压成功后删除压缩包。
# bootstrap.py
import os
import sys
import zipfile
import subprocess
from pathlib import Path
if getattr(sys, 'frozen', False):
BASE_DIR = Path(sys.executable).parent.absolute()
else:
BASE_DIR = Path(__file__).parent.absolute()
INTERNAL_DIR = os.path.join(BASE_DIR, "_internal")
INTERNAL_ZIP = os.path.join(BASE_DIR, "_internal.zip")
NOTOK_MARK = os.path.join(BASE_DIR, ".notok")
def extract_internal():
print("[bootstrap] extracting _internal.zip ...")
with zipfile.ZipFile(INTERNAL_ZIP, "r") as zf:
# 只解压不存在的文件,跳过已有文件
for member in zf.namelist():
# 如果是目录,确保目录存在(即使文件被跳过,目录结构也要保持)
if member.endswith('/'):
target_dir = os.path.join(BASE_DIR, member)
os.makedirs(target_dir, exist_ok=True)
continue
target_path = os.path.join(BASE_DIR, member)
# 如果文件已存在,跳过
if os.path.exists(target_path):
continue
# 确保目标目录存在
target_dir = os.path.dirname(target_path)
if target_dir:
os.makedirs(target_dir, exist_ok=True)
# 解压单个文件
zf.extract(member, BASE_DIR)
# 解压成功后删除 NOTOK 标记
if os.path.exists(NOTOK_MARK):
os.remove(NOTOK_MARK)
print("[bootstrap] removed .notok mark")
def remove_internal():
if os.path.exists(INTERNAL_ZIP):
os.remove(INTERNAL_ZIP)
def internal_ready():
# 如果 NOTOK 标记不存在,说明已经解压完成
return not os.path.exists(NOTOK_MARK)
def relaunch():
subprocess.Popen([sys.executable])
sys.exit(0)
def main():
# Windows DLL 搜索路径(非常重要)
if hasattr(os, "add_dll_directory") and os.path.isdir(INTERNAL_DIR):
os.add_dll_directory(INTERNAL_DIR)
# 如果存在 NOTOK 标记或 _internal.zip,则需要解压
if os.path.exists(NOTOK_MARK) or os.path.exists(INTERNAL_ZIP):
if os.path.exists(INTERNAL_ZIP):
extract_internal()
remove_internal()
relaunch()
else:
# 如果只有 NOTOK 但没有 zip 文件,删除 NOTOK(可能是误操作)
os.remove(NOTOK_MARK)
print("[bootstrap] .notok exists but no _internal.zip, removed .notok")
# ===== 到这里环境一定 OK =====
import main # ← 你的原 main.py
if hasattr(main, "main"):
main.main(BASE_DIR)
if __name__ == "__main__":
main()