Python GUI 开发
大约 17 分钟约 4962 字
Python GUI 开发
简介
Python 图形用户界面(GUI)开发是构建桌面应用的重要技能。Python 拥有多个成熟的 GUI 框架,从内置的 Tkinter 到功能强大的 PyQt/PySide6,再到现代化的 Dear PyGui 和 CustomTkinter。本文将系统性地介绍 Python GUI 开发的核心技术,涵盖 Tkinter 基础、PyQt/PySide6 高级开发、布局管理、事件处理、MVC 模式、图表集成、多线程、打包发布等主题。
特点
Python GUI 开发的主要特征:
- 快速原型: Tkinter 内置无需安装,适合快速原型开发
- 功能强大: PyQt/PySide6 提供专业级控件和丰富的功能
- 跨平台: 一次开发可在 Windows、macOS、Linux 上运行
- 生态完善: 拥有丰富的第三方控件库和工具链
- 打包便捷: PyInstaller 等工具支持一键打包为独立应用
实现
1. Tkinter 基础
1.1 窗口与基本控件
import tkinter as tk
from tkinter import ttk, messagebox, filedialog, colorchooser
from typing import Callable
class BasicWindow:
"""Tkinter 基础窗口"""
def __init__(self):
self.root = tk.Tk()
self.root.title("我的应用")
self.root.geometry("800x600")
self.root.minsize(400, 300)
# 设置窗口图标(可选)
# self.root.iconbitmap("icon.ico")
# 设置背景色
self.root.configure(bg="#f0f0f0")
# 窗口居中
self._center_window(800, 600)
self._create_widgets()
def _center_window(self, width: int, height: int):
"""窗口居中显示"""
screen_width = self.root.winfo_screenwidth()
screen_height = self.root.winfo_screenheight()
x = (screen_width - width) // 2
y = (screen_height - height) // 2
self.root.geometry(f"{width}x{height}+{x}+{y}")
def _create_widgets(self):
"""创建控件"""
# ---- 标签 ----
label = tk.Label(
self.root,
text="欢迎使用 Python GUI",
font=("Microsoft YaHei", 16, "bold"),
fg="#333333",
bg="#f0f0f0",
)
label.pack(pady=20)
# ---- 按钮 ----
button_frame = tk.Frame(self.root, bg="#f0f0f0")
button_frame.pack(pady=10)
tk.Button(
button_frame,
text="普通按钮",
command=self._on_button_click,
width=12,
font=("Microsoft YaHei", 10),
).pack(side=tk.LEFT, padx=5)
tk.Button(
button_frame,
text="退出",
command=self.root.quit,
width=12,
bg="#ff4444",
fg="white",
font=("Microsoft YaHei", 10),
).pack(side=tk.LEFT, padx=5)
# ---- 输入框 ----
input_frame = tk.Frame(self.root, bg="#f0f0f0")
input_frame.pack(pady=10, padx=20, fill=tk.X)
tk.Label(input_frame, text="姓名:", font=("Microsoft YaHei", 10)).pack(side=tk.LEFT)
self.name_var = tk.StringVar(value="张三")
entry = tk.Entry(
input_frame,
textvariable=self.name_var,
font=("Microsoft YaHei", 10),
width=30,
)
entry.pack(side=tk.LEFT, padx=5)
# ---- 文本框 ----
self.text = tk.Text(
self.root,
font=("Consolas", 11),
wrap=tk.WORD,
height=10,
)
self.text.pack(pady=10, padx=20, fill=tk.BOTH, expand=True)
self.text.insert(tk.END, "这是一个多行文本框...\n")
# ---- 列表框 ----
list_frame = tk.Frame(self.root, bg="#f0f0f0")
list_frame.pack(pady=10, padx=20, fill=tk.X)
self.listbox = tk.Listbox(
list_frame,
font=("Microsoft YaHei", 10),
height=5,
selectmode=tk.SINGLE,
)
for item in ["选项一", "选项二", "选项三", "选项四"]:
self.listbox.insert(tk.END, item)
self.listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
# 滚动条
scrollbar = tk.Scrollbar(list_frame, orient=tk.VERTICAL, command=self.listbox.yview)
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
self.listbox.config(yscrollcommand=scrollbar.set)
# ---- 状态栏 ----
self.status_var = tk.StringVar(value="就绪")
status_bar = tk.Label(
self.root,
textvariable=self.status_var,
bd=1,
relief=tk.SUNKEN,
anchor=tk.W,
font=("Microsoft YaHei", 9),
)
status_bar.pack(side=tk.BOTTOM, fill=tk.X)
def _on_button_click(self):
name = self.name_var.get()
messagebox.showinfo("提示", f"你好, {name}!")
self.status_var.set(f"已点击按钮 - {name}")
def run(self):
self.root.mainloop()
# ---- 常用对话框 ----
class DialogExamples:
"""常用对话框示例"""
def __init__(self, root: tk.Tk):
self.root = root
def show_info(self):
messagebox.showinfo("信息", "这是一条信息")
def show_warning(self):
messagebox.showwarning("警告", "这是一条警告")
def show_error(self):
messagebox.showerror("错误", "这是一条错误")
def ask_question(self):
result = messagebox.askyesno("确认", "确定要删除吗?")
if result:
print("用户确认删除")
def open_file(self):
filepath = filedialog.askopenfilename(
title="选择文件",
filetypes=[
("文本文件", "*.txt"),
("Python 文件", "*.py"),
("所有文件", "*.*"),
],
)
if filepath:
print(f"选择文件: {filepath}")
def save_file(self):
filepath = filedialog.asksaveasfilename(
title="保存文件",
defaultextension=".txt",
filetypes=[("文本文件", "*.txt")],
)
if filepath:
print(f"保存到: {filepath}")
def choose_color(self):
color = colorchooser.askcolor(title="选择颜色")
if color[1]:
print(f"选择的颜色: {color[1]}")1.2 Tkinter 布局管理
class LayoutDemo:
"""布局管理演示"""
def __init__(self):
self.root = tk.Tk()
self.root.title("布局管理")
self.root.geometry("600x400")
def demo_pack(self):
"""Pack 布局 - 按方向依次排列"""
frame = tk.Frame(self.root)
frame.pack(fill=tk.BOTH, expand=True)
tk.Label(frame, text="顶部", bg="red", fg="white").pack(side=tk.TOP, fill=tk.X)
tk.Label(frame, text="底部", bg="blue", fg="white").pack(side=tk.BOTTOM, fill=tk.X)
tk.Label(frame, text="左侧", bg="green", fg="white").pack(side=tk.LEFT, fill=tk.Y)
tk.Label(frame, text="右侧", bg="yellow").pack(side=tk.RIGHT, fill=tk.Y)
tk.Label(frame, text="中间", bg="white").pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
def demo_grid(self):
"""Grid 布局 - 表格式排列"""
frame = tk.Frame(self.root, padx=10, pady=10)
frame.pack(fill=tk.BOTH, expand=True)
# 登录表单
tk.Label(frame, text="用户名:").grid(row=0, column=0, sticky=tk.W, pady=5)
tk.Entry(frame, width=30).grid(row=0, column=1, columnspan=2, pady=5, sticky=tk.EW)
tk.Label(frame, text="密码:").grid(row=1, column=0, sticky=tk.W, pady=5)
tk.Entry(frame, width=30, show="*").grid(row=1, column=1, columnspan=2, pady=5, sticky=tk.EW)
tk.Label(frame, text="记住登录").grid(row=2, column=0, sticky=tk.W)
tk.Checkbutton(frame).grid(row=2, column=1, sticky=tk.W)
btn_frame = tk.Frame(frame)
btn_frame.grid(row=3, column=0, columnspan=3, pady=10)
tk.Button(btn_frame, text="登录", width=10).pack(side=tk.LEFT, padx=5)
tk.Button(btn_frame, text="取消", width=10).pack(side=tk.LEFT, padx=5)
# 配置列权重(实现自适应宽度)
frame.columnconfigure(1, weight=1)
def demo_place(self):
"""Place 布局 - 精确坐标定位"""
frame = tk.Frame(self.root, bg="#f0f0f0")
frame.pack(fill=tk.BOTH, expand=True)
# 使用绝对坐标
tk.Label(frame, text="坐标(100,100)", bg="red").place(x=100, y=100)
# 使用相对坐标
tk.Label(frame, text="居中", bg="green", fg="white").place(
relx=0.5, rely=0.5, anchor=tk.CENTER
)
# 使用相对尺寸
tk.Button(frame, text="宽50% 高10%").place(
relx=0.25, rely=0.8, relwidth=0.5, relheight=0.1
)2. PyQt/PySide6 高级开发
2.1 基础窗口与信号槽
# 使用 PySide6 (Qt for Python)
from PySide6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QPushButton, QLabel, QLineEdit, QTextEdit, QComboBox,
QSpinBox, QDoubleSpinBox, QCheckBox, QRadioButton,
QSlider, QProgressBar, QGroupBox, QTabWidget,
QTableWidget, QTableWidgetItem, QHeaderView,
QToolBar, QStatusBar, QMenuBar, QMenu,
QFileDialog, QMessageBox, QSplitter,
QTreeWidget, QTreeWidgetItem,
)
from PySide6.QtCore import Qt, Signal, QTimer, QSize
from PySide6.QtGui import QIcon, QFont, QAction, QKeySequence, QColor
class MainWindow(QMainWindow):
"""PySide6 主窗口"""
# 自定义信号
data_updated = Signal(str)
def __init__(self):
super().__init__()
self.setWindowTitle("PySide6 应用")
self.setMinimumSize(900, 600)
self._setup_menubar()
self._setup_toolbar()
self._setup_central_widget()
self._setup_statusbar()
def _setup_menubar(self):
"""设置菜单栏"""
menubar = self.menuBar()
# 文件菜单
file_menu = menubar.addMenu("文件(&F)")
new_action = QAction("新建(&N)", self)
new_action.setShortcut(QKeySequence.New)
new_action.triggered.connect(self._on_new)
file_menu.addAction(new_action)
open_action = QAction("打开(&O)", self)
open_action.setShortcut(QKeySequence.Open)
open_action.triggered.connect(self._on_open)
file_menu.addAction(open_action)
file_menu.addSeparator()
save_action = QAction("保存(&S)", self)
save_action.setShortcut(QKeySequence.Save)
save_action.triggered.connect(self._on_save)
file_menu.addAction(save_action)
file_menu.addSeparator()
exit_action = QAction("退出(&Q)", self)
exit_action.setShortcut(QKeySequence.Quit)
exit_action.triggered.connect(self.close)
file_menu.addAction(exit_action)
# 编辑菜单
edit_menu = menubar.addMenu("编辑(&E)")
edit_menu.addAction(QAction("撤销(&U)", self))
edit_menu.addAction(QAction("重做(&R)", self))
edit_menu.addSeparator()
edit_menu.addAction(QAction("剪切(&X)", self))
edit_menu.addAction(QAction("复制(&C)", self))
edit_menu.addAction(QAction("粘贴(&V)", self))
# 帮助菜单
help_menu = menubar.addMenu("帮助(&H)")
about_action = QAction("关于(&A)", self)
about_action.triggered.connect(self._show_about)
help_menu.addAction(about_action)
def _setup_toolbar(self):
"""设置工具栏"""
toolbar = QToolBar("主工具栏")
toolbar.setIconSize(QSize(24, 24))
self.addToolBar(toolbar)
toolbar.addAction("新建")
toolbar.addAction("打开")
toolbar.addAction("保存")
toolbar.addSeparator()
toolbar.addAction("撤销")
toolbar.addAction("重做")
def _setup_central_widget(self):
"""设置中央控件"""
central = QWidget()
self.setCentralWidget(central)
main_layout = QHBoxLayout(central)
# 左侧面板
left_panel = self._create_left_panel()
main_layout.addWidget(left_panel, stretch=1)
# 右侧面板(带分割器)
splitter = QSplitter(Qt.Vertical)
splitter.addWidget(self._create_table_widget())
splitter.addWidget(self._create_text_widget())
splitter.setSizes([300, 200])
main_layout.addWidget(splitter, stretch=3)
def _create_left_panel(self) -> QWidget:
"""创建左侧面板"""
panel = QWidget()
layout = QVBoxLayout(panel)
# 输入控件组
input_group = QGroupBox("输入控件")
input_layout = QVBoxLayout(input_group)
# 文本输入
name_layout = QHBoxLayout()
name_layout.addWidget(QLabel("姓名:"))
self.name_input = QLineEdit()
self.name_input.setPlaceholderText("请输入姓名")
name_layout.addWidget(self.name_input)
input_layout.addLayout(name_layout)
# 下拉选择
combo_layout = QHBoxLayout()
combo_layout.addWidget(QLabel("城市:"))
self.city_combo = QComboBox()
self.city_combo.addItems(["北京", "上海", "广州", "深圳", "杭州"])
combo_layout.addWidget(self.city_combo)
input_layout.addLayout(combo_layout)
# 数值输入
spin_layout = QHBoxLayout()
spin_layout.addWidget(QLabel("年龄:"))
self.age_spin = QSpinBox()
self.age_spin.setRange(1, 150)
self.age_spin.setValue(25)
spin_layout.addWidget(self.age_spin)
input_layout.addLayout(spin_layout)
# 复选框
self.check_python = QCheckBox("Python")
self.check_java = QCheckBox("Java")
self.check_cpp = QCheckBox("C++")
check_layout = QHBoxLayout()
check_layout.addWidget(self.check_python)
check_layout.addWidget(self.check_java)
check_layout.addWidget(self.check_cpp)
input_layout.addLayout(check_layout)
# 滑动条
self.slider = QSlider(Qt.Horizontal)
self.slider.setRange(0, 100)
self.slider.setValue(50)
self.slider_label = QLabel("50")
self.slider.valueChanged.connect(
lambda v: self.slider_label.setText(str(v))
)
slider_layout = QHBoxLayout()
slider_layout.addWidget(QLabel("进度:"))
slider_layout.addWidget(self.slider)
slider_layout.addWidget(self.slider_label)
input_layout.addLayout(slider_layout)
layout.addWidget(input_group)
# 操作按钮
btn_layout = QHBoxLayout()
add_btn = QPushButton("添加")
add_btn.clicked.connect(self._add_record)
clear_btn = QPushButton("清空")
clear_btn.clicked.connect(self._clear_form)
btn_layout.addWidget(add_btn)
btn_layout.addWidget(clear_btn)
layout.addLayout(btn_layout)
# 进度条
self.progress = QProgressBar()
self.progress.setValue(0)
layout.addWidget(self.progress)
layout.addStretch()
return panel
def _create_table_widget(self) -> QWidget:
"""创建表格控件"""
widget = QWidget()
layout = QVBoxLayout(widget)
self.table = QTableWidget()
self.table.setColumnCount(5)
self.table.setHorizontalHeaderLabels(
["姓名", "城市", "年龄", "技能", "添加时间"]
)
self.table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
self.table.setAlternatingRowColors(True)
self.table.setSelectionBehavior(QTableWidget.SelectRows)
layout.addWidget(self.table)
return widget
def _create_text_widget(self) -> QWidget:
"""创建文本输出区域"""
widget = QWidget()
layout = QVBoxLayout(widget)
self.text_output = QTextEdit()
self.text_output.setReadOnly(True)
self.text_output.setFont(QFont("Consolas", 10))
layout.addWidget(QLabel("日志输出:"))
layout.addWidget(self.text_output)
return widget
def _setup_statusbar(self):
"""设置状态栏"""
self.statusbar = QStatusBar()
self.setStatusBar(self.statusbar)
self.statusbar.showMessage("就绪")
# 永久显示的标签
self.record_count_label = QLabel("记录: 0")
self.statusbar.addPermanentWidget(self.record_count_label)
def _add_record(self):
"""添加记录到表格"""
name = self.name_input.text()
city = self.city_combo.currentText()
age = self.age_spin.value()
skills = []
if self.check_python.isChecked():
skills.append("Python")
if self.check_java.isChecked():
skills.append("Java")
if self.check_cpp.isChecked():
skills.append("C++")
if not name:
QMessageBox.warning(self, "警告", "请输入姓名")
return
from datetime import datetime
row = self.table.rowCount()
self.table.insertRow(row)
self.table.setItem(row, 0, QTableWidgetItem(name))
self.table.setItem(row, 1, QTableWidgetItem(city))
self.table.setItem(row, 2, QTableWidgetItem(str(age)))
self.table.setItem(row, 3, QTableWidgetItem(", ".join(skills)))
self.table.setItem(row, 4, QTableWidgetItem(datetime.now().strftime("%H:%M:%S")))
self.record_count_label.setText(f"记录: {self.table.rowCount()}")
self.statusbar.showMessage(f"已添加: {name}", 3000)
self.data_updated.emit(f"添加记录: {name}")
def _clear_form(self):
self.name_input.clear()
self.city_combo.setCurrentIndex(0)
self.age_spin.setValue(25)
self.check_python.setChecked(False)
self.check_java.setChecked(False)
self.check_cpp.setChecked(False)
def _on_new(self):
self.table.setRowCount(0)
self.text_output.clear()
self.statusbar.showMessage("已新建", 2000)
def _on_open(self):
filepath, _ = QFileDialog.getOpenFileName(
self, "打开文件", "",
"文本文件 (*.txt);;CSV 文件 (*.csv);;所有文件 (*)"
)
if filepath:
self.statusbar.showMessage(f"已打开: {filepath}")
def _on_save(self):
filepath, _ = QFileDialog.getSaveFileName(
self, "保存文件", "",
"CSV 文件 (*.csv);;文本文件 (*.txt)"
)
if filepath:
self._save_table_to_csv(filepath)
def _save_table_to_csv(self, filepath: str):
import csv
with open(filepath, "w", newline="", encoding="utf-8-sig") as f:
writer = csv.writer(f)
headers = [self.table.horizontalHeaderItem(i).text()
for i in range(self.table.columnCount())]
writer.writerow(headers)
for row in range(self.table.rowCount()):
row_data = [self.table.item(row, col).text()
for col in range(self.table.columnCount())]
writer.writerow(row_data)
self.statusbar.showMessage(f"已保存: {filepath}")
def _show_about(self):
QMessageBox.about(self, "关于", "Python GUI 应用 v1.0\n使用 PySide6 构建")
# 启动应用
if __name__ == "__main__":
import sys
app = QApplication(sys.argv)
window = MainWindow()
window.show()
sys.exit(app.exec())3. 事件处理与自定义信号
from PySide6.QtCore import QObject, Signal, Slot, QTimer
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel
from PySide6.QtGui import QMouseEvent, QKeyEvent
# ---- 自定义信号 ----
class DataEmitter(QObject):
"""自定义信号发射器"""
data_ready = Signal(str)
progress_updated = Signal(int)
error_occurred = Signal(str, int) # (错误消息, 错误码)
finished = Signal()
class EventWidget(QWidget):
"""自定义事件处理控件"""
clicked = Signal(int, int) # x, y 坐标
def __init__(self):
super().__init__()
self.setMinimumSize(300, 200)
self.setMouseTracking(True)
layout = QVBoxLayout(self)
self.pos_label = QLabel("移动鼠标到此处")
self.pos_label.setAlignment(Qt.AlignCenter)
layout.addWidget(self.pos_label)
def mousePressEvent(self, event: QMouseEvent):
"""鼠标按下事件"""
x, y = event.position().x(), event.position().y()
if event.button() == Qt.LeftButton:
self.pos_label.setText(f"左键点击: ({x:.0f}, {y:.0f})")
self.clicked.emit(int(x), int(y))
elif event.button() == Qt.RightButton:
self.pos_label.setText(f"右键点击: ({x:.0f}, {y:.0f})")
def mouseMoveEvent(self, event: QMouseEvent):
"""鼠标移动事件"""
x, y = event.position().x(), event.position().y()
self.pos_label.setText(f"鼠标位置: ({x:.0f}, {y:.0f})")
def mouseDoubleClickEvent(self, event: QMouseEvent):
"""双击事件"""
self.pos_label.setText("双击!")
def keyPressEvent(self, event: QKeyEvent):
"""键盘事件"""
key = event.key()
text = event.text()
self.pos_label.setText(f"按键: {text} (code: {key})")
# Ctrl+S 保存
if event.modifiers() == Qt.ControlModifier and key == Qt.Key_S:
self.pos_label.setText("保存快捷键")
# ---- 定时器使用 ----
class TimerExample(QWidget):
"""定时器示例"""
def __init__(self):
super().__init__()
self.counter = 0
layout = QVBoxLayout(self)
self.label = QLabel("计数: 0")
self.label.setFont(QFont("Arial", 24))
self.label.setAlignment(Qt.AlignCenter)
layout.addWidget(self.label)
# QTimer 定时器
self.timer = QTimer(self)
self.timer.timeout.connect(self._on_timer)
self.timer.start(1000) # 每1秒触发
def _on_timer(self):
self.counter += 1
self.label.setText(f"计数: {self.counter}")
def stop_timer(self):
self.timer.stop()4. MVC 模式在 GUI 中的应用
from dataclasses import dataclass, field
from typing import Optional
from PySide6.QtCore import QObject, Signal, Qt
# ---- Model: 数据模型 ----
@dataclass
class Contact:
"""联系人数据模型"""
name: str
phone: str
email: str
category: str = "朋友"
id: int = 0
class ContactModel:
"""联系人数据管理(Model)"""
def __init__(self):
self._contacts: list[Contact] = []
self._next_id = 1
self._listeners: list = []
def add_listener(self, callback):
self._listeners.append(callback)
def _notify(self, event_type: str, data=None):
for listener in self._listeners:
listener(event_type, data)
def add_contact(self, contact: Contact) -> Contact:
contact.id = self._next_id
self._next_id += 1
self._contacts.append(contact)
self._notify("added", contact)
return contact
def update_contact(self, contact: Contact) -> bool:
for i, c in enumerate(self._contacts):
if c.id == contact.id:
self._contacts[i] = contact
self._notify("updated", contact)
return True
return False
def delete_contact(self, contact_id: int) -> bool:
for i, c in enumerate(self._contacts):
if c.id == contact_id:
del self._contacts[i]
self._notify("deleted", contact_id)
return True
return False
def get_all(self) -> list[Contact]:
return list(self._contacts)
def search(self, keyword: str) -> list[Contact]:
keyword = keyword.lower()
return [
c for c in self._contacts
if keyword in c.name.lower()
or keyword in c.phone
or keyword in c.email.lower()
]
# ---- View: 视图层 ----
class ContactView(QObject):
"""联系人视图(View) - 负责显示"""
add_requested = Signal(str, str, str, str) # name, phone, email, category
delete_requested = Signal(int) # id
search_requested = Signal(str) # keyword
def __init__(self):
super().__init__()
self._setup_ui()
def _setup_ui(self):
# 这里构建实际的 UI 控件
pass
def display_contacts(self, contacts: list[Contact]):
"""显示联系人列表"""
for contact in contacts:
print(f" [{contact.id}] {contact.name} - {contact.phone} ({contact.category})")
def show_error(self, message: str):
print(f"[错误] {message}")
def show_success(self, message: str):
print(f"[成功] {message}")
# ---- Controller: 控制器 ----
class ContactController:
"""联系人控制器(Controller) - 连接 Model 和 View"""
def __init__(self, model: ContactModel, view: ContactView):
self.model = model
self.view = view
# 连接信号
self.view.add_requested.connect(self._handle_add)
self.view.delete_requested.connect(self._handle_delete)
self.view.search_requested.connect(self._handle_search)
# 监听模型变化
self.model.add_listener(self._on_model_changed)
def _handle_add(self, name: str, phone: str, email: str, category: str):
if not name:
self.view.show_error("姓名不能为空")
return
contact = Contact(name=name, phone=phone, email=email, category=category)
self.model.add_contact(contact)
self.view.show_success(f"已添加联系人: {name}")
def _handle_delete(self, contact_id: int):
if self.model.delete_contact(contact_id):
self.view.show_success(f"已删除联系人 ID: {contact_id}")
else:
self.view.show_error("联系人不存在")
def _handle_search(self, keyword: str):
results = self.model.search(keyword)
self.view.display_contacts(results)
def _on_model_changed(self, event_type: str, data=None):
"""模型变化时刷新视图"""
if event_type in ("added", "updated", "deleted"):
self.view.display_contacts(self.model.get_all())
def load_initial_data(self):
"""加载初始数据"""
self.model.add_contact(Contact("张三", "13800138000", "zhangsan@mail.com", "朋友"))
self.model.add_contact(Contact("李四", "13900139000", "lisi@mail.com", "同事"))
self.model.add_contact(Contact("王五", "13700137000", "wangwu@mail.com", "家人"))5. Matplotlib 图表集成
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QComboBox
import matplotlib
matplotlib.use('QtAgg')
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT as NavigationToolbar
from matplotlib.figure import Figure
import numpy as np
class ChartWidget(QWidget):
"""Matplotlib 图表控件"""
def __init__(self):
super().__init__()
self._setup_ui()
def _setup_ui(self):
layout = QVBoxLayout(self)
# 图表控制栏
control_layout = QHBoxLayout()
self.chart_type = QComboBox()
self.chart_type.addItems(["折线图", "柱状图", "散点图", "饼图", "直方图"])
self.chart_type.currentTextChanged.connect(self._update_chart)
control_layout.addWidget(self.chart_type)
refresh_btn = QPushButton("刷新数据")
refresh_btn.clicked.connect(self._update_chart)
control_layout.addWidget(refresh_btn)
control_layout.addStretch()
layout.addLayout(control_layout)
# Matplotlib 图表
self.figure = Figure(figsize=(8, 5), dpi=100)
self.canvas = FigureCanvas(self.figure)
self.toolbar = NavigationToolbar(self.canvas, self)
layout.addWidget(self.toolbar)
layout.addWidget(self.canvas)
self._update_chart()
def _generate_data(self) -> dict:
"""生成示例数据"""
np.random.seed(42)
return {
"x": np.arange(12),
"labels": ["1月", "2月", "3月", "4月", "5月", "6月",
"7月", "8月", "9月", "10月", "11月", "12月"],
"sales": np.random.randint(100, 500, 12),
"profit": np.random.randint(20, 100, 12),
}
def _update_chart(self):
chart_type = self.chart_type.currentText()
data = self._generate_data()
self.figure.clear()
ax = self.figure.add_subplot(111)
if chart_type == "折线图":
ax.plot(data["x"], data["sales"], "b-o", label="销售额", linewidth=2)
ax.plot(data["x"], data["profit"], "r--s", label="利润", linewidth=2)
ax.set_xlabel("月份")
ax.set_ylabel("金额(万元)")
ax.legend()
ax.grid(True, alpha=0.3)
elif chart_type == "柱状图":
width = 0.35
ax.bar(data["x"] - width/2, data["sales"], width, label="销售额", color="steelblue")
ax.bar(data["x"] + width/2, data["profit"], width, label="利润", color="coral")
ax.set_xticks(data["x"])
ax.set_xticklabels(data["labels"], rotation=45)
ax.legend()
elif chart_type == "散点图":
ax.scatter(data["sales"], data["profit"], c=data["x"],
cmap="viridis", s=100, alpha=0.7)
ax.set_xlabel("销售额")
ax.set_ylabel("利润")
elif chart_type == "饼图":
categories = ["A类", "B类", "C类", "D类"]
values = [35, 25, 20, 20]
ax.pie(values, labels=categories, autopct="%1.1f%%", startangle=90)
ax.set_title("产品类别分布")
elif chart_type == "直方图":
normal_data = np.random.randn(1000)
ax.hist(normal_data, bins=30, color="steelblue", edgecolor="white", alpha=0.7)
ax.set_xlabel("值")
ax.set_ylabel("频次")
ax.set_title(chart_type)
self.figure.tight_layout()
self.canvas.draw()6. GUI 中的多线程
import time
from PySide6.QtCore import QThread, Signal, QMutex, QMutexLocker
# ---- QThread 工作线程 ----
class WorkerThread(QThread):
"""后台工作线程"""
progress = Signal(int) # 进度 0-100
result_ready = Signal(object) # 结果
error = Signal(str) # 错误信息
finished = Signal()
def __init__(self, task, *args, **kwargs):
super().__init__()
self.task = task
self.args = args
self.kwargs = kwargs
self._is_cancelled = False
def run(self):
"""线程执行函数"""
try:
result = self.task(
*self.args,
progress_callback=self._report_progress,
cancel_check=self._check_cancelled,
**self.kwargs,
)
if not self._is_cancelled:
self.result_ready.emit(result)
except Exception as e:
self.error.emit(str(e))
finally:
self.finished.emit()
def _report_progress(self, value: int):
self.progress.emit(value)
def _check_cancelled(self) -> bool:
return self._is_cancelled
def cancel(self):
self._is_cancelled = True
# ---- 实际使用示例 ----
class DataProcessingApp(QWidget):
"""数据处理应用(带线程)"""
def __init__(self):
super().__init__()
self.worker: Optional[WorkerThread] = None
self._setup_ui()
def _setup_ui(self):
layout = QVBoxLayout(self)
self.start_btn = QPushButton("开始处理")
self.start_btn.clicked.connect(self._start_processing)
layout.addWidget(self.start_btn)
self.cancel_btn = QPushButton("取消")
self.cancel_btn.clicked.connect(self._cancel_processing)
self.cancel_btn.setEnabled(False)
layout.addWidget(self.cancel_btn)
self.progress_bar = QProgressBar()
layout.addWidget(self.progress_bar)
self.log_text = QTextEdit()
self.log_text.setReadOnly(True)
layout.addWidget(self.log_text)
def _start_processing(self):
self.start_btn.setEnabled(False)
self.cancel_btn.setEnabled(True)
self.progress_bar.setValue(0)
self.log_text.clear()
self.worker = WorkerThread(self._heavy_task, total=100)
self.worker.progress.connect(self._on_progress)
self.worker.result_ready.connect(self._on_result)
self.worker.error.connect(self._on_error)
self.worker.finished.connect(self._on_finished)
self.worker.start()
@staticmethod
def _heavy_task(total: int, progress_callback=None, cancel_check=None):
"""模拟耗时任务"""
results = []
for i in range(total):
if cancel_check and cancel_check():
return None
time.sleep(0.05) # 模拟处理
results.append(f"结果_{i}")
if progress_callback:
progress_callback(int((i + 1) / total * 100))
return results
def _on_progress(self, value: int):
self.progress_bar.setValue(value)
def _on_result(self, result):
self.log_text.append(f"处理完成,共 {len(result)} 条结果")
def _on_error(self, error_msg: str):
self.log_text.append(f"错误: {error_msg}")
def _on_finished(self):
self.start_btn.setEnabled(True)
self.cancel_btn.setEnabled(False)
def _cancel_processing(self):
if self.worker:
self.worker.cancel()
self.log_text.append("正在取消...")7. 样式与主题
# QSS (Qt Style Sheets) 样式
DARK_STYLE = """
QMainWindow {
background-color: #1e1e1e;
}
QWidget {
color: #e0e0e0;
font-family: "Microsoft YaHei", "Segoe UI", sans-serif;
font-size: 13px;
}
QPushButton {
background-color: #0d6efd;
color: white;
border: none;
border-radius: 4px;
padding: 8px 16px;
font-weight: bold;
}
QPushButton:hover {
background-color: #0b5ed7;
}
QPushButton:pressed {
background-color: #0a58ca;
}
QPushButton:disabled {
background-color: #555555;
color: #888888;
}
QLineEdit, QTextEdit, QComboBox {
background-color: #2d2d2d;
border: 1px solid #444444;
border-radius: 4px;
padding: 6px;
color: #e0e0e0;
}
QLineEdit:focus, QTextEdit:focus {
border-color: #0d6efd;
}
QTableWidget {
background-color: #1e1e1e;
alternate-background-color: #252525;
gridline-color: #333333;
border: 1px solid #444444;
}
QTableWidget::item:selected {
background-color: #0d6efd;
}
QHeaderView::section {
background-color: #333333;
color: #e0e0e0;
padding: 6px;
border: 1px solid #444444;
font-weight: bold;
}
QGroupBox {
border: 1px solid #444444;
border-radius: 6px;
margin-top: 10px;
padding-top: 15px;
font-weight: bold;
}
QGroupBox::title {
subcontrol-origin: margin;
subcontrol-position: top left;
padding: 0 8px;
color: #0d6efd;
}
QProgressBar {
border: 1px solid #444444;
border-radius: 4px;
text-align: center;
background-color: #2d2d2d;
height: 20px;
}
QProgressBar::chunk {
background-color: #0d6efd;
border-radius: 3px;
}
QScrollBar:vertical {
background-color: #1e1e1e;
width: 10px;
}
QScrollBar::handle:vertical {
background-color: #555555;
border-radius: 5px;
min-height: 30px;
}
QScrollBar::handle:vertical:hover {
background-color: #666666;
}
QMenuBar {
background-color: #252525;
color: #e0e0e0;
}
QMenuBar::item:selected {
background-color: #0d6efd;
}
QMenu {
background-color: #2d2d2d;
border: 1px solid #444444;
}
QMenu::item:selected {
background-color: #0d6efd;
}
QStatusBar {
background-color: #252525;
color: #aaaaaa;
}
QTabWidget::pane {
border: 1px solid #444444;
background-color: #1e1e1e;
}
QTabBar::tab {
background-color: #2d2d2d;
color: #aaaaaa;
padding: 8px 16px;
border: 1px solid #444444;
border-bottom: none;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
QTabBar::tab:selected {
background-color: #1e1e1e;
color: #e0e0e0;
}
"""
def apply_dark_theme(app: QApplication):
"""应用暗色主题"""
app.setStyleSheet(DARK_STYLE)
def apply_light_theme(app: QApplication):
"""应用亮色主题"""
app.setStyleSheet("""
QWidget {
font-family: "Microsoft YaHei", "Segoe UI", sans-serif;
font-size: 13px;
}
QPushButton {
background-color: #0078d4;
color: white;
border: none;
border-radius: 4px;
padding: 8px 16px;
}
QPushButton:hover {
background-color: #106ebe;
}
""")8. 应用打包
# ---- PyInstaller 打包配置 ----
# 命令行打包:
# pyinstaller --name="MyApp" --windowed --onefile --icon=icon.ico main.py
#
# 参数说明:
# --name: 应用名称
# --windowed: 不显示控制台窗口(GUI 应用必须)
# --onefile: 打包为单个 exe
# --icon: 应用图标
# --add-data: 添加资源文件
# --hidden-import: 显式指定隐式导入
# ---- .spec 文件配置 ----
# myapp.spec 示例:
"""
# -*- mode: python ; coding: utf-8 -*-
block_cipher = None
a = Analysis(
['main.py'],
pathex=[],
binaries=[],
datas=[
('resources', 'resources'), # 打包资源目录
('config.json', '.'), # 打包配置文件
],
hiddenimports=[
'PySide6.QtSvg',
'matplotlib.backends.backend_qt5agg',
],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False,
)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.zipfiles,
a.datas,
[],
name='MyApp',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=False, # GUI 应用设为 False
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon='icon.ico',
)
"""优点
- 快速开发: Tkinter 内置,无需额外安装
- 专业级界面: PyQt/PySide6 支持复杂的专业应用
- 跨平台: 同一代码在 Windows/macOS/Linux 上运行
- 丰富控件: 内置数百种控件,满足各种需求
- 打包分发: PyInstaller 支持打包为独立可执行文件
缺点
- 性能瓶颈: Python GUI 对大量数据的渲染性能不如原生应用
- 打包体积: PyInstaller 打包后体积较大(50MB+)
- 学习曲线: PyQt/PySide6 API 庞大,需要时间学习
- 外观差异: 跨平台外观可能不一致
- 移动端不支持: Python GUI 框架主要面向桌面平台
性能注意事项
- 避免主线程阻塞: 所有耗时操作放工作线程
- 大数据优化: 表格使用虚拟模式(QAbstractItemModel)处理大数据集
- 图表更新: 使用 blitting 技术加速 Matplotlib 动画
- 图片加载: 使用 QPixmap 缓存,避免重复加载
- 控件复用: 复用控件而非频繁创建销毁
总结
Python GUI 开发的关键要点:
- 框架选择: 简单工具用 Tkinter,专业应用用 PySide6
- 架构设计: 使用 MVC 模式分离数据、视图和逻辑
- 线程安全: 耗时操作使用 QThread,避免阻塞主线程
- 样式统一: 使用 QSS 统一应用风格
- 打包发布: 使用 PyInstaller 打包,注意资源文件和隐式导入
关键知识点
| 概念 | 说明 |
|---|---|
| Tkinter | Python 内置 GUI 框架,简单易用 |
| PySide6 | Qt6 的 Python 绑定,功能强大 |
| Signal/Slot | Qt 的事件通信机制,解耦组件 |
| QThread | Qt 的线程类,用于后台任务 |
| QSS | Qt Style Sheets,类似 CSS 的样式系统 |
| MVC | Model-View-Controller 架构模式 |
| PyInstaller | Python 应用打包工具 |
常见误区
误区: GUI 主线程可以做耗时操作
- 会冻结界面,导致"未响应"
- 解决: 使用 QThread 或 QRunnable
误区: 跨线程直接操作 GUI 控件
- Qt 不允许跨线程操作控件,会崩溃
- 解决: 通过信号槽(Signal/Slot)通信
误区: Tkinter 太弱,不能用
- Tkinter 足以构建中小型工具
- 解决: 根据项目复杂度选择框架
进阶路线
- 入门: 掌握 Tkinter 基础控件和布局
- 进阶: 学习 PySide6,掌握信号槽和布局系统
- 高级: 实现 MVC 架构,集成 Matplotlib,多线程处理
- 专家: 自定义控件(QPainter),模型/视图架构,应用打包优化
适用场景
- 数据分析和可视化工具
- 系统管理和运维工具
- 图像/视频编辑工具
- 自动化测试界面
- 小型桌面应用和内部工具
落地建议
- 选对框架: 快速原型用 Tkinter,生产应用用 PySide6
- 统一架构: 从一开始就采用 MVC 模式
- 主题一致: 使用 QSS 统一管理样式
- 测试打包: 及早测试 PyInstaller 打包,排除依赖问题
排错清单
| 问题 | 可能原因 | 解决方案 |
|---|---|---|
| 界面卡死 | 主线程执行耗时操作 | 使用 QThread |
| 程序崩溃 | 跨线程操作 GUI | 使用信号槽通信 |
| 中文乱码 | 编码问题 | 使用 utf-8 编码,设置字体 |
| 打包后运行失败 | 缺少依赖或资源 | 检查 hidden-import 和 datas |
| 样式不生效 | QSS 语法错误 | 检查选择器和属性名 |
| 图表不显示 | matplotlib 后端设置 | 设置 QtAgg 后端 |
复盘问题
- GUI 响应时间是否满足要求?是否有界面卡顿?
- 大数据量场景下表格加载是否流畅?
- 工作线程是否正确退出,是否存在内存泄漏?
- 跨平台测试是否通过?外观是否一致?
- 打包后体积是否合理?启动速度是否可接受?
延伸阅读
- Tkinter 官方文档
- PySide6 文档
- PyQt6 文档
- Qt Style Sheets
- PyInstaller
- Dear PyGui - 现代 GPU 加速 GUI
- CustomTkinter - 现代化 Tkinter
