上周把自己天天用的小工具发给同学,他打开后点了一下「切换」按钮,问:为什么窗口白了一下,像卡死了?我让他等两秒,确实恢复了。这个问题我自己早就习惯,久到忘了它本来是个 bug。工具叫 gpu-switch-helper,本质是给几条要手动敲、还要管理员权限的命令加个壳:一个窗口、三个按钮、一个日志框。另一边的 md-workflow-manager 也踩过同一套坑。
趁着改这两处,我把 tkinter 写桌面工具的几个关键点重新捋了一遍:布局、线程、高 DPI、打包。它们都不难,但如果按「先跑起来再说」的写法来,最后一定会返工。
一、先把需求砍到最小
这类工具的界面需求其实只有四条:按钮能点、日志能看、执行中不能重复点、窗口拉大时日志跟着变大。没有动画、没有图表、没有主题切换。
所以我没有上 Qt,也没有套一个本地网页。tkinter 是标准库自带的,装好 Python 就能 import;打包时不用额外带一整套 GUI 运行库,产出的 exe 更小,发给别人也不用教他装依赖。代价是控件样式偏旧、复杂布局写起来啰嗦,但这四条需求根本用不到那些能力。
| 方案 | 额外依赖 | 适合的场景 | 不适合的场景 |
|---|---|---|---|
| tkinter | 无,标准库 | 单窗口工具、内部脚本加壳 | 复杂动画、精细视觉 |
| PyQt / PySide | 需要安装,打包要带运行库 | 多窗口、表格、专业界面 | 只想点几个按钮 |
| 本地网页 + 浏览器 | 要起服务或嵌入 WebView | 界面要频繁改、多人共用 | 离线单机小工具 |
选型的判断标准不是「哪个更强」,而是「界面复杂度值不值得引入运行时」。我这两个工具都是给自己和同学用,显然不值得。
二、布局:用 grid 的权重,而不是坐标
最早我是用 place 写的,每个按钮都算好 x、y。本机 150% 缩放下看着正常,换到同学的 100% 缩放屏幕上,按钮挤成一团,日志框还盖住了半个按钮。原因是 place 接收的是像素,而像素在不同缩放下对应的物理尺寸不一样。
改成 grid 并交给 Tk 自己算之后,这类问题就消失了:
import tkinter as tk
from tkinter import ttk
root = tk.Tk()
root.title("GPU Switch")
root.minsize(420, 300)
# 只有第 0 列,占满整行;第 2 行(日志)吃掉剩余高度
root.columnconfigure(0, weight=1)
root.rowconfigure(2, weight=1)
bar = ttk.Frame(root, padding=8)
bar.grid(row=0, column=0, sticky="ew")
for i in range(3):
bar.columnconfigure(i, weight=1) # 三个按钮等宽
buttons = []
for i, text in enumerate(("切到核显", "切到独显", "刷新状态")):
b = ttk.Button(bar, text=text)
b.grid(row=0, column=i, sticky="ew", padx=4)
buttons.append(b)
log = tk.Text(root, height=10, state="disabled", wrap="word")
log.grid(row=2, column=0, sticky="nsew", padx=8, pady=(0, 8))
几个参数值得解释。columnconfigure(0, weight=1) 的意思是:窗口变宽时,多出来的宽度按权重分给第 0 列。没有这一行,窗口拉伸时控件会贴在左上角不动。sticky="ew" 让按钮在格子里横向铺满,否则它只占自己文字需要的宽度。日志框用 sticky="nsew",四个方向都贴紧,同时给它所在的行 weight=1,它才会跟着窗口一起长高。minsize 是下限,防止用户把窗口拖到按钮都放不下。
Text 控件默认是可编辑的,用户点到日志里就能改字,看起来很不专业。加上 state="disabled" 之后只读,但写入前必须先切回 normal,写完再切回来,这个顺序不能反。wrap="word" 是按词换行,日志里有长路径时不会横向溢出。我还给每个按钮的 grid 加了 padx=4 而不是在按钮内部塞空格,间距交给布局管理器,换字体也不容易错位。
三、耗时操作必须离开主线程
白屏两秒的原因很直接:Tk 的事件循环是单线程的,按钮的 command 回调就在主线程里执行。回调里调了 subprocess.run,整个进程就停在那里等子进程返回,期间窗口既不能重绘也不能响应鼠标,Windows 很快会把它标成「未响应」。
把命令丢进后台线程能解决卡顿,但立刻会引出第二个问题:后台线程不能直接改控件。Tcl 解释器是绑定在创建它的那个线程上的,跨线程调用轻则界面错乱,重则直接崩掉,而且崩的位置很随机,极难排查。稳妥的做法是让子线程只负责干活,把结果放进队列,主线程定时取:
import queue
import subprocess
import threading
import tkinter as tk
class App(tk.Tk):
def __init__(self):
super().__init__()
self.msgs = queue.Queue()
self.busy = False
self.after(100, self._drain) # 主线程每 100ms 取一次
def _append(self, text):
self.log.configure(state="normal")
self.log.insert("end", text + "\n")
self.log.see("end")
self.log.configure(state="disabled")
def _set_busy(self, busy):
self.busy = busy
state = "disabled" if busy else "normal"
for b in self.buttons:
b.configure(state=state)
def _drain(self):
try:
while True:
kind, text = self.msgs.get_nowait()
if kind == "log":
self._append(text)
elif kind == "error":
self._append("失败:" + text)
self._set_busy(False)
elif kind == "done":
self._set_busy(False)
except queue.Empty:
pass
self.after(100, self._drain) # 取完再排下一次
def run_cmd(self, cmd):
if self.busy:
return
self._set_busy(True)
self._append("$ " + " ".join(cmd))
threading.Thread(target=self._worker, args=(cmd,), daemon=True).start()
def _worker(self, cmd):
try:
p = subprocess.run(
cmd, capture_output=True, text=True, timeout=30,
creationflags=subprocess.CREATE_NO_WINDOW,
)
out = (p.stdout or "") + (p.stderr or "")
self.msgs.put(("log", out.strip() or "(无输出)"))
except Exception as e:
self.msgs.put(("error", str(e)))
finally:
self.msgs.put(("done", ""))
这段代码里有几个决定是我有意做的。
第一,后台线程里没有出现任何控件操作,包括读控件状态。它只往 self.msgs 里放元组,所有界面更新都发生在 _drain 里,而 _drain 是通过 after 由主线程调用的。after(100) 不是 sleep,它把回调挂进事件循环,期间窗口照常响应,所以界面不会假死。
第二,busy 标志防重入。只禁用按钮还不够,键盘快捷键、托盘菜单都可能再次触发命令。先判断 busy 再置位,比在每个入口处都记得禁按钮可靠。
第三,daemon=True 和 timeout=30 是一对保险。用户直接关窗口时,主线程退出,守护线程不会拖着进程不放;命令本身卡住时,timeout 会抛异常,走 except 分支回到界面,而不是永远停在「执行中」。
第四,CREATE_NO_WINDOW 是 Windows 专属的参数。不加它,调用 nvidia-smi 这类控制台程序时,屏幕上会闪一个黑窗口,虽然只有一瞬间,但每次点按钮都闪一下,体验很差。这个常量在非 Windows 上不存在,所以跨平台的话要用 getattr(subprocess, "CREATE_NO_WINDOW", 0) 兜一下。
顺便说清楚三种「把结果送回界面」的做法,我试过前面两种才换到第三种:
| 做法 | 现象 | 结论 |
|---|---|---|
子线程里直接 label.config(...) |
平时正常,偶尔崩在 Tcl_AsyncDelete |
不可用 |
子线程里调用 root.after(0, ...) |
多数时候能跑,但本质仍是跨线程 | 不推荐 |
子线程 put + 主线程 after 轮询 |
行为稳定,顺序可控 | 采用这个 |
四、150% 缩放:DPI 声明和 tk scaling 要一起做
布局改成 grid 之后,按钮位置对了,但字是糊的。Windows 默认把不认识 DPI 的程序按 96 DPI 渲染,再整体放大到 150%,相当于对一张图做拉伸,边缘自然发虚。
解决办法是在创建 Tk() 之前声明 DPI 感知:
import ctypes
import tkinter as tk
try:
ctypes.windll.shcore.SetProcessDpiAwareness(1)
except (AttributeError, OSError):
pass # Win7 没有 shcore,或已经设置过
root = tk.Tk()
root.tk.call("tk", "scaling", root.winfo_fpixels("1i") / 72)
声明之后窗口不再被系统拉伸,字变清晰了,但会发现字变小了。因为 Tk 仍然按 96 DPI 的逻辑单位画,而实际一个英寸里的像素更多了。所以第二句要按真实 DPI 重新算 scaling:winfo_fpixels("1i") 返回一英寸有多少像素,除以 72 就是「一个点等于几个像素」,把它交给 Tk,字体和控件才会按比例放大。
两个顺序都不能错。DPI 声明必须在 Tk() 之前,窗口已经创建后再声明,系统不会回头重绘;scaling 要在创建控件之前设,否则已经建好的字体不会跟着变。如果只想统一调整某类字体,也可以拿 tkinter.font.nametofont("TkDefaultFont") 改 size,但那只是字体变了,控件内边距还是按旧比例算,混在一起容易对不齐,不如整体设 scaling。
五、打包:单文件 exe 的路径和图标
本地跑通之后用 PyInstaller 打包:
pyinstaller --noconfirm --onefile --noconsole ^
--name GpuSwitch --icon assets\app.ico ^
--add-data "assets;assets" app.py
--onefile 把所有东西塞进一个 exe,方便发给别人;--noconsole 去掉黑色控制台窗口,对 GUI 程序是必须的;--add-data 的 Windows 写法是「源路径;目标路径」,中间是分号,Linux 和 macOS 要用冒号,这一点很多人第一次打包都会写错。
单文件模式有个必须理解的行为:运行时 PyInstaller 会把内容解压到一个临时目录,程序里用 __file__ 拼出来的路径指向的其实是那个临时目录,而不是 exe 所在目录。所以读图标、读配置模板都要走统一的资源路径函数:
import os
import sys
def res_path(name: str) -> str:
base = getattr(sys, "_MEIPASS", os.path.dirname(os.path.abspath(__file__)))
return os.path.join(base, name)
sys._MEIPASS 只在打包后的进程里存在,源码运行时没有这个属性,所以用 getattr 给一个回退值。窗口图标、托盘图标、默认配置都该用 res_path 读,而不是写死相对路径。
还有两个坑值得提前知道。第一,--noconsole 之后 print 没有地方可去,某些环境下 sys.stdout 甚至是 None,直接 print 会抛异常。调试信息应该写日志文件,路径放在 %LOCALAPPDATA% 下,而不是 exe 旁边——Program Files 里普通用户没有写权限。第二,--icon 只影响 exe 文件本身的图标,窗口左上角的图标要在代码里用 iconbitmap(res_path("app.ico")) 再设一次,两处是独立的。
| 现象 | 原因 | 处理 |
|---|---|---|
| 打包后图标找不到 | 相对路径指向临时目录 | 用 sys._MEIPASS 拼资源路径 |
--add-data 后程序仍读不到文件 |
用了 assets/... 斜杠或漏了目标名 |
Windows 写 "assets;assets" |
想改 ttk 元素细节,照抄 element_configure |
tkinter 的 Style 没有这个方法,3.8 上直接 AttributeError | 用 style.configure、style.map 或换主题 |
| 双击 exe 一闪而过 | 启动时报错但无控制台 | 临时用带控制台的版本跑一次看 traceback |
| 杀毒软件误报 | 单文件解压执行的行为特征 | 不追求绝对避免,交付时说明来源 |
最后一行是我自己的取舍:单文件方便,但确实更容易被拦。如果是给实验室同学内部用,我会附上源码和打包命令,对方不放心就自己打一个;如果要求更稳,就改成 --onedir 目录分发,牺牲一点整洁换更少的误报。
六、小结
这个小工具最终不到三百行,但它把 tkinter 最容易翻车的几处都过了一遍。回头看,白屏、错位、字糊、打包丢资源,没有一个是 tkinter 的 bug,全是我在最初图省事时留下的:在主线程里跑子进程、用像素算布局、没声明 DPI、用相对路径读文件。
如果让我给一条顺序建议,那就是先把「耗时操作永远不碰界面」这条线画死——用队列和 after 把计算与绘制分开,后面加功能时就不会再被卡顿和随机崩溃反复折腾。布局和缩放是体验问题,可以慢慢调;线程边界是正确性问题,最好一次做对。
评论与讨论
使用 GitHub 账号登录即可留言(首次使用需在 giscus 中授权)