Python 系列
EP.03

Tkinter GUI 開發
Python 桌面應用

事件驅動設計、Grid 佈局、與 Raspberry Pi 自動化測試的實際整合

Joseph Chen

2026
10 min read
工廠測試實戰

在 SCT 做測試工程師時,第一個任務是把原本靠人工操作的測試流程包成一個 GUI。用 Tkinter 做出第一個視窗,讓測試員點一個按鈕就能跑完整個流程——那種成就感到現在都記得。

Tkinter 是什麼

Tkinter 是 Python 內建的 GUI 函式庫,安裝 Python 就有,不需要額外 pip install。它基於 Tk GUI toolkit,跨平台支援 Windows / macOS / Linux。

適合場景

快速開發內部工具、測試介面、自動化控制台、小型桌面應用

最大優勢

零額外依賴,Python 安裝就能跑。嵌入式環境(Raspberry Pi)的首選

⚠️

限制

外觀較老舊,複雜 UI 建議用 PyQt6 或 customtkinter,商業 App 不適合

基本視窗架構

所有 Tkinter 程式的骨架都一樣:建立 root window → 加入 Widget → 呼叫 mainloop() 進入事件迴圈。

basic_window.py
import tkinter as tk
from tkinter import ttk  # themed widgets(外觀更好看)

# 建立主視窗
root = tk.Tk()
root.title("自動化測試工具 v1.0")
root.geometry("600x400")       # 寬x高(像素)
root.resizable(True, True)     # 允許調整大小
root.minsize(400, 300)         # 最小視窗大小

# 設定視窗背景色
root.configure(bg="#f0f0f0")

# 加入一個標籤
label = ttk.Label(root, text="歡迎使用自動化測試系統", font=("Arial", 16, "bold"))
label.pack(pady=20)

# 進入事件迴圈(程式從這裡開始「等待使用者操作」)
# mainloop() 會一直執行,直到視窗被關閉
root.mainloop()
print("視窗已關閉,程式結束")  # mainloop() 結束後才執行到這裡

四大佈局管理器

Tkinter 有三種佈局方式(加上 Frame 容器概念共四種)。實際開發幾乎只用 grid,它最靈活且可預測。

佈局原理適合場景推薦度
pack()由上到下或左到右依序堆疊簡單的垂直/水平排列★★☆☆☆
grid()表格定位,指定 row 和 column複雜表單、控制面板★★★★★
place()絕對像素座標定位需要精確位置的特殊需求★★☆☆☆
grid_layout.py
import tkinter as tk
from tkinter import ttk

root = tk.Tk()
root.title("Grid 佈局示範")

# grid(row, column, columnspan, sticky, padx, pady)
# sticky: "nsew" 讓 widget 填滿格子(N/S/E/W 方向延伸)

ttk.Label(root, text="測試項目:").grid(row=0, column=0, sticky="w", padx=10, pady=5)
ttk.Combobox(root, values=["燒機測試", "I/O 測試", "RF 測試"]).grid(row=0, column=1, columnspan=2, sticky="ew", padx=10)

ttk.Label(root, text="序號:").grid(row=1, column=0, sticky="w", padx=10, pady=5)
ttk.Entry(root).grid(row=1, column=1, sticky="ew", padx=10)
ttk.Button(root, text="掃描").grid(row=1, column=2, padx=5)

# 讓第 1 欄自動延展(填滿剩餘空間)
root.columnconfigure(1, weight=1)

root.mainloop()

常用 Widget 速查

Widget用途關鍵用法
ttk.Label顯示文字或圖片text="..." / textvariable=var
ttk.Button按鈕(觸發函數)command=my_func
ttk.Entry單行文字輸入框.get() 取值 / .delete(0, END)
tk.Text多行文字輸入/輸出.insert(END, text) / .get(1.0, END)
ttk.Combobox下拉選單values=[...] / .get() 取目前選項
ttk.Checkbutton勾選框variable=IntVar()
ttk.Radiobutton單選按鈕variable=var, value="A"
ttk.Frame容器(分區使用)可在裡面繼續用 grid/pack
tk.Scrollbar捲軸需與 Text/Listbox 連接
tk.Canvas繪圖畫布.create_line / create_rectangle
widgets_demo.py
import tkinter as tk
from tkinter import ttk

root = tk.Tk()

# StringVar:雙向綁定,Widget 更新 → 變數更新,程式設定 → Widget 自動更新
status_var = tk.StringVar(value="等待測試...")

# Label 綁定 StringVar
status_label = ttk.Label(root, textvariable=status_var, font=("Arial", 12))
status_label.pack(pady=10)

# 程式改變 StringVar,Label 自動更新(不需要手動 .config(text=...))
def update_status():
    status_var.set("測試執行中...")

ttk.Button(root, text="開始測試", command=update_status).pack()

# Combobox
combo_var = tk.StringVar()
combo = ttk.Combobox(root, textvariable=combo_var, values=["燒機測試", "I/O 測試"])
combo.set("燒機測試")  # 設定預設值
combo.pack(pady=5)

print(combo_var.get())  # 取目前選擇值

root.mainloop()

事件驅動設計

Tkinter 是事件驅動模型:程式在 mainloop() 裡等待使用者操作,有事件發生就呼叫對應的 callback 函數。理解這個模型是 GUI 開發的核心。

mainloop() 是什麼

mainloop() 是一個無限迴圈,持續監聽作業系統的事件佇列(滑鼠點擊、鍵盤輸入、視窗關閉等),有事件就分派給對應的 Widget 或 callback 處理。

command= vs bind()

command= 只用於 Button,綁定點擊事件。bind() 更通用,可以綁定任何事件(鍵盤、滑鼠移動、視窗大小改變)到任意 Widget,例如 widget.bind("<Return>", handler)。

