本书红线(贯穿全教程,回顾):
1. 每个界面都用class App:封装,__init__里建控件,方法即事件回调。
2. 所有控件示例一律写在类里,不用脱离类的扁平脚本。
3. 代码块带if __name__ == "__main__":守卫,可独立运行。
4. 星级:★ 了解 · ★★ 理解 · ★★★ 掌握 · ★★★★ 重点必会。
主线项目:图形界面通讯录管理器,随章节每章新增一个控件慢慢长大。
上节回顾(第 13 章):你学会了用 Notebook(分页) 和 PanedWindow(可拖拽分隔) 把"太多内容"组织得井井有条,并体会了"每页一个构建方法"带来的清爽。但每当新界面要用到"搜索栏""联系人表单""联系人表格"时,你都在重复写一遍同样的控件组合。本章彻底解决这个问题——把它们封装成自己的控件类。
本章目标(本版核心):把"一组常一起出现的控件"封装成自定义组件类,实现复用与解耦——这是 tkinter 工程化最关键的一步。你将学会:为什么需要自定义组件、继承 ttk.Frame 的标准写法、如何设计对外接口,并完整实现三个从通讯录提炼的组件:SearchBar、ContactForm、ContactTable。
自定义组件(复合控件)是什么:前面 11 章,我们用的都是 tkinter 自带的"原子控件"(Label、Entry、Button、Treeview……)。但工程里经常出现一组总在一起出现、一起干活的控件——比如"输入框 + 搜索按钮"。反复手写这两者很麻烦。我们可以自己定义一个类(比如 SearchBar),让它内部自动建好输入框和按钮、对外只暴露一个干净的 get_keyword() 方法。对外它就像一个普通控件,对内它是一组控件的组合——这就是"复合控件"。
为什么需要它(痛点):
SearchBar(root) 搞定,改一处全生效。bar.get_keyword()),不关心内部是 Entry 还是别的;内部怎么改(比如换成 Combobox)外部完全无感。这叫"封装内部细节,只暴露接口"。SearchBar(...)、ContactForm(...)、ContactTable(...) 几行——读代码像读句子。适用场景:任何"多次出现"或"逻辑内聚"的控件组合——搜索栏、登录框、表单、带刷新的表格、带标题的信息卡……
📌 核心思想:组合优于堆砌。与其在主界面里堆 30 个原子控件,不如把它们分组,每组做成一个自定义组件。
自定义组件的"标准骨架"只有三步,务必记牢:
import tkinter as tk
from tkinter import ttk
class MyWidget(ttk.Frame): # ① 继承 ttk.Frame
def __init__(self, master, **kw): # ② 第一个参数是父容器 master
super().__init__(master, **kw) # ③ 必须先初始化父类 Frame!
# ④ 在这里创建子控件,父容器写 self(不是 master)
# 并把它们 pack/grid 到 self 自己身上
# ⑤ 定义对外接口方法(如 get_xxx / set_xxx)逐条拆解:
ttk.Frame:自定义组件本身就是一个 Frame(容器),所以它天然能 pack/grid 到任意父窗口——用起来和内置控件一模一样。__init__(self, master, kw):master 是"把这个组件放到哪",kw 用来接收 padding、width 等传给父类 Frame 的选项(透传,避免遗漏)。super().__init__(master, kw):这是最容易忘、也最致命的一步**——必须先调用父类构造,把自己这个 Frame 真正创建出来,后面才能在它身上放子控件。漏掉这行会直接报错。self:输入框、按钮等的 master 应该写成 self(这个组件自身),而不是外面的 master。这样它们才"装进"组件内部。get_keyword()、refresh(data)),把内部细节藏起来。📌 因为继承了ttk.Frame,组件对象本身也有pack()/grid()/config(),所以外面可以写bar = SearchBar(root); bar.pack(),和用ttk.Button没区别。
"解耦"的关键就是接口约定。外部只通过这些方法和你的组件沟通:
| 接口类型 | 命名习惯 | 作用 | 示例 |
|---|---|---|---|
| 取值 | get_xxx() | 返回组件当前数据 | bar.get_keyword() → 字符串 |
| 设值 | set_xxx(data) / set(data) | 用外部数据填充组件 | form.set_data(d) |
| 重置 | clear() | 清空/复位内部状态 | form.clear() |
| 刷新 | refresh(data) | 用新数据整体刷新(常用于表格) | table.refresh(list) |
| 回调 | on_xxx 参数 / command= | 内部发生某事时通知外部 | SearchBar(..., on_search=cb) |
设计原则:
get_text()、一会儿 get_value()。全教程约定:get_keyword()(搜索栏)、get_data()/set_data()/clear()(表单)、refresh(data)(表格)。SearchBar(master, on_search=处理函数),组件内部保存并在合适时机调用,外部不必关心组件内部如何触发。self 上,外部拿不到也无需拿到——只拿最终数据。对外只暴露 get_keyword();点击按钮时把关键词交给外部回调 on_search。
# 第14章 例14.1:自定义组件 SearchBar(输入框 + 搜索按钮)
import tkinter as tk
from tkinter import ttk
class SearchBar(ttk.Frame):
"""搜索栏:一个输入框 + 一个搜索按钮,对外只暴露 get_keyword()。"""
def __init__(self, master, on_search=None, **kw):
super().__init__(master, **kw) # ① 必须先初始化父类 Frame
self.on_search = on_search # ② 保存外部传入的回调
# ③ 子控件:父容器写 self(装进组件自己)
self.entry = ttk.Entry(self)
self.entry.pack(side="left", fill="x", expand=True, padx=(0, 5))
self.btn = ttk.Button(self, text="搜索", command=self._on_click)
self.btn.pack(side="left")
def _on_click(self):
# 内部方法:点击搜索按钮时,把关键词交给外部回调
if self.on_search:
self.on_search(self.get_keyword())
def get_keyword(self):
"""对外接口:返回当前输入的关键词。"""
return self.entry.get()
if __name__ == "__main__":
root = tk.Tk()
root.title("通讯录管理器")
# 用法和内置控件一样:创建后 pack
bar = SearchBar(root, on_search=lambda kw: print("搜索:", kw))
bar.pack(padx=10, pady=10, fill="x")
root.mainloop()逐行说明:
class SearchBar(ttk.Frame)::搜索栏本质是 Frame,能直接 pack 到任意父。super().__init__(master, **kw):先把 Frame 建好,否则后面 self.entry = ttk.Entry(self) 会因 self 未初始化而报错。ttk.Entry(self):父是 self,输入框被装进这个搜索栏内部;pack(side="left", fill="x", expand=True) 让它在栏内靠左、随栏伸缩。command=self._on_click:按钮点击 → 调内部方法(方法即回调,见第 3 章)。get_keyword(self):对外接口,外部只需 bar.get_keyword() 拿文字,完全不知道里面是 Entry。对外 get_data()(读成字典)、set_data(data)(填充)、clear()(清空)。
# 第14章 例14.2:自定义组件 ContactForm(姓名/电话/分类/收藏)
import tkinter as tk
from tkinter import ttk
class ContactForm(ttk.Frame):
"""联系人表单:一组录入控件,对外提供 get_data()/set_data()/clear()。"""
def __init__(self, master, **kw):
super().__init__(master, **kw)
# 姓名
ttk.Label(self, text="姓名:").grid(row=0, column=0, padx=4, pady=4, sticky="e")
self.name_entry = ttk.Entry(self)
self.name_entry.grid(row=0, column=1, padx=4, pady=4)
# 电话
ttk.Label(self, text="电话:").grid(row=1, column=0, padx=4, pady=4, sticky="e")
self.phone_entry = ttk.Entry(self)
self.phone_entry.grid(row=1, column=1, padx=4, pady=4)
# 分类(下拉框 Combobox)
ttk.Label(self, text="分类:").grid(row=2, column=0, padx=4, pady=4, sticky="e")
self.cat_combo = ttk.Combobox(self, values=["家人", "朋友", "同事", "其他"])
self.cat_combo.grid(row=2, column=1, padx=4, pady=4)
self.cat_combo.current(0) # 默认选第一项
# 收藏(勾选框)
self.fav_var = tk.BooleanVar(value=False) # 用变量保存是否收藏
self.fav_check = ttk.Checkbutton(self, text="收藏", variable=self.fav_var)
self.fav_check.grid(row=3, column=0, columnspan=2, padx=4, pady=4, sticky="w")
def get_data(self):
"""对外接口:返回当前表单数据的字典。"""
return {
"name": self.name_entry.get(),
"phone": self.phone_entry.get(),
"category": self.cat_combo.get(),
"favorite": self.fav_var.get(),
}
def set_data(self, data):
"""对外接口:用字典填充表单。"""
self.name_entry.delete(0, "end")
self.name_entry.insert(0, data.get("name", ""))
self.phone_entry.delete(0, "end")
self.phone_entry.insert(0, data.get("phone", ""))
self.cat_combo.set(data.get("category", ""))
self.fav_var.set(data.get("favorite", False))
def clear(self):
"""对外接口:清空表单。"""
self.name_entry.delete(0, "end")
self.phone_entry.delete(0, "end")
self.cat_combo.set("")
self.fav_var.set(False)
if __name__ == "__main__":
root = tk.Tk()
root.title("通讯录管理器")
form = ContactForm(root)
form.pack(padx=10, pady=10)
root.mainloop()逐行说明:
self.fav_var = tk.BooleanVar(value=False):Checkbutton 的状态用一个 BooleanVar 变量承载,get() 返回 True/False,set() 设值——这是 tkinter "变量"控件的标准用法。get_data():把内部各控件的值打包成字典返回。外部拿到一个干净的 dict,不必知道里面有几个 Entry。set_data(data) / clear():从字典回填 / 清空,同样屏蔽内部细节。对外 refresh(data)(用数据列表刷新)、on_select 回调(选中某行时通知外部)。
# 第14章 例14.3:自定义组件 ContactTable(包装 Treeview + 刷新)
import tkinter as tk
from tkinter import ttk
class ContactTable(ttk.Frame):
"""联系人表格:内部包一个 Treeview,对外提供 refresh(data) 和 on_select 回调。"""
def __init__(self, master, on_select=None, **kw):
super().__init__(master, **kw)
self.on_select = on_select
# Treeview 的列定义:姓名/电话/分类/收藏
columns = ("name", "phone", "category", "favorite")
self.tree = ttk.Treeview(self, columns=columns, show="headings")
# 设置每列的标题(show="headings" 表示只显示列头、不显示首列 #0)
self.tree.heading("name", text="姓名")
self.tree.heading("phone", text="电话")
self.tree.heading("category", text="分类")
self.tree.heading("favorite", text="收藏")
self.tree.pack(fill="both", expand=True)
# 单击某行时触发内部回调
self.tree.bind("<<TreeviewSelect>>", self._on_select)
def refresh(self, data):
"""对外接口:用新的数据列表刷新表格。data 是字典组成的列表。"""
# 先清空旧数据(用 delete + get_children 拿所有行 id)
self.tree.delete(*self.tree.get_children())
# 逐行插入
for row in data:
self.tree.insert("", "end", values=(
row.get("name", ""),
row.get("phone", ""),
row.get("category", ""),
"★" if row.get("favorite") else "",
))
def _on_select(self, event):
# 内部方法:拿到选中行的数据,交给外部回调
sel = self.tree.selection()
if not sel:
return
item = self.tree.item(sel[0])
if self.on_select:
self.on_select(item["values"]) # values 是 (name, phone, category, favorite)
if __name__ == "__main__":
root = tk.Tk()
root.title("通讯录管理器")
table = ContactTable(root)
table.pack(fill="both", expand=True, padx=10, pady=10)
# 演示刷新
table.refresh([
{"name": "张三", "phone": "138", "category": "朋友", "favorite": True},
{"name": "李四", "phone": "139", "category": "同事", "favorite": False},
])
root.mainloop()逐行说明:
ttk.Treeview(self, columns=columns, show="headings"):组件内部包一个 Treeview(第 11 章学的表格),父是 self,所以表格"住在"这个组件里。show="headings" 只显示列头。refresh(data):先 delete(*self.tree.get_children()) 清空旧行,再逐行 insert。这是 Treeview 刷新的标准做法(别在旧数据上重复插入)。self.tree.bind("<<TreeviewSelect>>", self._on_select):绑定"选中行"事件到内部方法;选中后把该行 values 交给外部 on_select 回调。values 顺序必须和 columns 顺序一致(姓名/电话/分类/收藏)——这点在全教程 Treeview 红线里强调过。把上面三个组件像积木一样拼进 App,主界面清爽得只剩三行。这正是"组合优于堆砌"。
# 第14章 例14.4:把三个自定义组件组合成完整通讯录界面
import tkinter as tk
from tkinter import ttk
# —— 下面三个组件类与例 14.1~14.3 完全一致(真实工程里会从各自文件 import)——
class SearchBar(ttk.Frame):
def __init__(self, master, on_search=None, **kw):
super().__init__(master, **kw)
self.on_search = on_search
self.entry = ttk.Entry(self)
self.entry.pack(side="left", fill="x", expand=True, padx=(0, 5))
ttk.Button(self, text="搜索", command=self._on_click).pack(side="left")
def _on_click(self):
if self.on_search:
self.on_search(self.get_keyword())
def get_keyword(self):
return self.entry.get()
class ContactForm(ttk.Frame):
def __init__(self, master, **kw):
super().__init__(master, **kw)
ttk.Label(self, text="姓名:").grid(row=0, column=0, padx=4, pady=4, sticky="e")
self.name_entry = ttk.Entry(self)
self.name_entry.grid(row=0, column=1, padx=4, pady=4)
ttk.Label(self, text="电话:").grid(row=1, column=0, padx=4, pady=4, sticky="e")
self.phone_entry = ttk.Entry(self)
self.phone_entry.grid(row=1, column=1, padx=4, pady=4)
ttk.Label(self, text="分类:").grid(row=2, column=0, padx=4, pady=4, sticky="e")
self.cat_combo = ttk.Combobox(self, values=["家人", "朋友", "同事", "其他"])
self.cat_combo.grid(row=2, column=1, padx=4, pady=4)
self.cat_combo.current(0)
self.fav_var = tk.BooleanVar(value=False)
ttk.Checkbutton(self, text="收藏", variable=self.fav_var).grid(
row=3, column=0, columnspan=2, padx=4, pady=4, sticky="w")
def get_data(self):
return {"name": self.name_entry.get(), "phone": self.phone_entry.get(),
"category": self.cat_combo.get(), "favorite": self.fav_var.get()}
def clear(self):
self.name_entry.delete(0, "end")
self.phone_entry.delete(0, "end")
self.cat_combo.set("")
self.fav_var.set(False)
class ContactTable(ttk.Frame):
def __init__(self, master, on_select=None, **kw):
super().__init__(master, **kw)
self.on_select = on_select
columns = ("name", "phone", "category", "favorite")
self.tree = ttk.Treeview(self, columns=columns, show="headings")
self.tree.heading("name", text="姓名")
self.tree.heading("phone", text="电话")
self.tree.heading("category", text="分类")
self.tree.heading("favorite", text="收藏")
self.tree.pack(fill="both", expand=True)
self.tree.bind("<<TreeviewSelect>>", self._on_select)
def refresh(self, data):
self.tree.delete(*self.tree.get_children())
for row in data:
self.tree.insert("", "end", values=(
row.get("name", ""), row.get("phone", ""),
row.get("category", ""),
"★" if row.get("favorite") else ""))
def _on_select(self, event):
sel = self.tree.selection()
if sel and self.on_select:
self.on_select(self.tree.item(sel[0])["values"])
# —— 主界面:只用三行就把三大块拼起来 ——
class App:
def __init__(self, root):
self.root = root
self.root.title("通讯录管理器")
# 顶部:搜索栏(用 on_search 回调接外部逻辑)
self.search = SearchBar(root, on_search=self.do_search)
self.search.pack(fill="x", padx=10, pady=5)
# 中部:表单,用 LabelFrame 包一下更好看
self.form_frame = ttk.LabelFrame(root, text="新增联系人")
self.form_frame.pack(fill="x", padx=10, pady=5)
self.form = ContactForm(self.form_frame)
self.form.pack(padx=5, pady=5)
# 底部:表格
self.table = ContactTable(root, on_select=self.on_select)
self.table.pack(fill="both", expand=True, padx=10, pady=5)
# 初始数据
self.contacts = [
{"name": "张三", "phone": "138", "category": "朋友", "favorite": True},
{"name": "李四", "phone": "139", "category": "同事", "favorite": False},
]
self.table.refresh(self.contacts) # 把数据喂给表格组件
def do_search(self, keyword):
print("搜索关键词:", keyword)
def on_select(self, values):
print("选中行:", values)
if __name__ == "__main__":
root = tk.Tk()
App(root)
root.mainloop()逐行说明:
App.__init__ 里只剩三块:SearchBar(...)、ContactForm(...)(包进 LabelFrame)、ContactTable(...)。原来要几十行堆出来的界面,现在逻辑一眼可读。self.table.refresh(self.contacts):主程序只负责"把数据给表格组件",至于表格怎么画、怎么清旧数据,全在 ContactTable 内部——解耦体现得淋漓尽致。on_search、on_select)把内部事件"上报"给 App,App 只写业务逻辑,不碰组件内部。这就是"视图独立、控制器协调"的雏形,为第 20 章 MVC 直接铺路。本章是全教程 OOP 思想的集大成,请反复体会:
SearchBar + ContactForm + ContactTable 三个"对象"的组合。每个对象内部高内聚,外部低耦合。is-a Frame:SearchBar(ttk.Frame) 表示"搜索栏是一种 Frame",因此天然拥有 pack/grid/config,用起来和内置控件无差别——这就是"开闭原则"的落地:扩展新控件不改动旧代码。self 上;外部只调 get_data()/set_data()。内部实现(比如把 Entry 换成别的)变了,外部代码一行不用改。on_search/on_select 把内部事件通知外部,command=/bind 把"方法即回调"(第 3 章)用到组件内部——OOP 范式贯穿始终。一句话:"把一组总在一起的控件,封装成一个继承 Frame 的类;内部状态自己管,对外只留干净接口——界面从此可复用、可解耦、可生长。"
super().__init__(master, kw):后果是直接 AttributeError/TypeError——父类 Frame 没初始化,self 不是合法控件,后续 ttk.Entry(self) 立即报错。这是新手第一坑,必须第一行就写**。pack/grid 到 master 而非 self:写成 ttk.Entry(master) 会让子控件跑到组件外面、脱离组件。记住——组件内部的子控件,父一律写 self。get_text()、组件 B 叫 get_value(),团队难用、易错。全教程约定 get_keyword() / get_data() / set_data() / clear() / refresh(data) / on_xxx 回调,请遵守。kw 没透传给父类:def __init__(self, master, kw): 却写 super().__init__(master)(丢了 kw),导致外部传 padding= 等选项失效。务必 super().__init__(master, kw)。refresh 里只 insert 不 delete,旧行会越积越多。标准做法先 self.tree.delete(*self.tree.get_children()) 再插入。columns 与 insert 的 values 顺序不一致:表格列错位。务必保证两者顺序、数量对应(姓名/电话/分类/收藏)。mainloop():自定义组件类里绝不能写 root.mainloop();mainloop() 只在最外层启动一次。组件只负责"成为界面的一部分"。SearchBar,写一个 LabeledEntry(ttk.Frame):对外构造时传 label="姓名",内部由一个 Label + 一个 Entry 组成,对外暴露 get() 返回输入文字。ContactForm 增加一个"保存"按钮,点击时调用外部传入的 on_save(data) 回调(把 get_data() 的结果送出去),并把 on_save 通过构造参数传入。append 进 self.contacts 列表并 self.table.refresh(self.contacts) 刷新表格(综合例 14.4 改造)。ContactTable 内部的 Treeview 换成纯 Label 列表,App 里那行 self.table.refresh(self.contacts) 需要改吗?(提示:不需要——这正是"封装"的价值:外部只认 refresh 接口,内部实现随便换。)本章小结:你已掌握 tkinter 工程化的核心——自定义组件。从此通讯录管理器的界面由可复用的"积木"拼成,结构清晰、易维护、易扩展。下一组章节会把这些组件与"数据持久化(保存/读取文件、数据库)"结合,让通讯录真正能用起来。