关键词:Python 桌面开发、ctypes 调用 Win32 API、PyQt/PySide6、tkinter、PyInstaller 打包 exe、鼠标穿透、屏幕录制、剪贴板、高 DPI 适配


前言

网上找一个"只要标注功能"的屏幕画笔,装完发现附带了浏览器插件;找一个录屏,要注册登录还要下载 ffmpeg。这类需求本身的技术含量并不高——真正干活的往往只有几个 Win32 API 调用。

于是我用 Python 陆续写了 10 个 Windows 桌面小工具,全部用 PyInstaller 打成单文件 exe。本文把这批项目里可复用的技术点和踩过的坑整理出来,重点是代码和结论,方便同样在做 Python 桌面开发的同学直接抄走。

运行环境

项目说明
操作系统Windows 10 2004+ / Windows 11(部分特性需要 2004 以上)
Python3.11 及以上
GUI 框架PySide6(复杂界面)、tkinter(轻量工具,零依赖)
Win32 调用标准库 ctypes,不装 pywin32
打包PyInstaller 6.16+,--onefile --windowed

一、10 个工具速览

百度搜索 3Q工具箱,都已经发布到实用资源分享模块了,可自行取用,仅支持个人工作娱乐使用

工具一句话功能关键技术
AnyDraw 屏幕标注全屏浮层画笔/荧光笔/箭头/文字,可鼠标穿透WS_EX_TRANSPARENTBitBlt + CAPTUREBLT
ScreenRec 轻录屏全屏/选区/窗口录制,导出 6 种格式CreateDIBSection、ffmpeg 管道
ClipboardViewer 剪贴板查看器枚举剪贴板全部格式并解码预览EnumClipboardFormats、DIB 解析
ColorPicker 屏幕取色器实时取色,8 种格式一键复制GetPixelRegisterHotKey
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 窗口标志,但它有个致命问题:切换窗口标志会销毁并重建窗口,标注层上已经画好的内容会闪一下,桌宠会瞬间消失重现。需要频繁开关时不能用。

正确做法是直接改扩展样式:

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 感知,高分屏上抓屏拿到的是系统拉伸后的模糊画面,浮层坐标也会整体偏移。三级降级写法:

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 这个标志不能省

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)。 标注浮层本身就是分层窗口,截出来会只有干净的桌面、没有你画的批注。这个坑排查起来很费时间,因为截图"看起来是成功的"。

两个配套技巧:

  1. BITMAPINFOHEADER.biHeight负值,拿到自上而下的 DIB,可以直接喂给 QImage(Format_RGB32),省一次翻转;
  2. 录屏时用 CreateDIBSection 建一块可复用的 32 位 DIB,每帧 BitBlt 到同一块内存,grab() 返回 memoryview,零额外拷贝;
  3. GDI 抓屏不含鼠标指针,需要用 GetCursorInfo + DrawIconEx 单独画上去。注意 GetIconInfo 会产生两个位图,必须 DeleteObject,否则稳定泄漏 GDI 对象。

想让自己的控制条不被录进去,用:

WDA_EXCLUDEFROMCAPTURE = 0x00000011
user32.SetWindowDisplayAffinity(hwnd, WDA_EXCLUDEFROMCAPTURE)

这个 API 需要 Windows 10 2004 以上,早期系统只能靠抓屏前 hide() 并延迟 180ms 等系统重绘。

2.4 剪贴板:一次 Ctrl+C 到底放了多少东西

排查"复制粘贴格式错乱"必备。从 Word 复制一段文字,剪贴板上同时躺着 RTF、HTML FormatCF_DIB、纯文本、Ole Private Data 等七八种格式,粘贴方挑哪一个由它自己决定。

完整枚举代码:

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_HDROPshell32.DragQueryFileW(handle, 0xFFFFFFFF, None, 0) 先取文件数量,再逐个取路径;
  • 延迟渲染(delayed rendering)的格式 GetClipboardData 可能返回 NULL,这是正常现象,不是 bug。

