本书红线(贯穿全教程,回顾):
1. 每个界面都用class App:封装,__init__里建控件,方法即事件回调。
2. 所有控件示例一律写在类里,不用脱离类的扁平脚本。
3. 代码块带if __name__ == "__main__":守卫,可独立运行。
4. 星级:★ 了解 · ★★ 理解 · ★★★ 掌握 · ★★★★ 重点必会。
主线项目:图形界面通讯录管理器,随章节每章新增一个控件慢慢长大。
上节回顾(第 18 章):你学会了 Canvas(画布)自由绘图和 Progressbar(进度条),还把绘图逻辑收进了 draw_avatar(self) 方法。现在界面能"画"、能"显示进度"了。本章目标是扫清从"能跑"到"稳跑"的障碍——把真实项目里最容易翻车的坑一次性讲清。
本章目标:集中讲 6 个真实项目高频坑——① PhotoImage 必须保引用;② after(ms, func) 定时与动画;③ mainloop 是单线程,耗时任务要进子线程、UI 更新回主线程;④ 布局常见坑(pack/grid 混用、sticky 漏写);⑤ 高 DPI 与字体模糊;⑥ protocol("WM_DELETE_WINDOW", self.on_closing) 退出前保存。OOP 落点:把"退出保存"写成 on_closing(self) 方法,集中处理"保存再关"。
本章不是讲某个新控件,而是讲工程实践中最容易出错的六个点。你前面各章写的程序"能跑",但放到真实场景里往往会:
protocol);把这六个坑吃透,你的程序才算"稳跑"。
| API | 作用 | 易错点 | 重要度 |
|---|---|---|---|
tk.PhotoImage(file=...) | 载入图片对象 | 必须存进 self 保引用,否则被回收后图片消失 | ★★★★ |
widget.after(ms, func) | 延时 ms 毫秒后调用 func(定时器 / 动画) | 别用 time.sleep 代替,否则卡界面 | ★★★★ |
widget.after(0, func) | 下一轮事件循环立即执行 func | 子线程想改界面,用它"回到主线程" | ★★★★ |
root.protocol("WM_DELETE_WINDOW", func) | 注册"点关闭按钮"的回调 | 用来做"退出前保存" | ★★★★ |
threading.Thread(target=..., daemon=True) | 开子线程跑耗时任务 | 子线程里禁止直接改界面 | ★★★ |
windll.shcore.SetProcessDpiAwareness(1) | Windows 下开启 DPI 感知 | 解决高分屏字体模糊(仅 Windows) | ★★ |
| 技巧 | 说明 | 示例 |
|---|---|---|
| 图片保引用 | 把 PhotoImage 存到 self.xxx | self.photo = tk.PhotoImage(file="a.png") |
| 动画递归 | after 里再预约自己 | self.root.after(100, self.step) |
| 子线程回主线程 | 子线程算完,用 after(0, ...) 让主线程更新 UI | self.root.after(0, self.update_ui) |
| 退出拦截 | protocol 接 on_closing 方法 | root.protocol("WM_DELETE_WINDOW", self.on_closing) |
| grid 拉伸 | 父容器 columnconfigure/rowconfigure(weight=1) + 子控件 sticky="nsew" | 见 19.5 节 |
| DPI 感知 | Windows 程序开头调用一次 | 见 19.6 节 |
错误写法(不要学):PhotoImage 对象如果没有任何变量持有它,函数结束后会被 Python 垃圾回收,于是 Label 上的图片"一闪就没",变成空白。
# 第19章 例19.1(错误示范,仅用于说明,请勿照搬):
# tk.Label(root, image=tk.PhotoImage(file="logo.png")).pack()
# 问题:PhotoImage 是个临时对象,没存进任何变量,函数结束就被回收,图片消失。正确写法:把图片对象存进 self.photo,让 App 实例一直持有它,图片就能稳定显示。
# 第19章 例19.1(正确写法):把 PhotoImage 存进 self 保引用
import tkinter as tk
class App:
def __init__(self, root):
self.root = root
self.root.title("图片必须保引用")
# ✅ 关键:把 PhotoImage 存进 self.photo,App 对象一直持有它,不会被回收
self.photo = tk.PhotoImage(file="logo.png")
self.label = tk.Label(self.root, image=self.photo)
self.label.pack(padx=20, pady=20)
if __name__ == "__main__":
root = tk.Tk()
App(root)
root.mainloop()逐行说明:self.photo = tk.PhotoImage(file="logo.png") 是救命的一行——只要 self.photo 还在,mainloop 运行期间图片对象就不会被回收。记住铁律:凡是 PhotoImage,一定存进 self 或别的长期变量。
after(ms, func) 是 tkinter 的"定时器"。下面做一个 3→2→1→时间到 的倒计时。
# 第19章 例19.2:用 after 做倒计时(定时器)
import tkinter as tk
class App:
def __init__(self, root):
self.root = root
self.root.title("after 定时")
self.label = tk.Label(self.root, text="3", font=("微软雅黑", 24))
self.label.pack(padx=20, pady=20)
self.n = 3
self.root.after(1000, self.tick) # 1 秒后第一次 tick
def tick(self):
self.n -= 1
if self.n > 0:
self.label.config(text=str(self.n))
self.root.after(1000, self.tick) # 每隔 1 秒再调自己
else:
self.label.config(text="时间到!")
if __name__ == "__main__":
root = tk.Tk()
App(root)
root.mainloop()说明:after(1000, self.tick) 让 tkinter 在 1 秒后调用 tick;tick 里再 after 预约下一次,形成定时循环。注意绝不能用 time.sleep(1) 代替——sleep 会卡住整个 mainloop,界面直接"假死"。
原理:tkinter 的 mainloop 是单线程的,它一个人既要画界面又要处理你的点击。如果你在回调里写了"算一大堆数 / 读大文件"这种耗时操作,主线程就被占住,界面卡住不动(鼠标都点不了)。解决:把耗时任务丢进子线程,算完再用 root.after(0, ...) 回到主线程去更新界面(子线程里禁止直接改控件)。
# 第19章 例19.3:耗时任务进子线程,算完用 after(0,...) 回主线程更新 UI
import tkinter as tk
import threading
import time
from tkinter import ttk
class App:
def __init__(self, root):
self.root = root
self.root.title("子线程算,主线程显")
self.label = tk.Label(self.root, text="结果:等待中", font=("微软雅黑", 14))
self.label.pack(padx=20, pady=10)
self.btn = ttk.Button(self.root, text="开始计算", command=self.start_work)
self.btn.pack(padx=20, pady=10)
def start_work(self):
self.btn.config(state="disabled")
# 把耗时任务丢进子线程(daemon=True 表示主程序退出时它跟着退出)
t = threading.Thread(target=self.heavy_work, daemon=True)
t.start() # 启动子线程,主线程立刻返回、界面不卡
def heavy_work(self):
time.sleep(2) # 模拟耗时计算(主线程不能做这个)
result = 6 * 7
# ⚠️ 子线程里不能直接改界面!用 after(0, ...) 把更新"派发"回主线程
self.root.after(0, lambda: self.show_result(result))
def show_result(self, value):
self.label.config(text=f"结果:{value}")
self.btn.config(state="normal")
if __name__ == "__main__":
root = tk.Tk()
App(root)
root.mainloop()逐行说明:
threading.Thread(target=self.heavy_work, daemon=True):开一个子线程去跑 heavy_work,主线程 start() 后立刻返回,界面照常响应——不卡。time.sleep(2) 在子线程里睡 2 秒模拟耗时,主线程不受影响。self.root.after(0, lambda: self.show_result(result)):子线程算完后,用 after(0, ...) 请求主线程"尽快"执行 show_result 去改 Label。这就是"耗时进子线程、UI 回主线程"的标准范式。注意 after(0, ...) 的 0 表示"下一轮事件循环立即执行"。默认点窗口关闭按钮直接关掉,数据可能没保存。用 protocol("WM_DELETE_WINDOW", self.on_closing) 拦截关闭动作,先保存再关。这正是 OOP 落点:把"保存再关"集中写成 on_closing(self) 方法。
# 第19章 例19.4:protocol 拦截关闭,退出前先保存
import tkinter as tk
from tkinter import messagebox as msgbox
class App:
def __init__(self, root):
self.root = root
self.root.title("退出前保存")
self.text = tk.StringVar(value="")
tk.Label(self.root, text="随便输入点东西:").pack(padx=10, pady=5)
self.entry = tk.Entry(self.root, textvariable=self.text)
self.entry.pack(padx=10, pady=5)
# 注册"点关闭按钮"时改走 self.on_closing,而不是立刻关窗口
self.root.protocol("WM_DELETE_WINDOW", self.on_closing)
def on_closing(self):
# 集中处理"保存再关":先把草稿写盘,再真正销毁窗口
with open("draft.txt", "w", encoding="utf-8") as f:
f.write(self.text.get())
msgbox.showinfo("已保存", "草稿已保存到 draft.txt")
self.root.destroy() # 手动关窗(不写这行窗口不会关)
if __name__ == "__main__":
root = tk.Tk()
App(root)
root.mainloop()逐行说明:self.root.protocol("WM_DELETE_WINDOW", self.on_closing) 把"关闭按钮"绑定到 on_closing 方法;on_closing 里先写文件、再弹提示、最后 self.root.destroy() 真正关闭。注意:一旦注册了 protocol,默认的"点×就关"就失效了,所以必须在 on_closing 里自己调用 destroy(),否则窗口关不掉。
坑一:同一父容器混用 pack 和 grid。同一父容器里既有 pack() 又有 grid(),tkinter 直接报错 _tkinter.TclError: cannot use geometry manager grid inside ... already managed by pack。一个父容器的直接子控件,只能用同一种布局管理器。
# 第19章 例19.5a(错误示范,请勿照搬):
# tk.Label(root).pack() # 这个父容器用了 pack
# tk.Button(root).grid() # ❌ 同一父容器又用 grid → 报错坑二:grid 漏写 sticky,控件缩在角落不拉伸。下面这个正确写法演示如何让控件随窗口拉伸:
# 第19章 例19.5b(正确):grid + columnconfigure/rowconfigure + sticky 让控件拉伸
import tkinter as tk
from tkinter import ttk
class App:
def __init__(self, root):
self.root = root
self.root.title("grid 拉伸")
# 关键①:告诉父容器"第0列、第0行要随窗口拉伸"(weight=1)
self.root.columnconfigure(0, weight=1)
self.root.rowconfigure(0, weight=1)
# 关键②:子控件用 sticky="nsew" 粘满它所在的格子(n北s南e东w西)
self.text = tk.Text(self.root)
self.text.grid(row=0, column=0, sticky="nsew", padx=10, pady=10)
self.btn = ttk.Button(self.root, text="按钮")
self.btn.grid(row=1, column=0, sticky="we", padx=10, pady=5)
if __name__ == "__main__":
root = tk.Tk()
App(root)
root.mainloop()说明:columnconfigure(0, weight=1) + sticky="nsew" 是 grid 布局"让控件随窗口变大"的黄金组合。漏了 sticky,控件只会缩在格子左上角,看起来"没铺满"。
在高分屏(如 4K 屏、缩放 150%)下,tkinter 默认按 96 DPI 渲染,文字和线条会发虚。Windows 下可在程序开头调用一次 DPI 感知:
# 第19章 例19.6:Windows 下开启 DPI 感知,解决高分屏字体模糊
import tkinter as tk
try:
from ctypes import windll
windll.shcore.SetProcessDpiAwareness(1) # 1=系统 DPI 感知(仅 Windows 有效)
except Exception:
pass # 非 Windows 环境直接跳过
class App:
def __init__(self, root):
self.root = root
self.root.title("高 DPI 清晰文字")
self.label = tk.Label(self.root, text="高分屏下我也很清晰",
font=("微软雅黑", 14))
self.label.pack(padx=20, pady=20)
if __name__ == "__main__":
root = tk.Tk()
App(root)
root.mainloop()说明:windll.shcore.SetProcessDpiAwareness(1) 让程序按系统真实 DPI 渲染,文字不再发虚。try/except 包住是为了在非 Windows(如 macOS/Linux)上不报错。这是"锦上添花"的一步,不影响功能,但能让界面更专业。
本章把"退出保存"落点为面向对象的一个方法:
def on_closing(self):
self.manager.save(self.DATA_FILE) # 先保存(调用 Model 的方法)
self.root.destroy() # 再关窗root.protocol("WM_DELETE_WINDOW", self.on_closing) 又是一次"方法即回调"——你登记 on_closing,tkinter 在点关闭时回头调用它。command=self.on_click、本章的 protocol=self.on_closing,本质都是同一个 OOP 范式:把"将来某个时刻发生的事件"交给类里的一个方法处理。self:图片一闪消失。铁律——self.photo = tk.PhotoImage(...)。time.sleep 做等待/动画:卡死 mainloop,界面假死。改用 after(ms, func) 递归。root.after(0, ...) 回主线程再改。protocol 却忘了 destroy():点了关闭按钮窗口关不掉。记得 on_closing 末尾 self.root.destroy()。TclError。一个父容器只用一种布局管理器。sticky:控件缩在角落不铺满。配合 columnconfigure/rowconfigure(weight=1) + sticky="nsew"。SetProcessDpiAwareness(1) 即可缓解。after 做一个"跑马灯"——让一行文字每 0.5 秒在"加载中…""加载中..""加载中..."之间循环切换。heavy_work 改成"累加 1 到 1000000 的和",算完后用 after(0, ...) 在主线程把结果显示在 Label 上。msgbox.askyesno("确认","有未保存内容,确定退出?"),选"是"才 destroy()。after(0, ...)、直接在 heavy_work 里写 self.label.config(text=...),会怎样?(提示:跨线程改 tkinter 控件,可能崩溃或偶现异常——印证 19.6 的 🔴 易错点。)下节预告:第 20 章是全书收口——综合项目"OOP 通讯录管理器"。我们将把前面所有控件(Label/Button/Entry/Combobox/Checkbutton/Treeview/Menu/Canvas/Progressbar)与本章的坑(保引用、退出保存)组装成一个完整的 MVC 分层项目,整文件复制即可运行。