第20章 Tkinter GUI开发

学习目标

完成本章学习后,读者应能够:

  1. 理解GUI编程模型:掌握事件驱动编程、主循环机制与控件层次结构
  2. 精通布局管理:灵活运用pack、grid、place三种布局管理器构建复杂界面
  3. 掌握核心控件:熟练使用ttk主题控件、表单控件与数据显示控件
  4. 实现事件处理:掌握事件绑定、回调机制与自定义事件
  5. 构建菜单系统:实现菜单栏、工具栏、右键菜单与快捷键
  6. 运用对话框:使用标准对话框与自定义对话框
  7. 实现MVC架构:在Tkinter中应用Model-View-Controller设计模式
  8. 掌握多线程GUI:解决GUI线程阻塞问题,实现异步任务处理

20.1 GUI编程基础

20.1.1 事件驱动编程模型

Tkinter基于Tcl/Tk工具包,采用事件驱动编程模型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
┌──────────────────────────────────┐
│ 主循环(mainloop) │
│ ┌────────────────────────────┐ │
│ │ 事件队列(Event Queue) │ │
│ │ ┌──────┐ ┌──────┐ │ │
│ │ │鼠标 │ │键盘 │ ... │ │
│ │ └──┬───┘ └──┬───┘ │ │
│ └─────┼────────┼───────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌────────────────────────────┐ │
│ │ 事件分发器(Dispatcher) │ │
│ └────────────┬───────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────┐ │
│ │ 回调函数(Callback) │ │
│ └────────────┬───────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────┐ │
│ │ 控件更新(Widget Update)│ │
│ └────────────────────────────┘ │
└──────────────────────────────────┘

20.1.2 应用程序结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
import tkinter as tk
from tkinter import ttk


class Application(tk.Tk):
def __init__(self):
super().__init__()
self.title("My Application")
self.geometry("800x600")
self.minsize(600, 400)

self._configure_styles()
self._create_menu()
self._create_widgets()
self._create_statusbar()

def _configure_styles(self):
style = ttk.Style(self)
style.theme_use("clam")

def _create_menu(self):
menubar = tk.Menu(self)
file_menu = tk.Menu(menubar, tearoff=False)
file_menu.add_command(label="新建", accelerator="Ctrl+N", command=self.on_new)
file_menu.add_command(label="打开", accelerator="Ctrl+O", command=self.on_open)
file_menu.add_separator()
file_menu.add_command(label="退出", accelerator="Ctrl+Q", command=self.quit)
menubar.add_cascade(label="文件", menu=file_menu)
self.config(menu=menubar)

self.bind_all("<Control-n>", lambda e: self.on_new())
self.bind_all("<Control-o>", lambda e: self.on_open())
self.bind_all("<Control-q>", lambda e: self.quit())

def _create_widgets(self):
main_frame = ttk.Frame(self)
main_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)

def _create_statusbar(self):
self.status_var = tk.StringVar(value="就绪")
statusbar = ttk.Label(self, textvariable=self.status_var, relief=tk.SUNKEN)
statusbar.pack(fill=tk.X, side=tk.BOTTOM)

def on_new(self):
self.status_var.set("新建文件")

def on_open(self):
self.status_var.set("打开文件")


if __name__ == "__main__":
app = Application()
app.mainloop()

20.2 布局管理

20.2.1 Grid布局详解

Grid是最常用的布局管理器,将容器划分为行和列的网格:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
import tkinter as tk
from tkinter import ttk


class LoginForm(tk.Tk):
def __init__(self):
super().__init__()
self.title("用户登录")
self.geometry("400x250")
self.resizable(False, False)

main_frame = ttk.Frame(self, padding=20)
main_frame.pack(fill=tk.BOTH, expand=True)

main_frame.columnconfigure(1, weight=1)
main_frame.rowconfigure(3, weight=1)

ttk.Label(main_frame, text="用户名:").grid(
row=0, column=0, sticky=tk.W, pady=(0, 10)
)
self.username_var = tk.StringVar()
ttk.Entry(main_frame, textvariable=self.username_var).grid(
row=0, column=1, sticky=tk.EW, pady=(0, 10), padx=(10, 0)
)

ttk.Label(main_frame, text="密码:").grid(
row=1, column=0, sticky=tk.W, pady=(0, 10)
)
self.password_var = tk.StringVar()
ttk.Entry(main_frame, textvariable=self.password_var, show="●").grid(
row=1, column=1, sticky=tk.EW, pady=(0, 10), padx=(10, 0)
)

self.remember_var = tk.BooleanVar()
ttk.Checkbutton(main_frame, text="记住密码", variable=self.remember_var).grid(
row=2, column=1, sticky=tk.W, padx=(10, 0)
)

btn_frame = ttk.Frame(main_frame)
btn_frame.grid(row=3, column=0, columnspan=2, pady=(20, 0))
ttk.Button(btn_frame, text="登录", command=self.on_login, width=12).pack(
side=tk.LEFT, padx=(0, 10)
)
ttk.Button(btn_frame, text="取消", command=self.destroy, width=12).pack(
side=tk.LEFT
)

self.bind("<Return>", lambda e: self.on_login())

def on_login(self):
username = self.username_var.get()
password = self.password_var.get()
if username and password:
print(f"Login: {username}")
else:
from tkinter import messagebox
messagebox.showwarning("提示", "请输入用户名和密码")


if __name__ == "__main__":
app = LoginForm()
app.mainloop()

20.2.2 响应式布局

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class ResponsiveLayout(tk.Tk):
def __init__(self):
super().__init__()
self.title("响应式布局")
self.geometry("800x500")