自动刷新不要定时全量重读,轮询序列号即可,开销几乎为零:

if user32.GetClipboardSequenceNumber() != last_sequence:
    refresh()

2.5 tkinter 显示内存里的图片:不引入 Pillow

剪贴板里的 CF_DIB 要在 tkinter 里预览,又不想为了这一个功能引入 Pillow。做法是手工转成 PPM(P6)再交给 PhotoImage

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 回收,图片显示空白

两个坑:

  1. base64 编码只对 GIF/PNG 有效,PPM 必须传原始二进制,否则报 TclError: couldn't recognize image data
  2. PhotoImage 对象必须用成员变量持有,只放局部变量会被垃圾回收,界面上就是一片空白——这是 tkinter 最经典的坑。

解析 DIB 时注意像素数据起点:

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(转两层)

滤镜顺序固定"先 scaledrawtext",字号才与成片分辨率对得上。

帧率对齐用单调时钟,落后时补写上一帧:

target = start + frame_index / fps
if time.perf_counter() > target + tolerance:
    write(previous_frame)      # 补帧,否则丢帧会让成片整体变快

不补帧的直接后果:录 10 分钟,导出来 9 分钟,画面明显加速。

2.7 全局热键:不要占用 F1~F12

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

三条经验:

  1. RegisterHotKey 是系统独占的。你注册了 F5,所有其他程序就都收不到 F5 了。建议统一用 Ctrl+Alt+字母 这类组合键;
  2. 注册必须发生在有消息循环的线程上,所以要单独起守护线程跑 GetMessageW;回调通过 queue.Queue(tkinter)或 Qt 信号(PySide6)抛回主线程,不要跨线程操作 UI;
  3. 注册失败要有降级提示。热键被占用是很常见的,界面上给个状态提示,别静默失败。

2.8 ctypes 通用踩坑:必须声明 argtypes / restype

这条单独拎出来讲,因为它坑了我两次,而且表现为偶发

# 错误:不声明,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 打包优化

基础命令:

python -m PyInstaller --noconfirm --clean --onefile --windowed `
    --name ClipboardViewer `
    --icon assets\icon.ico `
    --add-data "assets\icon.ico;assets" `
    main.py

运行时定位打包进去的资源,必须兼容两种情况:

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 即可:

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%

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\ArrowCursorBaseSize,还要同步"设置 > 辅助功能"读的 HKCU\SOFTWARE\Microsoft\Accessibility\CursorSize 档位(32px = 1 档,每档 +16px),最后调 SystemParametersInfo(SPI_SETCURSORS) 立即生效。只写第一项的话,你会发现指针换了但尺寸不对。修改前务必把原值备份到 JSON,给用户留一键还原。


四、总结

这批工具做下来,我总结出的几条通用经验:

  1. 优先用 ctypes 直调 Win32,而不是找封装库。桌面工具的核心能力往往就是一两个 API,引一个大库反而带来体积和兼容问题;
  2. 所有 Win32 调用先写全 argtypes / restype,这是唯一能避免那个偶发 int too long to convert 的办法;
  3. DPI 感知要在创建 GUI 应用之前设置,坐标系"逻辑归绘制、物理归抓屏",中间不做多余换算;
  4. PyInstaller 的默认打包结果值得裁一裁,PySide6 项目通常能省下几十 MB;
  5. 能力边界写进文档。UIPI、独占全屏这类限制解释清楚,比用户遇到问题后再来问要好得多。

以上代码片段都来自实际跑起来的项目,可以直接拿去改。如果你也在做 Python 桌面工具,第 2.8 和 2.5 两节建议先看——这两个坑最容易让人一头雾水地卡半天。

有其他 Windows 桌面开发的坑,欢迎评论区交流补充。

欢迎来 3Q工具箱 一起用好用的小工具。3qtools.cn

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