资讯详情

C# 抓取鼠标形状实例:句柄比对、像素采样与踩坑记录

📅 2026/10/14 11:42:36 | 华诺云谱 👁 阅读
C# 抓取鼠标形状实例:句柄比对、像素采样与踩坑记录
简介在C#开发中实时获取并显示系统鼠标指针形状是一项涉及Windows API的常见需求此实例提供了一套完整可用的参考实现。项目核心借助user32.dll中的GetCursorInfo函数通过P/Invoke定义CURSORINFO结构体并以定时器轮询方式监控鼠标状态再将句柄转换为图标更新到界面控件完整覆盖了从调用、判断到渲染的关键流程。资源包总共21个文件压缩后仅26KB包含6个cs源文件、3个exe可执行文件以及resx资源、pdb调试符号、settings配置等结构紧凑、方便直接打开学习与二次修改。当前已有305人学习这一实例其代码风格清晰尤其适合具备一定C#基础、希望深入理解Windows API互操作和系统资源管理的开发者也可作为扩展其他底层交互功能的起点。1. 抓取鼠标形状从一句“光标变了没有”说起如果你正在写自动化测试脚本多半遇到过这种画面界面上点了一个按钮程序进入长时间计算鼠标从箭头变成沙漏脚本却还在傻等固定延时结果不是点早了就是点晚了。所谓c# 抓取鼠标形状 实例就是把系统当前光标当成一个可读对象实时拿到它的身份、位置甚至图像从而判断“界面现在忙不忙”“鼠标指的到底是什么”。这不是截屏识别图标而是直接对话 Windows 的光标管理机制。适合做自动化测试、远程协助、录屏工具和无障碍辅助的开发者新手能照步骤跑通熟手能看到句柄生命周期、DPI 缩放这类边界问题。这一篇我会把 API 原理、最小实现、识别方案和踩坑记录一次讲完。2. 抓取鼠标形状前的原理准备CURSORINFO 与 P/Invoke2.1 系统光标为什么是一个“句柄”Windows 里的光标本质上是一份系统资源一个光标对象包含位图数据、热点坐标和尺寸信息。桌面上所有进程共享同一套光标显示机制实时生效的那个光标由系统统一管理你拿到的不是一份像素副本而是一个IntPtr句柄。这个句柄就是访问光标身份的钥匙。抓取形状的第一步是调用GetCursorInfo把当前光标信息取出来。这个 API 会填充一个CURSORINFO结构体里面四个字段各有用处cbSize结构体字节数调用前必须手动赋值告诉系统“你期待的是哪个版本的结构体”。flags光标状态CURSOR_SHOWING表示正在显示CURSOR_SUPPRESSED表示被抑制。hCursor当前光标对象句柄这是整个抓取动作的核心。ptScreenPos光标在屏幕坐标系中的位置。理解了这四件事后面所有代码都围绕它们展开。拿句柄可以判断形状类型拿位置可以定位光标拿 flags 可以处理“触控时根本没有光标”的边界情况。整个方案的优点在于轻量句柄比对只是数字比较完全不涉及像素计算性能开销可以忽略不计。2.2 P/Invoke 声明把 USER32 接口翻译成 C#C# 要调用这些 Win32 API得用DllImport做平台调用声明。这里最常碰到的三个函数是GetCursorInfo、LoadCursor、CopyIcon还有一个GetIconInfo用于把光标句柄转成位图信息。声明如下using System; using System.Runtime.InteropServices; [StructLayout(LayoutKind.Sequential)] public struct CURSORINFO { public int cbSize; // 结构体大小调用前必须手动赋值 public int flags; // CURSOR_SHOWING0x1, CURSOR_SUPPRESSED0x2 public IntPtr hCursor; // 当前光标句柄 public POINT ptScreenPos; // 屏幕坐标 } [StructLayout(LayoutKind.Sequential)] public struct POINT { public int X; public int Y; } [StructLayout(LayoutKind.Sequential)] public struct ICONINFO { public bool fIcon; // true图标false光标 public int xHotspot; // 热点 X 坐标 public int yHotspot; // 热点 Y 坐标 public IntPtr hbmMask; // 掩码位图句柄 public IntPtr hbmColor; // 彩色位图句柄单色光标时可能为空 } [DllImport(user32.dll, SetLastError true)] public static extern bool GetCursorInfo(ref CURSORINFO pci); [DllImport(user32.dll, CharSet CharSet.Auto, SetLastError true)] public static extern IntPtr LoadCursor(IntPtr hInstance, int lpCursorName); [DllImport(user32.dll, SetLastError true)] public static extern IntPtr CopyIcon(IntPtr hIcon); [DllImport(user32.dll, SetLastError true)] public static extern bool GetIconInfo(IntPtr hIcon, out ICONINFO piconinfo); [DllImport(user32.dll, SetLastError true)] public static extern bool DestroyIcon(IntPtr hIcon); [DllImport(gdi32.dll, SetLastError true)] public static extern bool DeleteObject(IntPtr hObject);这段声明有几个值得注意的细节。GetCursorInfo的参数必须用ref而不是out因为调用前要传入已经赋好值的cbSize。CURSORINFO定义成Sequential顺序布局字段顺序和官方头文件保持一致不能调整。SetLastError true是为了在调用失败时能通过Marshal.GetLastWin32Error()拿到具体错误码排查问题时有据可查。LoadCursor的第二个参数是标准光标 ID后面识别形状类型就是靠它。2.3 为什么优先用句柄比对而不是图像识别很多新手看到“抓取鼠标形状”第一反应是把屏幕截图裁一块下来做模板匹配。这个思路不是不能用但代价很高光标尺寸随系统缩放变化、鼠标移动时会有拖影、不同 Windows 主题下同一光标的像素完全不同。图像识别方案要求你做大量样本标注换一台机器可能就翻车维护成本远超收益。更聪明的做法是直接比较句柄。系统内置的标准光标都有固定 ID例如IDC_ARROW是 32512IDC_IBEAM是 32513。通过LoadCursor(NULL, id)就能拿到标准光标的句柄拿它跟GetCursorInfo返回的hCursor做一次整数比较瞬间就知道当前鼠标是不是箭头、沙漏或手型。两种方案对比下来句柄比对适合识别系统预设光标速度快、零资源消耗像素采样适合自定义光标因为自定义光标没有固定 ID只有位图数据。实际项目里我会用句柄比对做第一层判断判断不了的回退到像素采样。这种分层设计既能保证性能又能覆盖大多数真实场景。3. 用 C# 抓取当前鼠标形状最小代码与图像导出3.1 新建控制台项目目标平台与最小结构实践部分从一个控制台项目开始。因为要用到 Win32 光标 API 和System.Drawing项目目标框架必须指定为 Windows 版本。我在 .NET 8 环境中的写法是net8.0-windows并在项目文件里开启AllowUnsafeBlocks因为后面单色光标转换会用到指针操作Project SdkMicrosoft.NET.Sdk PropertyGroup OutputTypeExe/OutputType TargetFrameworknet8.0-windows/TargetFramework AllowUnsafeBlockstrue/AllowUnsafeBlocks /PropertyGroup /Project目标框架写成-windows后缀有两个作用一是让System.Drawing.Common在 Windows 上可用二是让STAThread线程模型能正确使用。然后写一个最简主程序把GetCursorInfo跑通using System; using System.Runtime.InteropServices; class Program { [STAThread] static void Main() { CURSORINFO ci new CURSORINFO(); ci.cbSize Marshal.SizeOf(typeof(CURSORINFO)); if (!GetCursorInfo(ref ci)) { int err Marshal.GetLastWin32Error(); Console.WriteLine($GetCursorInfo 调用失败错误码: {err}); return; } Console.WriteLine($hCursor 0x{ci.hCursor:X}); Console.WriteLine($flags {ci.flags}); Console.WriteLine($屏幕坐标 ({ci.ptScreenPos.X}, {ci.ptScreenPos.Y})); } }cbSize的赋值是这段代码最容易被忽略的点。必须用Marshal.SizeOf(typeof(CURSORINFO))得到当前进程按 64 位还是 32 位封送的实际字节数如果填 0 或者填错API 会直接返回失败。运行这段程序你会看到一串十六进制句柄值和当前鼠标坐标这就说明抓取链路已经通了。3.2 识别形状的名称标准光标 ID 对照与比对函数拿到句柄只是第一步实际项目里要的是“当前鼠标叫什么名字”。把常用标准光标 ID 定义成一个字典用LoadCursor加载标准句柄然后与当前句柄比对就能返回形状名称private static readonly Dictionaryint, string StandardCursors new() { { 32512, 箭头(IDC_ARROW) }, { 32513, 文本选择(IDC_IBEAM) }, { 32514, 忙碌(IDC_WAIT) }, { 32515, 十字线(IDC_CROSS) }, { 32516, 向上箭头(IDC_UPARROW) }, { 32640, 尺寸调整(IDC_SIZE) }, { 32642, 不可用(IDC_NO) }, { 32649, 手型(IDC_HAND) }, { 32650, 后台忙碌(IDC_APPSTARTING) }, { 32651, 帮助(IDC_HELP) }, }; public static string GetCursorShapeName() { CURSORINFO ci new CURSORINFO(); ci.cbSize Marshal.SizeOf(typeof(CURSORINFO)); if (!GetCursorInfo(ref ci)) return 未知(调用失败); foreach (var kv in StandardCursors) { IntPtr hStd LoadCursor(IntPtr.Zero, kv.Key); if (hStd ci.hCursor) return kv.Value; } return 非标准光标或自定义光标; }这里的关键点是每次比对都现调LoadCursor而不是把句柄缓存到静态字段里。原因是 Windows 主题切换后系统会对标准光标重新创建资源同一个 ID 在不同时间可能拿到不同的句柄值。缓存旧句柄会导致比对永远失败这也是很多实现“昨天能用今天不能用”的根源。LoadCursor本身开销极低重复调用没有性能顾虑。提示IDC_APPSTARTING后台忙碌在 Windows 10/11 上经常被系统映射成普通箭头句柄单独用句柄比对很难识别出来这个问题我在第 5 章专门展开。3.3 导出位图从句柄到 Bitmap 的完整链路有些场景不只要知道形状名称还要把光标图像画到界面上比如录屏工具或远程协助。这时就需要把hCursor转成Bitmap。最稳妥的链路是先用CopyIcon复制一份句柄再用GetIconInfo取得位图句柄最后用Image.FromHbitmap转成 Bitmappublic static Bitmap? CursorToBitmap(out int hotX, out int hotY) { hotX 0; hotY 0; CURSORINFO ci new CURSORINFO(); ci.cbSize Marshal.SizeOf(typeof(CURSORINFO)); if (!GetCursorInfo(ref ci) || ci.hCursor IntPtr.Zero) return null; // 立刻复制副本避免原句柄在动画帧切换时被系统销毁 IntPtr hCopy CopyIcon(ci.hCursor); if (hCopy IntPtr.Zero) return null; try { if (!GetIconInfo(hCopy, out ICONINFO info)) return null; hotX info.xHotspot; hotY info.yHotspot; Bitmap? result null; if (info.hbmColor ! IntPtr.Zero) { // 彩色光标直接用色位图转换 result Image.FromHbitmap(info.hbmColor); } else if (info.hbmMask ! IntPtr.Zero) { // 单色光标需要把 AND/XOR mask 合成成可见像素 Bitmap mask Image.FromHbitmap(info.hbmMask); result ConvertMaskToCursorBitmap(mask); mask.Dispose(); } // 释放 GDI 对象避免句柄泄漏 if (info.hbmColor ! IntPtr.Zero) DeleteObject(info.hbmColor); if (info.hbmMask ! IntPtr.Zero) DeleteObject(info.hbmMask); return result; } finally { DestroyIcon(hCopy); } }这段代码有两个容易踩的细节。GetCursorInfo返回的hCursor是系统内部句柄系统可能在光标动画播放到下一帧时把它销毁所以拿到后必须立刻CopyIcon后续所有操作都基于副本。GetIconInfo返回的hbmColor和hbmMask是 GDI 位图句柄用完后必须DeleteObject释放否则每次抓取都会泄漏一个 GDI 对象长时间运行必然导致系统资源耗尽。很多“抓几次后界面开始花屏”的案例就是漏了这一步。单色光标的转换稍微复杂一点因为 Windows 的 mask 位图里同时包含了 AND mask 和 XOR mask。简化版本如下private static unsafe Bitmap ConvertMaskToCursorBitmap(Bitmap mask) { Bitmap bmp new Bitmap(mask.Width, mask.Height, System.Drawing.Imaging.PixelFormat.Format32bppArgb); Rectangle rect new Rectangle(0, 0, mask.Width, mask.Height); System.Drawing.Imaging.BitmapData md mask.LockBits(rect, System.Drawing.Imaging.ImageLockMode.ReadOnly, System.Drawing.Imaging.PixelFormat.Format32bppArgb); System.Drawing.Imaging.BitmapData bd bmp.LockBits(rect, System.Drawing.Imaging.ImageLockMode.WriteOnly, System.Drawing.Imaging.PixelFormat.Format32bppArgb); byte* pMask (byte*)md.Scan0.ToPointer(); byte* pBmp (byte*)bd.Scan0.ToPointer(); for (int y 0; y mask.Height; y) { for (int x 0; x mask.Width; x) { int maskIdx y * md.Stride x * 4; int bmpIdx y * bd.Stride x * 4; byte andVal pMask[maskIdx]; byte xorVal pMask[maskIdx 2]; // AND0 表示该像素画前景AND1 保留背景 byte r andVal 0 ? (byte)255 : (byte)0; byte g andVal 0 ? (byte)255 : (byte)0; byte b andVal 0 ? (byte)255 : (byte)0; // XOR1 时反色这里简化成黑色表示 if (xorVal ! 0) { r 0; g 0; b 0; } pBmp[bmpIdx] b; pBmp[bmpIdx 1] g; pBmp[bmpIdx 2] r; pBmp[bmpIdx 3] 255; } } bmp.UnlockBits(bd); mask.UnlockBits(md); return bmp; }这个版本处理黑白单色光标足够用但要注意完整版还涉及 mask 位图内部高度翻倍、按屏幕尺寸对齐等细节。生产环境如果遇到彩色光标会走hbmColor分支所以这个方法只影响极少数的旧式黑白光标。实际项目里我会先用Icon.FromHandle(copy).ToBitmap()做快速预览确认尺寸和形状再决定要不要走完整链路。4. 识别鼠标形状的三种方案句柄比对、像素采样与状态判断4.1 方案一句柄比对覆盖 90% 的系统预设光标句柄比对是最快、最容易维护的方案。上一章的GetCursorShapeName方法已经实现了完整逻辑维护一张 ID 到名称的映射表用LoadCursor逐个加载标准句柄和GetCursorInfo返回的当前句柄比较。这个方法能覆盖大多数系统预设光标包括箭头、文本选择、忙碌、手型、十字线、不可用等。它的优势是确定性高没有任何图像计算调用一次也就几个微秒。在自动化测试的定时器循环里跑几百次也消耗不了多少 CPU。局限也很明显系统安装了第三方光标主题或应用调用了SetCursor传入自定义光标时当前句柄不在标准 ID 映射表中返回结果就是“非标准光标或自定义光标”。遇到这种情况需要配合像素采样。4.2 方案二像素采样识别自定义光标自定义光标没有 ID 可查但形状图像是固定的。用CopyIcon加GetIconInfo把两个光标对象转成 Bitmap再做稀疏像素点比较就能判断两个光标是否相同public static bool CompareCursorPixel(IntPtr hCurA, IntPtr hCurB) { if (hCurA hCurB) return true; IntPtr copyA CopyIcon(hCurA); IntPtr copyB CopyIcon(hCurB); if (copyA IntPtr.Zero || copyB IntPtr.Zero) return false; try { GetIconInfo(copyA, out ICONINFO infoA); GetIconInfo(copyB, out ICONINFO infoB); using Bitmap bmpA CursorBitmapFromIconInfo(infoA); using Bitmap bmpB CursorBitmapFromIconInfo(infoB); if (bmpA.Width ! bmpB.Width || bmpA.Height ! bmpB.Height) return false; // 只抽 8 个固定点做像素比较避免整图逐像素 int stepX Math.Max(1, bmpA.Width / 3); int stepY Math.Max(1, bmpA.Height / 3); for (int y 1; y bmpA.Height - 1; y stepY) { for (int x 1; x bmpA.Width - 1; x stepX) { if (bmpA.GetPixel(x, y) ! bmpB.GetPixel(x, y)) return false; } } return true; } finally { if (copyA ! IntPtr.Zero) DestroyIcon(copyA); if (copyB ! IntPtr.Zero) DestroyIcon(copyB); } }为啥只抽 8 个点而不是逐像素比较因为光标的位图尺寸本来就不大通常是 32x32 或 64x64逐像素比较几十毫秒能跑完但多光标动画帧播放时同一个光标的某一帧存在反色和透明度变化整图比较会把“同一个形状”误判成不同形状。抽固定位置的特征点比如箭头尖、手型的中指根部对动画帧变化不敏感误判率反而更低。代价是特征点碰巧落在透明区域时会漏判所以生产环境我会用 16 个均匀分布的点并允许其中 2 个点不匹配。4.3 方案三结合窗口忙状态判断的取舍第三种方案更像一种工程取舍而不是纯识别技术。在 WinForms 或 WPF 应用内部Cursor.Current反映的是当前 UI 线程设置的光标它可能和系统光标不同步。比如你在应用里用Cursor.Override设置了一个临时等待光标GetCursorInfo拿到的就是临时光标而Cursor.Current拿到的还是应用自己的设置。实际操作中判断“应用界面忙不忙”用Control.Cursor更准确因为应用层设置的光标只影响自己的窗口判断“整个系统忙不忙”则必须用GetCursorInfo因为它反映的是全局光标。我一般会这样区分判断目标推荐取值方式注意点应用自身忙闲Control.Cursor或Panel.Cursor只取得到本窗口的光标全局切换不影响系统全局忙闲GetCursorInfo返回的hCursor受其他进程SetCursor影响远程桌面会话GetCursorInfo加flags判断本地与远程光标可能不同容易误判做自动化测试时优先用系统级判断做应用内的加载动画状态管理时优先用控件级光标。这个区分能避免很多“明明应用不忙了脚本还在等”的玄学问题。5. 抓取鼠标形状的 5 个坑DPI、句柄生命周期和主题切换5.1 抓出来一张黑图或者程序崩掉句柄生命周期太短现象GetCursorInfo拿到的hCursor直接用Icon.FromHandle转 Bitmap偶尔得到一张全黑图偶发程序崩溃重试又正常。原因GetCursorInfo返回的句柄是系统内部光标对象的引用。系统光标支持动画帧帧切换时会销毁当前帧对象并创建新对象如果你拿旧句柄去做 GDI 操作访问的就是已经释放的内存区域表现就是黑图或崩溃。解决拿到hCursor后立刻调用CopyIcon复制一份独立句柄后续所有操作只使用副本。用完副本调用DestroyIcon释放。这是血泪经验我自己早期实现里漏了CopyIcon结果在动画光标上稳定崩溃排查了半天才意识到是句柄生命周期问题。代码顺序必须是“先复制再使用最后销毁”少一步都不行。5.2 150% 缩放下位置对不上、图像变小现象程序在 125% 或 150% 缩放的显示器上运行时ptScreenPos返回的坐标和鼠标实际位置差了 20 到 60 像素导出的光标图像也比屏幕上的小一圈。原因进程默认不是 PerMonitorV2 DPI AwareGetCursorInfo返回的是物理像素坐标而 WinForms 窗口里的逻辑坐标是基于系统缩放系数换算的。光标位图尺寸由GetSystemMetrics(SM_CXCURSOR)返回这个值在非 DPI Aware 进程里也是物理像素大小。解决在应用清单里声明 PerMonitorV2 DPI Aware或者在启动时调用SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)。用物理像素坐标做全局判断时要自己乘上缩放系数再换算成逻辑坐标。程序里循环抓取时把当前进程的 DPI 值缓存起来避免每次计算都调 API。5.3 任务栏/托盘的忙碌光标识别不出来现象执行长时间任务时鼠标变成箭头加小圆圈但GetCursorShapeName始终返回“箭头”手动把鼠标移到任务栏上再移回来判断又正常了。原因Windows 区分“前台忙碌”和“后台忙碌”两种状态。IDC_WAIT是纯沙漏IDC_APPSTARTING是箭头加小圆圈。问题在于很多系统主题下IDC_APPSTARTING的句柄和IDC_ARROW完全一致句柄比对自然识别不出来。此外某些应用调用SetCursor设置的是临时忙碌光标系统会在一段时间后自动还原。解决识别“忙碌”时同时接受IDC_WAIT和IDC_APPSTARTING两种结果不要依赖句柄严格相等。想进一步确认应用是否处于忙碌状态可以用GetGUIThreadInfo查看目标窗口所属线程的状态标志两者结合判断。我的做法是形状名称包含“忙碌”两个关键字就标记为忙宁可多等一次轮询也不要漏判导致脚本点错位置。5.4 平板模式或触控时拿到空句柄现象在触控屏设备或平板模式下运行GetCursorInfo返回true但hCursor是IntPtr.ZeroCopyIcon也跟着失败。原因触控模式下系统抑制了光标的绘制flags里会带上CURSOR_SUPPRESSED状态。此时没有可见光标对象句柄自然为空。解决调用GetCursorInfo后先检查flags是否包含0x2CURSOR_SUPPRESSED。如果存在直接返回“无可见光标”状态不要拿空句柄去走后续链路。这个判断在远程协助和投屏场景尤其重要因为你不能假设所有用户都有物理鼠标。5.5 高频采样拖垮低配机器现象200 毫秒定时器里跑完整链路GetCursorInfo - CopyIcon - GetIconInfo - FromHbitmapCPU 占用从 1% 涨到 15%低配机器上甚至出现鼠标移动卡顿。原因每次循环都创建了位图对象、GDI 位图句柄和图标副本这些对象的创建和销毁成本叠加起来很高。尤其是GetIconInfo和FromHbitmap涉及底层 GDI 分配频繁调用会产生大量系统调用。解决把“识别形状”和“导出图像”拆成两条路径。日常判断只需要句柄比对走GetCursorShapeName完全不创建 GDI 对象只有需要视觉输出时才走CursorToBitmap完整链路。每次循环结束时确保所有Bitmap和DestroyIcon都被执行不要让临时对象进入 GC 等待区。我在实际项目里把轮询间隔调到 100 毫秒CPU 占用控制在 0.5% 以下。6. 进阶把鼠标形状变成“界面忙闲状态机”6.1 状态机连续 N 次空闲才算完成拿到形状信息后直接判断“忙”还是“闲”很容易抖动——光标刚切回箭头半秒又跳到沙漏脚本就可能提前继续执行。更好的做法是引入一个轻量状态机public enum CursorBusyState { Unknown, Busy, Idle } public class CursorStateMonitor { private readonly int _idleConfirmCount; private int _idleStreak; public CursorStateMonitor(int idleConfirmCount 3) { _idleConfirmCount idleConfirmCount; } public CursorBusyState Update() { string name GetCursorShapeName(); if (name.Contains(忙碌) || name.Contains(后台忙碌)) { _idleStreak 0; return CursorBusyState.Busy; } _idleStreak; if (_idleStreak _idleConfirmCount) return CursorBusyState.Idle; return CursorBusyState.Busy; } }连续 3 次检测到非忙碌状态才返回Idle可以有效过滤掉光标瞬间闪动造成的误判。这个参数不是固定的如果目标应用的光标切换本身就慢把确认次数调成 5如果追求响应速度调成 2。我自己常用 3因为它在大多数 Windows 版本上表现稳定既不迟钝也不激进。6.2 远程协助场景光标补绘时别把热点丢掉把抓取到的光标绘制到远程画面时最容易翻车的是热点偏移。光标的图像左上角并不是点击位置热点的位置在GetIconInfo的xHotspot和yHotspot字段里。绘制时必须把图像左上角对齐到“光标坐标减去热点偏移”public static void DrawCursor(Graphics g, Bitmap cursor, Point cursorPos, int hotX, int hotY) { // cursorPos 是鼠标锚点坐标图像左上角要减掉热点偏移 g.DrawImage(cursor, cursorPos.X - hotX, cursorPos.Y - hotY, cursor.Width, cursor.Height); }我最早做某远程协助工具时没减热点偏移箭头在画面上永远偏出 20 像素左右看着箭头已经挪到按钮上点击其实落在按钮左边。后来在代码里加了断言绘制前必须确认hotX/hotY非负且在图像尺寸范围内不符合就抛异常。这个习惯帮我拦下了后面几次回归。6.3 最小自测清单确认你的抓取实现没写错按这个顺序验证鼠标依次放到文本输入框、超链接、窗口边框观察识别结果是否分别对应IDC_IBEAM、IDC_HAND、IDC_SIZE。执行一个耗时三秒以上的操作期间确认状态机从Busy转到Idle并且不会提前触发。连续调用CursorToBitmap一百次用任务管理器观察 GDI 对象数量是否有增长趋势。在 125% 和 150% 缩放的显示器上各跑一遍确认坐标转换和图像尺寸正确。这四条跑过基本可以放心把抓取逻辑放进自动化框架或工具里。我现在的习惯是凡是涉及光标图像输出的功能第一版必须先写好热点断言的单元测试跑不过就禁止合入。这套方法帮我躲过了不少隐性 bug希望也能帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