Xây dựng ứng dụng Desktop chuyên nghiệp với Python và PySide6: Từ Qt Designer đến xử lý sự kiện nâng cao

Python tutorial - IT technology blog
Python tutorial - IT technology blog

Tại sao chọn PySide6 thay vì Tkinter hay PyQt5?

Dự án automation của mình ban đầu chỉ 200 dòng — một script nhỏ chạy trên terminal là đủ. Nhưng sau 6 tháng, nó phình lên 2000 dòng, khách hàng muốn có giao diện, muốn click button thay vì gõ lệnh. Mình thử Tkinter trước — nhanh nhưng giao diện trông như Windows 98. Sau đó chuyển sang PySide6 và không nhìn lại nữa.

PySide6 là binding chính thức của Qt 6 cho Python, được The Qt Company phát triển và cấp phép LGPL — nghĩa là bạn có thể dùng cho dự án thương mại miễn phí. So với PyQt5 (PyQt6), PySide6 có license thoải mái hơn và API tương đương. So với Tkinter, nó có widget hiện đại, hỗ trợ CSS-like styling, và quan trọng nhất: có Qt Designer — công cụ kéo thả thiết kế UI trực quan.

Sau 6 tháng dùng thực tế, đây là những gì mình học được về cách tổ chức một ứng dụng PySide6 đúng cách từ đầu.

Cài đặt môi trường

Bắt đầu với virtualenv để tránh conflict với các project khác:

python -m venv venv
source venv/bin/activate  # Linux/macOS
# hoặc: venv\Scripts\activate  # Windows

pip install PySide6

Lệnh trên cài toàn bộ Qt 6 binding bao gồm Qt Designer, Qt Quick, và các module cần thiết. Dung lượng khoảng 150MB — lần đầu hơi lâu, nhưng chỉ cần làm một lần.

Kiểm tra cài đặt thành công:

python -c "import PySide6; print(PySide6.__version__)"

Khởi động Qt Designer ngay từ terminal:

pyside6-designer

Thiết kế giao diện với Qt Designer

Qt Designer là điểm mình thấy PySide6 vượt trội hẳn các thư viện GUI Python khác. Thay vì viết code layout thủ công, bạn kéo thả widget, set property, rồi save thành file .ui (thực chất là XML). Mình mất khoảng 2 buổi làm quen với Designer — sau đó năng suất tăng hẳn.

Tạo file .ui đầu tiên

Trong Designer, chọn File → New → Main Window. Kéo các widget từ panel bên trái vào canvas:

  • QLabel — hiển thị text tĩnh
  • QLineEdit — input field
  • QPushButton — nút bấm
  • QTextEdit — vùng text nhiều dòng

Đặt tên objectName cho từng widget trong panel Properties — đây là tên bạn sẽ dùng trong code Python sau này. Đặt tên có nghĩa: btn_submit, input_username, label_status.

Save file thành main_window.ui.

Convert .ui sang Python

Có hai cách load file .ui trong code:

Cách 1: Load trực tiếp lúc runtime (tiện cho development):

from PySide6.QtWidgets import QApplication, QMainWindow
from PySide6.QtUiTools import QUiLoader
from PySide6.QtCore import QFile
import sys

loader = QUiLoader()
file = QFile("main_window.ui")
file.open(QFile.ReadOnly)
window = loader.load(file)
file.close()
window.show()

Cách 2: Convert sang Python class (khuyến nghị cho production):

pyside6-uic main_window.ui -o ui_main_window.py

File ui_main_window.py sinh ra chứa class Ui_MainWindow mà bạn có thể import và kế thừa. Mình dùng cách 2 vì nó cho phép IDE autocomplete tên widget, giảm bug do gõ sai tên.

Cấu trúc code production-ready

Đây là bài học đắt giá nhất sau 6 tháng: đừng nhồi hết logic vào một class. Khi project từ 200 lên 2000 dòng, cấu trúc flat sẽ biến thành cơn ác mộng. Mình tổ chức theo pattern Model-View-Controller đơn giản hóa:

myapp/
├── main.py              # Entry point
├── ui/
│   ├── main_window.ui   # Qt Designer file
│   └── ui_main_window.py # Auto-generated, KHÔNG sửa tay
├── views/
│   └── main_window.py   # Controller: kết nối UI với logic
└── core/
    └── processor.py     # Business logic thuần Python

File views/main_window.py — nơi kết nối mọi thứ

from PySide6.QtWidgets import QMainWindow, QMessageBox
from PySide6.QtCore import Qt, QThread, Signal
from ui.ui_main_window import Ui_MainWindow
from core.processor import DataProcessor


class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.ui = Ui_MainWindow()
        self.ui.setupUi(self)
        self.processor = DataProcessor()
        self._connect_signals()

    def _connect_signals(self):
        # Kết nối signal với slot — đây là trái tim của Qt
        self.ui.btn_submit.clicked.connect(self._on_submit_clicked)
        self.ui.input_username.returnPressed.connect(self._on_submit_clicked)

    def _on_submit_clicked(self):
        username = self.ui.input_username.text().strip()
        if not username:
            QMessageBox.warning(self, "Lỗi", "Vui lòng nhập tên người dùng")
            return
        result = self.processor.process(username)
        self.ui.label_status.setText(f"Kết quả: {result}")

