แผนการเรียน
/

OOP & Design Patterns
ใน PySide6 (Qt)

การประยุกต์ใช้ SOLID Principles กับการพัฒนา Desktop GUI สมัยใหม่

🎯 เป้าหมาย: เข้าใจสถาปัตยกรรมของ Qt และเขียน GUI ที่ดูแลรักษาได้ง่าย

ทำไมต้อง OOP ใน GUI Development?

แบบ Procedural / Script

  • ประกาศตัวแปรปุ่ม, กล่องข้อความ, หน้าต่าง กองรวมกันในไฟล์เดียว
  • การจัดการ State (สถานะ) ของหน้าจอทำได้ยากเมื่อแอปซับซ้อน
  • Spaghetti Code: ฟังก์ชัน Event Handler พันกันยุ่งเหยิง

แบบ OOP (PySide6)

  • Everything is a Widget: ทุกส่วนประกอบคือ Object ที่จัดการตัวเอง
  • Encapsulation: ซ่อน Logic การแสดงผลไว้ในคลาส
  • Inheritance: สร้าง Widget ใหม่โดยสืบทอดคุณสมบัติเดิม (เช่น ปุ่มที่มีสีพิเศษ)

สถาปัตยกรรมของ Qt Framework

PySide6 ไม่ได้เป็นแค่ Library แต่มันคือ Framework ที่บังคับใช้ Design Patterns หลายอย่าง

Composite Pattern

Widgets ถูกจัดเรียงเป็นต้นไม้ (Tree Hierarchy) มี Parent และ Children (เช่น หน้าต่างมีปุ่ม, ปุ่มมีไอคอน)

Observer Pattern

ใช้ระบบ Signals & Slots ในการสื่อสารระหว่าง Object โดยไม่ผูกมัดกันแน่น (Decoupling)

Event-Driven

โปรแกรมทำงานผ่าน Event Loop (app.exec()) รอรับเหตุการณ์จากผู้ใช้

S - Single Responsibility

"แยกส่วนแสดงผล (View) ออกจากตรรกะทางธุรกิจ (Business Logic)"

❌ ผิด (God Class)
class MainWindow(QMainWindow):
  def login(self):
    # เชื่อมต่อ Database ในนี้
    # ตรวจสอบ Password ในนี้
    # เปลี่ยนหน้าจอในนี้

ถ้าเปลี่ยน DB ต้องแก้ไฟล์ UI

✅ ถูก (Separation)
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()

O - Open/Closed Principle

"ขยายความสามารถของ 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

L - Liskov Substitution

"Custom Widget ของคุณต้องทำงานแทนที่ Widget แม่ได้โดย Layout ไม่พัง"

Layout System

QVBoxLayout.addWidget(widget) รับพารามิเตอร์เป็น QWidget

ถ้าคุณสร้างคลาส MyChart แต่ไม่ได้สืบทอดจาก QWidget คุณจะนำมันไปใส่ใน Layout ไม่ได้

✅ class MyChart(QWidget): ...

D - Dependency Inversion

Loose Coupling ด้วย Signals & Slots

ปุ่มกด (Button) ไม่ควรรู้จักกับฟังก์ชันที่จะทำงาน (Slot) โดยตรง แต่ควรสื่อสารผ่าน "สัญญาณ" (Signal)

Hard Dependency
class Button:
  def click(self):
    # ต้องรู้จัก database_save
    database_save()
Signal (Abstraction)
class Button(QPushButton):
  # ไม่รู้ว่าใครจะมารับ signal นี้
  clicked.connect(any_func)

Design Pattern: Observer

หัวใจของการสื่อสารใน 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)

Composition > Inheritance

ในการสร้าง UI ที่ซับซ้อน เรามักจะ "ประกอบ" Widget เล็กๆ เข้าด้วยกัน แทนที่จะสืบทอดลึกๆ

Composite Widget

