📚 目录 第 19 章 易错与进阶(真实项目踩坑)

第 19 章 易错与进阶(真实项目踩坑)

本书红线(贯穿全教程,回顾):
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) 方法,集中处理"保存再关"。


19.1 是什么 / 适用场景 ★

本章不是讲某个新控件,而是讲工程实践中最容易出错的六个点。你前面各章写的程序"能跑",但放到真实场景里往往会:

把这六个坑吃透,你的程序才算"稳跑"。


19.2 核心 API 速查(对照官方手册)★★

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)★★

19.3 常用技巧速查 ★★

技巧说明示例
图片保引用把 PhotoImage 存到 self.xxxself.photo = tk.PhotoImage(file="a.png")
动画递归after 里再预约自己self.root.after(100, self.step)
子线程回主线程子线程算完,用 after(0, ...) 让主线程更新 UIself.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 节

19.4 详细例子(循序渐进)★★★★

例 19.1 🔴 PhotoImage 必须保引用(错误 vs 正确)★★★★

错误写法(不要学):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 或别的长期变量。

例 19.2 after 定时 / 动画(倒计时)★★★

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,界面直接"假死"。

例 19.3 mainloop 单线程:耗时任务进子线程,UI 更新回主线程 ★★★★

原理: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()

逐行说明:

例 19.4 protocol 退出前保存(on_closing 方法)★★★★

默认点窗口关闭按钮直接关掉,数据可能没保存。用 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(),否则窗口关不掉。

例 19.5 布局坑回顾:pack/grid 混用、sticky 漏写 ★★★

坑一:同一父容器混用 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,控件只会缩在格子左上角,看起来"没铺满"。

例 19.6 高 DPI 与字体模糊(Windows 开启 DPI 感知)★★

在高分屏(如 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)上不报错。这是"锦上添花"的一步,不影响功能,但能让界面更专业。


19.5 OOP 落点(本章重点):on_closing 方法 ★★★★

本章把"退出保存"落点为面向对象的一个方法:

def on_closing(self):
    self.manager.save(self.DATA_FILE)   # 先保存(调用 Model 的方法)
    self.root.destroy()                 # 再关窗

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


19.7 本章练习

  1. 写一个程序:用 after 做一个"跑马灯"——让一行文字每 0.5 秒在"加载中…""加载中..""加载中..."之间循环切换。
  2. 改写例 19.3:把 heavy_work 改成"累加 1 到 1000000 的和",算完后用 after(0, ...) 在主线程把结果显示在 Label 上。
  3. 给例 19.4 增加"未保存就关闭时弹确认框":如果用户草稿非空,点关闭先问 msgbox.askyesno("确认","有未保存内容,确定退出?"),选"是"才 destroy()。
  4. 思考题:例 19.3 如果去掉 after(0, ...)、直接在 heavy_work 里写 self.label.config(text=...),会怎样?(提示:跨线程改 tkinter 控件,可能崩溃或偶现异常——印证 19.6 的 🔴 易错点。)
下节预告:第 20 章是全书收口——综合项目"OOP 通讯录管理器"。我们将把前面所有控件(Label/Button/Entry/Combobox/Checkbutton/Treeview/Menu/Canvas/Progressbar)与本章的坑(保引用、退出保存)组装成一个完整的 MVC 分层项目,整文件复制即可运行。