self.columnconfigure(0, weight=1)
self.columnconfigure(1, weight=3)
self.rowconfigure(0, weight=1)

sidebar = ttk.Frame(self, relief=tk.RIDGE, borderwidth=1)
sidebar.grid(row=0, column=0, sticky=tk.NSEW, padx=(5, 2), pady=5)

content = ttk.Frame(self, relief=tk.RIDGE, borderwidth=1)
content.grid(row=0, column=1, sticky=tk.NSEW, padx=(2, 5), pady=5)

for i, item in enumerate(["仪表盘", "文章管理", "用户管理", "系统设置"]):
ttk.Button(
sidebar, text=item,
command=lambda t=item: self.on_nav(t),
).pack(fill=tk.X, padx=5, pady=2)

content.columnconfigure(0, weight=1)
content.rowconfigure(0, weight=1)

self.content_text = tk.Text(content, wrap=tk.WORD)
self.content_text.grid(row=0, column=0, sticky=tk.NSEW, padx=5, pady=5)

scrollbar = ttk.Scrollbar(content, orient=tk.VERTICAL, command=self.content_text.yview)
scrollbar.grid(row=0, column=1, sticky=tk.NS)
self.content_text.config(yscrollcommand=scrollbar.set)

def on_nav(self, item):
self.content_text.delete("1.0", tk.END)
self.content_text.insert("1.0", f"当前页面: {item}")

20.3 核心控件

20.3.1 ttk主题控件

ttk(Themed Tkinter)提供更现代的控件外观:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
import tkinter as tk
from tkinter import ttk


class WidgetShowcase(tk.Tk):
def __init__(self):
super().__init__()
self.title("控件展示")
self.geometry("700x500")

notebook = ttk.Notebook(self)
notebook.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)

input_frame = ttk.Frame(notebook, padding=10)
notebook.add(input_frame, text="输入控件")
self._create_input_widgets(input_frame)

display_frame = ttk.Frame(notebook, padding=10)
notebook.add(display_frame, text="显示控件")
self._create_display_widgets(display_frame)

selection_frame = ttk.Frame(notebook, padding=10)
notebook.add(selection_frame, text="选择控件")
self._create_selection_widgets(selection_frame)

def _create_input_widgets(self, parent):
parent.columnconfigure(1, weight=1)

ttk.Label(parent, text="文本输入:").grid(row=0, column=0, sticky=tk.W, pady=5)
self.entry_var = tk.StringVar()
entry = ttk.Entry(parent, textvariable=self.entry_var)
entry.grid(row=0, column=1, sticky=tk.EW, pady=5, padx=(10, 0))

ttk.Label(parent, text="多行文本:").grid(row=1, column=0, sticky=tk.NW, pady=5)
text_frame = ttk.Frame(parent)
text_frame.grid(row=1, column=1, sticky=tk.NSEW, pady=5, padx=(10, 0))
parent.rowconfigure(1, weight=1)

text = tk.Text(text_frame, wrap=tk.WORD, height=8)
text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
scrollbar = ttk.Scrollbar(text_frame, orient=tk.VERTICAL, command=text.yview)
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
text.config(yscrollcommand=scrollbar.set)

ttk.Label(parent, text="数值输入:").grid(row=2, column=0, sticky=tk.W, pady=5)
spinbox = ttk.Spinbox(parent, from_=0, to=100, width=10)
spinbox.grid(row=2, column=1, sticky=tk.W, pady=5, padx=(10, 0))

def _create_display_widgets(self, parent):
tree = ttk.Treeview(
parent,
columns=("name", "age", "email"),
show="headings",
height=10,
)
tree.heading("name", text="姓名")
tree.heading("age", text="年龄")
tree.heading("email", text="邮箱")
tree.column("name", width=120)
tree.column("age", width=80, anchor=tk.CENTER)
tree.column("email", width=200)

data = [
("张三", 28, "zhangsan@example.com"),
("李四", 35, "lisi@example.com"),
("王五", 22, "wangwu@example.com"),
]
for item in data:
tree.insert("", tk.END, values=item)

tree.pack(fill=tk.BOTH, expand=True)

def _create_selection_widgets(self, parent):
self.combo_var = tk.StringVar()
ttk.Label(parent, text="下拉选择:").pack(anchor=tk.W, pady=(0, 5))
combobox = ttk.Combobox(
parent, textvariable=self.combo_var,
values=["Python", "Java", "Go", "Rust"],
state="readonly",
)
combobox.pack(fill=tk.X, pady=(0, 15))
combobox.set("Python")

ttk.Label(parent, text="单选:").pack(anchor=tk.W, pady=(0, 5))
self.radio_var = tk.StringVar(value="beginner")
for text, value in [("初级", "beginner"), ("中级", "intermediate"), ("高级", "advanced")]:
ttk.Radiobutton(parent, text=text, variable=self.radio_var, value=value).pack(
anchor=tk.W, padx=20
)

ttk.Label(parent, text="多选:").pack(anchor=tk.W, pady=(10, 5))
self.check_vars = {}
for item in ["Python", "JavaScript", "Go"]:
var = tk.BooleanVar()
self.check_vars[item] = var
ttk.Checkbutton(parent, text=item, variable=var).pack(anchor=tk.W, padx=20)


if __name__ == "__main__":
app = WidgetShowcase()
app.mainloop()

20.3.2 Treeview数据表格

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
class DataTable(tk.Tk):
def __init__(self):
super().__init__()
self.title("数据表格")
self.geometry("800x500")

