📚 目录 第 20 章 综合项目:OOP 通讯录管理器(MVC 收口)

第 20 章 综合项目:OOP 通讯录管理器(MVC 收口)

本书红线(贯穿全教程,回顾):
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 文件持久化(零依赖、易讲、适合零基础)。验收:增、删、改、查、分类筛选、收藏、打开/保存、退出保存——整文件复制即可运行。


20.1 项目总览:MVC 三层结构 ★

MVC 是 GUI 程序最经典的架构,把"数据""界面""逻辑"分开:

层类名职责依赖 GUI 吗
Model(模型)Contact / ContactManager存数据、增删查改、文件存取否(纯逻辑)
View(视图)ContactForm / ContactTable展示表单与表格(自定义组件)是
Controller(控制器)ContactApp接事件、调 Model、刷新 View是

数据流向:用户操作(点按钮/菜单)→ Controller 方法 → 调用 Model 改数据 → Controller 调用 View 的 refresh 刷新界面。Model 完全不知道 GUI 的存在,因此它可以单独测试、也可以将来换成数据库而不动界面。

红线汇总(全教程收口):类封装、方法即回调、self 持有状态、MVC 分层、自定义组件复用——本章全部用到。

20.2 Model 层:数据模型与文件存取 ★★

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]

逐行说明:

📌 想换真实数据库? 把 save/load 改成 SQLite 的 7 张表方案即可,详见现有文档 通讯录管理系统_SQLite数据库设计.md(本文不展开,仅指出路径)。Model 接口不变,View/Controller 一行都不用改——这正是 MVC 的好处。

20.3 View 层:两个自定义组件(继承 ttk.Frame)★★★

把"表单"和"表格"分别封装成可复用的组件类,都继承 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 真实下标

逐行说明(重点):


20.4 Controller 层:ContactApp 把一切串起来 ★★★★

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)} 条")

逐行说明(重点):


20.5 组装与启动:完整代码讲解 ★★★★

把 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 按从上到下顺序解析名字)。

运行后你能做什么:


20.6 OOP 落点(全教程红线汇总)★★★★


20.7 注意事项 / 易错清单 ★★


20.8 本章验收(练习)

  1. 增:用左侧表单添加 3 位不同分类的联系人,确认表格出现 3 行。
  2. 查/筛选:用工具栏下拉框选"同事",确认只显示同事;勾"只看收藏"确认只剩收藏项。
  3. 改:点一行 → 表单载入 → 改电话 → 点"保存修改",确认表格更新。
  4. 删:选中一行 → 菜单"编辑→删除" → 确认对话框选"是",确认该行消失。
  5. 收藏:选中一行 → "编辑→收藏/取消",确认表格"收藏"列出现/消失 ★。
  6. 打开/保存:菜单"文件→保存"导出 contacts.json,关闭再开程序,确认"退出前自动保存"已把它加载回来。
  7. 拓展思考题:如果把 ContactManager 换成 SQLite 版本(接口不变),ContactApp 需要改几行?(提示:理想情况下 0 行——这正是 MVC 的价值。)