关键词:Python 桌面开发、ctypes 调用 Win32 API、PyQt/PySide6、tkinter、PyInstaller 打包 exe、鼠标穿透、屏幕录制、剪贴板、高 DPI 适配
目录
-
- 前言
- [一、10 个工具速览](#一、10 个工具速览)
- 二、核心技术点与代码
-
- [2.1 鼠标穿透:让浮层"看得见、摸不着"](#2.1 鼠标穿透:让浮层"看得见、摸不着")
- [2.2 高 DPI 适配:必须在创建 QApplication 之前](#2.2 高 DPI 适配:必须在创建 QApplication 之前)
- [2.3 GDI 抓屏:CAPTUREBLT 这个标志不能省](#2.3 GDI 抓屏:CAPTUREBLT 这个标志不能省)
- [2.4 剪贴板:一次 Ctrl+C 到底放了多少东西](#2.4 剪贴板:一次 Ctrl+C 到底放了多少东西)
- [2.5 tkinter 显示内存里的图片:不引入 Pillow](#2.5 tkinter 显示内存里的图片:不引入 Pillow)
- [2.6 ffmpeg 管道与 drawtext 水印的三个坑](#2.6 ffmpeg 管道与 drawtext 水印的三个坑)
- [2.7 全局热键:不要占用 F1~F12](#2.7 全局热键:不要占用 F1~F12)
- [2.8 ctypes 通用踩坑:必须声明 argtypes / restype](#2.8 ctypes 通用踩坑:必须声明 argtypes / restype)
- [2.9 PyInstaller 打包优化](#2.9 PyInstaller 打包优化)
- [2.10 用户数据一律写 %APPDATA%](#2.10 用户数据一律写 %APPDATA%)
- [三、Windows 平台绕不过去的限制](#三、Windows 平台绕不过去的限制)
- 四、总结
前言
网上找一个"只要标注功能"的屏幕画笔,装完发现附带了浏览器插件;找一个录屏,要注册登录还要下载 ffmpeg。这类需求本身的技术含量并不高------真正干活的往往只有几个 Win32 API 调用。
于是我用 Python 陆续写了 10 个 Windows 桌面小工具,全部用 PyInstaller 打成单文件 exe。本文把这批项目里可复用的技术点和踩过的坑整理出来,重点是代码和结论,方便同样在做 Python 桌面开发的同学直接抄走。
运行环境
| 项目 | 说明 |
|---|---|
| 操作系统 | Windows 10 2004+ / Windows 11(部分特性需要 2004 以上) |
| Python | 3.11 及以上 |
| GUI 框架 | PySide6(复杂界面)、tkinter(轻量工具,零依赖) |
| Win32 调用 | 标准库 ctypes,不装 pywin32 |
| 打包 | PyInstaller 6.16+,--onefile --windowed |
一、10 个工具速览
百度搜索 3Q工具箱 ,都已经发布到实用资源分享模块了,可自行取用,仅支持个人工作娱乐使用
| 工具 | 一句话功能 | 关键技术 |
|---|---|---|
| AnyDraw 屏幕标注 | 全屏浮层画笔/荧光笔/箭头/文字,可鼠标穿透 | WS_EX_TRANSPARENT、BitBlt + CAPTUREBLT |
| ScreenRec 轻录屏 | 全屏/选区/窗口录制,导出 6 种格式 | CreateDIBSection、ffmpeg 管道 |
| ClipboardViewer 剪贴板查看器 | 枚举剪贴板全部格式并解码预览 | EnumClipboardFormats、DIB 解析 |
| ColorPicker 屏幕取色器 | 实时取色,8 种格式一键复制 | GetPixel、RegisterHotKey |
| MouseCursorChanger 指针替换 | 图片/GIF 换成系统鼠标指针 | .cur/.ani 生成、注册表 |
| PetFloat 桌面悬浮图片 | 图片钉在桌面顶层,可 AI 抠图 | onnxruntime + U2Net |
| PetFollow 点击跟随宠物 | 点哪儿宠物跑哪儿 | 贝塞尔路径、QPainter 程序化绘图 |
| BongoCat 键盘猫 | 敲键盘时 Live2D 猫跟着敲 | live2d-py、pynput、XInput |
| WawaFly 屏幕飘飘 | 随机时间/角度的图片穿屏动画 | 透明置顶窗口、随机轨迹 |
| WoodenFish 电子木鱼 | 敲木鱼计数,支持自动敲 | SVG 渲染、winsound 合成音 |
二、核心技术点与代码
2.1 鼠标穿透:让浮层"看得见、摸不着"
桌宠、标注层、飘屏动画都需要同一个能力:窗口显示在最上层,但不接收鼠标事件,点击直接落到下面的窗口。
PySide6 提供了 Qt.WindowTransparentForInput 窗口标志,但它有个致命问题:切换窗口标志会销毁并重建窗口,标注层上已经画好的内容会闪一下,桌宠会瞬间消失重现。需要频繁开关时不能用。
正确做法是直接改扩展样式:
python
import ctypes
from ctypes import wintypes
user32 = ctypes.WinDLL("user32", use_last_error=True)
GWL_EXSTYLE = -20
WS_EX_TRANSPARENT = 0x00000020
WS_EX_LAYERED = 0x00080000
user32.GetWindowLongW.argtypes = [wintypes.HWND, ctypes.c_int]
user32.GetWindowLongW.restype = ctypes.c_long
user32.SetWindowLongW.argtypes = [wintypes.HWND, ctypes.c_int, ctypes.c_long]
user32.SetWindowLongW.restype = ctypes.c_long
def set_click_through(hwnd: int, enabled: bool) -> None:
"""开关鼠标穿透。只改命中测试,不影响绘制内容。"""
handle = wintypes.HWND(hwnd)
style = user32.GetWindowLongW(handle, GWL_EXSTYLE)
if enabled:
style |= WS_EX_TRANSPARENT | WS_EX_LAYERED
else:
style &= ~WS_EX_TRANSPARENT
user32.SetWindowLongW(handle, GWL_EXSTYLE, style)
配合 WS_EX_NOACTIVATE(值 0x08000000)使用效果更好:在浮层上操作不会抢走目标程序的焦点。注意如果浮层里有文本输入框,输入期间要临时去掉这个样式,否则键盘事件进不来。
2.2 高 DPI 适配:必须在创建 QApplication 之前
不做 DPI 感知,高分屏上抓屏拿到的是系统拉伸后的模糊画面,浮层坐标也会整体偏移。三级降级写法:
python
DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 = ctypes.c_void_p(-4)
def enable_dpi_awareness() -> None:
"""置 per-monitor DPI aware v2。必须在创建 Qt 应用之前调用。"""
try:
user32.SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)
except (AttributeError, OSError):
try:
ctypes.WinDLL("shcore").SetProcessDpiAwareness(2)
except (AttributeError, OSError):
user32.SetProcessDPIAware()
配套的坐标系原则:选区遮罩用 Qt 逻辑坐标绘制,返回给抓屏/录制模块的矩形用 GetCursorPos 的物理坐标。不要在中间反复换算,误差会累积到几个像素,录出来的视频边缘就会多一条别的窗口。
多屏 + 混合 DPI 场景下,全局钩子(pynput)给的是物理像素,Qt 窗口坐标是逻辑像素,需要用 EnumDisplayMonitors 取每块屏的物理矩形,配合 QScreen 的逻辑矩形做映射。
2.3 GDI 抓屏:CAPTUREBLT 这个标志不能省
python
gdi32 = ctypes.WinDLL("gdi32", use_last_error=True)
SRCCOPY = 0x00CC0020
CAPTUREBLT = 0x40000000
gdi32.BitBlt(mem_dc, 0, 0, width, height,
screen_dc, left, top, SRCCOPY | CAPTUREBLT)
不带 CAPTUREBLT 抓不到分层窗口(layered window)。 标注浮层本身就是分层窗口,截出来会只有干净的桌面、没有你画的批注。这个坑排查起来很费时间,因为截图"看起来是成功的"。
两个配套技巧:
BITMAPINFOHEADER.biHeight取负值 ,拿到自上而下的 DIB,可以直接喂给QImage(Format_RGB32),省一次翻转;- 录屏时用
CreateDIBSection建一块可复用的 32 位 DIB,每帧BitBlt到同一块内存,grab()返回memoryview,零额外拷贝; - GDI 抓屏不含鼠标指针 ,需要用
GetCursorInfo+DrawIconEx单独画上去。注意GetIconInfo会产生两个位图,必须DeleteObject,否则稳定泄漏 GDI 对象。
想让自己的控制条不被录进去,用:
python
WDA_EXCLUDEFROMCAPTURE = 0x00000011
user32.SetWindowDisplayAffinity(hwnd, WDA_EXCLUDEFROMCAPTURE)
这个 API 需要 Windows 10 2004 以上,早期系统只能靠抓屏前 hide() 并延迟 180ms 等系统重绘。
2.4 剪贴板:一次 Ctrl+C 到底放了多少东西
排查"复制粘贴格式错乱"必备。从 Word 复制一段文字,剪贴板上同时躺着 RTF、HTML Format、CF_DIB、纯文本、Ole Private Data 等七八种格式,粘贴方挑哪一个由它自己决定。
完整枚举代码:
python
def read_all():
if not user32.OpenClipboard(None): # 剪贴板是全局独占资源,失败要重试
return []
try:
entries = []
fmt = user32.EnumClipboardFormats(0)
while fmt:
entries.append(_read_one(fmt))
fmt = user32.EnumClipboardFormats(fmt)
return entries
finally:
user32.CloseClipboard() # 一定要及时关,否则别的程序复制会失败
def _copy_global(handle):
size = kernel32.GlobalSize(handle)
ptr = kernel32.GlobalLock(handle)
try:
return ctypes.string_at(ptr, size)
finally:
kernel32.GlobalUnlock(handle)
四个必须注意的点:
OpenClipboard会失败。剪贴板是全局独占资源,别的程序正持有时打不开,要重试(我用的是 10 次 × 50ms);CF_BITMAP/CF_PALETTE/CF_ENHMETAFILE拿到的是 GDI 对象句柄,不是内存块 ,对它们调GlobalSize/GlobalLock是错的,要单独分支处理;CF_HDROP用shell32.DragQueryFileW(handle, 0xFFFFFFFF, None, 0)先取文件数量,再逐个取路径;- 延迟渲染(delayed rendering)的格式
GetClipboardData可能返回 NULL,这是正常现象,不是 bug。
自动刷新不要定时全量重读,轮询序列号即可,开销几乎为零:
python
if user32.GetClipboardSequenceNumber() != last_sequence:
refresh()
2.5 tkinter 显示内存里的图片:不引入 Pillow
剪贴板里的 CF_DIB 要在 tkinter 里预览,又不想为了这一个功能引入 Pillow。做法是手工转成 PPM(P6)再交给 PhotoImage:
python
ppm = b"P6\n%d %d\n255\n" % (out_w, out_h) + b"".join(rows)
photo = tk.PhotoImage(data=ppm) # 注意:PPM 必须传二进制,不能 base64
self._photo = photo # 必须自己持有引用,否则被 GC 回收,图片显示空白
两个坑:
base64编码只对 GIF/PNG 有效 ,PPM 必须传原始二进制,否则报TclError: couldn't recognize image data;PhotoImage对象必须用成员变量持有,只放局部变量会被垃圾回收,界面上就是一片空白------这是 tkinter 最经典的坑。
解析 DIB 时注意像素数据起点:
python
header_size, width, height, planes, bits, compression = struct.unpack_from("<IiiHHI", data)
palette_bytes = clr_used * 4
if compression == 3 and header_size == 40: # BI_BITFIELDS:头后面还有 3 个 4 字节掩码
palette_bytes = max(palette_bytes, 12)
pixels_at = header_size + palette_bytes
少算这 12 个字节,图像会整体偏色 / 错位。另外行按 4 字节对齐(stride = ((width * bits + 31) // 32) * 4),biHeight > 0 表示自底向上存储,像素顺序是 BGR(A) 不是 RGB。
2.6 ffmpeg 管道与 drawtext 水印的三个坑
录屏把 BGRA 裸帧写进 ffmpeg 的 pipe:0,ffmpeg 二进制直接用 imageio-ffmpeg 随包携带的,用户无需自己安装。
必须起一个线程持续读走 stderr,否则管道缓冲写满后 ffmpeg 会直接卡死------这个现象很像"程序假死",容易误判成编码性能问题。
水印用 drawtext 滤镜,三个坑逐一说明:
| 坑 | 现象 | 解决 |
|---|---|---|
| 文字直接拼进 filtergraph | 用户输入含 : , ' \ 时报错或截断 |
用 textfile= 从临时文件读,彻底免转义 |
未加 expansion=none |
文字里的 % {} 被当变量语法,警告 Stray % 且文字被吃掉 |
显式加 expansion=none |
| Windows 字体路径转义不足 | 盘符冒号被当成选项分隔符,滤镜解析失败 | 写成 C\\:/Windows/Fonts/msyh.ttc(转两层) |
滤镜顺序固定"先 scale 再 drawtext",字号才与成片分辨率对得上。
帧率对齐用单调时钟,落后时补写上一帧:
python
target = start + frame_index / fps
if time.perf_counter() > target + tolerance:
write(previous_frame) # 补帧,否则丢帧会让成片整体变快
不补帧的直接后果:录 10 分钟,导出来 9 分钟,画面明显加速。
2.7 全局热键:不要占用 F1~F12
python
MOD_ALT = 0x0001
MOD_NOREPEAT = 0x4000 # 防长按连发
# RegisterHotKey 必须在跑消息循环的线程上注册
user32.RegisterHotKey(None, hotkey_id, MOD_ALT | MOD_NOREPEAT, VK_C)
while user32.GetMessageW(ctypes.byref(msg), None, 0, 0):
if msg.message == WM_HOTKEY:
callback()
三条经验:
RegisterHotKey是系统独占的 。你注册了F5,所有其他程序就都收不到F5了。建议统一用Ctrl+Alt+字母这类组合键;- 注册必须发生在有消息循环的线程 上,所以要单独起守护线程跑
GetMessageW;回调通过queue.Queue(tkinter)或 Qt 信号(PySide6)抛回主线程,不要跨线程操作 UI; - 注册失败要有降级提示。热键被占用是很常见的,界面上给个状态提示,别静默失败。
2.8 ctypes 通用踩坑:必须声明 argtypes / restype
这条单独拎出来讲,因为它坑了我两次,而且表现为偶发:
python
# 错误:不声明,ctypes 默认按 32 位 int 处理句柄
user32.GetWindowLongW(hwnd, GWL_EXSTYLE)
# OverflowError: int too long to convert
# 正确
user32.GetWindowLongW.argtypes = [wintypes.HWND, ctypes.c_int]
user32.GetWindowLongW.restype = ctypes.c_long
64 位系统下句柄是指针(8 字节),不声明 argtypes 时 ctypes 按 32 位 int 传参。句柄数值小的时候程序完全正常 ,运行久了句柄值变大就突然抛 int too long to convert。所以:所有 Win32 函数一律先写全 argtypes / restype,别偷懒。
2.9 PyInstaller 打包优化
基础命令:
powershell
python -m PyInstaller --noconfirm --clean --onefile --windowed `
--name ClipboardViewer `
--icon assets\icon.ico `
--add-data "assets\icon.ico;assets" `
main.py
运行时定位打包进去的资源,必须兼容两种情况:
python
from pathlib import Path
import sys
# 打包后资源解包到 sys._MEIPASS;源码运行时在项目目录
base = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parent.parent))
icon = base / "assets" / "icon.ico"
PySide6 项目体积优化 :默认会把 Qml/Quick、Multimedia + FFmpeg、Network + OpenSSL、Pdf、软件 OpenGL(opengl32sw.dll)以及 96 个 Qt 语言包全打进去。在 spec 里过滤 a.binaries / a.datas 即可:
python
EXCLUDE_KEYWORDS = ("Qt6Quick", "Qt6Qml", "Qt6Pdf", "Qt6Network", "opengl32sw", "libssl", "libcrypto")
a.binaries = [b for b in a.binaries if not any(k in b[0] for k in EXCLUDE_KEYWORDS)]
a.datas = [d for d in a.datas if "translations" not in d[0]]
实测效果:桌面悬浮图片项目解包体积从 354.5 MB 降到 294.9 MB,文件数从 224 降到 112。
顺带两个体积相关的取舍:
- 音效播放用标准库
winsound而不是 QtMultimedia,少约 20 MB,代价是同时只有一路声音,连击会打断上一声余音; - AI 抠图直接用 onnxruntime 推理 U2Net,不引 rembg------后者会带来 scipy / scikit-image / opencv 一整条依赖链。三档模型对比:原版 exe 208.5 MB(单帧 0.61s)、int8 量化 77 MB(0.82s)、轻量 u2netp 55.4 MB(0.34s)。
顺便提一个模型评测上的教训:我最初用合成测试图(纯色块 + 平背景)比较三个模型,掩膜 IoU 都在 0.99 附近,根本测不出差异。真实差距只出现在头发丝、半透明边缘和杂乱背景上。做这类评测一定要用真实素材。
2.10 用户数据一律写 %APPDATA%
python
import os
from pathlib import Path
data_dir = Path(os.environ["APPDATA"]) / "WoodenFish"
data_dir.mkdir(parents=True, exist_ok=True)
不要写 exe 同级目录(可能是只读介质、Program Files 需要提权),更不要写 sys._MEIPASS(单文件模式下退出即被删除)。写到 %APPDATA% / %LOCALAPPDATA% 后,exe 换位置、重新打包、放 U 盘上,用户数据都还在。
三、Windows 平台绕不过去的限制
这几条不是 bug,是系统设计如此,做桌面工具前最好先知道:
| 限制 | 说明 |
|---|---|
| UIPI(用户界面特权隔离) | 低权限进程的全局热键、键鼠钩子在管理员权限窗口上无效。需要支持就以管理员身份运行 |
| 独占全屏 | 真全屏的游戏/播放器上方,任何普通浮层都无法显示,只能让对方切窗口化或无边框全屏 |
WDA_EXCLUDEFROMCAPTURE |
需要 Windows 10 2004+,早期系统只能靠抓屏前隐藏窗口 |
| 光标尺寸 | .cur 文件本身的像素尺寸不 决定显示大小,Windows 一律按注册表 CursorBaseSize 渲染 |
| 硬件编码 | 未启用 NVENC / QSV 时,4K 60fps 录制 CPU 占用会比较高 |
其中光标那条值得展开:改系统指针要同时写 HKCU\Control Panel\Cursors\Arrow、CursorBaseSize,还要同步"设置 > 辅助功能"读的 HKCU\SOFTWARE\Microsoft\Accessibility\CursorSize 档位(32px = 1 档,每档 +16px),最后调 SystemParametersInfo(SPI_SETCURSORS) 立即生效。只写第一项的话,你会发现指针换了但尺寸不对。修改前务必把原值备份到 JSON,给用户留一键还原。
四、总结
这批工具做下来,我总结出的几条通用经验:
- 优先用 ctypes 直调 Win32,而不是找封装库。桌面工具的核心能力往往就是一两个 API,引一个大库反而带来体积和兼容问题;
- 所有 Win32 调用先写全
argtypes/restype,这是唯一能避免那个偶发int too long to convert的办法; - DPI 感知要在创建 GUI 应用之前设置,坐标系"逻辑归绘制、物理归抓屏",中间不做多余换算;
- PyInstaller 的默认打包结果值得裁一裁,PySide6 项目通常能省下几十 MB;
- 能力边界写进文档。UIPI、独占全屏这类限制解释清楚,比用户遇到问题后再来问要好得多。
以上代码片段都来自实际跑起来的项目,可以直接拿去改。如果你也在做 Python 桌面工具,第 2.8 和 2.5 两节建议先看------这两个坑最容易让人一头雾水地卡半天。
有其他 Windows 桌面开发的坑,欢迎评论区交流补充。
欢迎来 3Q工具箱 一起用好用的小工具。3qtools.cn