PySide6 寫的 GUI 程式在自己電腦上跑得好好的,打包成 exe 丟給同事,雙擊就閃退,或是視窗開了但圖示、樣式表全空白。問題幾乎都出在同一個地方:onefile 模式下資源檔的路徑跟開發時不一樣。這篇用一個 Hexo 文章編輯器(HexoEditor)的打包流程當例子,把 PyInstaller 打包 PySide6 會踩到的幾個坑攤開講,重點放在最容易卡住人的 sys._MEIPASS 資源解析。
PyInstaller 在做什麼
PyInstaller 會掃描你的程式進入點,分析它 import 了哪些模組,把 Python 直譯器、相依函式庫、你的程式碼全部收進一個 bundle。它有兩種輸出模式:
- onedir(預設):產生一個資料夾,exe 旁邊放著一堆
.dll、.pyd和一個_internal目錄(把相依集中收進_internal是 PyInstaller 6.0 起的配置,舊版是直接散在 exe 旁邊)。 - onefile(
--onefile/-F):全部壓成單一 exe,啟動時自解壓到暫存目錄再執行。
很多人為了「只給一個檔案比較乾淨」選 onefile,然後就撞上資源路徑的問題。先把基本流程跑通,再來解這個坑。
先把環境隔離乾淨
打包前一定要在乾淨的虛擬環境裡做。理由很實際:PyInstaller 是靠掃描相依關係決定要打包什麼,如果你的全域 Python 裝了一堆跟這個專案無關的套件,它有機會把不該帶的東西也掃進來,exe 體積暴增,還可能因為版本衝突在別人電腦上掛掉。
1 | # 建立虛擬環境 |
裝完先確認 pip list 裡只有你預期的東西。這一步偷懶,後面 debug exe 體積或缺檔會花更多時間。
核心坑:onefile 後找不到資源檔
這是 PyInstaller 打包 GUI 最常見、也最容易讓人摸不著頭緒的問題。開發時你大概是這樣讀資源檔的:
1 | from pathlib import Path |
開發環境下這沒問題,__file__ 指向你的 .py 檔,相對路徑都對得上。但 onefile 打包後執行,bootloader 會把 bundle 裡的內容解壓到一個臨時資料夾(每次執行路徑都不同,像 C:\Users\xxx\AppData\Local\Temp\_MEI123456),而程式的工作目錄通常是 exe 所在的位置。你用相對路徑或 __file__ 去組路徑,指到的地方根本沒有那個檔案,於是圖示載不出來、qss 套不上、讀設定檔直接拋 FileNotFoundError。
PyInstaller 解決這件事的方式是:程式被打包執行時,bootloader 會設定 sys.frozen = True,並把 bundle 資料夾的絕對路徑存進 sys._MEIPASS。對 onefile 來說,sys._MEIPASS 就是那個解壓出來的暫存目錄;對 onedir 來說,它指向 _internal 目錄。你所有打包進去的資源,都要透過這個 base path 去組路徑才找得到。
官方文件給的標準寫法是用 try/except 包住 sys._MEIPASS,沒打包時 fall back 到當前目錄:
1 | import sys |
也可以寫成顯式判斷 sys.frozen 的版本,意思一樣,可讀性更高一點:
1 | import sys |
接著把所有讀資源檔的地方都改成走這個函式:
1 | # 原本(打包後會壞) |
關鍵在於:**resource_path 要拿到的路徑,必須跟 --add-data 把檔案放進去的位置對得上。** 下一節講語法時會把這兩件事接起來。
補一句:
sys._MEIPASS不是唯一解法,但它相容範圍最廣、跨版本最穩、出問題時也最好定位,所以這篇一律用它。其他寫法(例如直接依賴__file__)在不同 PyInstaller 版本與 onefile/onedir 模式下行為不一致,要用前先在你的目標版本上驗一次再決定。
--add-data 的語法,跨平台不一樣
資源檔要先用 --add-data 收進 bundle,執行期才抓得到。它的語法是 來源:目的地,而分隔符號在不同平台不同——這是另一個常被忽略的細節:
- Windows:用分號
;分隔,例如--add-data "styles.json;." - Linux / macOS:用冒號
:分隔,例如--add-data "styles.json:."
目的地是相對於 bundle 頂層目錄的路徑,不能省略。要放到根目錄就寫 .。所以 --add-data "styles.json;." 的意思是「把 styles.json 收進去,放在 bundle 根目錄」,而執行期 resource_path('styles.json') 算出來的就是 sys._MEIPASS/styles.json——兩邊對上了,檔案才找得到。
PyInstaller 6 之後官方統一推薦 POSIX 風格的冒號語法(來源:目的地),Windows 為了向後相容仍然吃分號,但官方建議改用冒號讓指令跨平台一致。如果你需要同一份指令在 Windows 跟 Linux 都能跑,用冒號版本比較省事。
PySide6 應用的完整打包指令
把上面幾件事湊起來,HexoEditor 的打包指令長這樣(Windows,用 ^ 換行):
1 | pyinstaller --name="HexoEditor" ^ |
幾個參數的作用:
--name:指定輸出執行檔名稱--windowed:建出沒有主控台視窗的 GUI 程式(下一節有它的副作用)--icon:指定執行檔圖示--add-data:把資源檔收進 bundle,搭配前面的resource_path使用
跑完第一次後,PyInstaller 會在當前目錄產生一個
.spec檔,等於把你這串命令列參數寫成設定檔。之後要重複打包,直接pyinstaller HexoEditor.spec就好;參數一多(一長串--add-data、--hidden-import)也比每次手敲命令列好維護。複雜的客製化(例如多個資料目錄、額外 binaries)官方建議直接改.spec。
注意我沒有加 --hidden-import=PySide6.QtXml。原因是 PyInstaller 內建了 PySide6 的 hook,會自動掃描你用到的 Qt 模組、把對應的相依、translations 和 plugins(包含 platforms 下的 qwindows.dll 這類關鍵 platform plugin)一起收進去。大多數 PySide6 程式不需要手動指定 hidden-import。
--hidden-import 真正派上用場的場合,是你用了 PyInstaller 靜態掃描看不到的動態 import——例如透過字串 importlib.import_module() 載入模組、或某個冷門 Qt 模組 hook 沒覆蓋到。判斷方式很簡單:先不加,打包後跑跑看,如果報 ModuleNotFoundError: No module named 'xxx',再針對那個模組補 --hidden-import=xxx。不要預先一股腦塞一堆,那只會讓 exe 變大、問題更難定位。
--windowed 的隱藏代價:stdout 變成 None
--windowed(等同 --noconsole / -w)在 Windows 上會用 windowed 版本的 bootloader 來建程式,這個 bootloader 是以 /SUBSYSTEM:WINDOWS 編譯的,所以執行時不會附帶主控台視窗。對 GUI 程式這正是你要的——使用者看到的是視窗,不是一個黑色 console。(這篇講的是 Windows;macOS 上 --windowed 是去建 .app bundle,下面 stdout 變 None 那個坑主要是 Windows /SUBSYSTEM:WINDOWS 的行為。)
但它有個容易讓人栽跟頭的副作用:沒有主控台,sys.stdin、sys.stdout、sys.stderr 全部會是 None。 如果你的程式裡某個地方直接 print()、或某個函式庫往 sys.stdout 寫東西,在開發時跑得好好的,打包成 windowed 後可能因為對 None 做操作而拋例外。我自己就吃過這種苦——開發時 print 一切正常,打包後雙擊沒反應,因為某段背景邏輯 print log 直接炸了,而且因為沒 console,連錯誤訊息都看不到。
兩個務實的應對:
1 | # 1. debug 階段先拿掉 --windowed,留著 console 看 traceback |
把 log 寫進檔案這招特別有用:windowed 模式下程式閃退時你什麼都看不到,有一份 app.log 就能知道它死在哪一行。
打包後常見問題對照
| 症狀 | 通常原因 | 解法 |
|---|---|---|
缺模組 ModuleNotFoundError |
動態 import 沒被掃到 | 針對該模組加 --hidden-import |
| 圖示 / qss / 設定檔讀不到 | 用相對路徑而非 _MEIPASS |
改走 resource_path + --add-data |
| 雙擊沒反應、無錯誤訊息 | --windowed 下程式在某處炸了 |
先拿掉 --windowed 看 traceback,或改用 logging 寫檔 |
| exe 體積過大 | 虛擬環境帶了多餘套件 | 在乾淨 venv 重打包,或 --exclude-module 排除 |
| Qt platform plugin 載入失敗 | 罕見,hook 沒收到 platforms | 確認用較新版 PyInstaller,必要時手動 --add-data 收 plugins |
成品檢查清單
打包完別只在自己機器上點一下就交差。資源路徑、windowed 副作用這些坑,常常要到一台「沒裝 Python 的乾淨電腦」上才會現形:
- 在沒有 Python 環境的電腦上能正常啟動
- 圖示、樣式表、設定檔都正確載入(這項直接驗證
_MEIPASS有沒有接對) - 所有功能跑一遍,特別是會讀寫檔案的部分
- windowed 模式下沒有因為
print/ stdout 而閃退 - exe 體積在合理範圍,沒帶多餘套件
寫在最後
PyInstaller 打包 PySide6,真正會卡住人的不是基本指令,而是 onefile 後資源路徑變了、以及 --windowed 砍掉 stdout 這兩件「開發時看不到、換台電腦才爆」的事。把 resource_path 函式建好、所有資源讀取都走它,再記得 --add-data 的目的地要跟 resource_path 算出來的路徑對上,這條最常見的死路就通了。至於 hidden-import,先信任 PyInstaller 的 PySide6 hook,缺什麼再補什麼——預先塞一堆只是替自己製造更難查的問題。




