Tkinter 桌面应用开发与验收
面向"写一个本地 GUI 小工具"这类任务。核心价值:不用人肉盯屏幕也能验收界面,
以及一份能省掉反复试错的 tk 坑位清单。
1. 先解决解释器:优先用系统级 Python 3.14
# 逐个探测(这是唯一可靠的判据,不要看 PATH)
for P in "C:/Program Files/Python313/python.exe" \
"C:/Users/Administrator/AppData/Local/Programs/Python/Python314/python.exe" \
"C:/Users/Administrator/.workbuddy/binaries/python/envs/default/Scripts/python.exe"; do
echo "=== $P"; "$P" -c "import sys,tkinter;print(sys.version.split()[0],'tk',tkinter.TkVersion)" 2>&1 | tail -1
done
实测结论(2026-10-20 复核):
| 解释器 | tkinter |
|---|---|
系统级 C:/Users/Administrator/AppData/Local/Programs/Python/Python314/python.exe |
✅ 3.14.2 / tk 8.6(首选) |
托管 Python binaries/python/versions/*、venv envs/default |
❌ 无 |
打包用的 _build/venv/Scripts/python.exe |
✅(由 uv 的 3.12.14 创建,项目自带) |
⚠ 容易搞反的点:托管 Python 与 envs/default 都没有 tkinter——
所以跑界面/截图/版面验收优先用系统级 C:/Users/Administrator/AppData/Local/Programs/Python/Python314/python.exe(有 tk 8.6,
且已装齐 numpy/pandas/pymupdf/docx/lxml/pywin32/PIL 等全套生态)。
envs/default 那个 venv 装了 pywin32 却没有 tkinter,别拿它跑界面测试
> 2026-10-20 更新:envs/default 已精简为 13 MB 的极简 venv(仅 pip),
> 不再带 Pillow。需要 Pillow 做 PNG 转换时,改用系统级 3.14.2
> (Pillow 13.0.0 + tkinter 8.6,两者兼有,比旧 venv 更全)。
(症状是 ModuleNotFoundError: No module named 'tkinter',一头雾水)。
→ 交付给用户时,.bat 启动器必须按「工具目录自带 python → 系统级 Python313 → PATH → py -3 → 常见安装位置」
的顺序探测,并且用 "%PY%" -c "import tkinter" 做一次实际验证再启动,否则会静默失败。
平时写代码用普通解释器即可(业务模块无需 tkinter),只有跑界面/自检才用上面那两个。
2. 无界面自检:一次跑完所有页面
把「逐页构建」做成 selftest.py,构建期间不进入事件循环,只 root.update() 一次:
import tkinter as tk, main as app_main
root = tk.Tk(); root.withdraw()
app_main.PAGES.update(app_main._build_pages())
app = app_main.CleanerApp(root)
for key in [n.key for n in app_main.NAV_ITEMS]:
try:
app.goto(key); root.update() # 不调 mainloop,才不会卡住
except Exception as e:
print("FAIL", key, e)
root.destroy()
要点:
- 不要 mainloop(),否则脚本不退出;update() 够触发布局计算。
- 页面里若用 after(ms, ...) 延迟加载,这种自检不会执行到,属于正常。
- 后台线程回调要能容忍"控件已销毁":widget.after(...) 外层 catch tk.TclError,
回调内部先判 widget.winfo_exists()。
3. 视觉验收:PrintWindow 截图 + 读图
用 scripts/shot_win.py(本 skill 自带):
# 启动(背景任务,不要用 nohup;用 & + $! 拿到 PID 便于精确回收)
pythonw main.py --page=clean & PID=$!
sleep 7
py -3.14 scripts/shot_win.py "窗口标题关键字" out.png # 打印 HWND/size
kill $PID 2>/dev/null; sleep 2
py -3.14 scripts/shot_win.py "窗口标题关键字" x.png # 期望输出 NOT_FOUND,确认没有残留
技术要点:
- 必须用 PrintWindow(hwnd, mdc, 2)(2=PW_RENDERFULLCONTENT)。截屏法/SetForegroundWindow
会截到壁纸或被其它窗口遮挡。
- 取像素用 GetDIBits + BITMAPINFOHEADER(biHeight=-h)(负数=自上而下),
再 PIL.Image.frombuffer("RGBA", ..., "raw", "BGRA", 0, 1)。
- 残留实例是最常见的干扰源:标题相同时 EnumWindows 返回的是 Z 序最上的那个,
很容易截到上一次没杀干净的窗口,得出"功能没生效"的错误结论。
每次截图后必须再跑一次确认 NOT_FOUND。
- 刚启动 1~2 秒内窗口可能还没应用 geometry,截出来只有一条标题栏 → 等 6~10 秒再截。
- 目标解释器常常没有 PIL(豆包运行时 3.14.7 就没有)→ 不要靠 PIL 存图,
直接手写 BMP:b"BM" + struct.pack("<IHHI", off+len(px), 0, 0, 54+40)
+ struct.pack("<IiiHHIIiiII", 40, w, -h, 1, 32, 0, len(px), 2835, 2835, 0, 0)
+ GetDIBits 出来的 BGRA 原始字节(biHeight=-h 即自顶向下)。
需要转 PNG 时再用另一个带 PIL 的解释器转
(系统级 C:/Users/Administrator/AppData/Local/Programs/Python/Python314/python.exe
有 Pillow 13.0.0 + tkinter 8.6;envs/default 已精简为仅 pip,不再有 Pillow)。
- 当前模型读不了图时,用"调色板占比"代替肉眼:把截图按程序 UI 的配色表逐点比对,
能客观判断"是否渲染出真界面"。例:左侧 200px 区域深蓝 #1E3A5F 占 80.8%(导航栏)、
右侧内容区浅底 #F4F6FA 占 94.0%(内容区)、缩略图唯一色数 1930(不是纯色空白)→ 渲染正常。
- 冒烟测试用心跳文件判"是否真的起来了"最稳:启动器先写心跳再拉界面,
测试侧轮询该文件,比 sleep 固定秒数可靠。
- 截对话框(Toplevel)比截主窗口要等更久。实测:创建后 0.78s 抓,
拿到的整片是 DWM 窗口底色 #F2F3F4(颜色种类只有几百、关键配色像素全为 0),
一度误判成"内容没渲染出来";等到 2.4s 后恢复正常(颜色种类 869、绿色色条 #12A150 正确)。
判据:采样颜色种类 < 300 基本就是空白图,别急着下结论。
- Tk 的 Toplevel 在 Windows 上有两个 HWND:EnumWindows 按标题找到的(含标题栏,
宽出 16px)与 dlg.winfo_id()(纯客户区)。两个都能抓到内容,
稳妥做法是两个都抓、取"颜色种类最多"的那个;要连标题栏一起看就用前者。
4. tk 坑位清单(都是本次实测踩到的)
pack_propagate(False)必须配height:只给width会让 Frame 塌陷成 0 高度,
卡片里的内容全部不可见。- tk 缩放会放大字体:
tk scaling在高 DPI 下约 1.33,15pt实际约 20px,
所以固定像素高度的容器要按 1.3~1.4 倍留余量(例:三行文字的卡片给 104px 而不是 78px)。 - 滚轮不要用全局
bind_all常驻:多个滚动容器会互相串。做法是每个容器在
<Enter>时bind_all("<MouseWheel>")、<Leave>时unbind_all。
⚠ 但光这样会有一个隐蔽 bug:指针从容器本身移到它自己的子控件上时,
Tk 也会给容器发<Leave>→ 把绑定拆掉 → 鼠标一放到文字/表格上就滚不动了。
所以解绑前必须先确认指针真的离开了这棵子树:
```python
def _ptr_inside(self):
w = self.winfo_containing(self.winfo_pointerx(), self.winfo_pointery())
while w is not None:
if w is self:
return True
w = getattr(w, "master", None)
return False
def _unbind_wheel(self, _e=None):
if self._ptr_inside(): # 只是移到子控件上,别解绑
return
self.unbind_all("<MouseWheel>")
``
另外滚轮回调里要**从event.widget往上找祖先**,遇到自带滚动条的控件
(Treeview/Text/Canvas/Listbox)就return让给内层滚,否则会出现
"表格滚不动、外面反而乱滚"。
4. **Microsoft YaHei UI 缺 ⑪ 及以后的圈码**,会渲染成豆腐块 → 导航编号用1.2.这类纯数字。
5. **中文文案里不要嵌 ASCII 双引号**:"找出真正的"黑洞""会直接语法错误,
用「」或引号转义。写完务必python -m py_compile .py全量编译。
6. **ttk 主题外观不一致**:要完全可控的按钮就用tk.Label自绘(三态配色 + cursor),
比折腾 ttk style 省事。ttk.Style用clam打底再改色。
7. **页面重建是常态**:切换页面时销毁重建最省心,但要保证重建后_current状态一致,
且**依赖异步数据的页面在数据就绪后要主动重建**(否则用户在数据到达前点进去只会看到空列表)。
8. 表格用ttk.Treeview(show="headings"),iid用行索引、values_fn(row)统一映射,
排序时按列类型(num/str)取键,比到处写 lambda 干净。
9. **tk 9.0 下widget["opt"]/cget()返回Tcl_Obj,不是str**:
它**不可哈希**(放进set报TypeError: cannot use '_tkinter.Tcl_Obj' as a set element),
与str比较也不可靠。断言/统计前**必须先str(...)归一化**。
实测踩坑:验证脚本里写if bar["mode"] == "determinate"恒为假,
导致"0 采样",一度误判成"跨线程不生效",白查一轮。
10. **不要从后台线程读 Tk 控件状态做断言**:读widget[...]、cget()本身就不是线程安全操作,
采样会漏甚至报错。正确做法是**在主线程用after()定时器采样**,
后台线程只负责"驱动"(调show()/busy())。
11. **判"跨线程 UI 是否真生效"必须在真实mainloop()里测**,不能用update()循环代替:update()循环不置位 Tk 的dispatching标志,跨线程调用会抛RuntimeError: main thread is not in main loop——**这是测试假象,不是代码缺陷**
(本次因此先误判了一轮)。
12. **pack顺序=垂直空间的分配优先级(会造成"内容静默消失")**:
pack 按**加入顺序**分配,先加入的先拿满自己请求的高度。若先 pack 了带expand=True的大表格,它会把高度全吃掉,**排在它后面的固定块只能分到残渣**。
实测两次:① 一张"怎么用这份说明"卡(需 130px)被压成 **1px,从上线起就没露过面**;
② 窗口缩到最小尺寸时,详情卡被切 62px,「为什么这么建议」整行消失。
这类 bug **不报错**,且只在特定窗口尺寸才露出来,靠肉眼极易漏。
**正确结构:固定块先占位(顶部side="top"、底部side="bottom"),
唯一的可伸缩控件(表格/列表)最后 pack。** 注意side="bottom"时
**先 pack 的贴着最下边**,所以要"表格→(上面)详情卡→(最下)按钮行"这个视觉顺序,
得先 pack 按钮行、再 pack 详情卡。
⚠ **但顺序不是万能的**:Tk 的 parcel 分配是「先按顺序把每个块放到自己那一侧,
剩余空间再给expand」——当**某一侧的总需求本身就已经超过容器**时,
该侧最后那个 parcel 还是会被压扁。实测在 1366x768 笔记本上,
「日志与恢复」把台账表格和底部按钮行**整块压没**,怎么调登记顺序都救不回来。
→ 兜底手段见 §4.3「滚动视口」。
13. **在mainloop()之前调app.goto()会丢异步回调**:ui.run_bg的widget.after(0, done)若赶在mainloop启动前发出就会丢失,**页面永远 0 行**。
同样的代码"有时能跑"纯属时序侥幸(本机连挂两次才发现)。
测试脚本一律写成root.after(150, step_goto); root.mainloop()。
14. **<Enter>/<Leave>的回调签名要写成lambda _e=None:**:Tk 9 在**销毁**控件时
会不带事件对象触发一次<Leave>,写lambda e: ...就会TypeError: <lambda>() missing 1 required positional argument: 'e'。
自绘按钮的悬停态最常这么写,而一旦被全局的report_callback_exception兜住,表现就是"**每次切页面都弹一个错误框**"——很难联想到是销毁引起的。
15. **side="bottom"+expand=True` 混用时要留意 parcel 的两趟分配:
实测顺序是「祖先 → 父 → top 侧(按加入序)→ bottom 侧 → left/right 侧」,
两侧之间还会交错。结论:能确定顺序的场景就靠顺序,不能确定的场景必须上 §4.3
的滚动视口兜底*,别指望调顺序能覆盖所有尺寸。
4.1 版面验收:用"被挤扁"检测代替肉眼看
判断"底部信息会不会显示不全"不要靠截图(截图只能证明某一个尺寸好看),
直接比 winfo_height()(实际分到)与 winfo_reqheight()(内容需要):
前者小于后者就是内容被裁,与窗口尺寸、DPI 都无关。
for c in page.winfo_children():
if c.winfo_height() < c.winfo_reqheight() - 1:
print("被挤扁:", c, c.winfo_reqheight(), c.winfo_height())
例外:自带滚动条的控件(Treeview / Text / Canvas / Listbox)允许被压小
(压小只是要滚动,不算丢信息)——用"自身或子孙里有滚动型控件"来放行,
否则表格会被误报。把这个检查逐页跑一遍(变更窗口尺寸多跑几轮),
就能一次扫出所有"静默消失"的块,本机用它扫出 1 处并确认其余 13 页干净。
⚠ 这个判据必须只有一份实现(放 _dev/页面可见性.py 之类,验收脚本共用),
否则两个脚本会各自演化、给出互相矛盾的结论(踩过:探针说没问题、截图有问题)。
另外必须等真的映射完再量:Tk 对未映射控件一律返回 1x1,会假报
"每个块都被压成 1px"。判据用 winfo_ismapped() and winfo_height() > 50 轮询:
def wait_until(pred, cb, tries=60, every=250):
def tick(n=0):
try: ok = pred()
except tk.TclError: ok = True
return cb() if (ok or n >= tries) else root.after(every, lambda: tick(n + 1))
tick()
启动后要 1~2 秒才把子窗口全部映射完(有过"改字号 + 顶部渐变条反复重绘"的话更久),
这个假失败本机踩过两次。
还有一条判据要区分"滚得到的裁切":页面放进滚动视口(§4.3)之后,
内容越过视口下沿是设计如此,不能算丢。做法是自底向上遍历祖先,
遇到"stretch 版滚动容器"就跳过它和它外壳的裁切,再往上才取 min。
4.2 窗口尺寸按「桌面工作区」算,不是按屏幕尺寸算
症状:笔记本上(任务栏不隐藏)窗口底边被切,状态栏 / 页面底部按钮行 /
导航最下面的署名全部看不见,不报任何错。
根因:root.geometry("1280x880") 给的是客户区,整窗还要加标题栏与边框
(本机 880+39 = 919),而 1366×768 笔记本扣掉任务栏的工作区只有 728px。
写死尺寸 = 必然超屏。
_SPI_GETWORKAREA = 0x0030 # 取"桌面工作区",任务栏在任何一边都会被扣掉
def work_area():
u = ctypes.windll.user32
r = _WRect()
if u.SystemParametersInfoW(_SPI_GETWORKAREA, 0, ctypes.byref(r), 0):
return r.left, r.top, r.right - r.left, r.bottom - r.top
return None
⚠ 最大的坑:整窗比客户区高多少,公式里有个 ×2。
Windows 官方口径(带标题栏 + 可缩放边框):
整窗高 = 客户区高 + SM_CYCAPTION + 2 * (SM_CYSIZEFRAME + SM_CXPADDEDBORDER)
整窗宽 = 客户区宽 + 2 * (SM_CXSIZEFRAME + SM_CXPADDEDBORDER)
本机 caption=23, frame=4, pad=4 → 高 = 23 + 16 = 39px。
我第一版写成 caption + frame + pad 得 31px —— 差的 8px 恰好等于定位留的 margin,
于是窗口紧贴工作区下沿、零余量,现在没露出去纯属侥幸。
必须写一条回归断言:建一个真实 Toplevel、用 GetWindowRect 量外框,与公式比对:
r_fh = (rc.bottom - rc.top) - probe.winfo_height()
assert abs(r_fh - formula_fh) <= 2
再上一层保险 clamp_position():窗口映射后用 GetWindowRect 读真实外框,
真超界就 SetWindowPos 往上/往左挪(有标题栏时窗口外框在 GetAncestor(hwnd, 2) 上)。
公式只能"应该对",实测才叫"确实对"。用 SetWindowPos(HWND_TOPMOST) 而不是
SetForegroundWindow(后者常被系统拦掉)。
其它要点:
- DPI 不用自己换算:程序若没声明 DPI 感知,Windows 会把高 DPI 坐标虚拟化回
96 DPI,SPI_GETWORKAREA 与 winfo_screenwidth 落在同一套坐标里
(本机物理 1920×1080@125%,两处都报 1536×864)。
- minsize 也要夹,否则矮屏上"最小高度"本身就超过屏幕,怎么拖都超屏。
- 对话框(进度框/完成框)同样要夹:它们的底部就是「确定/取消」按钮,
越过屏幕下沿就点不到了。上限用 maxsize。
- fit_window() 应该把 (w,h,x,y,squeezed) 返回给调用方,并让"被压缩"驱动
「紧凑排版」(收紧行距、缩小 logo)。别让调用方去读 win.geometry() ——
Tk 对尚未映射的窗口一律报 1x1,读不出真值(模拟测试里因此假失败过一轮)。
- 自己程序选的几何(win_geom)要存下来,验收/截图脚本"还原真实尺寸"时用这一份,
而不是脚本自己再摆一次位(摆错会把锅算到程序头上)。
4.3 页面内容装不下:滚动视口(ScrollFrame(stretch=True))
窗口能夹进屏幕了,但单个页面可能还是比视口高(实测「日志与恢复」整页 859px,
矮屏视口只有 555px)。§4「pack 顺序」只能缓解,救不了"某一侧总需求超容器"。
做法:把页面宿主换成一个伸缩滚动容器,内容有多高就让 inner 有多高,
装不下就出滚动条;放得下时行为与以前完全一致(带 expand 的表格照旧填满)。
class ScrollFrame(tk.Frame):
def __init__(self, parent, bg=None, stretch=False, **kw):
...
self.inner.pack_propagate(False) # stretch 模式:高度自己算
self.canvas.create_window((0, 0), window=self.inner, anchor="nw")
主界面里:
self.body = ui.ScrollFrame(right, stretch=True)
self.body.pack(fill="both", expand=True)
self._page_host = self.body.inner # 以后页面都挂这里
# goto(): 先 self.body.begin_page() → builder(...) → self.body.sync_height()
三条纪律(都不是猜的,是被验收脚本逼出来的):
- 变高立即生效,变矮要"连续两次读到同一个值"才生效。
扫描结束后页面会重建(goto()重跑 builder),中途测得的content_need
会短暂偏小(实测「应用专清」593→518)。跟着缩就会把已经建好的块裁掉 75px。 - 滚动条只看"有效内容高度",与 inner 高度同源。
否则会出现"内容明明超出视口、滚动条却是空的"——验收脚本真抓到过一次,
就是因为滚动条用了瞬时读数、inner 用了延迟收缩后的值。
两个都用同一个_eff_need,就不会自相矛盾。 begin_page()换页时把_eff_need归零,否则从高页面切到矮页面会先
维持上一页的高度、闪一下滚动条。
另外:
- content_need() 只是"请求高度之和",不等于实际高度;带 expand 的表格会被
撑大,非伸缩的 Canvas(默认 10c x 7c)会虚报 ~265px。所以"放得下"的页面
也用不到精确值,别拿它当布局真值。
- 内容变化时如果控件自身尺寸没变,Tk 一个事件都不发 → 只能靠轮询兜底,
间隔取 150ms(别用 400ms,理由见下),content_need() 求和(主块通常
不到 10 个),开销可忽略。
这是唯一能兜住"异步重渲染"的手段:页面数据常常是后台线程扫出来的,扫完
重建整页,而 inner 尺寸可能一直没变 → 一个 <Configure> 都不会发。
实测 400ms 会留下最长 0.4 秒的窗口:内容已比视口高、滚动条还没出来、页面被压
38px(实测「应用专清」555 vs 593)。150ms 后肉眼无感。这段窗口只能缩短、不能
消除,所以验收脚本必须容忍它(见 §4.5)。
- 轮询变密后注意别白刷:canvas.configure(scrollregion=...) 只在内容真变时
下发(bbox() 是一次 Tcl 往返,150ms 一次就值得省)。
- 同步构建的页面(builder 跑完内容就定了)再补一次 request_sync()
(内部 after_idle(sync_height)),把"内容就绪 → inner 跟上"压到一个事件循环内。
- 滚动条显隐加 20px 迟滞:竖滚动条只改变宽度,而宽度变化可能让文字多折一行、
内容反而更高,没有迟滞临界点会来回闪。
- 换页后 scroll_to_top(),否则新页面会停在上一页的滚动位置。
4.4 截图不依赖 Pillow:纯标准库写 PNG
本机带 tkinter 的解释器没有 Pillow,带 Pillow 的又没有 tkinter。
以前搞成"截图存 BMP → 换个解释器转 PNG",两个进程、两处容易忘。
zlib + struct 就能直接写 PNG(scripts/png_writer.py):
from png_writer import write_png
write_png("shot.png", w, h, px) # px 是 GetDIBits 的原样输出(BGRA、自顶向下)
输入正是 GetDIBits(..., biHeight=-h, biBitCount=32) 的输出:每行 w*4 字节、
无行填充、通道序 BGRA。注意两点:
- BGRA → RGBA 要交换首尾通道(只换这两个);
- alpha 一律置 255 —— PrintWindow 常把 alpha 写成 0,直接透传会得到一张全透明的图。
顺带好处:颜色统计也不需要解码图片了,直接在原始像素上按 BGRA 比对即可
(比对 RGB 参考色时记得 B/R 对调),于是"截图 + 断言"可以单进程完成。
⚠ PrintWindow 抓的是窗口自己,无论它是否跑到屏幕外都能拿到完整内容 ——
所以它永远证明不了"被任务栏切掉"。要证明这件事只能抓屏幕上的工作区矩形
(BitBlt 屏幕 DC),并且把窗口 SetWindowPos(HWND_TOPMOST) 提到最前
(SetForegroundWindow 会被拦)。抓"窗口 + 下方任务栏"这一条更直观:
一张图里同时看到窗口下边缘和任务栏,切没切一眼可辨。
4.5 验收门不能随机变红:判"有问题"之前先复检
一个会随机变红的验收门比没有门更糟 —— 它训练你忽略红灯,真问题就漏了。
本机实测:逐页布局检查在 1366×681 下连跑 4 轮有 3 轮随机报「应用专清」,
而前一天那次是"14/14 全绿"通过。全绿是运气好,不是修好了。
根因(用探针抓现场定性,别靠猜):该页数据是异步扫出来的,扫完重渲染,
内容 209→593px。中间有一拍 inner 还停在视口高 555、滚动条未显示、内容高出
38px,200ms 内自愈(见 §4.3 的轮询说明)。这不是"底部滚不到",
只是可伸缩表格矮了一点、底部固定内容一行不少。
# 判定为问题之前先给它自愈的机会;只有"持续"超限才算真问题
def measure(key, i, n=0, t0=None):
t0 = t0 or time.time()
kind, page, squeezed, clipped, vh, need, bar = read(key)
if kind != "ok" and n < SETTLE_TRIES: # 200ms × 7 ≈ 1.4s
return root.after(SETTLE_MS, lambda: measure(key, i, n + 1, t0))
... # 到这里仍不对 → 才记问题
要点:
- 先把"瞬时"和"持续"分开再动代码。写个探针反复加载该页,对同一现场隔
200ms / 1.2s 各读一次,打印 inner 高 / 内容需要 / 有效高度 / 滚动条是否 mapped。
数据摆出来,"过渡态 vs 卡死"一眼分得清(实测 6 轮全为过渡态、0 次卡死)。
- 自愈的不要静默放过,在输出里标注「判定前复检 N 次」—— 既稳,又留下
"这里曾经抖过"的线索;哪天真的退化成卡死,那行日志就是关键证据。
- 等待只在非 ok 时才发生,正常路径零开销(否则 1.4s × 14 页 = 20s)。
- 同类坑:断言"体积/大小/哈希"这类量时,别写死期望值。写死了每次重新构建
都会假报异常(本机踩过:清理脚本里写死旧 exe 的哈希,重打包后必报"交付物异常")。
改成"多份副本互相一致 + 打印实际值"。
4.6 页面自带滚动容器时,几何验收给不出结论 —— 必须滚到底截图
§4.1 的"被挤扁"判据会放过一类页面:页面自己又套了一层滚动容器
(如"设置"页 —— 标题固定在页面里,正文整体放进 ScrollFrame)。
本机实测("设置"页,1280×880,往"关于"区新增了一整块反馈卡片之后):
逐页布局检查:页面高 719 | 内容需要 **405** | 一屏放得下 ✔
真实滚动容器:内容需要 **1233** | 视口 578 | 滚动条可见
外层页面只有一个 scroll 容器,它的 reqheight 只反映视口/早期值,
容器内部那 1233px 完全不在统计口径里 —— 于是这个页面无论往里加多少内容,
几何门都会说"没事"。自动验收全绿 ≠ 新内容真的显示出来了。
规则:凡是"页面里再套一层滚动容器"的页面,几何数字只能证明"容器没被裁",
"容器内部的新增内容可不可见"必须滚到底截图,用眼睛看。
(_dev/截图页面.py 的第 4 个参数 bottom 就是为它加的:)
py -3.14 _dev/截图页面.py settings _dev/shot_settings_bottom.png 1280x880 bottom
⚠ yview_moveto(1.0) 一次到不了底:实测末次 yview() 停在 (0.516, 0.935),
还差 65px。原因是 moveto 按调用那一刻的 scrollregion 定位,之后异步内容
或折行把 scrollregion 撑高,视图的相对位置就"缩水"了。必须迭代到 yview()[1] ≈ 1:
def scroll_bottom(sf, n=0):
sf.canvas.configure(scrollregion=sf.canvas.bbox("all"))
sf.canvas.yview_moveto(1.0)
root.update_idletasks()
if sf.canvas.yview()[1] < 0.999 and n < 4: # 到位判据看 yview 末端
return root.after(120, lambda: scroll_bottom(sf, n + 1))
root.after(250, dump_and_grab) # 画面稳定后再抓
配套两个小坑:
- 长文案(如"…请截图发送到邮箱:xxx@xxx")用 wraplength 跟随视口自动折行时,
别让下限小于文本自然宽。验收方式:tkfont.Font(...).measure(text) 得自然宽,
与 wraplength 比 —— 自然宽 ≤ wraplength 就一定不折行(邮箱这类无空格长串
一旦被折行就可能拆断)。本机实测自然宽 438px、最小窗口下可用 602px,安全。
- 验收"写入剪贴板"这类交互,直接调自绘按钮的 _on_click() 即可(等价于用户点击),
比造真实鼠标事件稳;同一进程内 clipboard_get() 立刻读得回来。
5. 沙箱限制(写"会删东西"的程序时注意)
本机沙箱有批量删除防护,会拦截 os.remove / shutil.rmtree / rm,报
[safe-delete][SAFE_DELETE_BULK_GUARD_ERROR] state lock timeout。
因此销毁性代码路径无法在沙箱内跑通:只能静态审查 + 让用户实机验证;
但移动/重命名(os.rename/shutil.move)不受影响,可以正常做往返测试。
6. 启动链路:bat 编码坑 + pythonw 静默失败(用户报"双击没反应"必查)
GUI 工具最常见的用户反馈是「启动不了 / 双击没反应」。先查这两条,命中率极高。
6.1 bat 必须纯 ASCII,且绝不能调 chcp
症状(用户端只看到黑窗一闪):
系统找不到指定的路径。
'heck' 不是内部或外部命令 ← 本该是 "goto check"
'fined' 不是内部或外部命令 ← 本该是 "if not defined"
'鐢ㄨВ閲婂櫒锛?PYW' ... ← 中文全乱码
根因:文件按 UTF-8 存(中文 3 字节)+ 脚本里调了 chcp 65001。cmd 是逐字节读文件的,
中途切代码页会让后续行按错位偏移解析,于是行首几个字符被吃掉、%~dp0 的中文路径失效。
三者叠加必然崩。
规矩:
- .bat/.cmd 一个非 ASCII 字节都不能有(注释也用英文)。中文提示交给 Python 侧输出。
- 不要写 chcp。中文 Windows 默认就是 936,英文提示显示正常。
- 校验(应输出 0):
python
b = open('启动工具.bat','rb').read(); print(sum(1 for x in b if x > 127))
6.2 pythonw 无控制台 → 启动期异常全部静默
pythonw.exe 没有 stdout/stderr,缺 tkinter、模块导入失败、编码异常都会被吞掉,
表现为"双击没反应"。三层兜底:
- 预检启动器(
launcher.py):依次验证 解释器 →import tkinter→ 关键模块逐个
__import__→ 真实Tk(); withdraw(); update(); destroy()建窗 → 数据目录可写。
全过才启动界面;任一失败就写日志 +ctypes.windll.user32.MessageBoxW(None, 中文报告, 标题, 0x10|0x40000)
(原生弹窗,不依赖 tkinter,所以 tkinter 挂了也弹得出来)。 - 心跳文件:预检通过即写
data/launch.ok;bat 用start拉起后轮询该文件,
ping -n 2 127.0.0.1 >nul循环 3 次仍没有 → 判定失败,改用python.exe launcher.py --console
重跑一遍把中文诊断打在控制台,再pause停住。 - main.py 兜底:
sys.excepthook+root.report_callback_exception都指向同一个
handler(写启动错误.log+ 弹原生框),否则 Tk 回调里的异常在 pythonw 下等于消失。
6.3 sys.stdout is None 会让一句日志把程序弄崩
pythonw 下 sys.stdout/sys.stderr 是 None,任何 print() 抛 AttributeError。
入口第一步必须兜底重定向:
if sys.stdout is None or sys.stderr is None:
f = open(os.path.join(DATA_DIR, "界面日志.log"), "a", encoding="utf-8", buffering=1)
if sys.stdout is None: sys.stdout = f
if sys.stderr is None: sys.stderr = f
主线程以外不要弹窗(Tk 非线程安全):if threading.current_thread() is not threading.main_thread(): return。
6.4 bat 探测解释器:逐个验证 tkinter,别用第一个命中的
- 找到
pythonw.exe后,必须用同目录python.exe跑-c "import tkinter"验证,通过才采用;
不通过就继续试下一个候选(本机 PATH 上很可能刚好是那个没 tkinter 的)。 - 跳过 Microsoft Store 别名:
where python.exe可能返回
...\WindowsApps\python.exe,执行它会弹出应用商店而不是跑 Python。用延迟展开过滤:
bat setlocal enabledelayedexpansion for /f "delims=" %%P in ('where pythonw.exe 2^>nul') do ( set "CAND=%%P" if "!CAND:WindowsApps=!"=="!CAND!" if not defined PYW call :try "%%P" ) - 候选顺序:
工具目录\python\→ 豆包内置运行时 → PATH →py -3→ 常见安装位置。
注意py在只有 Store 别名的机器上同样会弹商店,也要过滤。
6.5 调试 bat 时:重定向到文件,不要管道给 head
沙箱里 cmd //c "x.bat" | head 会被 SIGTERM(无输出、退出码 1,容易误判成脚本有问题)。
改成落盘再看:
cmd //c "启动工具.bat > _dev\bat_out.txt 2>&1"; cat _dev/bat_out.txt
进程存活判定:tasklist //FI "IMAGENAME eq pythonw.exe"。
注意沙箱随命令结束回收子进程,所以 start 起的 GUI 在沙箱里查不到——属正常,
用户双击时窗口会保留。要截图验收就改用 pythonw launcher.py & PID=$! 同命令内完成。
7. 进度反馈:最常见的"进度条不走"
症状:用户点「一键瘦身/清理」,进度条纹丝不动,最后直接跳到完成。
根因几乎总在同一处:只有部分分支调用了 progress_cb。
本次实测:clean() 的 recycle(默认!)与 quarantine 分支只做
done += len(paths) 却从不调回调,只有 permanent 分支调了 → 默认路径永远 0%。
自查清单:
1. 逐个模式都走一遍,确认每个分支都上报(含"空目录清理"这类收尾步骤)。
2. 底层批量函数(如 recycle_paths)要接受 progress_cb,并按批(每 2000 项)上报;
否则一个批次几万文件,进度条会长时间呆滞然后瞬间跳满。
3. 进度值必须单调不减:用 done = max(done, min(total, int(v))),
且 total = max(计划数,1) + max(len(selected),1),避免除零和回退。
4. 递归遍历(磁盘分析/查重)事先不知道总数 → 别硬套 (done,total),
改用 ttk.Progressbar(mode="indeterminate") + start(14) 做循环动画,
配一行"当前正在处理 X"的文字。
5. 收尾要显式 finish() 并在关窗前 after(180, close),
否则用户看不到 100%(窗口先消失)。
6. 回调签名易错:quarantine_move 的回调是 (i, n, src) 三参,
写成两参会 TypeError;且务必把回调包在 try/except 里——
进度上报失败绝不能连累真正的文件操作。
7. 后台线程不要直接改控件(Tk 非线程安全)。两种可靠写法:
- widget.after(0, lambda: apply()) 投递回主线程(本次采用,实测可用);
- 或后台只写共享状态 + 加锁,主线程 after(80, poll) 轮询绘制。
8. 任务结束后的"结果提示框"(比 messagebox 好用的固定套路)
场景:清理 / 转换 / 批处理跑完后,既要告诉用户结果,又要给出下一步入口。
messagebox.showinfo 两个硬伤:只能放纯文本、放不了按钮;而且是阻塞式的。
本工具已落地的组件(ui_common.CleanDoneDialog),骨架可直接照搬:
class DoneDialog(tk.Toplevel):
"""结果摘要 + 操作按钮。非阻塞:grab_set 但不 wait_window。"""
def __init__(self, parent, res, actions=None, tips=None,
title="完成", on_close=None):
...
self._closed = False
self.protocol("WM_DELETE_WINDOW", self._close)
try:
self.grab_set() # 模态输入,但不阻塞调用方
except tk.TclError:
pass
self.bind("<Escape>", lambda _e: self._close())
def _invoke(self, cb):
self._close() # ① 先关窗并收尾
try:
if cb:
cb() # ② 再执行操作回调(主线程)
except Exception as e:
messagebox.showerror("操作失败", str(e))
def _close(self):
if self._closed: # ← 防重入:destroy 可能被多条路径触发
return
self._closed = True
try:
self.grab_release()
except tk.TclError:
pass
try:
self.destroy()
except tk.TclError:
pass
if self._on_close:
try:
self._on_close() # 收尾:清空选中、重新扫描…
except Exception:
pass
要点:
1. 不要 wait_window。用它等于把调用方阻塞在局部事件循环里,流程卡住、异常难回传。
改用 on_close 回调表达"关窗之后要做什么"。
2. close → on_close → action 的顺序(本工具的选择):先收尾再执行操作,
这样操作若是"切页",切过去的页面已是刷新后的状态。代价是 action 里再弹窗会叠在新窗口上,
注意别弹冲突。顺序必须写测试断言(实测断言 calls == ["close", "act:history"])。
3. _closed 标志防重入:关窗按钮、WM_DELETE_WINDOW、程序化 destroy() 都可能触发,
收尾动作(如重新扫描)重复执行会很难查。
4. 按钮由调用方注入:actions=[(文字, 回调, kind)],界面组件不反向 import 业务模块,
组件才能跨页面复用。同理,要展示的数字全部来自业务层返回值,组件内不二次计算。
5. 尺寸:用 wraplength 控制宽度、高度由内容决定 + resizable(False, False);
居中前先 update_idletasks(),并且父窗口还没布局时 winfo_width() 返回 1,
要退化为屏幕居中,否则窗口会跑到 (0,0):
python
pw, ph = parent.winfo_width(), parent.winfo_height()
if pw <= 1 or ph <= 1:
sw, sh = self.winfo_screenwidth(), self.winfo_screenheight()
self.geometry(f"+{max((sw-w)//2,0)}+{max((sh-h)//3,0)}"); return
6. 内容分块(自上而下):成功徽标 + 标题 + 副标题 → 大数字(实际释放等)+ 右侧计数 →
明细(限 8 条,超出提示"详见日志页")→ 分色提示 → 等宽按钮条。
提示文字按语义配色(danger 红字加粗 / warn 橙 / ok 绿 / info 灰),与页面上的风险口径一致。
7. 完成后通常还要重新扫描一次刷新界面——把它放进 on_close,用户不用手动点"刷新"。
验收这类组件,四个断言维度就够(见本工具 _dev/验证清理完成提示.py):
① 内容渲染(大数字/计数/明细条数)② 提示分色(danger 必须 fg == red_text 且字体含 bold)
③ 按钮清单(随场景变化)④ 交互时序(先 close 再 action;点"关闭"只触发 on_close)。
另加一条端到端:用 root.report_callback_exception 捕获 Tk 回调异常
(pythonw 下这类异常会被静默吞掉),跑完整"弹出→点击→收尾"链路并断言无异常。
9. 打包成单文件 exe,以及打包后的验收
用户最终要的多半是「双击即用、目标电脑不装 Python」。本机可行且已跑通的方案是 PyInstaller 单文件。
> 完整细则(构建脚本、界面取证、清理脚本、分发审计、数据目录迁移)→ references/packaging-exe.md
> 本节只留打包必守的硬约束、必踩的三个坑、验收硬门。
9.1 硬约束(不守会踩坑)
- 能打包的解释器只有一个:豆包客户端内置的 Python(3.14.7 / Tk 9.0.4)。用它
-m venv _build\venv再装 pyinstaller。
Tcl/Tk 库编进了 dll 的 zipfs,磁盘上没有tcl/目录,带上tcl90.dll/tcl9tk90.dll即可。 - 解释器分工:带 tkinter 的没有 Pillow,带 Pillow 的没有 tkinter → 截图
grab()只出裸像素,PNG 转换交另一个解释器。 - 产物名先纯 ASCII,产完再改中文(bootloader 靠自身路径定位,改名不影响运行)。
- 全程不删除任何文件:
--clean、复用 work 目录、覆盖已有产物都会触发"先删后写",在沙箱/安全策略下返回码 1 且报错与真因无关(实测被拦 4 次)。
→ 每次挑全新workN/outN;落地用shutil.copy2(截断写入)而非os.replace;目录合并用copytree(dirs_exist_ok=True)。 - 版本资源走官方
VSVersionInfo序列化,不要手写模板字符串(它eval(text)读文件,缩进差一格即 IndentationError);repr()要.replace("versioninfo.", "");
生成后立刻用load_version_info_from_text_file()自解析一遍,把错误提前到打包前。 - 体积:
--exclude-module掉sqlite3 asyncio concurrent multiprocessing unittest test distutils lib2to3 idlelib等。
9.2 必踩的三个坑
| 坑 | 现象 | 判据 / 做法 |
|---|---|---|
| 源码改了,exe 还是旧的 | 平铺模块 + 函数内 import 会被分析器漏掉,静默少功能、不报错 |
① exe 的 mtime 必须晚于所有 .py(本次靠这条发现"exe 建于 10:56、源码改于 11:16");② pyi-archive_viewer.exe -l -r "dist\工具.exe" \| grep 模块名 递归确认模块在 PYZ 里(顶层 toc 只有 dll/pyz);③ 比对模块体积——改过的变、没动的不变才可信 |
| 延迟导入的模块漏打 | 功能静默消失,无任何报错 | 手工维护 HIDDEN_IMPORTS = [...],每新增一个模块都必须补进去 |
copytree 是合并不是镜像 |
发布夹每版都留残影 | 同步后逐份比 sha256,不要假设脚本复制的一定是最新版 |
补充两条同源教训:
- 打包进行中不要改源码或文档——实测落进去的是改到一半的快照(比项目根少 2 字节)。
- 元信息只留一处来源:构建脚本用
ast读settings.py的常量(不要import settings,构建机可能缺运行期依赖)。
否则改界面署名后,exe 右键属性里还是旧名字。
9.3 打包后的验收(不要只信"构建成功")
_dev/验证打包exe.py 的硬门(本机 11/11 通过):PE 子系统 = GUI(2)、manifest requireAdministrator、
版本资源可读且正确、图标资源存在、--diag 返回 0、数据目录建在 exe 旁边、日志含"环境正常"、
无 %TEMP%\_MEI* 泄漏、真实窗口出现、冷启动 < 15 s、窗口可正常关闭。
三个容易写错的判据:
- 版本资源用
version.dll的VerQueryValueW,须显式声明 argtypes/restype;手写 pefile 解 RT_VERSION 反复失败过。 - "窗口出现"要按进程树匹配 PID:onefile 的 bootloader 会拉起子进程,界面窗口属于子进程,只匹配
Popen.pid永远找不到;
也别按标题子串匹配(会命中标题含同一文件夹名的资源管理器)。 - 数据目录隔离性:把 exe 复制到不含
data的空目录再跑,验证它把数据建在 exe 旁边,而不是临时解压目录或用户目录。
10. 数据驱动的"说明/规则库"(给条目配人话解释)
给启动项、清理项这类既有对象逐条配中文说明时,用"分层优先级 + 数据表"最省事:
① 风险特征识别(最高,可覆盖其余)→ ② 内置关键词规则表 → ③ 版本资源/元信息 → ④ 路径特征兜底。
规则按子串匹配 (名称|命令|路径) 的小写串,列表顺序即优先级(具体规则必须排在通用规则之前,
例如 Wallpaper Engine 装在 Steam\steamapps\... 下,排在 Steam 规则之后就会被抢走结论)。
踩过的大坑:单元素元组。 规则表里写 (("steam"), ...) 得到的是字符串而不是元组,
for k in keys 会逐字符迭代,于是单字母 e 匹配一切——本次导致全部启动项都被说成同一个软件。
必须加导入期自检,让它再也犯不了:
def _validate_rules():
for keys, title, _desc, advice in _RULES:
if isinstance(keys, str) or not all(isinstance(k, str) for k in keys):
raise ValueError("规则「%s」关键字组必须是元组(单元素也要写逗号:(\"x\",))" % title)
for k in keys:
if len(k) >= 3:
continue
if len(k) == 2 and any("\u4e00" <= ch <= "\u9fff" for ch in k):
continue # 2 字中文(火绒、钉钉)放行
raise ValueError("规则「%s」关键字「%s」太短,会误匹配" % (title, k))
_validate_rules()
自检还要用一个"陷阱清单"反向验证:拿 10 条无关命令去跑,确认几乎不命中。
本次就是靠它逼出 tim/amd/pps/obs/115/ccb 这些过短英文关键字,逐个改长。
10.1 更大的坑:关键词是「别人的子串」(同音不同字)
真实事故(用户当场验收时指出):关键词 pingbao 命中了 duohuipingbao,
于是把 多绘屏保讲成了「购物推广组件 / 返利插件」——
屏保(píngbǎo) 与 拼宝(pīnbǎo) 同音不同字,子串匹配必然认错。
更根本的问题是优先级排错了:关键词是猜的,exe 版本资源是程序自己写的,
而当时"猜的"居然覆盖了"自报的"——describe() 明明读到了 FileDescription=多绘屏保 却没用上。
两道修补,第二道才是关键:
① 歧义关键词黑名单(导入期拦截,永久生效)
# 本身没错,但是别的产品名的子串,一加进来就张冠李戴
_AMBIGUOUS_KEYWORDS = {
"pingbao": "是 duohuipingbao(多绘屏保)的子串 —— 屏保≠拼宝,同音不同字",
"ping": "太泛", "hui": "太泛",
}
# _validate_rules() 里: low = k.lower(); if low in _AMBIGUOUS_KEYWORDS: raise ValueError(...)
(附带好处:它当场把我新写的合法关键词 dhpingbao 也拦了——dh 本就是"多绘"的缩写。
报错把问题顶到脸上,正好反证自检生效,随后把它移出黑名单即可。)
② 拿程序自报名称去复核说明库的结论——通用,且能覆盖所有没想到的同音词
def version_name_conflict(rule_title, rule_desc, fd, product):
"""说明库那串汉字与程序自报名称里的汉字一个都不重合 → 判为矛盾。
名称里没有汉字(WeChat / pdfFactory)时不判断,避免误报。"""
rule_cjk = _cjk(rule_title) | _cjk(rule_desc) # 只收 [\u4e00-\u9fff]
name_cjk = _cjk(fd) | _cjk(product)
if not rule_cjk or not name_cjk or (rule_cjk & name_cjk):
return ""
return (fd or product).strip()
判矛盾后要一整套动作,不能只改一个字:
撤回推断的说明 → 标题改用程序自报名称 → 建议自动降一档(可禁用→看需求,别再误劝用户禁用)
→ 备注首条写明"说明库推断 X ≠ 程序自报 Y,以程序为准" → 列表短说明以 ⚠ 开头
→ 详情卡加一个橙色「名称不符」徽标 → 导出加一列「识别」。
判定要保守:只比汉字、有一字重合就放过。否则会把好结论也推翻——
必须加一条回归断言:真机上不允许出现任何"名称不符"误报(本项目真机 7 项零误报)。
回归用例要主动构造冲突才测得到:临时往 _RULES 头部 insert() 一条必定误匹配的假规则,
拿真实的同名 exe 去撞,断言冲突路径全部生效,finally 里 remove() 掉。
只测纯函数是不够的——describe() 里那段接线才是最容易漏的。
10.2 其它
性能可忽略(本机实测:枚举 0.001 s,首次生成说明 0.017 s,命中缓存 0.002 s),
但要给结果加缓存,且判定依赖"文件是否存在"的规则在测试里要能关掉
(check_file=False),否则合成路径永远被判成"文件已不存在"。
修改说明库这类会给用户下结论的表,交付前一定要用真实数据跑一遍并把结果贴给用户看——
本次就是用户从结果表格里一眼看出"duohuipingbao 不可能是返利插件"的。