toolbar = ttk.Frame(self)
toolbar.pack(fill=tk.X, padx=5, pady=5)
ttk.Button(toolbar, text="添加", command=self.add_item).pack(side=tk.LEFT, padx=2)
ttk.Button(toolbar, text="删除", command=self.delete_item).pack(side=tk.LEFT, padx=2)
ttk.Button(toolbar, text="刷新", command=self.refresh_data).pack(side=tk.LEFT, padx=2)

columns = ("id", "name", "category", "price", "stock", "status")
self.tree = ttk.Treeview(self, columns=columns, show="headings", selectmode="extended")

for col, heading, width, anchor in [
("id", "ID", 60, tk.CENTER),
("name", "名称", 150, tk.W),
("category", "分类", 100, tk.CENTER),
("price", "价格", 80, tk.E),
("stock", "库存", 80, tk.CENTER),
("status", "状态", 80, tk.CENTER),
]:
self.tree.heading(col, text=heading, command=lambda c=col: self.sort_by(c))
self.tree.column(col, width=width, anchor=anchor)

vsb = ttk.Scrollbar(self, orient=tk.VERTICAL, command=self.tree.yview)
hsb = ttk.Scrollbar(self, orient=tk.HORIZONTAL, command=self.tree.xview)
self.tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set)

self.tree.grid(row=1, column=0, sticky=tk.NSEW, padx=5)
vsb.grid(row=1, column=1, sticky=tk.NS)
hsb.grid(row=2, column=0, sticky=tk.EW)
self.grid_rowconfigure(1, weight=1)
self.grid_columnconfigure(0, weight=1)

self.tree.bind("<Double-1>", self.on_double_click)

self.sort_reverse = False
self.refresh_data()

def refresh_data(self):
for item in self.tree.get_children():
self.tree.delete(item)

products = [
(1, "Python编程", "书籍", 89.00, 150, "在售"),
(2, "机械键盘", "外设", 399.00, 45, "在售"),
(3, "显示器", "外设", 2499.00, 0, "缺货"),
]
for product in products:
self.tree.insert("", tk.END, values=product)

def add_item(self):
dialog = ProductDialog(self)
self.wait_window(dialog)
if dialog.result:
self.tree.insert("", tk.END, values=dialog.result)

def delete_item(self):
for item in self.tree.selection():
self.tree.delete(item)

def sort_by(self, column):
items = [(self.tree.set(k, column), k) for k in self.tree.get_children("")]
items.sort(reverse=self.sort_reverse)
for index, (_, k) in enumerate(items):
self.tree.move(k, "", index)
self.sort_reverse = not self.sort_reverse

def on_double_click(self, event):
item = self.tree.identify_row(event.y)
if item:
values = self.tree.item(item, "values")
print(f"编辑: {values}")


class ProductDialog(tk.Toplevel):
def __init__(self, parent):
super().__init__(parent)
self.title("添加商品")
self.geometry("400x300")
self.transient(parent)
self.grab_set()
self.result = None

frame = ttk.Frame(self, padding=20)
frame.pack(fill=tk.BOTH, expand=True)
frame.columnconfigure(1, weight=1)

self.vars = {}
for i, (label, key) in enumerate([
("名称", "name"), ("分类", "category"),
("价格", "price"), ("库存", "stock"),
]):
ttk.Label(frame, text=f"{label}:").grid(row=i, column=0, sticky=tk.W, pady=5)
var = tk.StringVar()
self.vars[key] = var
ttk.Entry(frame, textvariable=var).grid(
row=i, column=1, sticky=tk.EW, pady=5, padx=(10, 0)
)

btn_frame = ttk.Frame(frame)
btn_frame.grid(row=4, column=0, columnspan=2, pady=(20, 0))
ttk.Button(btn_frame, text="确定", command=self.on_ok).pack(side=tk.LEFT, padx=5)
ttk.Button(btn_frame, text="取消", command=self.destroy).pack(side=tk.LEFT, padx=5)

def on_ok(self):
try:
self.result = (
0,
self.vars["name"].get(),
self.vars["category"].get(),
float(self.vars["price"].get()),
int(self.vars["stock"].get()),
"在售",
)
self.destroy()
except ValueError:
from tkinter import messagebox
messagebox.showerror("错误", "请输入有效的数值", parent=self)


if __name__ == "__main__":
app = DataTable()
app.mainloop()

20.4 事件处理

20.4.1 事件绑定机制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
import tkinter as tk


class EventDemo(tk.Tk):
def __init__(self):
super().__init__()
self.title("事件处理演示")
self.geometry("500x400")

self.canvas = tk.Canvas(self, bg="white", cursor="crosshair")
self.canvas.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)

self.canvas.bind("<Button-1>", self.on_left_click)
self.canvas.bind("<Button-3>", self.on_right_click)
self.canvas.bind("<B1-Motion>", self.on_drag)
self.canvas.bind("<MouseWheel>", self.on_scroll)
self.canvas.bind("<Double-Button-1>", self.on_double_click)

self.bind("<Key>", self.on_key)
self.bind("<Control-z>", self.on_undo)
self.bind("<Control-s>", self.on_save)

self.items = []
self.current_item = None

def on_left_click(self, event):
item = self.canvas.create_oval(
event.x - 5, event.y - 5, event.x + 5, event.y + 5,
fill="blue",
)
self.items.append(item)

def on_right_click(self, event):
menu = tk.Menu(self, tearoff=False)
menu.add_command(label="清除画布", command=self.clear_canvas)
menu.add_command(label="撤销", command=self.undo)
menu.post(event.x_root, event.y_root)

