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
2
3
4
5
6
7
8
9
10
11
12
# 建立虛擬環境
python -m venv venv

# 啟動虛擬環境
# Windows
venv\Scripts\activate
# Linux/Mac
source venv/bin/activate

# 只裝這個專案真正需要的套件
pip install -r requirements.txt
pip install pyinstaller

裝完先確認 pip list 裡只有你預期的東西。這一步偷懶,後面 debug exe 體積或缺檔會花更多時間。

核心坑:onefile 後找不到資源檔

這是 PyInstaller 打包 GUI 最常見、也最容易讓人摸不著頭緒的問題。開發時你大概是這樣讀資源檔的:

1
2
3
4
from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent
style_file = BASE_DIR / 'styles.json'

開發環境下這沒問題,__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
2
3
4
5
6
7
8
9
10
11
12
import sys
import os

def resource_path(relative_path):
"""取得資源的絕對路徑,開發環境和 PyInstaller 打包後都適用"""
try:
# 打包後 PyInstaller 把 base path 存在 sys._MEIPASS
base_path = sys._MEIPASS
except AttributeError:
# 沒打包時就用當前工作目錄
base_path = os.path.abspath('.')
return os.path.join(base_path, relative_path)

也可以寫成顯式判斷 sys.frozen 的版本,意思一樣,可讀性更高一點:

1
2
3
4
5
6
7
8
9
10
11
import sys
import os

def resource_path(relative_path):
if getattr(sys, 'frozen', False):
# 打包執行:用 bootloader 設定的 _MEIPASS
base_path = getattr(sys, '_MEIPASS', os.path.abspath('.'))
else:
# 開發執行:以本檔所在目錄為基準
base_path = os.path.dirname(os.path.abspath(__file__))
return os.path.join(base_path, relative_path)

接著把所有讀資源檔的地方都改成走這個函式:

1
2
3
4
5
6
# 原本(打包後會壞)
style_file = BASE_DIR / 'styles.json'

# 改成
style_file = resource_path('styles.json')
icon_path = resource_path('icon.ico')

關鍵在於:**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
2
3
4
5
6
7
pyinstaller --name="HexoEditor" ^
--windowed ^
--icon=icon.ico ^
--add-data="styles.json;." ^
--add-data="category_config.json;." ^
--add-data="tag_config.json;." ^
main.py

幾個參數的作用:

  • --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.stdinsys.stdoutsys.stderr 全部會是 None 如果你的程式裡某個地方直接 print()、或某個函式庫往 sys.stdout 寫東西,在開發時跑得好好的,打包成 windowed 後可能因為對 None 做操作而拋例外。我自己就吃過這種苦——開發時 print 一切正常,打包後雙擊沒反應,因為某段背景邏輯 print log 直接炸了,而且因為沒 console,連錯誤訊息都看不到。

兩個務實的應對:

1
2
3
4
5
6
7
8
9
10
11
# 1. debug 階段先拿掉 --windowed,留著 console 看 traceback
# 確認程式跑得起來、再加回 --windowed 出正式版

# 2. 程式裡別假設 stdout 一定存在,用 logging 寫檔取代 print
import logging
logging.basicConfig(
filename='app.log',
level=logging.INFO,
format='%(asctime)s %(levelname)s %(message)s',
)
logging.info('啟動完成') # 不依賴 stdout,windowed 模式也安全

把 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,缺什麼再補什麼——預先塞一堆只是替自己製造更難查的問題。

參考資料