สร้างคลาส UserProfileWidget (สืบทอด QWidget) ที่ ประกอบด้วย (Has-A):

  • QLabel (รูปภาพ)
  • QLabel (ชื่อ)
  • QPushButton (ปุ่ม Logout)
🧩

Qt's MVC Architecture

Qt ใช้สถาปัตยกรรมแบบ Model/View เพื่อแยกข้อมูลออกจากการแสดงผล

Model

จัดการข้อมูล (Data) และโครงสร้าง (Structure) เช่น QAbstractTableModel

View

แสดงผลข้อมูลให้ผู้ใช้เห็น เช่น QTableView, QListView

Delegate

ควบคุมการวาด (Render) และการแก้ไข (Edit) ของแต่ละ Item

การสร้าง Custom Model

การสืบทอดจาก 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)

Design Pattern: Singleton

ใน GUI App เรามักมีสิ่งที่มีได้แค่อันเดียว เช่น QApplication หรือ ConfigurationManager

Global Access

ใช้ QApplication.instance() เพื่อเข้าถึงตัวแอปหลักจากที่ไหนก็ได้

# ใน PySide6, qApp เป็น Global Pointer ที่เข้าถึงได้เสมอ
from PySide6.QtWidgets import QApplication, qApp

def some_function():
    # ปิดโปรแกรมจากที่ไหนก็ได้
    qApp.quit()

Factory Pattern? (Qt Designer)

ไฟล์ .ui ที่ได้จาก Qt Designer จะถูกแปลงเป็น Python class ที่ทำหน้าที่เหมือน Factory สร้าง Widget ให้เรา

ui_mainwindow.py

คลาส Ui_MainWindow มีเมธอด setupUi(self, MainWindow)
ทำหน้าที่ Instantiate ปุ่ม, เมนู, เลย์เอาต์ ทั้งหมดและแปะลงไปในหน้าต่างหลัก

การจัดการ Threading

กฎเหล็ก: ห้ามทำงานหนัก (Long-running task) ใน GUI Thread (Main Thread) เพราะจะทำให้หน้าจอค้าง

QThread & Worker

ใช้ Pattern: Worker Object ย้ายไปอยู่บน QThread

thread = QThread()
worker = Worker()
worker.moveToThread(thread)
thread.started.connect(worker.run)
thread.start()

Event Filter & Chain of Responsibility

Event ใน Qt (เช่น เมาส์คลิก, ปุ่มกด) จะถูกส่งต่อกันเป็นลูกโซ่

เราสามารถ "ดักจับ" (Intercept) Event ก่อนที่มันจะไปถึง Widget เป้าหมายได้โดยใช้ installEventFilter

def eventFilter(self, obj, event):
  if event.type() == QEvent.KeyPress:
    # ดักจับการกดปุ่ม
    return True # หยุดโซ่ตรงนี้
  return super().eventFilter(obj, event)

Design Pattern: Command

ใน 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)

Base Dialog Class

ใช้ 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

โครงสร้างโปรเจกต์ (Best Practice)

my_app/
├── main.py # Entry point
├── views/ # UI Classes (Windows, Widgets)
│ ├── main_window.py
│ └── custom_widget.py
├── models/ # Data Classes / Qt Models
├── controllers/ # Logic / Signals connections
├── resources/ # Icons, Styles (.qss)
└── assets/

QSS: Decorating the UI

Qt Style Sheets (QSS) ช่วยให้เราแยกการตกแต่ง (Appearance) ออกจากโครงสร้างโค้ด (คล้าย CSS ในเว็บ)

ข้อดี

เปลี่ยน Theme ของแอปได้โดยไม่ต้องแก้โค้ด Python เลยแม้แต่บรรทัดเดียว (OCP)

QPushButton {
  background-color: #3498db;
  color: white;
  border-radius: 5px;
}
QPushButton:hover {
  background-color: #2980b9;
}

เตรียมตัวเข้าสู่ Lab Session