def on_drag(self, event):
if self.current_item:
self.canvas.coords(
self.current_item,
event.x - 5, event.y - 5, event.x + 5, event.y + 5,
)

def on_scroll(self, event):
scale = 1.1 if event.delta > 0 else 0.9
self.canvas.scale(tk.ALL, event.x, event.y, scale, scale)

def on_double_click(self, event):
item = self.canvas.create_text(
event.x, event.y, text="双击!", fill="red", font=("Arial", 14),
)
self.items.append(item)

def on_key(self, event):
print(f"按键: {event.keysym} (char={event.char}, keycode={event.keycode})")

def on_undo(self, event):
self.undo()

def on_save(self, event):
print("保存操作")

def undo(self):
if self.items:
self.canvas.delete(self.items.pop())

def clear_canvas(self):
self.canvas.delete(tk.ALL)
self.items.clear()


if __name__ == "__main__":
app = EventDemo()
app.mainloop()

20.5 菜单与工具栏

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
import tkinter as tk
from tkinter import ttk, messagebox


class MenuApp(tk.Tk):
def __init__(self):
super().__init__()
self.title("菜单与工具栏")
self.geometry("800x600")

self._create_menubar()
self._create_toolbar()
self._create_content()

def _create_menubar(self):
menubar = tk.Menu(self)

file_menu = tk.Menu(menubar, tearoff=False)
file_menu.add_command(label="新建", accelerator="Ctrl+N", command=self.on_new)
file_menu.add_command(label="打开", accelerator="Ctrl+O", command=self.on_open)
file_menu.add_command(label="保存", accelerator="Ctrl+S", command=self.on_save)
file_menu.add_separator()
recent_menu = tk.Menu(file_menu, tearoff=False)
recent_menu.add_command(label="最近文件1", command=lambda: print("Recent 1"))
recent_menu.add_command(label="最近文件2", command=lambda: print("Recent 2"))
file_menu.add_cascade(label="最近打开", menu=recent_menu)
file_menu.add_separator()
file_menu.add_command(label="退出", accelerator="Alt+F4", command=self.on_exit)
menubar.add_cascade(label="文件", menu=file_menu)

edit_menu = tk.Menu(menubar, tearoff=False)
edit_menu.add_command(label="撤销", accelerator="Ctrl+Z", command=self.on_undo)
edit_menu.add_command(label="重做", accelerator="Ctrl+Y", command=self.on_redo)
edit_menu.add_separator()
edit_menu.add_command(label="剪切", accelerator="Ctrl+X", command=self.on_cut)
edit_menu.add_command(label="复制", accelerator="Ctrl+C", command=self.on_copy)
edit_menu.add_command(label="粘贴", accelerator="Ctrl+V", command=self.on_paste)
menubar.add_cascade(label="编辑", menu=edit_menu)

view_menu = tk.Menu(menubar, tearoff=False)
self.show_toolbar_var = tk.BooleanVar(value=True)
self.show_statusbar_var = tk.BooleanVar(value=True)
view_menu.add_checkbutton(label="工具栏", variable=self.show_toolbar_var, command=self.toggle_toolbar)
view_menu.add_checkbutton(label="状态栏", variable=self.show_statusbar_var, command=self.toggle_statusbar)
menubar.add_cascade(label="视图", menu=view_menu)

help_menu = tk.Menu(menubar, tearoff=False)
help_menu.add_command(label="关于", command=self.on_about)
menubar.add_cascade(label="帮助", menu=help_menu)

self.config(menu=menubar)

def _create_toolbar(self):
self.toolbar = ttk.Frame(self, relief=tk.RAISED)
self.toolbar.pack(fill=tk.X, padx=2, pady=2)

for text, command in [
("新建", self.on_new), ("打开", self.on_open), ("保存", self.on_save),
]:
ttk.Button(self.toolbar, text=text, command=command).pack(side=tk.LEFT, padx=1)

ttk.Separator(self.toolbar, orient=tk.VERTICAL).pack(side=tk.LEFT, fill=tk.Y, padx=5, pady=2)

for text, command in [
("撤销", self.on_undo), ("重做", self.on_redo),
]:
ttk.Button(self.toolbar, text=text, command=command).pack(side=tk.LEFT, padx=1)

def _create_content(self):
self.text = tk.Text(self, wrap=tk.WORD, undo=True)
self.text.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)

self.status_var = tk.StringVar(value="就绪")
self.statusbar = ttk.Label(self, textvariable=self.status_var, relief=tk.SUNKEN, anchor=tk.W)
self.statusbar.pack(fill=tk.X)

def toggle_toolbar(self):
if self.show_toolbar_var.get():
self.toolbar.pack(fill=tk.X, padx=2, pady=2, before=self.text)
else:
self.toolbar.pack_forget()

def toggle_statusbar(self):
if self.show_statusbar_var.get():
self.statusbar.pack(fill=tk.X)
else:
self.statusbar.pack_forget()

def on_new(self): self.text.delete("1.0", tk.END); self.status_var.set("新建文件")
def on_open(self): self.status_var.set("打开文件")
def on_save(self): self.status_var.set("文件已保存")
def on_exit(self): self.quit()
def on_undo(self): self.text.event_generate("<<Undo>>")
def on_redo(self): self.text.event_generate("<<Redo>>")
def on_cut(self): self.text.event_generate("<<Cut>>")
def on_copy(self): self.text.event_generate("<<Copy>>")
def on_paste(self): self.text.event_generate("<<Paste>>")
def on_about(self): messagebox.showinfo("关于", "Tkinter应用示例 v1.0")


if __name__ == "__main__":
app = MenuApp()
app.mainloop()

20.6 MVC架构实践

20.6.1 MVC模式实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
import tkinter as tk
from tkinter import ttk, messagebox
from dataclasses import dataclass, field
from typing import Optional
import json
import os


@dataclass
class Contact:
name: str
phone: str
email: str = ""
group: str = "默认"
id: Optional[int] = field(default=None)


class ContactModel:
def __init__(self):
self.contacts: list[Contact] = []
self._next_id = 1
self._observers = []

def add_observer(self, callback):
self._observers.append(callback)

def notify_observers(self):
for callback in self._observers:
callback()

def add_contact(self, contact: Contact) -> Contact:
contact.id = self._next_id
self._next_id += 1
self.contacts.append(contact)
self.notify_observers()
return contact

def update_contact(self, contact_id: int, **kwargs) -> Optional[Contact]:
for contact in self.contacts:
if contact.id == contact_id:
for key, value in kwargs.items():
if hasattr(contact, key):
setattr(contact, key, value)
self.notify_observers()
return contact
return None

def delete_contact(self, contact_id: int) -> bool:
for i, contact in enumerate(self.contacts):
if contact.id == contact_id:
self.contacts.pop(i)
self.notify_observers()
return True
return False

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()
]

def get_by_group(self, group: str) -> list[Contact]:
if group == "全部":
return self.contacts
return [c for c in self.contacts if c.group == group]

def get_groups(self) -> list[str]:
groups = {"全部", "默认"}
groups.update(c.group for c in self.contacts)
return sorted(groups)

def save_to_file(self, filepath: str):
data = [{"id": c.id, "name": c.name, "phone": c.phone, "email": c.email, "group": c.group} for c in self.contacts]
with open(filepath, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)

def load_from_file(self, filepath: str):
if os.path.exists(filepath):
with open(filepath, "r", encoding="utf-8") as f:
data = json.load(f)
self.contacts.clear()
for item in data:
self.contacts.append(Contact(**item))
self._next_id = max((c.id for c in self.contacts), default=0) + 1
self.notify_observers()


class ContactView(tk.Tk):
def __init__(self):
super().__init__()
self.title("通讯录管理")
self.geometry("700x500")
self.minsize(600, 400)

self.controller: Optional["ContactController"] = None

self._create_widgets()

def set_controller(self, controller: "ContactController"):
self.controller = controller

def _create_widgets(self):
toolbar = ttk.Frame(self)
toolbar.pack(fill=tk.X, padx=5, pady=5)

ttk.Label(toolbar, text="搜索:").pack(side=tk.LEFT, padx=(0, 5))
self.search_var = tk.StringVar()
self.search_var.trace_add("write", lambda *_: self.on_search())
ttk.Entry(toolbar, textvariable=self.search_var, width=20).pack(side=tk.LEFT, padx=(0, 10))

ttk.Label(toolbar, text="分组:").pack(side=tk.LEFT, padx=(0, 5))
self.group_var = tk.StringVar(value="全部")
self.group_combo = ttk.Combobox(toolbar, textvariable=self.group_var, state="readonly", width=10)
self.group_combo.pack(side=tk.LEFT, padx=(0, 10))
self.group_combo.bind("<<ComboboxSelected>>", lambda e: self.on_group_change())

ttk.Separator(toolbar, orient=tk.VERTICAL).pack(side=tk.LEFT, fill=tk.Y, padx=5)
ttk.Button(toolbar, text="添加", command=self.on_add).pack(side=tk.LEFT, padx=2)
ttk.Button(toolbar, text="编辑", command=self.on_edit).pack(side=tk.LEFT, padx=2)
ttk.Button(toolbar, text="删除", command=self.on_delete).pack(side=tk.LEFT, padx=2)

columns = ("name", "phone", "email", "group")
self.tree = ttk.Treeview(self, columns=columns, show="headings", selectmode="browse")
for col, heading, width in [
("name", "姓名", 150), ("phone", "电话", 130),
("email", "邮箱", 200), ("group", "分组", 100),
]:
self.tree.heading(col, text=heading)
self.tree.column(col, width=width)

vsb = ttk.Scrollbar(self, orient=tk.VERTICAL, command=self.tree.yview)
self.tree.configure(yscrollcommand=vsb.set)
self.tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(5, 0), pady=5)
vsb.pack(side=tk.RIGHT, fill=tk.Y, padx=(0, 5), pady=5)

self.tree.bind("<Double-1>", lambda e: self.on_edit())

self.status_var = tk.StringVar(value="就绪")
ttk.Label(self, textvariable=self.status_var, relief=tk.SUNKEN).pack(fill=tk.X, side=tk.BOTTOM)

def update_contact_list(self, contacts: list[Contact]):
for item in self.tree.get_children():
self.tree.delete(item)
for contact in contacts:
self.tree.insert("", tk.END, iid=str(contact.id), values=(
contact.name, contact.phone, contact.email, contact.group,
))
self.status_var.set(f"共 {len(contacts)} 条记录")

def update_groups(self, groups: list[str]):
self.group_combo["values"] = groups

def on_search(self):
if self.controller:
self.controller.search(self.search_var.get())

def on_group_change(self):
if self.controller:
self.controller.filter_by_group(self.group_var.get())

def on_add(self):
if self.controller:
self.controller.add_contact()