多執行緒:最重要的陷阱

GUI 的 mainloop() 跑在主執行緒。若在主執行緒執行長時間任務(串口通訊、等待 I/O),mainloop() 無法處理事件,視窗會「凍結」。解法:把長任務放到 threading.Thread 裡,用 root.after() 或 Queue 回傳結果給主執行緒更新 UI。

實際範例:自動化測試 GUI

以下是在 SCT 類似場景的簡化版本:選擇測試項目 → 點開始 → 背景執行測試(不凍結 UI)→ 結果顯示在 Text widget。

test_gui.py
import tkinter as tk
from tkinter import ttk
import threading
import time
import queue

class TestGUI:
    def __init__(self, root):
        self.root = root
        self.root.title("自動化測試系統")
        self.root.geometry("600x480")
        self.result_queue = queue.Queue()

        self._build_ui()
        self._poll_queue()   # 開始定期檢查背景執行緒的結果

    def _build_ui(self):
        # 頂部:選擇測試項目
        frame_top = ttk.LabelFrame(self.root, text="測試設定", padding=10)
        frame_top.pack(fill="x", padx=15, pady=10)

        ttk.Label(frame_top, text="測試項目:").grid(row=0, column=0, sticky="w")
        self.combo = ttk.Combobox(frame_top, values=["燒機測試", "I/O 測試", "RF 測試"], width=20)
        self.combo.set("燒機測試")
        self.combo.grid(row=0, column=1, padx=10)

        # 開始按鈕
        self.btn_start = ttk.Button(frame_top, text="開始測試", command=self._start_test)
        self.btn_start.grid(row=0, column=2, padx=5)

        # 進度狀態
        self.status_var = tk.StringVar(value="等待測試...")
        ttk.Label(self.root, textvariable=self.status_var, foreground="gray").pack(anchor="w", padx=15)

        # 進度條
        self.progress = ttk.Progressbar(self.root, mode="indeterminate")
        self.progress.pack(fill="x", padx=15, pady=5)

        # 結果輸出區
        frame_result = ttk.LabelFrame(self.root, text="測試結果", padding=10)
        frame_result.pack(fill="both", expand=True, padx=15, pady=5)

        self.result_text = tk.Text(frame_result, height=12, font=("Courier", 10))
        scrollbar = ttk.Scrollbar(frame_result, command=self.result_text.yview)
        self.result_text.configure(yscrollcommand=scrollbar.set)
        self.result_text.pack(side="left", fill="both", expand=True)
        scrollbar.pack(side="right", fill="y")

    def _start_test(self):
        """點擊開始按鈕 → 在背景執行緒跑測試,避免凍結 UI"""
        test_name = self.combo.get()
        self.btn_start.config(state="disabled")
        self.progress.start(10)
        self.status_var.set(f"執行中:{test_name}...")
        self.result_text.delete(1.0, tk.END)

        # 把長任務交給背景執行緒
        thread = threading.Thread(target=self._run_test, args=(test_name,), daemon=True)
        thread.start()

    def _run_test(self, test_name):
        """背景執行緒:執行測試,結果放入 queue"""
        steps = ["初始化硬體...", "執行第 1 階段測試...", "執行第 2 階段測試...", "生成報告..."]
        for step in steps:
            time.sleep(0.8)  # 模擬耗時操作(實際是串口通訊等)
            self.result_queue.put(("log", step))
        self.result_queue.put(("done", f"[PASS] {test_name} 完成"))

    def _poll_queue(self):
        """主執行緒定期(每 100ms)檢查 queue,更新 UI"""
        try:
            while True:
                msg_type, msg = self.result_queue.get_nowait()
                self.result_text.insert(tk.END, msg + "\n")
                self.result_text.see(tk.END)
                if msg_type == "done":
                    self.progress.stop()
                    self.status_var.set("測試完成")
                    self.btn_start.config(state="normal")
        except queue.Empty:
            pass
        self.root.after(100, self._poll_queue)  # 100ms 後再次檢查

if __name__ == "__main__":
    root = tk.Tk()
    app = TestGUI(root)
    root.mainloop()

面試常考題

Q1. Tkinter 的 mainloop() 做什麼?

mainloop() 是一個持續運行的事件迴圈,不斷監聽作業系統送來的事件(點擊、鍵盤、計時器等),並分派給對應的 callback 函數處理。呼叫 mainloop() 後程式才「活起來」,關閉視窗後 mainloop() 才返回。

Q2. 如何避免 GUI 在執行長任務時凍結?

使用 threading.Thread 把長任務(I/O、計算、串口通訊)移到背景執行緒,主執行緒繼續跑 mainloop()。背景執行緒完成後透過 Queue + root.after() 回傳結果到主執行緒更新 UI。注意:絕對不能在背景執行緒直接操作 Tkinter Widget(非執行緒安全)。

Q3. StringVar 和普通變數有什麼差?

StringVar 是 Tkinter 的「可觀察變數」,與 Widget 雙向綁定。改變 StringVar 的值(.set()),綁定的 Label/Entry 會自動更新顯示;使用者在 Entry 輸入,StringVar 也會自動同步。普通 Python 變數改變後 Widget 不會自動更新,需要手動 .config(text=...)。

Q4. grid() 的 sticky 參數是什麼?

sticky 決定 Widget 在格子內如何對齊或延伸,使用 "n"(上)/"s"(下)/"e"(右)/"w"(左)或組合。sticky="ew" 讓 Widget 水平填滿格子;sticky="nsew" 讓 Widget 四個方向都填滿,通常配合 columnconfigure(weight=1) 使用。

Tkinter
Python
GUI
自動化測試
Raspberry Pi
EP.03