Xử lý tác vụ nặng với QThread

Vấn đề lớn nhất khi mới dùng Qt: chạy tác vụ nặng (gọi API, đọc file lớn) trực tiếp trong slot sẽ freeze toàn bộ UI. Giải pháp là QThread:

from PySide6.QtCore import QThread, Signal


class WorkerThread(QThread):
    # Signal để gửi kết quả về main thread
    result_ready = Signal(str)
    error_occurred = Signal(str)

    def __init__(self, username: str):
        super().__init__()
        self.username = username

    def run(self):
        # Chạy trong thread riêng — KHÔNG được cập nhật UI ở đây
        try:
            import time
            time.sleep(2)  # Giả lập tác vụ nặng
            result = f"Processed: {self.username.upper()}"
            self.result_ready.emit(result)
        except Exception as e:
            self.error_occurred.emit(str(e))


# Trong MainWindow._on_submit_clicked():
def _on_submit_clicked(self):
    username = self.ui.input_username.text().strip()
    self.ui.btn_submit.setEnabled(False)  # Disable để tránh click đúp
    self.ui.label_status.setText("Đang xử lý...")

    self.worker = WorkerThread(username)
    self.worker.result_ready.connect(self._on_result_ready)
    self.worker.error_occurred.connect(self._on_error)
    self.worker.finished.connect(lambda: self.ui.btn_submit.setEnabled(True))
    self.worker.start()

def _on_result_ready(self, result: str):
    self.ui.label_status.setText(result)

def _on_error(self, error: str):
    QMessageBox.critical(self, "Lỗi", error)

Lưu ý quan trọng: luôn giữ reference đến worker thread (self.worker) — nếu để là local variable, Python garbage collector sẽ destroy thread trước khi nó chạy xong.

Styling với QSS — CSS của Qt

Qt hỗ trợ stylesheet tương tự CSS, gọi là QSS. Mình thường load từ file riêng để dễ chỉnh sửa:

# Trong main.py
app = QApplication(sys.argv)
with open("style.qss", "r") as f:
    app.setStyleSheet(f.read())
/* style.qss */
QPushButton {
    background-color: #2563eb;
    color: white;
    border: none;
    padding: 8px 16px;
    border-radius: 4px;
    font-size: 14px;
}

QPushButton:hover {
    background-color: #1d4ed8;
}

QPushButton:disabled {
    background-color: #9ca3af;
}

QLineEdit {
    border: 1px solid #d1d5db;
    border-radius: 4px;
    padding: 6px 10px;
    font-size: 14px;
}

Kiểm tra và đóng gói ứng dụng

Chạy ứng dụng

# main.py
import sys
from PySide6.QtWidgets import QApplication
from views.main_window import MainWindow


def main():
    app = QApplication(sys.argv)
    app.setApplicationName("MyApp")
    app.setApplicationVersion("1.0.0")
    window = MainWindow()
    window.show()
    sys.exit(app.exec())


if __name__ == "__main__":
    main()

Đóng gói thành file .exe hoặc binary

Dùng pyinstaller để đóng gói:

pip install pyinstaller

# Đóng gói thành một file duy nhất
pyinstaller --onefile --windowed \
    --add-data "ui/*.ui:ui" \
    --add-data "style.qss:." \
    main.py

File output nằm trong thư mục dist/. Trên Windows ra .exe, Linux ra binary không extension, macOS ra .app.

Một số vấn đề thường gặp

  • UI freeze: Tác vụ nặng trong main thread → dùng QThread như mình hướng dẫn ở trên
  • Widget không cập nhật: Quên emit signal hoặc cập nhật UI từ thread phụ → chỉ cập nhật UI từ main thread qua signal
  • File .ui không tìm thấy sau khi đóng gói: Phải thêm --add-data vào lệnh pyinstaller, hoặc convert sang Python class (cách 2 ở trên) để tránh vấn đề này hoàn toàn
  • Memory leak: Tạo widget không có parent → Qt không tự cleanup. Luôn truyền parent=self khi tạo widget con

Tổng kết

Sau 6 tháng dùng PySide6, mình đúc kết lại: điểm mạnh nhất của nó là hệ thống Signal/Slot — một khi đã hiểu cơ chế này, mọi tương tác UI đều trở nên rõ ràng và có thể test được. Qt Designer giúp tiết kiệm thời gian thiết kế layout đáng kể so với viết code thủ công.

Nếu project của bạn cần GUI chạy cross-platform, có styling đẹp, và xử lý được tác vụ nặng mà không freeze — PySide6 là lựa chọn solid. Cấu trúc code theo pattern tách biệt UI/Logic/Core từ đầu sẽ giúp bạn không phải refactor lại toàn bộ khi project lớn dần.

Share: