Skip to content

第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工具包,采用事件驱动编程模型:

┌──────────────────────────────────┐
│          主循环(mainloop)        │
│  ┌────────────────────────────┐  │
│  │   事件队列(Event Queue)   │  │
│  │  ┌──────┐ ┌──────┐       │  │
│  │  │鼠标  │ │键盘  │ ...   │  │
│  │  └──┬───┘ └──┬───┘       │  │
│  └─────┼────────┼───────────┘  │
│        │        │              │
│        ▼        ▼              │
│  ┌────────────────────────────┐  │
│  │    事件分发器(Dispatcher) │  │
│  └────────────┬───────────────┘  │
│               │                  │
│               ▼                  │
│  ┌────────────────────────────┐  │
│  │    回调函数(Callback)     │  │
│  └────────────┬───────────────┘  │
│               │                  │
│               ▼                  │
│  ┌────────────────────────────┐  │
│  │    控件更新(Widget Update)│  │
│  └────────────────────────────┘  │
└──────────────────────────────────┘

20.1.2 应用程序结构

python
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是最常用的布局管理器,将容器划分为行和列的网格:

python
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 响应式布局

python
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)提供更现代的控件外观:

python
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数据表格

python
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 事件绑定机制

python
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 菜单与工具栏

python
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模式实现

python
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主线程不能执行耗时操作,否则界面会冻结。需要使用线程处理后台任务:

python
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绘图

python
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风格
python
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与数据可视化

python
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开发

青少年创意编程 - 高中Python组 - 江苏省宿城中等专业学校