【原文地址:https://shuhari.dev/blog/2018/6/pyinstaller-onedir-and-onefile】
在 本文系列前一篇 - 簡介 中,我們介紹了 PyInstaller 的入門知識。本文重點講解一下 PyInstaller 的單目錄(onedir)和單文件(onefile) 模式,並解釋我個人一直強調的觀點:不應該使用單文件模式。
單目錄模式(onedir)
我們還是一步一步來。所謂單目錄模式,就是 PyInstaller 將 Python 程序編譯為同一個目錄下的多個文件,其中 xxxx.exe 是程序入口點(xxxx 是腳本文件名稱,你也可以通過命令行修改),以及其他的輔助文件。單目錄是 PyInstaller 的默認模式,並不需要特意指明,不過你想要更明確的話,也可以自己加上 -D 或者 --onedir 開關。單目錄模式生成的結果大概是下圖這樣的:

可以看到,除了主程序之外,其他文件還包括 Python 解釋器(PythonXX.dll)、系統運行庫(ucrtbase.dll 以及一大堆 apixx.dll),以及一些編譯后的 Python 模塊(.pyd 文件)。
這里可以稍微解釋一下 PyInstaller 打包程序的運行原理。主程序文件之所以比較大,是因為它包含了運行程序的啟動(Bootstrap)代碼。簡而言之,Bootstrap 代碼的工作過程大概是這樣的:
- 修改運行配置,並設置一些內部變量,為下一步的解釋器執行創建環境;
- 加載 Python 解釋器和內置模塊;
- 如果有需要的話,執行一些稱為運行時鈎子(Runtime Hook)的特殊過程;
- 加載編譯過的入口腳本;
- 調用解釋器執行入口腳本。腳本運行后,接下來的工作就由解釋器接管了;
- 當解釋器執行完畢后,清理環境並退出。
這個過程總體來說還是比較容易理解的。其中 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)。
