跳转至

常见问题与排查

这一页收集的是真实踩过的坑——大部分来自 Tkinter 本身的语义,而不是 tkdeft 的 bug。每条都按 症状 / 原因 / 怎么办 写,配可运行的片段。

排查顺序建议:

  1. 先看当前引擎对不对:python -c "from tkdeft.engines import get_engine_name; print(get_engine_name())"
  2. 再跑一遍自检:python -m tkflu --check -r <引擎名>
  3. 还不对就跑回归:python benchmarks/run_all.py --quick(详见 回归与性能)

一、画面不对

1. 画布上的图片变成空白

症状:同一画布上画了多张图片,过一会儿(触发 GC 后)除最后一张外全部变空白; 或者窗口 resize 之后图元消失。

原因:Tkinter 的画布元素不会替 Python 侧持有 PhotoImage 引用。图片对象一旦被 垃圾回收,对应的 canvas item 就变成空白——这是 Tkinter 的经典陷阱。历史代码只用 self._tkimg 保存"最后一张",所以画第二张时第一张就失去了引用。

怎么办:让画布替你保管引用,或者直接用统一入口(内部已经保活)。

# ✓ 推荐:统一入口自己会 _keep_photo,返回的一定是 canvas item id
item = canvas.draw_roundrect(0, 0, 120, 32, 6, fill="#ffffff")

# ✓ 手工绘制:把 PhotoImage 交给画布保管
photo = canvas.svgdraw.create_svg_image(path)
item = canvas.create_image(0, 0, anchor="nw", image=photo)
canvas._keep_photo(item, photo)          # ← 关键的一行
# ✗ 历史错误写法:只存"最后一张"
self._tkimg = canvas.svgdraw.create_svg_image(path)
canvas.create_image(0, 0, anchor="nw", image=self._tkimg)
# 下一次绘制覆盖 self._tkimg 后,上一张就可能在 GC 时变空白

DCanvas._keep_photo(item, photo) 按 item id 记录引用,并在元素失效后自动清理; draw_roundrect / draw_track / draw_thumb 内部都调了它。

为什么缓存不做 LRU 淘汰

因为"淘汰"就等于"回收图片"就等于"画面变空白"。缓存改用像素预算 (默认 8M 像素,满了就不再写入),已经发出去的图片永远存活。 详见 绘制引擎。

2. canvas.delete("all") 把叠加内容一起删掉

症状:自己往画布上追加的元素(文字、图元、内嵌控件)在组件下一次重绘后消失。

原因:delete("all") 删除的是画布上的全部 item,包括子窗口 item 和所有叠加元素。 "整幅重绘"风格的组件会在 _draw() 开头调用它,于是你以为"加进去了"的东西立刻被擦掉。

怎么办:叠加内容要放在 delete("all") 之后画;稳妥的做法是覆盖 _draw, 先让基类画完背景,再画自己的东西。FluFrame(tkfluent)这类"画布 + 内嵌控件"的结构 必须这样处理。

from tkdeft.windows.canvas import DCanvas
from tkdeft.windows.drawwidget import DDrawWidget


class MyCanvas(DCanvas):
    """只提供绘制后端——注意 _draw 不在 DCanvas 上,而在 DDrawWidget 上。"""


class MyWidget(MyCanvas, DDrawWidget):
    def _draw(self, event=None):
        super()._draw(event)           # 基类:同步背景色;未映射时它会提前返回
        if not self.winfo_ismapped():  # 但那只是结束 super(),这里要自己再判一次
            return

        self.delete("all")             # 整幅重绘:清掉画布上的全部 item
        self.element = self.draw_roundrect(
            0, 0, self.winfo_width(), self.winfo_height(), 6,
            fill="#ffffff", outline="#000000",
        )
        self.tag_raise(self.element)   # 需要压在最上层时显式抬升

更多"怎么搭一个自己的控件"见 自定义组件。


二、尺寸与布局

3. withdraw() 状态下量不到尺寸

症状:控件的 winfo_width() 恒为 1,布局断言全部失败,按尺寸画的图只有一个小点。

原因:窗口未映射时 Tk 不做几何计算,winfo_width()/winfo_height() 一律返回 1 (它们反映的是"最后一次 <Configure> 确定的几何",而还没映射时压根没有这个事件)。

怎么办:先让窗口真实映射并 update(),再测量。

import tkinter

root = tkinter.Tk()
root.geometry("640x480")
root.update()                     # ← 让 Tk 完成一次布局

widget.pack()
root.update()
print(widget.winfo_width())       # 现在才是真实宽度

写布局检查时

没有可用桌面会话的环境里应当跳过而不是失败—— benchmarks/check_layout.py 就是这么做的。

4. 不要用 len(text) * N 估算文本宽度

症状:中文菜单项/标签被裁掉,或者按钮挤成一团("文件" 按 len * 8 只算出 16px)。

原因:CJK 是宽字符,一个汉字大约等于两个西文字符的宽度;按字符个数乘常数 严重偏窄,中英文混排时误差更大。

怎么办:向 Tk 要真实字体度量。

from tkinter.font import Font

font = Font(family="Segoe UI", size=10)

width = font.measure("文件")      # ✓ 真实像素宽度
# width = len("文件") * 8         # ✗ 对中文差一倍以上

三、生命周期与资源

5. 不要直接对 root 调 after_cancel()

症状:关窗时刷 TclError: can't delete Tcl command;严重时 root.destroy() 根本没销毁窗口、tkinter._default_root 残留,之后再建窗口就撞 bad window path name。

原因:after 回调注册在 Tcl 解释器上,而生成的 Tcl 命令名记在发起控件的 _tclCommands 列表里。直接对 root 调 after_cancel() 会删掉那条 Tcl 命令,但 root._tclCommands.remove(name) 会静默失败(名字不在 root 的列表里);等那个控件 自己被销毁时,Misc.destroy 拿这个已经删掉的命令再删一次 → 抛异常 → 异常一路冒到 root.destroy()。

怎么办:谁注册,谁取消。tkfluent 的做法是把 after id 记在控件上, 销毁时用控件自己取消(实现见 tkflu/_after.py)。

class MyWidget(DCanvas, DDrawWidget):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._after_id = None
        self.bind("<Destroy>", self._on_destroy, add="+")
        self._after_id = self.after(10, self._deferred_update)

    def _deferred_update(self):
        self._after_id = None
        if self.winfo_exists():
            self.update()

    def _on_destroy(self, event=None):
        if event is not None and event.widget is not self:
            return          # 子控件的 <Destroy> 也会冒泡到父控件
        if self._after_id is not None:
            try:
                self.after_cancel(self._after_id)   # self 就是注册者,正确
            except Exception:
                pass
            self._after_id = None

别图省事

root.after_cancel(some_id) 往往"看起来能跑"——代价要到控件销毁时才结算, 而且症状出现在关窗那一刻,很难联想到元凶。

6. 临时文件与文件描述符:不要自己 mkstemp()

症状:长跑程序句柄耗尽;系统临时目录里堆满 tkdeft.temp.*。历史实测: 20 个按钮泄漏 80 个文件 + 80 个 fd。

原因:旧实现每次绘制都 mkstemp() 新建文件,且从不 close(fd)。

怎么办:用绘制对象提供的 scratch 路径——惰性创建、复用一个文件、控件销毁时回收。

# ✓ 绘制后端:同一个 slot 始终复用同一个路径
path = draw.scratch_path(".svg", slot=1)

# ✓ 控件内部:直接用现成的 temppath ~ temppath4(惰性属性)
item = self.draw_roundrect(..., temppath=self.temppath, temppath2=self.temppath3)

# ✗ 不要自己建
from tempfile import mkstemp
fd, path = mkstemp(suffix=".svg")   # fd 忘了关 → 每帧泄漏一个

谁负责回收

DSvgDraw.cleanup() 会删掉本对象创建的 scratch 文件;DCanvas 在 <Destroy> 事件里自动调用它。

7. 长期运行程序的句柄回收

症状:反复创建/销毁控件后,临时文件不减少、图片引用(_photo_refs)一直涨。

原因:清理动作挂在 <Destroy> 事件上。如果你覆盖了 DCanvas._event_destroy_draw 却没有调用 super(),或者用的是裸 tkinter.Canvas 而不是 DCanvas,清理就不会发生。

怎么办:别丢掉父类实现。

def _event_destroy_draw(self, event=None):
    super()._event_destroy_draw(event)   # ← cleanup() + 清空 _photo_refs
    # 自己的清理逻辑写在这里

它做两件事:调用 svgdraw.cleanup() 删除 scratch 临时文件;清空 _photo_refs。


四、Tk 解释器与 master

8. 创建控件不传 master

症状:内嵌控件"跑到别的窗口去了"、控件销毁后内嵌的原生控件不回收、多 Toplevel 场景下嵌不进去。

原因:不传 master 时,Tkinter 会把控件挂到默认根窗口(tkinter._default_root), 而不是你正在构造的那个控件。

怎么办:显式传 master;自定义控件里创建原生控件时传 master=self。

from tkinter import Entry

class MyEntry(DCanvas, DDrawWidget):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.entry = Entry(self)      # ✓ 挂到自身,随自身一起销毁
        # Entry()                     # ✗ 不传 master → 挂到默认根窗口

图片也绑定解释器

PhotoImage 同样绑定在具体的 Tk 解释器上,所以创建图片时要带 master=(DCanvas 会把自身注入给绘制后端)。多解释器场景下漏传会报 image "pyimage1" doesn't exist。


五、绘制引擎

9. 引擎装了却用不了

症状:set_engine("skia") 抛 ValueError,提示缺少依赖。

原因:依赖确实没装(或装在另一个 Python 解释器里),于是该引擎的 available() 为 False。tkdeft 宁可报错也不静默退回慢路径。

怎么办:先看清单,再切。

from tkdeft.engines import list_engines, describe_engines, set_engine

list_engines()          # {'tksvg': True, 'wand': True, 'skia': True, ...}
describe_engines()      # 每项的编号 / 类型 / 依赖 / 是否可用 / 是否当前

for row in describe_engines():
    if not row["available"]:
        print(row["name"], "缺少依赖:", row["requires"])

set_engine("skia")      # 缺依赖时抛 ValueError,并说明缺哪个包

名字拼错会怎样

拼错会抛 UnknownEngineError(ValueError 的子类),错误信息里附带 "你是不是想用 …" 的提示。引擎清单与自定义引擎见 绘制引擎 与 engines API。

10. 想固定走 SVG 做对照

症状:想验证"栅格引擎和 tksvg 画得是不是一样",或想排查只在 SVG 路径下出现的问题, 但默认已经走了快速路径。

怎么办:按画布关,或按单次调用关。

canvas.raster_enabled = False             # 这个画布之后全走 SVG
canvas.draw_roundrect(..., raster=False)  # 只这一次走 SVG

from tkdeft.engines import reset_engine
reset_engine()                            # 引擎整体退回默认 tksvg(=0)

11. 引擎渲染失败不会被抛出来

症状:界面"看着不太对",但控制台只有一条 RuntimeWarning(每个引擎只警告一次), 之后再无任何提示。

原因:单个引擎渲染失败会自动回退到 SVG 路径,以免整个界面挂掉;错误只记录 最后一次。这是刻意的容错设计,但也就意味着异常不会冒泡到你面前。

怎么办:主动查。

from tkdeft.engines import last_engine_error, clear_engine_error

print(last_engine_error())   # -> "skia: ValueError: ..." 或 None(正常)
clear_engine_error()         # 清掉记录,允许下次失败时重新警告一次

12. 以为开了 skia,其实还在走慢路径

症状:切了引擎但没变快。

原因:设置被别处覆盖了(环境变量、子进程、其他模块导入时又调 set_renderer(0)), 或者当前根本就没切成功。

怎么办:别凭记忆,直接问当前状态。

from tkdeft.engines import get_engine_name, get_engine

print(get_engine_name())     # -> "skia"
print(get_engine().kind)     # -> "raster"(只有栅格引擎才走快速路径)

# tkfluent 里对应的是:
from tkflu.designs.renderer import get_renderer, get_renderer_name

print(get_renderer(), get_renderer_name())    # -> 2 skia

环境变量里也有一份

tkfluent 会把当前编号记在环境变量 tkfluent.renderer 里,便于子进程继承; 直接对比它与 get_engine_name(),就能确认两边是否一致。


六、验证手段

13. 截 Tk 窗口不要用 PIL.ImageGrab

症状:截出来的图是别的窗口(例如浏览器),或者内容错位、缺一块。

原因:ImageGrab 按屏幕坐标抓取,在 DPI 缩放、多虚拟桌面、窗口被遮挡的情况下 都会抓错对象。

怎么办:按窗口句柄抓(Win32 PrintWindow)——既不受遮挡影响,也不受坐标映射影响。

# 把整个组件画廊真实显示出来并截图,输出到 benchmarks/_out/
python benchmarks/visual_demo.py light skia
python benchmarks/visual_demo.py dark pillow

实现见 benchmarks/visual_demo.py 里的 capture_window() / grab();更多见 回归与性能。

截图前记得让窗口映射

窗口处于 withdraw() 状态时 PrintWindow 抓到的是空白——截图前先 deiconify() 或 update()。

PrintWindow 抓不到 canvas 上的半透明叠加层

症状:窗口里明明有一层半透明的东西(涟漪、遮罩、阴影),截图里只剩底色; 同一个窗口连截几帧,文件哈希一模一样。

原因:带 alpha 的 PhotoImage 贴到 canvas 上时,合成发生在窗口表面这一层; PrintWindow 走 WM_PRINT 重画路径,实测拿不到这部分结果(不透明的图能截到)。 屏幕 BitBlt 反而能截到——但那就回到了"受遮挡 / 坐标映射影响"的老问题上。

怎么办:要出图就先把图层合成到不透明的底图上再截(docs/gen_figures.py 里的 figure_ripple() 就是把涟漪图层直接 alpha_composite 到底色上)。 只在交互中看得见的效果,别指望 visual_demo.py 能截到。