def on_edit(self):
if self.controller:
selection = self.tree.selection()
if selection:
self.controller.edit_contact(int(selection[0]))

def on_delete(self):
if self.controller:
selection = self.tree.selection()
if selection:
self.controller.delete_contact(int(selection[0]))


class ContactController:
def __init__(self, model: ContactModel, view: ContactView):
self.model = model
self.view = view
self.view.set_controller(self)

self.model.add_observer(self.refresh_view)
self.refresh_view()

def refresh_view(self):
contacts = self.model.get_by_group(self.view.group_var.get())
self.view.update_contact_list(contacts)
self.view.update_groups(self.model.get_groups())

def search(self, keyword: str):
if keyword:
contacts = self.model.search(keyword)
else:
contacts = self.model.get_by_group(self.view.group_var.get())
self.view.update_contact_list(contacts)

def filter_by_group(self, group: str):
contacts = self.model.get_by_group(group)
self.view.update_contact_list(contacts)

def add_contact(self):
dialog = ContactDialog(self.view, title="添加联系人")
self.view.wait_window(dialog)
if dialog.result:
self.model.add_contact(Contact(**dialog.result))

def edit_contact(self, contact_id: int):
contact = next((c for c in self.model.contacts if c.id == contact_id), None)
if contact:
dialog = ContactDialog(
self.view, title="编辑联系人",
initial_data={"name": contact.name, "phone": contact.phone, "email": contact.email, "group": contact.group},
)
self.view.wait_window(dialog)
if dialog.result:
self.model.update_contact(contact_id, **dialog.result)

def delete_contact(self, contact_id: int):
if messagebox.askyesno("确认", "确定删除此联系人?"):
self.model.delete_contact(contact_id)


class ContactDialog(tk.Toplevel):
def __init__(self, parent, title="联系人", initial_data=None):
super().__init__(parent)
self.title(title)
self.geometry("400x300")
self.transient(parent)
self.grab_set()
self.result = None

frame = ttk.Frame(self, padding=20)
frame.pack(fill=tk.BOTH, expand=True)
frame.columnconfigure(1, weight=1)

self.vars = {}
for i, (label, key) in enumerate([
("姓名", "name"), ("电话", "phone"), ("邮箱", "email"), ("分组", "group"),
]):
ttk.Label(frame, text=f"{label}:").grid(row=i, column=0, sticky=tk.W, pady=5)
var = tk.StringVar(value=initial_data.get(key, "") if initial_data else "")
self.vars[key] = var
if key == "group":
ttk.Entry(frame, textvariable=var).grid(row=i, column=1, sticky=tk.EW, pady=5, padx=(10, 0))
else:
ttk.Entry(frame, textvariable=var).grid(row=i, column=1, sticky=tk.EW, pady=5, padx=(10, 0))

btn_frame = ttk.Frame(frame)
btn_frame.grid(row=4, column=0, columnspan=2, pady=(20, 0))
ttk.Button(btn_frame, text="确定", command=self.on_ok).pack(side=tk.LEFT, padx=5)
ttk.Button(btn_frame, text="取消", command=self.destroy).pack(side=tk.LEFT, padx=5)

def on_ok(self):
name = self.vars["name"].get().strip()
phone = self.vars["phone"].get().strip()
if not name or not phone:
messagebox.showwarning("提示", "姓名和电话不能为空", parent=self)
return
self.result = {
"name": name, "phone": phone,
"email": self.vars["email"].get().strip(),
"group": self.vars["group"].get().strip() or "默认",
}
self.destroy()


if __name__ == "__main__":
model = ContactModel()
model.add_contact(Contact(name="张三", phone="13800138001", email="zhangsan@example.com", group="工作"))
model.add_contact(Contact(name="李四", phone="13800138002", email="lisi@example.com", group="朋友"))

view = ContactView()
controller = ContactController(model, view)
view.mainloop()

20.7 多线程与异步

20.7.1 后台任务处理

GUI主线程不能执行耗时操作,否则界面会冻结。需要使用线程处理后台任务:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
import tkinter as tk
from tkinter import ttk
import threading
import queue
import time


class AsyncApp(tk.Tk):
def __init__(self):
super().__init__()
self.title("异步任务演示")
self.geometry("500x300")

self.task_queue = queue.Queue()
self._poll_queue()

main_frame = ttk.Frame(self, padding=20)
main_frame.pack(fill=tk.BOTH, expand=True)

self.progress = ttk.Progressbar(main_frame, mode="determinate")
self.progress.pack(fill=tk.X, pady=(0, 10))

self.status_var = tk.StringVar(value="就绪")
ttk.Label(main_frame, textvariable=self.status_var).pack(anchor=tk.W)

self.result_var = tk.StringVar()
ttk.Label(main_frame, textvariable=self.result_var, wraplength=400).pack(
anchor=tk.W, pady=(10, 0)
)

btn_frame = ttk.Frame(main_frame)
btn_frame.pack(pady=(20, 0))
self.start_btn = ttk.Button(btn_frame, text="开始任务", command=self.start_task)
self.start_btn.pack(side=tk.LEFT, padx=5)
ttk.Button(btn_frame, text="取消", command=self.cancel_task).pack(side=tk.LEFT, padx=5)

self._cancel_flag = False

def start_task(self):
self._cancel_flag = False
self.start_btn.config(state=tk.DISABLED)
self.progress["value"] = 0
self.status_var.set("任务执行中...")

thread = threading.Thread(target=self._background_task, daemon=True)
thread.start()

