第37章 GUI高级开发
学习目标
完成本章学习后,你将能够:
- 掌握高级布局:嵌套布局、动态布局、响应式设计
- 创建自定义控件:复合控件、绘制控件、动画控件
- 实现样式定制:QSS样式表、主题切换、动态样式
- 处理多线程界面:信号槽机制、线程安全更新、工作线程
- 实现数据绑定:Model-View架构、数据同步、表单验证
- 开发复杂组件:表格编辑、树形视图、拖放功能
- 实现国际化和本地化:多语言支持、资源管理
- 构建企业级应用:插件系统、配置管理、日志集成
37.1 高级布局管理
37.1.1 布局系统原理
python
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QGridLayout,
QFormLayout, QStackedLayout, QSplitter, QTabWidget,
QMainWindow, QLabel, QPushButton, QLineEdit, QTextEdit,
QGroupBox, QScrollArea, QFrame, QSizePolicy
)
from PyQt6.QtCore import Qt, QSize, QRect, QPoint
from PyQt6.QtGui import QPainter, QColor, QFont
from typing import Optional, List, Dict, Any
from dataclasses import dataclass
from enum import Enum
class LayoutType(Enum):
VERTICAL = "vertical"
HORIZONTAL = "horizontal"
GRID = "grid"
FORM = "form"
STACKED = "stacked"
@dataclass
class LayoutConfig:
margin: int = 10
spacing: int = 5
stretch: int = 0
alignment: Qt.AlignmentFlag = Qt.AlignmentFlag.AlignTop
class ResponsiveLayout(QWidget):
def __init__(self, parent: Optional[QWidget] = None):
super().__init__(parent)
self._layouts: Dict[str, QWidget] = {}
self._breakpoints = {
"mobile": 600,
"tablet": 900,
"desktop": 1200
}
self._current_layout = "desktop"
def add_responsive_widget(
self,
widget: QWidget,
mobile_visible: bool = True,
tablet_visible: bool = True,
desktop_visible: bool = True
) -> None:
self._layouts[widget.objectName() or str(id(widget))] = {
"widget": widget,
"mobile_visible": mobile_visible,
"tablet_visible": tablet_visible,
"desktop_visible": desktop_visible
}
def resizeEvent(self, event) -> None:
super().resizeEvent(event)
width = self.width()
if width < self._breakpoints["mobile"]:
new_layout = "mobile"
elif width < self._breakpoints["tablet"]:
new_layout = "tablet"
else:
new_layout = "desktop"
if new_layout != self._current_layout:
self._current_layout = new_layout
self._update_visibility()
def _update_visibility(self) -> None:
for name, config in self._layouts.items():
widget = config["widget"]
if self._current_layout == "mobile":
widget.setVisible(config["mobile_visible"])
elif self._current_layout == "tablet":
widget.setVisible(config["tablet_visible"])
else:
widget.setVisible(config["desktop_visible"])
class FlowLayout(QWidget):
def __init__(self, parent: Optional[QWidget] = None, spacing: int = 10):
super().__init__(parent)
self._items: List[QWidget] = []
self._spacing = spacing
self._horizontal_spacing = spacing
self._vertical_spacing = spacing
def add_widget(self, widget: QWidget) -> None:
widget.setParent(self)
self._items.append(widget)
self.update_geometry()
def remove_widget(self, widget: QWidget) -> None:
if widget in self._items:
self._items.remove(widget)
widget.setParent(None)
self.update_geometry()
def clear(self) -> None:
for widget in self._items:
widget.setParent(None)
self._items.clear()
self.update_geometry()
def update_geometry(self) -> None:
if not self._items:
return
x = 0
y = 0
line_height = 0
width = self.width()
for widget in self._items:
size_hint = widget.sizeHint()
widget_width = size_hint.width()
widget_height = size_hint.height()
if x + widget_width > width and x > 0:
x = 0
y += line_height + self._vertical_spacing
line_height = 0
widget.setGeometry(x, y, widget_width, widget_height)
x += widget_width + self._horizontal_spacing
line_height = max(line_height, widget_height)
total_height = y + line_height
self.setMinimumHeight(total_height)
def resizeEvent(self, event) -> None:
super().resizeEvent(event)
self.update_geometry()
class BorderLayout(QWidget):
class Position(Enum):
NORTH = 0
SOUTH = 1
EAST = 2
WEST = 3
CENTER = 4
def __init__(self, parent: Optional[QWidget] = None, spacing: int = 5):
super().__init__(parent)
self._widgets: Dict[BorderLayout.Position, QWidget] = {}
self._spacing = spacing
def add_widget(self, widget: QWidget, position: Position) -> None:
if position in self._widgets:
self._widgets[position].setParent(None)
widget.setParent(self)
self._widgets[position] = widget
def remove_widget(self, position: Position) -> None:
if position in self._widgets:
self._widgets[position].setParent(None)
del self._widgets[position]
def resizeEvent(self, event) -> None:
super().resizeEvent(event)
self._update_layout()
def _update_layout(self) -> None:
rect = self.rect()
center_rect = QRect(rect)
if BorderLayout.Position.NORTH in self._widgets:
widget = self._widgets[BorderLayout.Position.NORTH]
size_hint = widget.sizeHint()
height = size_hint.height()
widget.setGeometry(rect.x(), rect.y(), rect.width(), height)
center_rect.setTop(center_rect.top() + height + self._spacing)
if BorderLayout.Position.SOUTH in self._widgets:
widget = self._widgets[BorderLayout.Position.SOUTH]
size_hint = widget.sizeHint()
height = size_hint.height()
widget.setGeometry(rect.x(), rect.bottom() - height, rect.width(), height)
center_rect.setBottom(center_rect.bottom() - height - self._spacing)
if BorderLayout.Position.WEST in self._widgets:
widget = self._widgets[BorderLayout.Position.WEST]
size_hint = widget.sizeHint()
width = size_hint.width()
widget.setGeometry(center_rect.x(), center_rect.y(), width, center_rect.height())
center_rect.setLeft(center_rect.left() + width + self._spacing)
if BorderLayout.Position.EAST in self._widgets:
widget = self._widgets[BorderLayout.Position.EAST]
size_hint = widget.sizeHint()
width = size_hint.width()
widget.setGeometry(center_rect.right() - width, center_rect.y(), width, center_rect.height())
center_rect.setRight(center_rect.right() - width - self._spacing)
if BorderLayout.Position.CENTER in self._widgets:
self._widgets[BorderLayout.Position.CENTER].setGeometry(center_rect)37.1.2 动态布局
python
class DynamicForm(QWidget):
def __init__(self, parent: Optional[QWidget] = None):
super().__init__(parent)
self._main_layout = QVBoxLayout(self)
self._form_layout = QFormLayout()
self._main_layout.addLayout(self._form_layout)
self._fields: Dict[str, QWidget] = {}
def add_field(
self,
name: str,
label: str,
widget_type: type = QLineEdit,
default_value: Any = None
) -> QWidget:
widget = widget_type()
if default_value is not None:
if isinstance(widget, QLineEdit):
widget.setText(str(default_value))
elif isinstance(widget, QTextEdit):
widget.setPlainText(str(default_value))
self._form_layout.addRow(label, widget)
self._fields[name] = widget
return widget
def remove_field(self, name: str) -> None:
if name in self._fields:
widget = self._fields[name]
self._form_layout.removeRow(widget)
del self._fields[name]
def get_value(self, name: str) -> Any:
if name not in self._fields:
return None
widget = self._fields[name]
if isinstance(widget, QLineEdit):
return widget.text()
elif isinstance(widget, QTextEdit):
return widget.toPlainText()
return None
def get_all_values(self) -> Dict[str, Any]:
return {name: self.get_value(name) for name in self._fields}
def set_value(self, name: str, value: Any) -> None:
if name not in self._fields:
return
widget = self._fields[name]
if isinstance(widget, QLineEdit):
widget.setText(str(value))
elif isinstance(widget, QTextEdit):
widget.setPlainText(str(value))
def clear_all(self) -> None:
for widget in self._fields.values():
if isinstance(widget, QLineEdit):
widget.clear()
elif isinstance(widget, QTextEdit):
widget.clear()
class CollapsibleSection(QWidget):
def __init__(self, title: str = "", parent: Optional[QWidget] = None):
super().__init__(parent)
self._is_collapsed = False
self._main_layout = QVBoxLayout(self)
self._main_layout.setContentsMargins(0, 0, 0, 0)
self._main_layout.setSpacing(0)
self._header = QPushButton(title)
self._header.setCheckable(True)
self._header.setChecked(True)
self._header.clicked.connect(self._toggle)
self._main_layout.addWidget(self._header)
self._content = QWidget()
self._content_layout = QVBoxLayout(self._content)
self._main_layout.addWidget(self._content)
def _toggle(self) -> None:
self._is_collapsed = not self._header.isChecked()
self._content.setVisible(not self._is_collapsed)
def add_widget(self, widget: QWidget) -> None:
self._content_layout.addWidget(widget)
def set_collapsed(self, collapsed: bool) -> None:
self._is_collapsed = collapsed
self._header.setChecked(not collapsed)
self._content.setVisible(not collapsed)
def is_collapsed(self) -> bool:
return self._is_collapsed
class TabManager(QWidget):
def __init__(self, parent: Optional[QWidget] = None):
super().__init__(parent)
self._main_layout = QVBoxLayout(self)
self._tab_widget = QTabWidget()
self._tab_widget.setTabsClosable(True)
self._tab_widget.tabCloseRequested.connect(self._close_tab)
self._main_layout.addWidget(self._tab_widget)
self._tabs: Dict[str, QWidget] = {}
def add_tab(self, name: str, title: str, widget: QWidget) -> int:
index = self._tab_widget.addTab(widget, title)
self._tabs[name] = widget
return index
def remove_tab(self, name: str) -> None:
if name in self._tabs:
widget = self._tabs[name]
index = self._tab_widget.indexOf(widget)
if index >= 0:
self._tab_widget.removeTab(index)
del self._tabs[name]
def get_tab(self, name: str) -> Optional[QWidget]:
return self._tabs.get(name)
def _close_tab(self, index: int) -> None:
widget = self._tab_widget.widget(index)
for name, w in list(self._tabs.items()):
if w == widget:
del self._tabs[name]
break
self._tab_widget.removeTab(index)
def clear_all(self) -> None:
self._tab_widget.clear()
self._tabs.clear()37.2 自定义控件
37.2.1 绘制控件
python
from PyQt6.QtGui import (
QPainter, QColor, QPen, QBrush, QFont, QFontMetrics,
QLinearGradient, QRadialGradient, QPainterPath, QPixmap
)
from PyQt6.QtCore import Qt, QTimer, pyqtSignal, QPointF, QRectF
class CircularProgress(QWidget):
valueChanged = pyqtSignal(int)
def __init__(self, parent: Optional[QWidget] = None):
super().__init__(parent)
self._value = 0
self._minimum = 0
self._maximum = 100
self._line_width = 8
self._progress_color = QColor(0, 150, 255)
self._background_color = QColor(200, 200, 200)
self._text_color = QColor(50, 50, 50)
self.setMinimumSize(100, 100)
def setValue(self, value: int) -> None:
self._value = max(self._minimum, min(self._maximum, value))
self.valueChanged.emit(self._value)
self.update()
def value(self) -> int:
return self._value
def setLineWidth(self, width: int) -> None:
self._line_width = width
self.update()
def setProgressColor(self, color: QColor) -> None:
self._progress_color = color
self.update()
def paintEvent(self, event) -> None:
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
rect = self.rect()
center = rect.center()
radius = min(rect.width(), rect.height()) // 2 - self._line_width
pen = QPen(self._background_color, self._line_width)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
painter.setPen(pen)
painter.drawEllipse(center, radius, radius)
if self._value > 0:
progress = (self._value - self._minimum) / (self._maximum - self._minimum)
angle = int(360 * progress * 16)
pen.setColor(self._progress_color)
painter.setPen(pen)
rect_arc = QRectF(
center.x() - radius,
center.y() - radius,
radius * 2,
radius * 2
)
painter.drawArc(rect_arc, 90 * 16, -angle)
painter.setPen(self._text_color)
font = QFont("Arial", radius // 3)
painter.setFont(font)
text = f"{self._value}%"
painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, text)
class ToggleSwitch(QWidget):
toggled = pyqtSignal(bool)
def __init__(self, parent: Optional[QWidget] = None):
super().__init__(parent)
self._checked = False
self._animation_offset = 0
self._track_color_on = QColor(0, 150, 255)
self._track_color_off = QColor(200, 200, 200)
self._thumb_color = QColor(255, 255, 255)
self.setFixedSize(50, 26)
self.setCursor(Qt.CursorShape.PointingHandCursor)
def isChecked(self) -> bool:
return self._checked
def setChecked(self, checked: bool) -> None:
if self._checked != checked:
self._checked = checked
self.toggled.emit(checked)
self.update()
def mousePressEvent(self, event) -> None:
self.setChecked(not self._checked)
def paintEvent(self, event) -> None:
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
track_color = self._track_color_on if self._checked else self._track_color_off
painter.setBrush(QBrush(track_color))
painter.setPen(Qt.PenStyle.NoPen)
painter.drawRoundedRect(0, 0, self.width(), self.height(), 13, 13)
thumb_radius = 10
thumb_x = self.width() - thumb_radius - 3 if self._checked else 3
thumb_y = (self.height() - thumb_radius * 2) // 2
painter.setBrush(QBrush(self._thumb_color))
painter.drawEllipse(thumb_x, thumb_y, thumb_radius * 2, thumb_radius * 2)
class RatingWidget(QWidget):
ratingChanged = pyqtSignal(int)
def __init__(self, max_rating: int = 5, parent: Optional[QWidget] = None):
super().__init__(parent)
self._rating = 0
self._max_rating = max_rating
self._star_size = 24
self._star_color = QColor(255, 180, 0)
self._empty_color = QColor(200, 200, 200)
self._hover_rating = -1
self.setMouseTracking(True)
self.setFixedHeight(self._star_size + 10)
def rating(self) -> int:
return self._rating
def setRating(self, rating: int) -> None:
self._rating = max(0, min(self._max_rating, rating))
self.ratingChanged.emit(self._rating)
self.update()
def sizeHint(self) -> QSize:
return QSize(self._star_size * self._max_rating + 10, self._star_size + 10)
def paintEvent(self, event) -> None:
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
for i in range(self._max_rating):
x = i * (self._star_size + 2) + 5
y = 5
if i < self._rating or (self._hover_rating >= 0 and i <= self._hover_rating):
color = self._star_color
else:
color = self._empty_color
self._draw_star(painter, x, y, self._star_size, color)
def _draw_star(self, painter: QPainter, x: int, y: int, size: int, color: QColor) -> None:
painter.setBrush(QBrush(color))
painter.setPen(Qt.PenStyle.NoPen)
path = QPainterPath()
points = []
center_x = x + size / 2
center_y = y + size / 2
outer_radius = size / 2
inner_radius = size / 4
for i in range(10):
angle = i * 36 - 90
radius = outer_radius if i % 2 == 0 else inner_radius
px = center_x + radius * (2 ** 0.5) * 0.5 * (1 if i % 2 == 0 else 0.5)
py = center_y + radius * (2 ** 0.5) * 0.5
painter.drawEllipse(int(x), int(y), size, size)
def mouseMoveEvent(self, event) -> None:
x = event.position().x()
star_width = self._star_size + 2
self._hover_rating = int((x - 5) / star_width)
self.update()
def mousePressEvent(self, event) -> None:
self.setRating(self._hover_rating + 1)
def leaveEvent(self, event) -> None:
self._hover_rating = -1
self.update()37.2.2 复合控件
python
class SearchBox(QWidget):
textChanged = pyqtSignal(str)
returnPressed = pyqtSignal(str)
def __init__(self, placeholder: str = "Search...", parent: Optional[QWidget] = None):
super().__init__(parent)
self._setup_ui(placeholder)
def _setup_ui(self, placeholder: str) -> None:
layout = QHBoxLayout(self)
layout.setContentsMargins(5, 2, 5, 2)
self._search_icon = QLabel("🔍")
layout.addWidget(self._search_icon)
self._line_edit = QLineEdit()
self._line_edit.setPlaceholderText(placeholder)
self._line_edit.textChanged.connect(self.textChanged.emit)
self._line_edit.returnPressed.connect(
lambda: self.returnPressed.emit(self._line_edit.text())
)
layout.addWidget(self._line_edit)
self._clear_button = QPushButton("×")
self._clear_button.setFixedSize(20, 20)
self._clear_button.clicked.connect(self.clear)
self._clear_button.setVisible(False)
layout.addWidget(self._clear_button)
self._line_edit.textChanged.connect(
lambda text: self._clear_button.setVisible(bool(text))
)
def text(self) -> str:
return self._line_edit.text()
def setText(self, text: str) -> None:
self._line_edit.setText(text)
def clear(self) -> None:
self._line_edit.clear()
class ColorPicker(QWidget):
colorChanged = pyqtSignal(QColor)
def __init__(self, initial_color: QColor = None, parent: Optional[QWidget] = None):
super().__init__(parent)
self._color = initial_color or QColor(255, 255, 255)
self._setup_ui()
def _setup_ui(self) -> None:
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
self._color_button = QPushButton()
self._color_button.setFixedSize(40, 40)
self._color_button.clicked.connect(self._pick_color)
self._update_button_color()
layout.addWidget(self._color_button)
self._color_label = QLabel(self._color.name())
layout.addWidget(self._color_label)
def _pick_color(self) -> None:
from PyQt6.QtWidgets import QColorDialog
color = QColorDialog.getColor(self._color, self)
if color.isValid():
self._color = color
self._update_button_color()
self._color_label.setText(color.name())
self.colorChanged.emit(color)
def _update_button_color(self) -> None:
self._color_button.setStyleSheet(
f"background-color: {self._color.name()}; border: 1px solid #999;"
)
def color(self) -> QColor:
return self._color
def setColor(self, color: QColor) -> None:
self._color = color
self._update_button_color()
self._color_label.setText(color.name())
class StatusBar(QWidget):
def __init__(self, parent: Optional[QWidget] = None):
super().__init__(parent)
self._setup_ui()
def _setup_ui(self) -> None:
self.setStyleSheet("""
QWidget {
background-color: #f0f0f0;
border-top: 1px solid #c0c0c0;
}
QLabel {
padding: 2px 8px;
}
""")
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
self._message_label = QLabel("Ready")
layout.addWidget(self._message_label)
layout.addStretch()
self._position_label = QLabel("Ln 1, Col 1")
layout.addWidget(self._position_label)
self._encoding_label = QLabel("UTF-8")
layout.addWidget(self._encoding_label)
def showMessage(self, message: str, timeout: int = 0) -> None:
self._message_label.setText(message)
if timeout > 0:
QTimer.singleShot(timeout, lambda: self._message_label.setText("Ready"))
def setPosition(self, line: int, column: int) -> None:
self._position_label.setText(f"Ln {line}, Col {column}")
def setEncoding(self, encoding: str) -> None:
self._encoding_label.setText(encoding)37.3 样式定制
37.3.1 QSS样式表
python
class StyleSheetManager:
DARK_THEME = """
QWidget {
background-color: #1e1e1e;
color: #d4d4d4;
font-family: 'Segoe UI', Arial, sans-serif;
font-size: 13px;
}
QMainWindow {
background-color: #1e1e1e;
}
QPushButton {
background-color: #0e639c;
color: #ffffff;
border: none;
padding: 6px 12px;
border-radius: 3px;
}
QPushButton:hover {
background-color: #1177bb;
}
QPushButton:pressed {
background-color: #0d5a8a;
}
QLineEdit, QTextEdit, QPlainTextEdit {
background-color: #252526;
color: #d4d4d4;
border: 1px solid #3c3c3c;
padding: 4px 8px;
border-radius: 3px;
}
QLineEdit:focus, QTextEdit:focus, QPlainTextEdit:focus {
border: 1px solid #0078d4;
}
QLabel {
color: #d4d4d4;
background: transparent;
}
QComboBox {
background-color: #3c3c3c;
color: #d4d4d4;
border: 1px solid #555555;
padding: 4px 8px;
border-radius: 3px;
}
QComboBox::drop-down {
border: none;
width: 20px;
}
QComboBox::down-arrow {
image: none;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 5px solid #d4d4d4;
}
QScrollBar:vertical {
background-color: #1e1e1e;
width: 12px;
border-radius: 6px;
}
QScrollBar::handle:vertical {
background-color: #424242;
border-radius: 6px;
min-height: 30px;
}
QScrollBar::handle:vertical:hover {
background-color: #4f4f4f;
}
QTableWidget, QTableView {
background-color: #1e1e1e;
alternate-background-color: #252526;
gridline-color: #3c3c3c;
}
QHeaderView::section {
background-color: #2d2d2d;
color: #d4d4d4;
padding: 4px;
border: 1px solid #3c3c3c;
}
QMenu {
background-color: #252526;
color: #d4d4d4;
border: 1px solid #3c3c3c;
}
QMenu::item:selected {
background-color: #094771;
}
QTabWidget::pane {
border: 1px solid #3c3c3c;
background-color: #1e1e1e;
}
QTabBar::tab {
background-color: #2d2d2d;
color: #969696;
padding: 6px 12px;
border: 1px solid #3c3c3c;
}
QTabBar::tab:selected {
background-color: #1e1e1e;
color: #d4d4d4;
border-bottom-color: #1e1e1e;
}
"""
LIGHT_THEME = """
QWidget {
background-color: #ffffff;
color: #333333;
font-family: 'Segoe UI', Arial, sans-serif;
font-size: 13px;
}
QMainWindow {
background-color: #f5f5f5;
}
QPushButton {
background-color: #0078d4;
color: #ffffff;
border: none;
padding: 6px 12px;
border-radius: 3px;
}
QPushButton:hover {
background-color: #106ebe;
}
QPushButton:pressed {
background-color: #005a9e;
}
QLineEdit, QTextEdit, QPlainTextEdit {
background-color: #ffffff;
color: #333333;
border: 1px solid #cccccc;
padding: 4px 8px;
border-radius: 3px;
}
QLineEdit:focus, QTextEdit:focus, QPlainTextEdit:focus {
border: 1px solid #0078d4;
}
QLabel {
color: #333333;
background: transparent;
}
"""
def __init__(self, app):
self._app = app
self._current_theme = "light"
def apply_theme(self, theme: str) -> None:
self._current_theme = theme
if theme == "dark":
self._app.setStyleSheet(self.DARK_THEME)
else:
self._app.setStyleSheet(self.LIGHT_THEME)
def current_theme(self) -> str:
return self._current_theme
def toggle_theme(self) -> None:
if self._current_theme == "dark":
self.apply_theme("light")
else:
self.apply_theme("dark")
class CustomStyle(QWidget):
def __init__(self, parent: Optional[QWidget] = None):
super().__init__(parent)
self._custom_properties: Dict[str, Any] = {}
def set_custom_property(self, name: str, value: Any) -> None:
self._custom_properties[name] = value
self.setStyleSheet(self._generate_stylesheet())
def _generate_stylesheet(self) -> str:
return ""
def apply_class_style(self, class_name: str, style: str) -> None:
current_style = self.styleSheet()
new_style = f".{class_name} {{ {style} }}"
self.setStyleSheet(current_style + new_style)
self.setProperty("class", class_name)
self.style().unpolish(self)
self.style().polish(self)37.4 多线程界面
37.4.1 工作线程
python
from PyQt6.QtCore import QThread, pyqtSignal, QMutex, QWaitCondition, QObject
class Worker(QThread):
started = pyqtSignal()
finished = pyqtSignal()
progress = pyqtSignal(int)
error = pyqtSignal(str)
result = pyqtSignal(object)
def __init__(self, parent: Optional[QObject] = None):
super().__init__(parent)
self._is_cancelled = False
self._is_paused = False
self._mutex = QMutex()
self._wait_condition = QWaitCondition()
def run(self) -> None:
self.started.emit()
try:
result = self.do_work()
if not self._is_cancelled:
self.result.emit(result)
except Exception as e:
self.error.emit(str(e))
finally:
self.finished.emit()
def do_work(self) -> Any:
return None
def cancel(self) -> None:
self._mutex.lock()
self._is_cancelled = True
self._mutex.unlock()
def is_cancelled(self) -> bool:
self._mutex.lock()
cancelled = self._is_cancelled
self._mutex.unlock()
return cancelled
def pause(self) -> None:
self._mutex.lock()
self._is_paused = True
self._mutex.unlock()
def resume(self) -> None:
self._mutex.lock()
self._is_paused = False
self._wait_condition.wakeAll()
self._mutex.unlock()
def check_paused(self) -> None:
self._mutex.lock()
while self._is_paused and not self._is_cancelled:
self._wait_condition.wait(self._mutex)
self._mutex.unlock()
class TaskWorker(Worker):
def __init__(self, task: callable, *args, **kwargs):
super().__init__()
self._task = task
self._args = args
self._kwargs = kwargs
def do_work(self) -> Any:
return self._task(*self._args, **self._kwargs)
class WorkerPool(QObject):
def __init__(self, max_workers: int = 4, parent: Optional[QObject] = None):
super().__init__(parent)
self._max_workers = max_workers
self._workers: List[Worker] = []
self._queue: List[tuple] = []
def submit(self, task: callable, *args, on_result: callable = None, **kwargs) -> Worker:
worker = TaskWorker(task, *args, **kwargs)
if on_result:
worker.result.connect(on_result)
worker.finished.connect(lambda: self._on_worker_finished(worker))
if len(self._workers) < self._max_workers:
self._workers.append(worker)
worker.start()
else:
self._queue.append(worker)
return worker
def _on_worker_finished(self, worker: Worker) -> None:
if worker in self._workers:
self._workers.remove(worker)
if self._queue:
next_worker = self._queue.pop(0)
self._workers.append(next_worker)
next_worker.start()
def cancel_all(self) -> None:
for worker in self._workers:
worker.cancel()
self._queue.clear()
def wait_all(self) -> None:
for worker in self._workers:
worker.wait()37.4.2 信号槽进阶
python
from PyQt6.QtCore import pyqtSlot, pyqtSignal, QObject
class SignalBus(QObject):
_instance = None
document_saved = pyqtSignal(str)
document_opened = pyqtSignal(str)
settings_changed = pyqtSignal(str, object)
theme_changed = pyqtSignal(str)
status_message = pyqtSignal(str, int)
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance.__init__()
return cls._instance
def __init__(self):
if hasattr(self, "_initialized"):
return
super().__init__()
self._initialized = True
class DataBinding(QObject):
valueChanged = pyqtSignal(object)
def __init__(self, initial_value: Any = None):
super().__init__()
self._value = initial_value
def get(self) -> Any:
return self._value
def set(self, value: Any) -> None:
if self._value != value:
self._value = value
self.valueChanged.emit(value)
def bind_to_widget(self, widget: QWidget, property_name: str) -> None:
self.valueChanged.connect(lambda v: setattr(widget, property_name, v))
class FormValidator(QObject):
validationChanged = pyqtSignal(bool)
def __init__(self, parent: Optional[QObject] = None):
super().__init__(parent)
self._validators: Dict[str, callable] = {}
self._errors: Dict[str, str] = {}
def add_validator(self, field_name: str, validator: callable) -> None:
self._validators[field_name] = validator
def validate(self, field_name: str, value: Any) -> bool:
if field_name not in self._validators:
return True
result = self._validators[field_name](value)
if result is True:
self._errors.pop(field_name, None)
return True
else:
self._errors[field_name] = result
return False
def validate_all(self, data: Dict[str, Any]) -> bool:
all_valid = True
for field_name, validator in self._validators.items():
if not self.validate(field_name, data.get(field_name)):
all_valid = False
self.validationChanged.emit(all_valid)
return all_valid
def get_errors(self) -> Dict[str, str]:
return self._errors.copy()
def has_errors(self) -> bool:
return len(self._errors) > 0
@staticmethod
def required(message: str = "This field is required"):
def validator(value):
if value is None or (isinstance(value, str) and not value.strip()):
return message
return True
return validator
@staticmethod
def min_length(min_len: int, message: str = None):
def validator(value):
if len(str(value)) < min_len:
return message or f"Minimum length is {min_len}"
return True
return validator
@staticmethod
def email(message: str = "Invalid email format"):
import re
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
def validator(value):
if re.match(pattern, str(value)):
return True
return message
return validator37.5 Model-View架构
37.5.1 自定义模型
python
from PyQt6.QtCore import (
Qt, QAbstractTableModel, QAbstractListModel,
QModelIndex, QVariant
)
from typing import Any, List, Dict, Optional
class TableModel(QAbstractTableModel):
def __init__(self, data: List[Dict] = None, headers: List[str] = None):
super().__init__()
self._data = data or []
self._headers = headers or []
def rowCount(self, parent: QModelIndex = QModelIndex()) -> int:
return len(self._data)
def columnCount(self, parent: QModelIndex = QModelIndex()) -> int:
return len(self._headers)
def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any:
if not index.isValid():
return QVariant()
if role == Qt.ItemDataRole.DisplayRole or role == Qt.ItemDataRole.EditRole:
row = index.row()
col = index.column()
if row < len(self._data) and col < len(self._headers):
key = self._headers[col]
return str(self._data[row].get(key, ""))
elif role == Qt.ItemDataRole.TextAlignmentRole:
return Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
return QVariant()
def headerData(self, section: int, orientation: Qt.Orientation, role: int = Qt.ItemDataRole.DisplayRole) -> Any:
if role == Qt.ItemDataRole.DisplayRole:
if orientation == Qt.Orientation.Horizontal and section < len(self._headers):
return self._headers[section]
elif orientation == Qt.Orientation.Vertical:
return str(section + 1)
return QVariant()
def setData(self, index: QModelIndex, value: Any, role: int = Qt.ItemDataRole.EditRole) -> bool:
if role == Qt.ItemDataRole.EditRole and index.isValid():
row = index.row()
col = index.column()
if row < len(self._data) and col < len(self._headers):
key = self._headers[col]
self._data[row][key] = value
self.dataChanged.emit(index, index)
return True
return False
def flags(self, index: QModelIndex) -> Qt.ItemFlag:
return super().flags(index) | Qt.ItemFlag.ItemIsEditable
def insertRows(self, row: int, count: int, parent: QModelIndex = QModelIndex()) -> bool:
self.beginInsertRows(parent, row, row + count - 1)
for _ in range(count):
self._data.insert(row, {key: "" for key in self._headers})
self.endInsertRows()
return True
def removeRows(self, row: int, count: int, parent: QModelIndex = QModelIndex()) -> bool:
self.beginRemoveRows(parent, row, row + count - 1)
for _ in range(count):
if row < len(self._data):
del self._data[row]
self.endRemoveRows()
return True
def set_data(self, data: List[Dict]) -> None:
self.beginResetModel()
self._data = data
self.endResetModel()
def get_data(self) -> List[Dict]:
return self._data.copy()
class ListModel(QAbstractListModel):
def __init__(self, items: List[Any] = None):
super().__init__()
self._items = items or []
def rowCount(self, parent: QModelIndex = QModelIndex()) -> int:
return len(self._items)
def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any:
if not index.isValid():
return QVariant()
if role == Qt.ItemDataRole.DisplayRole:
return str(self._items[index.row()])
return QVariant()
def insertRows(self, row: int, count: int, parent: QModelIndex = QModelIndex()) -> bool:
self.beginInsertRows(parent, row, row + count - 1)
for i in range(count):
self._items.insert(row + i, None)
self.endInsertRows()
return True
def removeRows(self, row: int, count: int, parent: QModelIndex = QModelIndex()) -> bool:
self.beginRemoveRows(parent, row, row + count - 1)
for _ in range(count):
if row < len(self._items):
del self._items[row]
self.endRemoveRows()
return True37.6 知识图谱
37.6.1 PyQt/PySide架构体系
┌─────────────────────────────────────────────────────────────────────┐
│ PyQt/PySide 技术架构 │
├─────────────────────────────────────────────────────────────────────┤
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 应用层 (Application) │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │QApplication│ │QMainWindow│ │QDialog │ │QWidget │ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌─────────────────────────────┴───────────────────────────────┐ │
│ │ 界面层 (UI Components) │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │ 布局管理 │ │ 控件系统 │ │ 样式系统 │ │ 事件系统 │ │ │
│ │ │QLayout │ │QWidget │ │QSS │ │QEvent │ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │QVBoxLayou│ │QPushButton│ │QStyle │ │信号槽 │ │ │
│ │ │QGridLayou│ │QLineEdit │ │主题切换 │ │pyqtSignal│ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌─────────────────────────────┴───────────────────────────────┐ │
│ │ 数据层 (Data & Model) │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │ Model │ │ View │ │ Delegate │ │ 数据绑定 │ │ │
│ │ │QAbstractM│ │QAbstractV│ │QItemDeleg│ │ 表单验证 │ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌─────────────────────────────┴───────────────────────────────┐ │
│ │ 核心层 (Qt Core) │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │QObject │ │QThread │ │QTimer │ │QSettings │ │ │
│ │ │信号槽机制 │ │多线程 │ │定时器 │ │配置存储 │ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌─────────────────────────────┴───────────────────────────────┐ │
│ │ 绘图层 (Qt GUI) │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │QPainter │ │QPen/Brush│ │QFont │ │QPixmap │ │ │
│ │ │自定义绘制 │ │画笔画刷 │ │字体 │ │图像处理 │ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │
│ └─────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘37.6.2 信号槽通信机制
┌─────────────────────────────────────────────────────────────────────┐
│ 信号槽通信流程 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ 发送者 │ │ 接收者 │ │
│ │ (Sender) │ │ (Receiver) │ │
│ └───────┬──────┘ └───────┬──────┘ │
│ │ │ │
│ │ 1. emit(signal) │ │
│ ▼ │ │
│ ┌──────────────┐ │ │
│ │ 信号 │ │ │
│ │ pyqtSignal │ │ │
│ └───────┬──────┘ │ │
│ │ │ │
│ │ 2. 连接检查 │ │
│ ▼ │ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ 连接类型选择 │ │
│ │ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ │
│ │ │ Qt.AutoConn│ │Qt.DirectCon│ │Qt.QueuedCon│ │ │
│ │ │ 自动选择 │ │ 直接调用 │ │ 队列调用 │ │ │
│ │ └────────────┘ └────────────┘ └────────────┘ │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ │ │
│ │ 3. 参数传递 │ │
│ ▼ ▼ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ 参数打包 │ ──────────────────▶ │ 槽函数 │ │
│ │ (序列化) │ 跨线程安全传递 │ @pyqtSlot │ │
│ └──────────────┘ └──────────────┘ │
│ │ │
│ │ 4. 执行处理 │
│ ▼ │
│ ┌──────────────┐ │
│ │ 业务逻辑 │ │
│ │ 更新UI │ │
│ └──────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘37.7 技术选型指南
37.7.1 GUI框架选型
| 框架 | 适用场景 | 跨平台 | 许可证 | 学习曲线 | 推荐指数 |
|---|---|---|---|---|---|
| PyQt6 | 专业桌面应用 | ✅ 全平台 | GPL/商业 | 中高 | ★★★★★ |
| PySide6 | 商业项目首选 | ✅ 全平台 | LGPL | 中高 | ★★★★★ |
| Tkinter | 简单工具、教学 | ✅ 全平台 | Python | 低 | ★★★☆☆ |
| wxPython | 原生外观应用 | ✅ 全平台 | wxWindows | 中 | ★★★★☆ |
| Kivy | 跨平台移动端 | ✅ 全平台 | MIT | 中高 | ★★★☆☆ |
| Dear PyGui | 数据可视化工具 | ✅ 全平台 | MIT | 中 | ★★★☆☆ |
37.7.2 布局管理器选型
| 布局类型 | 适用场景 | 特点 | 使用建议 |
|---|---|---|---|
| QVBoxLayout | 垂直排列控件 | 简单直观 | 侧边栏、列表项 |
| QHBoxLayout | 水平排列控件 | 简单直观 | 工具栏、按钮组 |
| QGridLayout | 表格形式布局 | 灵活对齐 | 表单、仪表盘 |
| QFormLayout | 标签-输入配对 | 自动对齐 | 设置对话框 |
| QStackedLayout | 切换显示页面 | 节省空间 | 向导、多步骤 |
| 嵌套布局 | 复杂界面结构 | 高度灵活 | 企业级应用 |
37.7.3 多线程方案选型
| 方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| QThread | 长时间后台任务 | Qt集成、信号槽通信 | 需要正确管理生命周期 |
| QRunnable | 短任务池处理 | 线程池管理 | 不能直接发信号 |
| threading | 简单后台操作 | Python原生 | 需要信号槽桥接 |
| asyncio | I/O密集型任务 | 高效并发 | 与Qt事件循环集成复杂 |
37.8 常见问题与解决方案
37.8.1 线程安全UI更新
python
from PyQt6.QtCore import QThread, pyqtSignal, QObject, pyqtSlot
from PyQt6.QtWidgets import QLabel, QVBoxLayout, QWidget
class WorkerSignals(QObject):
"""工作线程信号"""
finished = pyqtSignal(object)
error = pyqtSignal(str)
progress = pyqtSignal(int)
class SafeWorker(QThread):
"""线程安全的工作线程"""
def __init__(self, task, *args, **kwargs):
super().__init__()
self.task = task
self.args = args
self.kwargs = kwargs
self.signals = WorkerSignals()
def run(self):
try:
result = self.task(*self.args, **self.kwargs)
self.signals.finished.emit(result)
except Exception as e:
self.signals.error.emit(str(e))
class ThreadSafeUI(QWidget):
"""线程安全UI更新示例"""
def __init__(self):
super().__init__()
self._setup_ui()
def _setup_ui(self):
layout = QVBoxLayout(self)
self.status_label = QLabel("Ready")
self.progress_label = QLabel("0%")
layout.addWidget(self.status_label)
layout.addWidget(self.progress_label)
def start_background_task(self):
"""启动后台任务"""
worker = SafeWorker(self._long_running_task)
worker.signals.finished.connect(self._on_task_finished)
worker.signals.error.connect(self._on_task_error)
worker.signals.progress.connect(self._on_progress)
worker.start()
def _long_running_task(self):
"""长时间运行的任务(在工作线程中执行)"""
import time
for i in range(100):
time.sleep(0.1)
self.signals.progress.emit(i + 1)
return "Task completed"
@pyqtSlot(object)
def _on_task_finished(self, result):
"""槽函数:任务完成(在主线程中执行)"""
self.status_label.setText(str(result))
@pyqtSlot(str)
def _on_task_error(self, error):
"""槽函数:任务错误(在主线程中执行)"""
self.status_label.setText(f"Error: {error}")
@pyqtSlot(int)
def _on_progress(self, value):
"""槽函数:进度更新(在主线程中执行)"""
self.progress_label.setText(f"{value}%")37.8.2 内存泄漏与资源管理
python
from PyQt6.QtCore import QObject, QTimer, pyqtSignal
from PyQt6.QtWidgets import QWidget, QPushButton, QVBoxLayout
import weakref
class ResourceManagedWidget(QWidget):
"""资源管理型控件"""
def __init__(self, parent=None):
super().__init__(parent)
self._timers = []
self._workers = []
self._connections = []
self._setup_ui()
def _setup_ui(self):
layout = QVBoxLayout(self)
self.button = QPushButton("Start")
self.button.clicked.connect(self._on_click)
layout.addWidget(self.button)
def add_managed_timer(self, interval, callback):
"""添加受管理的定时器"""
timer = QTimer(self)
timer.timeout.connect(callback)
timer.start(interval)
self._timers.append(timer)
return timer
def add_managed_connection(self, signal, slot):
"""添加受管理的信号连接"""
connection = signal.connect(slot)
self._connections.append((signal, slot))
return connection
def cleanup(self):
"""清理所有资源"""
for timer in self._timers:
timer.stop()
timer.deleteLater()
self._timers.clear()
for worker in self._workers:
if hasattr(worker, 'quit'):
worker.quit()
worker.wait()
self._workers.clear()
for signal, slot in self._connections:
try:
signal.disconnect(slot)
except TypeError:
pass
self._connections.clear()
def closeEvent(self, event):
"""窗口关闭时清理资源"""
self.cleanup()
super().closeEvent(event)
class WeakRefManager:
"""弱引用管理器,防止循环引用"""
@staticmethod
def create_weak_callback(obj, method_name):
"""创建弱引用回调"""
weak_obj = weakref.ref(obj)
def callback(*args, **kwargs):
obj = weak_obj()
if obj is not None:
method = getattr(obj, method_name)
return method(*args, **kwargs)
return callback
@staticmethod
def safe_connect(signal, obj, method_name):
"""安全的信号连接"""
callback = WeakRefManager.create_weak_callback(obj, method_name)
signal.connect(callback)
return callback37.8.3 自定义控件事件处理
python
from PyQt6.QtCore import Qt, pyqtSignal, QPointF
from PyQt6.QtGui import QMouseEvent, QKeyEvent, QWheelEvent
from PyQt6.QtWidgets import QWidget
class InteractiveWidget(QWidget):
"""交互式自定义控件"""
clicked = pyqtSignal(QPointF)
doubleClicked = pyqtSignal(QPointF)
keyPressed = pyqtSignal(int)
scrolled = pyqtSignal(float)
def __init__(self, parent=None):
super().__init__(parent)
self._last_click_time = 0
self._double_click_threshold = 300 # ms
self.setMouseTracking(True)
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
def mousePressEvent(self, event: QMouseEvent):
"""鼠标按下事件"""
if event.button() == Qt.MouseButton.LeftButton:
pos = event.position()
self.clicked.emit(pos)
super().mousePressEvent(event)
def mouseDoubleClickEvent(self, event: QMouseEvent):
"""鼠标双击事件"""
if event.button() == Qt.MouseButton.LeftButton:
pos = event.position()
self.doubleClicked.emit(pos)
super().mouseDoubleClickEvent(event)
def keyPressEvent(self, event: QKeyEvent):
"""键盘按下事件"""
key = event.key()
self.keyPressed.emit(key)
if key == Qt.Key.Key_Escape:
self._on_escape()
elif key == Qt.Key.Key_Return:
self._on_enter()
super().keyPressEvent(event)
def wheelEvent(self, event: QWheelEvent):
"""滚轮事件"""
delta = event.angleDelta().y()
self.scrolled.emit(delta)
super().wheelEvent(event)
def _on_escape(self):
"""ESC键处理"""
pass
def _on_enter(self):
"""Enter键处理"""
pass
class DragDropWidget(QWidget):
"""支持拖放的控件"""
filesDropped = pyqtSignal(list)
def __init__(self, parent=None):
super().__init__(parent)
self.setAcceptDrops(True)
self._drag_enter_callback = None
self._drop_callback = None
def dragEnterEvent(self, event):
"""拖入事件"""
if event.mimeData().hasUrls():
event.acceptProposedAction()
else:
event.ignore()
def dragMoveEvent(self, event):
"""拖动移动事件"""
if event.mimeData().hasUrls():
event.acceptProposedAction()
else:
event.ignore()
def dropEvent(self, event):
"""放下事件"""
mime_data = event.mimeData()
if mime_data.hasUrls():
files = [url.toLocalFile() for url in mime_data.urls()]
self.filesDropped.emit(files)
if self._drop_callback:
self._drop_callback(files)
event.acceptProposedAction()
def set_drop_callback(self, callback):
"""设置放下回调"""
self._drop_callback = callback37.8.4 样式动态切换
python
from PyQt6.QtWidgets import QApplication, QWidget, QPushButton, QVBoxLayout
from PyQt6.QtCore import QSettings
class ThemeManager:
"""主题管理器"""
THEMES = {
"light": """
QWidget {
background-color: #ffffff;
color: #333333;
}
QPushButton {
background-color: #0078d4;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
}
QPushButton:hover {
background-color: #106ebe;
}
""",
"dark": """
QWidget {
background-color: #1e1e1e;
color: #d4d4d4;
}
QPushButton {
background-color: #0e639c;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
}
QPushButton:hover {
background-color: #1177bb;
}
""",
"high_contrast": """
QWidget {
background-color: #000000;
color: #ffffff;
border: 2px solid #ffffff;
}
QPushButton {
background-color: #ffffff;
color: #000000;
border: 2px solid #ffffff;
padding: 8px 16px;
}
QPushButton:hover {
background-color: #ffff00;
color: #000000;
}
"""
}
def __init__(self, app: QApplication):
self.app = app
self.settings = QSettings("MyApp", "ThemeSettings")
self._current_theme = self.settings.value("theme", "light")
self.apply_theme(self._current_theme)
def apply_theme(self, theme_name: str):
"""应用主题"""
if theme_name in self.THEMES:
self._current_theme = theme_name
self.app.setStyleSheet(self.THEMES[theme_name])
self.settings.setValue("theme", theme_name)
def toggle_theme(self):
"""切换主题"""
themes = list(self.THEMES.keys())
current_index = themes.index(self._current_theme)
next_index = (current_index + 1) % len(themes)
self.apply_theme(themes[next_index])
def current_theme(self) -> str:
"""获取当前主题"""
return self._current_theme
def available_themes(self) -> list:
"""获取可用主题列表"""
return list(self.THEMES.keys())
def add_custom_theme(self, name: str, stylesheet: str):
"""添加自定义主题"""
self.THEMES[name] = stylesheet
class ThemeAwareWidget(QWidget):
"""主题感知控件"""
def __init__(self, parent=None):
super().__init__(parent)
self._theme_callbacks = []
def register_theme_callback(self, callback):
"""注册主题变更回调"""
self._theme_callbacks.append(callback)
def on_theme_changed(self, theme_name: str):
"""主题变更处理"""
for callback in self._theme_callbacks:
callback(theme_name)37.9 本章小结
本章详细介绍了Python GUI高级开发的核心概念和实践:
- 高级布局:响应式布局、流式布局、边框布局
- 自定义控件:绘制控件、复合控件、动画控件
- 样式定制:QSS样式表、主题切换、动态样式
- 多线程界面:工作线程、线程池、信号槽机制
- 数据绑定:表单验证、数据同步
- Model-View:自定义模型、数据展示
练习题
- 实现一个可拖拽排序的列表控件
- 开发一个自定义日历控件,支持日期范围选择
- 实现一个属性面板,支持动态添加和编辑属性
- 开发一个多文档界面(MDI)应用程序
- 实现一个支持撤销/重做的文本编辑器