สิ่งที่เราจะสร้าง: Inventory Manager

  • สร้าง Product Model โดยสืบทอด QAbstractTableModel
  • สร้าง Main View ด้วย QTableView
  • ใช้ Input Widget แยกต่างหาก (Composition)
  • เชื่อมต่อด้วย Signals & Slots

Lab: Inventory Manager

สร้างโปรแกรมจัดการสินค้าโดยใช้สถาปัตยกรรม Model/View

ภารกิจวันนี้

1
สร้าง Data Model ที่กำหนดเอง (Custom Model)
2
ประกอบหน้าจอด้วย Layouts และ Widgets (Composition)
3
จัดการ Event ด้วย Signals & Slots

Step 1: เริ่มต้นโปรเจกต์

สร้างโครงสร้างไฟล์ดังนี้:

inventory_app/
├── main.py
├── model.py # เก็บข้อมูล
└── view.py # ส่วนแสดงผล

ติดตั้ง Library:

uv add pyside6
หรือ
pip install pyside6

Step 2: กำหนดโครงสร้างข้อมูล

ในไฟล์ 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
*ใช้ dataclass เพื่อลด boilerplate code (Python 3.7+)*

Step 3: สร้าง InventoryModel

ยังอยู่ใน 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]

Step 4: เมธอดสำหรับเพิ่มข้อมูล

เพิ่มเมธอด 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 จะไม่อัปเดต

Step 5: สร้าง Input Form (View)

แยกส่วนกรอกข้อมูลออกมาเป็น 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)

Step 6: รวมร่าง (Main Window)

สร้าง 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)

Step 7: เชื่อม Logic (Slot)

เพิ่มเมธอด 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)

Step 8: Entry Point (main.py)

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

Step 9: ตกแต่งด้วย CSS (QSS)

ลองเพิ่มโค้ดนี้ใน 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;
    }
""")

ภารกิจท้าทาย #1: Delete Feature

โจทย์:

  1. เพิ่มปุ่ม "Remove Selected" ใน ProductForm
  2. ใน MainWindow เชื่อมต่อปุ่มนี้กับเมธอดใหม่ handle_remove
  3. ดึงแถวที่เลือกจาก self.table.selectionModel().currentIndex()
  4. เพิ่มเมธอด remove_product(index) ใน InventoryModel (อย่าลืม beginRemoveRows)

ภารกิจท้าทาย #2: Search Filter

โจทย์: QSortFilterProxyModel

ใน Qt เราไม่ควรแก้ Model หลักเพื่อการค้นหา แต่ควรใช้ Proxy Model

  • สร้าง QSortFilterProxyModel ใน MainWindow
  • ตั้งค่า proxy.setSourceModel(self.model)
  • ตั้งค่า self.table.setModel(proxy) แทน
  • เพิ่มช่อง Search Bar แล้วต่อ Signal textChanged เข้ากับ proxy.setFilterFixedString

สรุปผลการเรียนรู้

Architectural Patterns

  • MVC: แยกข้อมูล (Model) ออกจากหน้าจอ (View) อย่างชัดเจน
  • Observer: Signals & Slots ช่วยลดการผูกมัด (Decoupling)
  • Composition: สร้าง UI ที่ซับซ้อนจาก Widget ย่อยๆ

SOLID Benefits

  • SRP: แก้ไข UI ไม่กระทบ Logic ข้อมูล
  • OCP: เพิ่มฟีเจอร์ด้วยการเพิ่ม Proxy หรือ Widget ใหม่ได้ง่าย
  • DIP: View ไม่ขึ้นกับ Model ที่เฉพาะเจาะจง (ผ่าน Abstract Interface)

เอกสารอ้างอิง

จบการนำเสนอ

"GUI ที่ดี ไม่ใช่แค่สวย แต่ต้องมีโครงสร้างที่ดีด้วย"

sys.exit(app.exec())