def _background_task(self):
total = 100
for i in range(total + 1):
if self._cancel_flag:
self.task_queue.put(("cancelled", None))
return
time.sleep(0.05)
self.task_queue.put(("progress", i))
self.task_queue.put(("completed", f"任务完成!处理了 {total} 个项目"))

def _poll_queue(self):
try:
while True:
event_type, data = self.task_queue.get_nowait()
if event_type == "progress":
self.progress["value"] = data
self.status_var.set(f"进度: {data}%")
elif event_type == "completed":
self.result_var.set(data)
self.status_var.set("任务完成")
self.start_btn.config(state=tk.NORMAL)
elif event_type == "cancelled":
self.status_var.set("任务已取消")
self.start_btn.config(state=tk.NORMAL)
except queue.Empty:
pass
self.after(100, self._poll_queue)

def cancel_task(self):
self._cancel_flag = True


if __name__ == "__main__":
app = AsyncApp()
app.mainloop()

20.8 Canvas绘图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
import tkinter as tk
from tkinter import ttk, colorchooser


class DrawingApp(tk.Tk):
def __init__(self):
super().__init__()
self.title("画图程序")
self.geometry("900x600")

self.current_tool = "pen"
self.current_color = "#000000"
self.line_width = 2
self._last_x = None
self._last_y = None
self._items = []

self._create_toolbar()
self._create_canvas()

def _create_toolbar(self):
toolbar = ttk.Frame(self)
toolbar.pack(fill=tk.X, padx=5, pady=5)

ttk.Label(toolbar, text="工具:").pack(side=tk.LEFT, padx=(0, 5))
self.tool_var = tk.StringVar(value="pen")
for text, value in [("画笔", "pen"), ("直线", "line"), ("矩形", "rect"), ("椭圆", "oval"), ("橡皮", "eraser")]:
ttk.Radiobutton(toolbar, text=text, variable=self.tool_var, value=value).pack(side=tk.LEFT, padx=2)

ttk.Separator(toolbar, orient=tk.VERTICAL).pack(side=tk.LEFT, fill=tk.Y, padx=10)

self.color_btn = tk.Button(toolbar, bg=self.current_color, width=3, command=self.choose_color)
self.color_btn.pack(side=tk.LEFT, padx=5)
ttk.Label(toolbar, text="颜色").pack(side=tk.LEFT)

ttk.Separator(toolbar, orient=tk.VERTICAL).pack(side=tk.LEFT, fill=tk.Y, padx=10)

ttk.Label(toolbar, text="线宽:").pack(side=tk.LEFT)
self.width_var = tk.IntVar(value=2)
ttk.Spinbox(toolbar, from_=1, to=20, textvariable=self.width_var, width=5).pack(side=tk.LEFT, padx=5)

ttk.Separator(toolbar, orient=tk.VERTICAL).pack(side=tk.LEFT, fill=tk.Y, padx=10)
ttk.Button(toolbar, text="撤销", command=self.undo).pack(side=tk.LEFT, padx=2)
ttk.Button(toolbar, text="清空", command=self.clear).pack(side=tk.LEFT, padx=2)

def _create_canvas(self):
self.canvas = tk.Canvas(self, bg="white", cursor="crosshair")
self.canvas.pack(fill=tk.BOTH, expand=True, padx=5, pady=(0, 5))

self.canvas.bind("<Button-1>", self.on_press)
self.canvas.bind("<B1-Motion>", self.on_drag)
self.canvas.bind("<ButtonRelease-1>", self.on_release)

def choose_color(self):
color = colorchooser.askcolor(self.current_color, title="选择颜色")
if color[1]:
self.current_color = color[1]
self.color_btn.config(bg=self.current_color)

def on_press(self, event):
self._last_x = event.x
self._last_y = event.y
self._start_x = event.x
self._start_y = event.y

if self.tool_var.get() == "pen" or self.tool_var.get() == "eraser":
color = "white" if self.tool_var.get() == "eraser" else self.current_color
width = self.width_var.get() * 3 if self.tool_var.get() == "eraser" else self.width_var.get()
item = self.canvas.create_line(
event.x, event.y, event.x + 1, event.y + 1,
fill=color, width=width, capstyle=tk.ROUND, smooth=True,
)
self._items.append(item)

def on_drag(self, event):
tool = self.tool_var.get()
if tool in ("pen", "eraser"):
color = "white" if tool == "eraser" else self.current_color
width = self.width_var.get() * 3 if tool == "eraser" else self.width_var.get()
item = self.canvas.create_line(
self._last_x, self._last_y, event.x, event.y,
fill=color, width=width, capstyle=tk.ROUND, smooth=True,
)
self._items.append(item)
elif tool in ("line", "rect", "oval"):
if hasattr(self, "_preview_item"):
self.canvas.delete(self._preview_item)
if tool == "line":
self._preview_item = self.canvas.create_line(
self._start_x, self._start_y, event.x, event.y,
fill=self.current_color, width=self.width_var.get(), dash=(4, 4),
)
elif tool == "rect":
self._preview_item = self.canvas.create_rectangle(
self._start_x, self._start_y, event.x, event.y,
outline=self.current_color, width=self.width_var.get(), dash=(4, 4),
)
elif tool == "oval":
self._preview_item = self.canvas.create_oval(
self._start_x, self._start_y, event.x, event.y,
outline=self.current_color, width=self.width_var.get(), dash=(4, 4),
)

self._last_x = event.x
self._last_y = event.y

def on_release(self, event):
tool = self.tool_var.get()
if hasattr(self, "_preview_item"):
self.canvas.delete(self._preview_item)
del self._preview_item

