PyInstaller 系列 - 单目录和单文件模式

版权声明:所有博客文章除特殊声明外均为原创,允许转载,但要求注明出处。

本文系列前一篇 - 简介 中,我们介绍了 PyInstaller 的入门知识。本文重点讲解一下 PyInstaller 的单目录(onedir)和单文件(onefile) 模式,并解释我个人一直强调的观点:不应该使用单文件模式

单目录模式(onedir)

我们还是一步一步来。所谓单目录模式,就是 PyInstaller 将 Python 程序编译为同一个目录下的多个文件,其中 xxxx.exe 是程序入口点(xxxx 是脚本文件名称,你也可以通过命令行修改),以及其他的辅助文件。单目录是 PyInstaller 的默认模式,并不需要特意指明,不过你想要更明确的话,也可以自己加上 -D 或者 --onedir 开关。单目录模式生成的结果大概是下图这样的:

Dist 目录内容

可以看到,除了主程序之外,其他文件还包括 Python 解释器(PythonXX.dll)、系统运行库(ucrtbase.dll 以及一大堆 apixx.dll),以及一些编译后的 Python 模块(.pyd 文件)。

这里可以稍微解释一下 PyInstaller 打包程序的运行原理。主程序文件之所以比较大,是因为它包含了运行程序的启动(Bootstrap)代码。简而言之,Bootstrap 代码的工作过程大概是这样的:

  1. 修改运行配置,并设置一些内部变量,为下一步的解释器执行创建环境;
  2. 加载 Python 解释器和内置模块;
  3. 如果有需要的话,执行一些称为运行时钩子(Runtime Hook)的特殊过程;
  4. 加载编译过的入口脚本;
  5. 调用解释器执行入口脚本。脚本运行后,接下来的工作就由解释器接管了;
  6. 当解释器执行完毕后,清理环境并退出。

这个过程总体来说还是比较容易理解的。其中 Runtime Hook 是 PyInstaller 定义的一种特殊机制,后续有机会的话会讲解。

单文件模式(onefile)

和单目录模式不同,单文件模式是将整个程序编译为单一的可执行文件。要开启的话,需要在命令行添加 -F 或者 --onefile 开关。生成的结果是这样的:

单文件模式输出内容

可以看到,只有单个.exe 文件,显得非常清爽。可能正是因为这个原因,我接触到的用户大多喜欢使用该模式。对这些用户,我通常首先会说一句话:不要用onefile模式! 为什么呢?接下来就解释这个问题。

为什么不推荐使用单文件模式

首先声明,我之所以强调这一点,并不是因为单文件模式存在什么无法解决的问题。如果你非常清楚该模式的运行机制,并且在写代码的时候小心避开这些坑的话,那么所有问题都是可以避免的。但实际上,可以说 PyInstaller 的用户 99% 都达不到这个要求,而只要你写的程序有点规模的话,几乎无一例外会踩到坑里。基于这种考虑,我从来不推荐用户使用单文件模式。如果你认真看过本文,并非常肯定自己能避开下面提到的问题,那么请使用单文件模式无妨。否则,还是老老实实的使用默认模式吧。

有个问题你不妨考虑一下:我们把程序编译成了单一的可执行文件,但是从上面的单目录模式结果可以知道,要让程序运行还需要其他很多的辅助文件,此外我们自己也可以添加数据文件(--add-data)和二进制文件(--add-binary),那么这些文件哪里去了?你如何访问这些文件?

这才是秘密所在!本质上,Python 是解释程序,而不是 native 的编译程序,它并不能真正产生出真正单一的可执行文件。PyInstaller 这里变了个小戏法,如果我们使用单文件模式的话,那么 PyInstaller 生成的实际上类似于 WinZIP/WinRAR 生成的自动解压程序。它需要先把所有文件解压到一个临时目录(通常名为_MEIxxxx,xxxx是随机数字),再从临时目录加载解释器和附属文件。程序运行完毕后,如果一切正常,那么它会把临时目录再悄悄删除掉。

