1. 程式人生 > 實用技巧 >PyInstaller 系列 - 單目錄和單檔案模式

PyInstaller 系列 - 單目錄和單檔案模式

【原文地址: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 程式碼的工作過程大概是這樣的:

  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)。