🎯 เป้าหมาย: เข้าใจสถาปัตยกรรมของ Qt และเขียน GUI ที่ดูแลรักษาได้ง่าย
PySide6 ไม่ได้เป็นแค่ Library แต่มันคือ Framework ที่บังคับใช้ Design Patterns หลายอย่าง
Widgets ถูกจัดเรียงเป็นต้นไม้ (Tree Hierarchy) มี Parent และ Children (เช่น หน้าต่างมีปุ่ม, ปุ่มมีไอคอน)
ใช้ระบบ Signals & Slots ในการสื่อสารระหว่าง Object โดยไม่ผูกมัดกันแน่น (Decoupling)
โปรแกรมทำงานผ่าน Event Loop (app.exec()) รอรับเหตุการณ์จากผู้ใช้
"แยกส่วนแสดงผล (View) ออกจากตรรกะทางธุรกิจ (Business Logic)"
class MainWindow(QMainWindow):
def login(self):
# เชื่อมต่อ Database ในนี้
# ตรวจสอบ Password ในนี้
# เปลี่ยนหน้าจอในนี้
ถ้าเปลี่ยน DB ต้องแก้ไฟล์ UI
class AuthService: # Logic
def verify(self, user, pass): ...
class LoginWindow(QMainWindow): # View
def on_login_click(self):
# เรียกใช้ AuthService
if self.auth_service.verify(...):
self.show_main()
"ขยายความสามารถของ Widget ด้วยการสืบทอด (Subclassing) แทนการแก้ไขโค้ดเดิม"
สมมติเราต้องการปุ่มที่กะพริบได้ (Blinking Button)
QPushButton (ทำไม่ได้และไม่ควรทำ)BlinkingButton ที่สืบทอดจาก QPushButton แล้วเพิ่ม Timer เข้าไปclass BlinkingButton(QPushButton): def __init__(self, text): super().__init__(text) self.timer = QTimer(self) self.timer.timeout.connect(self.toggle_color) self.timer.start(500) def toggle_color(self): # Logic เปลี่ยนสี... pass
"Custom Widget ของคุณต้องทำงานแทนที่ Widget แม่ได้โดย Layout ไม่พัง"
QVBoxLayout.addWidget(widget) รับพารามิเตอร์เป็น QWidget
MyChart แต่ไม่ได้สืบทอดจาก QWidget คุณจะนำมันไปใส่ใน Layout ไม่ได้ปุ่มกด (Button) ไม่ควรรู้จักกับฟังก์ชันที่จะทำงาน (Slot) โดยตรง แต่ควรสื่อสารผ่าน "สัญญาณ" (Signal)
class Button:
def click(self):
# ต้องรู้จัก database_save
database_save()
class Button(QPushButton): # ไม่รู้ว่าใครจะมารับ signal นี้ clicked.connect(any_func)
หัวใจของการสื่อสารใน Qt คือ Signal (Publisher) และ Slot (Subscriber)
from PySide6.QtCore import Signal, QObject class DataSensor(QObject): # 1. ประกาศ Signal data_changed = Signal(float) def read_value(self): new_val = 25.5 # 2. ปล่อยสัญญาณ (Notify Observers) self.data_changed.emit(new_val) # 3. เชื่อมต่อ (Subscribe) sensor = DataSensor() label = QLabel() sensor.data_changed.connect(label.setNum)
ในการสร้าง UI ที่ซับซ้อน เรามักจะ "ประกอบ" Widget เล็กๆ เข้าด้วยกัน แทนที่จะสืบทอดลึกๆ
สร้างคลาส UserProfileWidget (สืบทอด QWidget) ที่ ประกอบด้วย (Has-A):
Qt ใช้สถาปัตยกรรมแบบ Model/View เพื่อแยกข้อมูลออกจากการแสดงผล
จัดการข้อมูล (Data) และโครงสร้าง (Structure) เช่น QAbstractTableModel
แสดงผลข้อมูลให้ผู้ใช้เห็น เช่น QTableView, QListView
ควบคุมการวาด (Render) และการแก้ไข (Edit) ของแต่ละ Item
การสืบทอดจาก QAbstractListModel หรือ QAbstractTableModel ต้อง Override เมธอดสำคัญ:
class MyModel(QAbstractListModel): def data(self, index, role): # คืนค่าข้อมูลตาม role (DisplayRole, BackgroundRole ฯลฯ) if role == Qt.DisplayRole: return self.my_data[index.row()] def rowCount(self, index): return len(self.my_data)
ใน GUI App เรามักมีสิ่งที่มีได้แค่อันเดียว เช่น QApplication หรือ ConfigurationManager
ใช้ QApplication.instance() เพื่อเข้าถึงตัวแอปหลักจากที่ไหนก็ได้
# ใน PySide6, qApp เป็น Global Pointer ที่เข้าถึงได้เสมอ from PySide6.QtWidgets import QApplication, qApp def some_function(): # ปิดโปรแกรมจากที่ไหนก็ได้ qApp.quit()
ไฟล์ .ui ที่ได้จาก Qt Designer จะถูกแปลงเป็น Python class ที่ทำหน้าที่เหมือน Factory สร้าง Widget ให้เรา
คลาส Ui_MainWindow มีเมธอด setupUi(self, MainWindow)
ทำหน้าที่ Instantiate ปุ่ม, เมนู, เลย์เอาต์ ทั้งหมดและแปะลงไปในหน้าต่างหลัก
ใช้ Pattern: Worker Object ย้ายไปอยู่บน QThread
thread = QThread() worker = Worker() worker.moveToThread(thread) thread.started.connect(worker.run) thread.start()
Event ใน Qt (เช่น เมาส์คลิก, ปุ่มกด) จะถูกส่งต่อกันเป็นลูกโซ่
เราสามารถ "ดักจับ" (Intercept) Event ก่อนที่มันจะไปถึง Widget เป้าหมายได้โดยใช้ installEventFilter
def eventFilter(self, obj, event):
if event.type() == QEvent.KeyPress:
# ดักจับการกดปุ่ม
return True # หยุดโซ่ตรงนี้
return super().eventFilter(obj, event)
ใน Qt, QAction คือการนำ Command Pattern มาใช้
QAction เป็น Object ที่ห่อหุ้ม "คำสั่ง" (เช่น Save, Copy) เอาไว้ สามารถนำไปแปะไว้ที่ Menu Bar, Tool Bar หรือ Context Menu ได้พร้อมกัน โดยใช้ Logic เดียวกัน
save_action = QAction("Save", self)
save_action.setShortcut("Ctrl+S")
save_action.triggered.connect(self.save_file)
# ใช้ Action เดียวกันในหลายที่
file_menu.addAction(save_action)
toolbar.addAction(save_action)
ใช้ Inheritance สร้าง Template สำหรับ Dialog
class BaseEditorDialog(QDialog): def __init__(self): super().__init__() self.setup_common_ui() # ปุ่ม OK/Cancel def accept(self): if self.validate(): # Template Method Pattern super().accept() def validate(self): raise NotImplementedError
Qt Style Sheets (QSS) ช่วยให้เราแยกการตกแต่ง (Appearance) ออกจากโครงสร้างโค้ด (คล้าย CSS ในเว็บ)
เปลี่ยน Theme ของแอปได้โดยไม่ต้องแก้โค้ด Python เลยแม้แต่บรรทัดเดียว (OCP)
QAbstractTableModelQTableViewสร้างโครงสร้างไฟล์ดังนี้:
ติดตั้ง Library:
uv add pyside6pip install pyside6
ในไฟล์ model.py สร้างคลาส Product เพื่อเป็นตัวแทนข้อมูล (DTO)
from dataclasses import dataclass @dataclass class Product: name: str price: float quantity: int def total_value(self): return self.price * self.quantity
ยังอยู่ใน model.py สร้าง Model สำหรับ Qt Table View
from PySide6.QtCore import Qt, QAbstractTableModel class InventoryModel(QAbstractTableModel): def __init__(self, products=None): super().__init__() self.products = products or [] self.headers = ["Name", "Price", "Qty", "Total"] def rowCount(self, index): return len(self.products) def columnCount(self, index): return len(self.headers) def data(self, index, role): if role == Qt.DisplayRole: product = self.products[index.row()] col = index.column() if col == 0: return product.name if col == 1: return f"{product.price:.2f}" if col == 2: return str(product.quantity) if col == 3: return f"{product.total_value():.2f}" def headerData(self, section, orientation, role): if role == Qt.DisplayRole and orientation == Qt.Horizontal: return self.headers[section]
เพิ่มเมธอด add_product ใน InventoryModel เพื่อให้ View อัปเดตอัตโนมัติ (Observer Pattern)
def add_product(self, product): # แจ้ง View ว่ากำลังจะเพิ่มแถวใหม่ 1 แถว self.beginInsertRows(self.index(0, 0), self.rowCount(None), self.rowCount(None)) self.products.append(product) # แจ้ง View ว่าเพิ่มเสร็จแล้ว (View จะวาดใหม่เอง) self.endInsertRows()
beginInsertRows และ endInsertRows เสมอ ไม่งั้น Table จะไม่อัปเดต
แยกส่วนกรอกข้อมูลออกมาเป็น Widget อิสระ (SRP & Composition) ใน view.py
from PySide6.QtWidgets import QWidget, QFormLayout, QLineEdit, QSpinBox, QDoubleSpinBox, QPushButton, QVBoxLayout class ProductForm(QWidget): def __init__(self): super().__init__() layout = QFormLayout(self) self.name_input = QLineEdit() self.price_input = QDoubleSpinBox() self.price_input.setMaximum(100000) self.qty_input = QSpinBox() self.add_btn = QPushButton("Add Product") layout.addRow("Name:", self.name_input) layout.addRow("Price:", self.price_input) layout.addRow("Quantity:", self.qty_input) layout.addRow(self.add_btn)
สร้าง MainWindow ใน view.py และนำ Model มาเชื่อมกับ View
from PySide6.QtWidgets import QMainWindow, QTableView, QVBoxLayout, QWidget from model import InventoryModel, Product class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("Inventory Manager") # 1. Setup Model self.model = InventoryModel() # 2. Setup Views (Composition) self.table = QTableView() self.table.setModel(self.model) # Binding self.form = ProductForm() # 3. Layout container = QWidget() layout = QVBoxLayout(container) layout.addWidget(self.table) layout.addWidget(self.form) self.setCentralWidget(container) # 4. Signals self.form.add_btn.clicked.connect(self.handle_add)
เพิ่มเมธอด handle_add ใน MainWindow เพื่อรับค่าจาก Form และส่งเข้า Model
def handle_add(self): name = self.form.name_input.text() price = self.form.price_input.value() qty = self.form.qty_input.value() if name: new_product = Product(name, price, qty) self.model.add_product(new_product) # Clear inputs self.form.name_input.clear() self.form.price_input.setValue(0) self.form.qty_input.setValue(0)
import sys from PySide6.QtWidgets import QApplication from view import MainWindow if __name__ == "__main__": app = QApplication(sys.argv) window = MainWindow() window.show() sys.exit(app.exec())
รันคำสั่ง:
python main.py
ลองเพิ่มโค้ดนี้ใน main.py ก่อน window.show()
app.setStyleSheet("""
QMainWindow { background-color: #2c3e50; }
QLabel { color: #ecf0f1; font-size: 14px; }
QLineEdit, QSpinBox, QDoubleSpinBox {
padding: 5px;
border-radius: 4px;
border: 1px solid #bdc3c7;
}
QPushButton {
background-color: #27ae60;
color: white;
padding: 8px;
border-radius: 4px;
font-weight: bold;
}
QPushButton:hover { background-color: #2ecc71; }
QTableView {
background-color: white;
alternate-background-color: #ecf0f1;
selection-background-color: #3498db;
}
""")
ProductFormMainWindow เชื่อมต่อปุ่มนี้กับเมธอดใหม่ handle_removeself.table.selectionModel().currentIndex()remove_product(index) ใน InventoryModel (อย่าลืม beginRemoveRows)ใน Qt เราไม่ควรแก้ Model หลักเพื่อการค้นหา แต่ควรใช้ Proxy Model
QSortFilterProxyModel ใน MainWindowproxy.setSourceModel(self.model)self.table.setModel(proxy) แทนtextChanged เข้ากับ proxy.setFilterFixedString"GUI ที่ดี ไม่ใช่แค่สวย แต่ต้องมีโครงสร้างที่ดีด้วย"
sys.exit(app.exec())