为了让这个过程顺利执行,PyInstaller 会对运行时的 Python 解释器做一些修改,特别是下面两个变量:

  • sys.frozen 如果你直接运行 Python 脚本的话,那么该变量是不存在的。但 PyInstaller 则会设置它为 True(不论单目录还是单文件模式)。因此,你可以用它来判断程序是手工运行的,还是通过 PyInstaller 生成的可执行文件运行的;
  • sys._MEIPASS 如果使用单文件模式,该变量包含了 PyInstaller 自动创建的临时目录名。你可以用 --runtime-tmpdir 命令行开关来强制使用特定的目录,但是鉴于最终用户有哪些目录不在程序员控制范围内,通常还是应该避免使用它。

我们可以自己写一个程序来验证:

import sys
import os

print('__file__:', __file__)
print('sys.executable:', sys.executable)
print('sys.argv[0]:', sys.argv[0])
print('os.getcwd():', os.getcwd())
print('sys.frozen:', getattr(sys, 'frozen', False))
print('sys._MEIPASS:', getattr(sys, '_MEIPASS', None))
input('Press any key to exit...')

把该脚本编译到单文件模式,然后执行。注意,先不要按任何键(否则程序退出,临时目录就不存在了),然后根据输出结果,可以到资源管理器中找到对应的临时目录:

单文件模式临时目录

你可以看到临时目录包含了运行输出所需的各种辅助文件,除了主程序.EXE 之外。仔细分析一下,我们也能明白为什么单文件模式下容易出错了。尽管 PyInstaller 努力使得各种输出和直接运行脚本的结果尽可能相似,但差别还是很明显的:

  • __file__ 指向的脚本名不变,但该文件已经不存在于磁盘上了。这使得依赖于 __file__ 去解析相对文件位置的代码非常容易出错。这也是绝大多数错误的来源,请务必注意!
  • sys.executable 不再指向 Python.exe,而是指向生成的文件位置了。如果你使用该变量判断系统库位置的话,那么也请小心;
  • os.getcwd() 指向执行文件的位置(双击运行的话是这样,但如果从命令行启动的话则未必)。但请注意,你添加的数据/二进制文件并非位于此目录,而是在临时目录上,不明白这一点的话,也很容易出现找不到文件的问题。

需要说明的是,上述问题不只存在于你自己写的代码里。有相当多的库没有考虑到在 PyInstaller 打包后下执行的场景,它们在使用这些变量的时候很有可能会出问题。事实上这也是 PyInstaller 添加 Runtime Hook 机制的一个重要原因。

如果你的脚本需要引用辅助文件路径的话,那么一种可能的形式如下:

if getattr(sys, 'frozen', False):
    tmpdir = getattr(sys, '_MEIPASS', None) 
    if tmpdir:
        filepath = os.path.join(tmpdir, 'README.txt')
    else:
        filepath = os.path.join(os.getcwd(), 'README.txt')
else:
    filepath = os.path.join(os.path.dirname(__file__), 'README.txt')

上述代码并不是唯一可行的代码,或许也不是最简洁的,但是你应当明白了,要正确处理该过程并不是轻而易举的事情。很多用户之所以出错又找不到问题,就是因为他们根本不清楚临时目录这回事,也不知道上哪里去找这些文件。如果使用单目录模式的话,那么文件在哪里是可以直接看到的,出现问题的可能性就小多了,即使有问题也很容易排查。这就是我为什么强烈推荐用户不要使用单文件模式的原因————除了看起来比较清爽之外,单文件模式基本上没有其他好处,而且它带来的麻烦比这一点好处要多太多了。

除此之外,单文件模式还带来了其他一些负面效应:

  • 因为有临时目录和解压文件这个过程,所以单文件模式的程序启动速度会比较慢。对于稍大的程序,这个延迟是肉眼可以感觉到的;
  • 如果你的程序运行到一半崩溃了,那么临时目录将没有机会被删除。日积月累的话,可能会在临时目录下遗留一大堆 _MEIxxxx 目录,占用大量磁盘空间。

或许对你来说上面这两个问题并不是特别重要,但知道它们的存在还是有好处的。

希望本文能够帮助你明白这个过程,并理解我为什么要这样建议。

下一篇文章中,我们将讲解 PyInstaller 的规格文件(.spec)。

PyInstaller 系列索引