本书红线(贯穿全教程,回顾):
1. 每个界面都用class App:封装,__init__里建控件,方法即事件回调。
2. 所有控件示例一律写在类里,不用脱离类的扁平脚本。
3. 代码块带if __name__ == "__main__":守卫,可独立运行。
4. 星级:★ 了解 · ★★ 理解 · ★★★ 掌握 · ★★★★ 重点必会。
主线项目:图形界面通讯录管理器,随章节每章新增一个控件慢慢长大。
上节回顾(第 19 章):你扫清了从"能跑"到"稳跑"的六大坑——PhotoImage 保引用、after 定时、耗时任务进子线程、布局坑、高 DPI、以及 protocol 退出前保存。现在万事俱备。本章把全书能力组装成一个完整可运行的通讯录管理器,并采用 MVC 分层架构。
本章目标:把全书控件与知识组装成完整可运行项目——OOP 通讯录管理器。采用三层结构:Model(数据) Contact + ContactManager、View + Controller(界面+交互) ContactApp,并复用两个自定义组件 ContactForm / ContactTable(继承 ttk.Frame)。数据用 JSON 文件持久化(零依赖、易讲、适合零基础)。验收:增、删、改、查、分类筛选、收藏、打开/保存、退出保存——整文件复制即可运行。
MVC 是 GUI 程序最经典的架构,把"数据""界面""逻辑"分开:
| 层 | 类名 | 职责 | 依赖 GUI 吗 |
|---|---|---|---|
| Model(模型) | Contact / ContactManager | 存数据、增删查改、文件存取 | 否(纯逻辑) |
| View(视图) | ContactForm / ContactTable | 展示表单与表格(自定义组件) | 是 |
| Controller(控制器) | ContactApp | 接事件、调 Model、刷新 View | 是 |
数据流向:用户操作(点按钮/菜单)→ Controller 方法 → 调用 Model 改数据 → Controller 调用 View 的 refresh 刷新界面。Model 完全不知道 GUI 的存在,因此它可以单独测试、也可以将来换成数据库而不动界面。
红线汇总(全教程收口):类封装、方法即回调、self 持有状态、MVC 分层、自定义组件复用——本章全部用到。
Model 是"纯逻辑",不 import tkinter、不知道界面。先定义数据模型 Contact(用 @dataclass 省去手写 __init__),再定义 ContactManager 负责增删查改与 JSON 存取。
# 第20章 Model 层:Contact(数据模型) + ContactManager(纯逻辑,不依赖 GUI)
import json
import os
from dataclasses import dataclass, asdict
@dataclass # 装饰器自动生成 __init__/__repr__ 等
class Contact:
name: str = "" # 姓名
phone: str = "" # 电话
category: str = "朋友" # 分类:朋友/家人/同事/其他
favorite: bool = False # 是否收藏
def to_dict(self):
return asdict(self) # 把字段转成普通字典,方便写 JSON
@classmethod
def from_dict(cls, d):
# 从字典还原成 Contact(读 JSON 时用)
return cls(
name=d.get("name", ""),
phone=d.get("phone", ""),
category=d.get("category", "朋友"),
favorite=bool(d.get("favorite", False)),
)
class ContactManager:
"""纯逻辑:增删改查 + 文件存取,完全不依赖 tkinter。"""
def __init__(self):
self.contacts = [] # 通讯录数据列表(元素是 Contact)
# ---- 增删改查 ----
def add(self, contact):
self.contacts.append(contact)
def remove(self, index):
if 0 <= index < len(self.contacts):
self.contacts.pop(index)
def update(self, index, contact):
if 0 <= index < len(self.contacts):
self.contacts[index] = contact
def get(self, index):
if 0 <= index < len(self.contacts):
return self.contacts[index]
return None
def count(self):
return len(self.contacts)
# ---- 筛选(查的子集)----
def filter_with_indices(self, category="全部", favorites_only=False):
# 返回 [(contact, 在 self.contacts 中的真实下标), ...],方便后面删改定位
result = []
for i, c in enumerate(self.contacts):
if category != "全部" and c.category != category:
continue
if favorites_only and not c.favorite:
continue
result.append((c, i))
return result
# ---- 文件存取(JSON,零依赖)----
def save(self, path):
with open(path, "w", encoding="utf-8") as f:
json.dump([c.to_dict() for c in self.contacts],
f, ensure_ascii=False, indent=2)
def load(self, path):
if not os.path.exists(path):
return
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
self.contacts = [Contact.from_dict(d) for d in data]逐行说明:
@dataclass:自动生成 Contact(name, phone, category, favorite) 的构造器,省去手写 __init__。to_dict / from_dict:Model 与"序列化格式(JSON)"之间的桥梁;这样 Model 本身仍不依赖 GUI。filter_with_indices:筛选时同时记下"真实下标",因为表格显示的是筛选后的子集,用户选中后我们要知道它对应 self.contacts 里的第几条,才能正确删改。save/load:用标准库 json 读写,零第三方依赖;ensure_ascii=False 让中文正常保存。📌 想换真实数据库? 把save/load改成 SQLite 的 7 张表方案即可,详见现有文档通讯录管理系统_SQLite数据库设计.md(本文不展开,仅指出路径)。Model 接口不变,View/Controller 一行都不用改——这正是 MVC 的好处。
把"表单"和"表格"分别封装成可复用的组件类,都继承 ttk.Frame,符合本书"自定义组件复用"红线。
# 第20章 View 层:两个自定义组件(继承 ttk.Frame 复用)
import tkinter as tk
from tkinter import ttk
from tkinter import messagebox as msgbox
from dataclasses import dataclass # 仅用于类型提示,Contact 已在上文定义
class ContactForm(ttk.Frame):
"""录入/编辑一条联系人的表单组件。"""
CATEGORIES = ["朋友", "家人", "同事", "其他"]
def __init__(self, master, on_submit=None, **kw):
super().__init__(master, **kw)
self.on_submit = on_submit # 提交回调,由 Controller 注入
self._build_widgets()
def _build_widgets(self):
# 姓名
ttk.Label(self, text="姓名:").grid(row=0, column=0, sticky="e", padx=4, pady=4)
self.name_var = tk.StringVar()
ttk.Entry(self, textvariable=self.name_var, width=18).grid(
row=0, column=1, sticky="we", padx=4, pady=4)
# 电话
ttk.Label(self, text="电话:").grid(row=1, column=0, sticky="e", padx=4, pady=4)
self.phone_var = tk.StringVar()
ttk.Entry(self, textvariable=self.phone_var, width=18).grid(
row=1, column=1, sticky="we", padx=4, pady=4)
# 分类(下拉框)
ttk.Label(self, text="分类:").grid(row=2, column=0, sticky="e", padx=4, pady=4)
self.cat_var = tk.StringVar(value="朋友")
ttk.Combobox(self, textvariable=self.cat_var, values=self.CATEGORIES,
state="readonly", width=16).grid(
row=2, column=1, sticky="we", padx=4, pady=4)
# 收藏(勾选框)
self.fav_var = tk.BooleanVar()
ttk.Checkbutton(self, text="设为收藏", variable=self.fav_var).grid(
row=3, column=0, columnspan=2, sticky="w", padx=4, pady=4)
# 提交按钮
self.submit_btn = ttk.Button(self, text="添加联系人", command=self._submit)
self.submit_btn.grid(row=4, column=0, columnspan=2, sticky="we", padx=4, pady=8)
def _submit(self):
# 收集表单 → 构造 Contact → 回调给 Controller
contact = Contact(
name=self.name_var.get().strip(),
phone=self.phone_var.get().strip(),
category=self.cat_var.get(),
favorite=self.fav_var.get(),
)
if not contact.name:
msgbox.showwarning("提示", "姓名不能为空")
return
if self.on_submit:
self.on_submit(contact) # 把数据交还给 Controller
def load_contact(self, contact):
"""把一条联系人填进表单(用于编辑)。"""
self.name_var.set(contact.name)
self.phone_var.set(contact.phone)
self.cat_var.set(contact.category)
self.fav_var.set(contact.favorite)
self.submit_btn.config(text="保存修改")
def clear(self):
self.name_var.set("")
self.phone_var.set("")
self.cat_var.set("朋友")
self.fav_var.set(False)
self.submit_btn.config(text="添加联系人")
class ContactTable(ttk.Frame):
"""用 Treeview 展示联系人列表的表格组件。"""
COLUMNS = ("name", "phone", "category", "favorite")
def __init__(self, master, on_select=None, **kw):
super().__init__(master, **kw)
self.on_select = on_select # 选中回调,由 Controller 注入
self._indices = [] # 每行对应的 manager 真实下标
self._build()
def _build(self):
self.tree = ttk.Treeview(self, columns=self.COLUMNS,
show="headings", height=12)
# 设表头文字与列宽
self.tree.heading("name", text="姓名")
self.tree.heading("phone", text="电话")
self.tree.heading("category", text="分类")
self.tree.heading("favorite", text="收藏")
self.tree.column("name", width=90)
self.tree.column("phone", width=120)
self.tree.column("category", width=80)
self.tree.column("favorite", width=50)
self.tree.grid(row=0, column=0, sticky="nsew")
# 纵向滚动条
vsb = ttk.Scrollbar(self, orient="vertical", command=self.tree.yview)
vsb.grid(row=0, column=1, sticky="ns")
self.tree.configure(yscrollcommand=vsb.set)
# 让表格随父容器拉伸
self.rowconfigure(0, weight=1)
self.columnconfigure(0, weight=1)
# 选中事件 → 回调 Controller
self.tree.bind("<<TreeviewSelect>>", self._on_select)
def refresh(self, contacts, indices=None):
self.tree.delete(*self.tree.get_children()) # 先清空(*展开所有子项 id)
self._indices = indices if indices is not None else list(range(len(contacts)))
for c in contacts:
# 收藏列用 ★ 表示
self.tree.insert("", "end", values=(
c.name, c.phone, c.category, "★" if c.favorite else ""))
def _on_select(self, event):
if self.on_select:
self.on_select(self.get_selected_index())
def get_selected_index(self):
sel = self.tree.selection()
if not sel:
return None
pos = self.tree.index(sel[0]) # 当前可见行号
return self._indices[pos] # 映射回 manager 真实下标逐行说明(重点):
class ContactForm(ttk.Frame) / class ContactTable(ttk.Frame):组件继承 ttk.Frame,本身就是一个"带内容的容器",可以像普通控件一样 pack/grid 放进别处——这是自定义组件复用的核心。super().__init__(master, **kw):先调用父类 ttk.Frame 的构造,把 master(父容器)接好。on_submit / on_select:Controller 在创建组件时把"回调方法"注入进来(如 ContactForm(body, on_submit=self.on_submit))。组件内部只管"收集数据/通知选中",不知道 Controller 怎么处理——职责解耦。self.tree.delete(self.tree.get_children()):清空 Treeview 的标准写法( 把 id 列表展开成多个参数)。show="headings" 且 columns 与 insert 的 values 顺序严格对应——呼应全书 Treeview 红线。get_selected_index:把"可见行号"通过 self._indices 映射回 Manager 里的真实下标,保证删改定位正确。ContactApp 是"大脑":建菜单、工具栏、表单、表格,并把 Model 与 View 接起来。事件触发它的方法 → 调 Model → 刷新 View。
# 第20章 Controller 层:ContactApp(接事件、调 Model、刷新 View)
import tkinter as tk
from tkinter import ttk
from tkinter import filedialog
from tkinter import messagebox as msgbox
class ContactApp:
DATA_FILE = "contacts.json" # 默认自动保存的文件
CATEGORIES = ["全部", "朋友", "家人", "同事", "其他"]
def __init__(self, root):
self.root = root
self.root.title("通讯录管理器(MVC)")
self.root.geometry("640x420")
self.manager = ContactManager() # Model 实例
self.selected_index = None # 当前选中的联系人下标
self._build_menu() # 菜单栏
self._build_toolbar() # 筛选工具栏
self._build_body() # 表单 + 表格
self._build_status() # 状态栏
# 退出前自动保存(呼应第19章 protocol 坑)
self.root.protocol("WM_DELETE_WINDOW", self.on_closing)
self._refresh() # 首次刷新
# ---- 构建界面 ----
def _build_menu(self):
menubar = tk.Menu(self.root)
file_menu = tk.Menu(menubar, tearoff=0)
file_menu.add_command(label="打开…", command=self.open_file)
file_menu.add_command(label="保存", command=self.save_file)
file_menu.add_separator()
file_menu.add_command(label="退出", command=self.on_closing)
menubar.add_cascade(label="文件", menu=file_menu)
edit_menu = tk.Menu(menubar, tearoff=0)
edit_menu.add_command(label="新增", command=self.on_new)
edit_menu.add_command(label="删除", command=self.on_delete)
edit_menu.add_command(label="收藏/取消", command=self.on_toggle_fav)
menubar.add_cascade(label="编辑", menu=edit_menu)
self.root.config(menu=menubar)
def _build_toolbar(self):
bar = ttk.Frame(self.root)
bar.pack(fill="x")
ttk.Label(bar, text="筛选分类:").pack(side="left", padx=4)
self.filter_var = tk.StringVar(value="全部")
ttk.Combobox(bar, textvariable=self.filter_var, values=self.CATEGORIES,
state="readonly", width=10).pack(side="left", padx=2)
# 下拉框变化 → 立即刷新(trace_add 是 StringVar 的"值变化"监听)
self.filter_var.trace_add("write", lambda *a: self._refresh())
self.fav_only_var = tk.BooleanVar()
ttk.Checkbutton(bar, text="只看收藏", variable=self.fav_only_var,
command=self._refresh).pack(side="left", padx=8)
def _build_body(self):
body = ttk.Frame(self.root)
body.pack(fill="both", expand=True, padx=6, pady=6)
# 左:表单(注入 on_submit 回调)
self.form = ContactForm(body, on_submit=self.on_submit)
self.form.pack(side="left", fill="y", padx=4)
# 右:表格(注入 on_select 回调)
self.table = ContactTable(body, on_select=self.on_select)
self.table.pack(side="left", fill="both", expand=True, padx=4)
def _build_status(self):
self.status_var = tk.StringVar(value="就绪")
ttk.Label(self.root, textvariable=self.status_var, relief="sunken",
anchor="w").pack(side="bottom", fill="x")
# ---- 表单/表格回调(Controller 逻辑)----
def on_submit(self, contact):
if self.selected_index is not None:
self.manager.update(self.selected_index, contact) # 改
self.status_var.set(f"已修改:{contact.name}")
else:
self.manager.add(contact) # 增
self.status_var.set(f"已添加:{contact.name}")
self.selected_index = None
self.form.clear()
self._refresh()
def on_new(self):
self.selected_index = None
self.form.clear()
self.status_var.set("请输入新联系人")
def on_select(self, index):
self.selected_index = index
if index is not None:
c = self.manager.get(index)
if c:
self.form.load_contact(c) # 把选中联系人载入表单供编辑
self.status_var.set(f"已选中:{c.name}")
else:
self.form.clear()
def on_delete(self):
if self.selected_index is None:
msgbox.showwarning("提示", "请先选中一条联系人")
return
c = self.manager.get(self.selected_index)
if c and msgbox.askyesno("确认", f"确定删除 {c.name} 吗?"):
self.manager.remove(self.selected_index)
self.selected_index = None
self.form.clear()
self._refresh()
self.status_var.set(f"已删除:{c.name}")
def on_toggle_fav(self):
if self.selected_index is None:
msgbox.showwarning("提示", "请先选中一条联系人")
return
c = self.manager.get(self.selected_index)
if c:
c.favorite = not c.favorite # 直接改 Model 数据
self._refresh()
self.status_var.set(f"{c.name} 收藏状态:{c.favorite}")
# ---- 文件操作(菜单触发)----
def open_file(self):
path = filedialog.askopenfilename(filetypes=[("JSON 文件", "*.json")])
if path:
self.manager.load(path)
self._refresh()
self.status_var.set(f"已打开:{path}")
def save_file(self):
path = filedialog.asksaveasfilename(defaultextension=".json",
filetypes=[("JSON 文件", "*.json")])
if path:
self.manager.save(path)
self.status_var.set(f"已保存:{path}")
# ---- 退出前保存(呼应第19章 on_closing 方法)----
def on_closing(self):
self.manager.save(self.DATA_FILE) # 先保存
self.root.destroy() # 再关窗
# ---- 刷新界面(统一出口)----
def _refresh(self):
cat = self.filter_var.get()
fav = self.fav_only_var.get()
pairs = self.manager.filter_with_indices(category=cat, favorites_only=fav)
contacts = [c for c, _ in pairs]
indices = [i for _, i in pairs]
self.table.refresh(contacts, indices)
self.status_var.set(f"共 {self.manager.count()} 条,显示 {len(contacts)} 条")逐行说明(重点):
__init__ 里先 self.manager = ContactManager()(Model),再建界面,最后 _refresh()。经典"Model 先就位 → 建 View → 首次刷新"。ContactForm(body, on_submit=self.on_submit):把 Controller 的方法 self.on_submit 作为回调"注入"组件。用户点表单的"添加"按钮 → 组件收集好数据 → 调用 self.on_submit(contact) → Controller 收到后改 Model 并 _refresh()。这就是 MVC 的数据流闭环。on_toggle_fav:c.favorite = not c.favorite 直接改 Model 里的数据对象,再 _refresh()——因为 self.contacts 里存的是同一个 Contact 对象引用,改它即改数据。on_closing:第 19 章讲过的退出保存,集中在这里处理"保存再关"。把 20.2~20.4 的 Model、两个自定义组件、Controller 放进同一个文件,末尾加 if __name__ == "__main__": 守卫启动。整文件复制即可运行(见附录 C 获取一份可直接复制的完整代码)。这里给出"启动段"与拼接要点:
# 第20章 启动段(与前面 Model/View/Controller 同文件)
if __name__ == "__main__":
root = tk.Tk() # 建主窗口
app = ContactApp(root) # 把窗口交给 Controller 初始化
root.mainloop() # 进入主循环拼接顺序(完整代码见附录 C):import 区 → @dataclass Contact + ContactManager(Model)→ ContactForm + ContactTable(View)→ ContactApp(Controller)→ if __name__ == "__main__": 启动。注意 ContactManager/Contact 在 ContactForm/ContactApp 里被用到,所以它们必须定义在前面(Python 按从上到下顺序解析名字)。
运行后你能做什么:
contacts.json。Contact(数据)、ContactManager(逻辑)、ContactForm/ContactTable(组件)、ContactApp(控制器)。command=self.on_submit、trace_add(..., self._refresh)、protocol(..., self.on_closing)、tree.bind("<<TreeviewSelect>>", self._on_select)——全书贯穿的范式在此集大成。self 持有状态:self.manager、self.form、self.table、self.selected_index、self.filter_var 全挂在 self 上,方法间靠 self 沟通,没有全局变量。ContactForm/ContactTable 继承 ttk.Frame,像积木一样 pack 进任意父容器,将来别的项目也能直接拿去用。import tkinter:会让"纯逻辑"变得依赖界面,失去可测试性与可替换性。Model 只管数据,绝不碰 GUI。columns 与 insert 的 values 顺序/数量不一致:显示错位或报错。务必一一对应。filter_with_indices + _indices)定位,不能直接用可见行号去删 self.contacts。on_closing 里 destroy():窗口关不掉(第 19 章坑复现)。trace_add 回调签名:它调用时会传入 3 个参数,所以用 lambda *a: self._refresh() 吃掉多余参数,避免报错。contacts.json,关闭再开程序,确认"退出前自动保存"已把它加载回来。ContactManager 换成 SQLite 版本(接口不变),ContactApp 需要改几行?(提示:理想情况下 0 行——这正是 MVC 的价值。)