if tool == "line":
item = self.canvas.create_line(
self._start_x, self._start_y, event.x, event.y,
fill=self.current_color, width=self.width_var.get(),
)
self._items.append(item)
elif tool == "rect":
item = self.canvas.create_rectangle(
self._start_x, self._start_y, event.x, event.y,
outline=self.current_color, width=self.width_var.get(),
)
self._items.append(item)
elif tool == "oval":
item = self.canvas.create_oval(
self._start_x, self._start_y, event.x, event.y,
outline=self.current_color, width=self.width_var.get(),
)
self._items.append(item)

def undo(self):
if self._items:
self.canvas.delete(self._items.pop())

def clear(self):
self.canvas.delete(tk.ALL)
self._items.clear()


if __name__ == "__main__":
app = DrawingApp()
app.mainloop()

20.9 前沿技术动态

20.9.1 Tkinter与现代Python

  • CustomTkinter:基于Tkinter的现代UI库,提供圆角控件、暗色主题
  • tkinterdnd2:支持拖放操作
  • sv_ttk:Sun Valley主题,模拟Windows 11风格
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import customtkinter as ctk

ctk.set_appearance_mode("dark")
ctk.set_default_color_theme("blue")

app = ctk.CTk()
app.geometry("400x300")

frame = ctk.CTkFrame(app)
frame.pack(fill=tk.BOTH, expand=True, padx=20, pady=20)

entry = ctk.CTkEntry(frame, placeholder_text="输入文本...")
entry.pack(pady=(20, 10))

button = ctk.CTkButton(frame, text="点击我", command=lambda: print(entry.get()))
button.pack(pady=10)

app.mainloop()

20.9.2 Tkinter与数据可视化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import tkinter as tk
from tkinter import ttk
from matplotlib.figure import Figure
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg, NavigationToolbar2Tk


class ChartApp(tk.Tk):
def __init__(self):
super().__init__()
self.title("数据可视化")
self.geometry("800x600")

self.fig = Figure(figsize=(8, 5), dpi=100)
self.ax = self.fig.add_subplot(111)
self.canvas = FigureCanvasTkAgg(self.fig, master=self)
self.canvas.get_tk_widget().pack(fill=tk.BOTH, expand=True)

toolbar = NavigationToolbar2Tk(self.canvas, self)
toolbar.update()
toolbar.pack(side=tk.BOTTOM, fill=tk.X)

self.plot_data()

def plot_data(self):
import numpy as np
x = np.linspace(0, 10, 100)
self.ax.plot(x, np.sin(x), label="sin(x)")
self.ax.plot(x, np.cos(x), label="cos(x)")
self.ax.set_title("三角函数")
self.ax.legend()
self.ax.grid(True)
self.canvas.draw()


if __name__ == "__main__":
app = ChartApp()
app.mainloop()

20.10 本章小结

本章系统阐述了Tkinter GUI开发的核心知识体系:

  1. GUI编程模型:事件驱动编程、主循环机制与控件层次结构
  2. 布局管理:Grid响应式布局、Frame嵌套与权重分配
  3. 核心控件:ttk主题控件、Treeview数据表格与表单控件
  4. 事件处理:事件绑定、回调机制与自定义事件
  5. 菜单系统:菜单栏、工具栏、右键菜单与快捷键绑定
  6. MVC架构:Model-View-Controller在Tkinter中的实践
  7. 多线程GUI:队列轮询机制解决GUI线程阻塞问题
  8. Canvas绘图:绘图工具、图形操作与交互式画板
  9. 数据可视化:Matplotlib与Tkinter的集成

20.11 习题与项目练习

基础题

  1. 创建一个登录窗口,包含用户名、密码输入框和登录按钮,实现基本的表单验证。

  2. 使用Grid布局创建一个计算器界面,支持加减乘除四则运算。

  3. 实现一个简单的文本编辑器,包含新建、打开、保存功能和基本的编辑操作。

进阶题

  1. 使用MVC架构实现一个学生成绩管理系统,包含成绩录入、查询、统计和图表展示功能。

  2. 实现一个多线程文件搜索工具,搜索过程中界面保持响应,实时显示搜索进度和结果。

  3. 使用Canvas实现一个简单的流程图编辑器,支持节点的拖拽、连线和删除操作。

综合项目

  1. 个人财务管理应用:构建一个完整的桌面财务管理应用,包含:

    • 收支记录管理(增删改查)
    • 分类统计与饼图展示
    • 月度/年度报表
    • 数据导入/导出(CSV、JSON)
    • 预算管理与预警
    • MVC架构与数据持久化
  2. Markdown编辑器:构建一个Markdown编辑器,包含:

    • 实时预览
    • 语法高亮
    • 文件管理(新建、打开、保存)
    • 工具栏快捷操作
    • 主题切换(亮色/暗色)
    • 导出HTML/PDF

思考题

  1. Tkinter的after()方法与Python的threading.Timer在GUI定时任务中有何区别?为什么在GUI编程中应优先使用after()

  2. 在MVC架构中,Model如何实现观察者模式以通知View更新?请比较回调函数、事件绑定和变量追踪(trace)三种机制的优劣。

20.12 延伸阅读

20.12.1 Tkinter官方资源

20.12.2 现代Tkinter扩展

20.12.3 进阶书籍

  • 《Python and Tkinter Programming》 (John Grayson) — Tkinter经典教程
  • 《Tkinter GUI Application Development Blueprints》 — 实战项目教程

20.12.4 其他GUI框架


下一章:第21章 PyQt GUI开发