news 2026/10/26 12:50:31

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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/Invoke

2.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_SHOWING=0x1, CURSOR_SUPPRESSED=0x2 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是 32512,IDC_IBEAM是 32513。通过LoadCursor(NULL, id)就能拿到标准光标的句柄,拿它跟GetCursorInfo返回的hCursor做一次整数比较,瞬间就知道当前鼠标是不是箭头、沙漏或手型。

两种方案对比下来,句柄比对适合识别系统预设光标,速度快、零资源消耗;像素采样适合自定义光标,因为自定义光标没有固定 ID,只有位图数据。实际项目里我会用句柄比对做第一层判断,判断不了的回退到像素采样。这种分层设计既能保证性能,又能覆盖大多数真实场景。

3. 用 C# 抓取当前鼠标形状:最小代码与图像导出

3.1 新建控制台项目:目标平台与最小结构

实践部分从一个控制台项目开始。因为要用到 Win32 光标 API 和System.Drawing,项目目标框架必须指定为 Windows 版本。我在 .NET 8 环境中的写法是net8.0-windows,并在项目文件里开启AllowUnsafeBlocks,因为后面单色光标转换会用到指针操作:

<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <OutputType>Exe</OutputType> <TargetFramework>net8.0-windows</TargetFramework> <AllowUnsafeBlocks>true</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 Dictionary<int, 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转成 Bitmap:

public 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]; // AND=0 表示该像素画前景,AND=1 保留背景 byte r = andVal == 0 ? (byte)255 : (byte)0; byte g = andVal == 0 ? (byte)255 : (byte)0; byte b = andVal == 0 ? (byte)255 : (byte)0; // XOR=1 时反色,这里简化成黑色表示 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 Aware,GetCursorInfo返回的是物理像素坐标,而 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.Zero,CopyIcon也跟着失败。

原因:触控模式下系统抑制了光标的绘制,flags里会带上CURSOR_SUPPRESSED状态。此时没有可见光标对象,句柄自然为空。

解决:调用GetCursorInfo后先检查flags是否包含0x2(CURSOR_SUPPRESSED)。如果存在,直接返回“无可见光标”状态,不要拿空句柄去走后续链路。这个判断在远程协助和投屏场景尤其重要,因为你不能假设所有用户都有物理鼠标。

5.5 高频采样拖垮低配机器

现象:200 毫秒定时器里跑完整链路GetCursorInfo -> CopyIcon -> GetIconInfo -> FromHbitmap,CPU 占用从 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 最小自测清单

确认你的抓取实现没写错,按这个顺序验证:

  1. 鼠标依次放到文本输入框、超链接、窗口边框,观察识别结果是否分别对应IDC_IBEAM、IDC_HAND、IDC_SIZE。
  2. 执行一个耗时三秒以上的操作,期间确认状态机从Busy转到Idle,并且不会提前触发。
  3. 连续调用CursorToBitmap一百次,用任务管理器观察 GDI 对象数量是否有增长趋势。
  4. 在 125% 和 150% 缩放的显示器上各跑一遍,确认坐标转换和图像尺寸正确。

这四条跑过,基本可以放心把抓取逻辑放进自动化框架或工具里。我现在的习惯是:凡是涉及光标图像输出的功能,第一版必须先写好热点断言的单元测试,跑不过就禁止合入。这套方法帮我躲过了不少隐性 bug,希望也能帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/25 10:23:06

纪录片播放量预测:机器学习构建行为指纹库

1. 项目概述&#xff1a;用机器学习给纪录片“算命”&#xff0c;预测它能火到什么程度你有没有刷到过这样一部纪录片——画面沉静、节奏舒缓、旁白低沉&#xff0c;讲的是西南山坳里一位老木匠守着三把凿子过了四十年&#xff1f;它没上热搜&#xff0c;没请顶流站台&#xff…

作者头像 李华
网站建设 2026/10/26 0:38:51

企业级多媒体资产管理系统的SpringBoot+Vue实践

1. 项目概述&#xff1a;企业级多媒体资产管理解决方案 这个前后端分离的多媒体素材管理系统&#xff0c;是我为某文化传媒公司开发的数字化资产管控平台。系统采用SpringBootVue的技术栈&#xff0c;完美解决了传统FTP服务器管理混乱、素材检索效率低下、版本控制缺失等痛点。…

作者头像 李华
网站建设 2026/10/26 3:28:23

AI编程多模型接入与切换:Coding Plan 边界管理实战指南

开头 做了几年 AI 编程工具的落地&#xff0c;我最大的体会是&#xff1a;真正让人心累的从来不是模型本身&#xff0c;而是切换模型这件事。你可能今天用 A 模型做代码补全觉得顺手&#xff0c;明天项目里多个文件联调需要 B 模型的长上下文&#xff0c;后天想让 Agent 去自动…

作者头像 李华
网站建设 2026/10/24 17:23:00

Spring AI 2.0 ETL:把 PDF、Word 接入知识库

大家好&#xff0c;我是一言&#xff0c;继续更新 Spring AI 2.0 系列。 前面几篇&#xff0c;我们一直在聊资料入库后怎么检索。这次往前走一步&#xff1a;售后规则放在 PDF、Word 里&#xff0c;怎样让 RAG 用上它们&#xff1f; Spring AI 的 ETL 管道负责这段准备工作&…

作者头像 李华
网站建设 2026/10/24 5:26:37

力扣49题字母异位词分组:哈希表键设计的两种解法与优化

最近刷力扣刷到第49题"字母异位词分组"&#xff0c;这道题在面试里出现频率相当高&#xff0c;而且它考察的点非常典型——哈希表的灵活运用。很多人在初次接触这道题时&#xff0c;容易被"异位词"这个概念绕晕&#xff0c;其实把它翻译成大白话就是&#…

作者头像 李华
网站建设 2026/10/25 9:08:54

AI Agent本地部署与远程接管实战指南:从模型选型到安全暴露

1. 想清楚再动手&#xff1a;AI Agent 本地养成的全景思路 我最近一直在折腾一件事&#xff1a;把 AI Agent 真正养在自己的电脑上&#xff0c;而不是每次想用都去网页端排队。所谓“养”&#xff0c;不是下载一个聊天软件那么简单&#xff0c;而是让模型权重、工具调用、记忆存…

作者头像 李华