☰
用C#抓取鼠标形状:从API调用到实时绘制与状态识别
2026/10/13 19:19:05 网站建设 项目流程

简介:这是一份面向C#初学者与窗体应用开发者的完整实例工程,演示如何通过系统应用程序接口与平台调用技术实时抓取并显示鼠标形状。工程核心代码调用用户界面库中的获取光标信息函数,精确定义光标信息结构体,并利用定时器每100毫秒轮询鼠标状态,比较光标句柄是否变化,再借助图标句柄转换将其变为可显示图标,更新到图片框控件,同时妥善处理定时器与图标的资源释放。压缩包共包含21个文件,以C#源文件、项目工程文件、界面资源文件为主,另有编译生成的可执行文件、调试符号文件及临时缓存等,整体仅26KB,结构紧凑,便于直接打开运行与二次修改。已有305人学习下载。通过该实例,读者可掌握系统接口声明与调用、托管与非托管代码交互、结构体封送、句柄与图标转换等关键技能,无论是学习平台调用原理,还是为实际项目添加系统级鼠标状态监控功能,都能获得可直接借鉴的代码模板与排错思路。

1. 抓取鼠标形状是什么?一个 C# 截图工具的「隐藏需求」

做过桌面录制或截图工具的人应该都遇到过这个问题:画面里鼠标指针很清晰,但截出来的图里没有鼠标,或者鼠标图标被画成一个黑色方块。C# 抓取鼠标形状这个实例,解决的就是「实时拿到当前系统鼠标指针的图标,并把它画到自己的窗口或位图上」这件事。它不是简单地截屏,而是通过系统 API 读取鼠标指针的句柄,再用图像接口把它绘制出来。适合做屏幕标注、教学演示录制、自动化测试中的鼠标状态可视化,或者只是想在托盘区显示当前光标长什么样的工具。

很多人第一反应是「直接截屏不就包含鼠标了吗」,但实际上普通 GDI 截屏(比如 CopyFromScreen)在一些显卡加速场景下抓不到硬件光标,画出来的区域是空的;另一些低层驱动方案又太重。这个实例的价值在于:它只关心鼠标形状本身,拿到独立的光标图标,轻量、干净、可控。

2. 先搞清楚系统 API:图标句柄与 ImageList_Draw 的调用层级

2.1 为什么不能直接拿鼠标的 Image 对象

C# 里没有现成的System.Windows.Forms.Cursor.Current转图像的 API,Cursor类只暴露了位置和热区,Cursor.Current拿到的也不是一个可以直接绘制成 PNG 的Icon。Windows 系统把鼠标指针当作一个图标资源,由 user32.dll 管理,每个指针有一个HCURSOR句柄。要拿到它,需要调用GetCursorInfo或者CopyCursor这类底层函数。

我最早做这个功能的时候,试图用Icon.FromHandle(Cursor.Current.Handle)直接转,结果画出来的图要么是空的,要么是只有一个黑点。原因很简单:Cursor.Handle不是标准图标句柄,而且指针图标内部除了颜色层还有掩码层(AND mask + XOR mask),FromHandle不会帮你正确地处理掩码合成。这个实例里你需要自己走一遍完整链路:获取全局指针信息 → 复制句柄 → 用 ImageList 合成绘制。

2.2 核心 API 全景与调用顺序

整个抓取流程涉及三个 DLL:user32.dll(拿句柄)、comctl32.dll(ImageList 绘制)、gdi32.dll(位图操作)。最常见的调用顺序是这样:

// 1. 定义 CURSORINFO 结构,cbSize 必须初始化 CURSORINFO ci = new CURSORINFO(); ci.cbSize = Marshal.SizeOf(typeof(CURSORINFO)); // 2. 获取全局光标信息,成功返回 TRUE bool ok = GetCursorInfo(ref ci); // 3. 如果光标可见(flags == CURSOR_SHOWING),复制句柄 IntPtr cursorCopy = CopyIcon(ci.hCursor); // 4. 用 ImageList 创建图标列表并添加句柄 IntPtr imageList = ImageList_Create(width, height, ILC_COLOR32 | ILC_MASK, 1, 0); ImageList_AddIcon(imageList, cursorCopy); // 5. 绘制到目标画布 ImageList_Draw(imageList, 0, hdc, x, y, ILD_TRANSPARENT); // 6. 清理资源 ImageList_Destroy(imageList); DestroyIcon(cursorCopy);

这段代码的逻辑是:先通过GetCursorInfo问到当前鼠标指针的hCursor,这是系统级的,不依赖你在哪个控件上;然后CopyIcon复制一份,防止原句柄在绘制过程中被系统释放;接着借助ImageList把图标展开成可绘制的图像,因为hCursor本身不能直接交给 GDI 的DrawIcon做透明合成,而ImageList_Draw能正确处理鼠标指针的掩码通道。

参数说明:CURSORINFO.cbSize必须赋结构体真实大小,否则 API 返回 FALSE;ILC_COLOR32表示 32 位色,ILC_MASK表示同时保留掩码;ILD_TRANSPARENT表示用掩码做透明绘制。这个链路里最容易丢的细节是cbSize,新手经常把ci直接default传给 API,结果拿到的hCursor永远是 0。

2.3 句柄的生命周期管理

GetCursorInfo返回的hCursor不是给你随便持有的,系统可能在鼠标样式切换时(比如从箭头变成沙漏、变成文本 I 型)自行销毁并重建指针。正确的做法是拿到后立刻CopyIcon复制,用完DestroyIcon。这一步叫「句柄快照」。

我见过有人在循环里反复调GetCursorInfo但不复制,也不释放,跑到几百帧后 GDI 句柄数暴涨,程序绘制越来越卡,最后抛异常。做过 GDI 编程的人应该都知道那种「句柄数从 800 涨到 5000 最终崩溃」的滋味。这个实例原理解释里特别强调这一点,是因为它决定了你的工具能不能长时间运行。

复制与释放的规范化写法:

public static Icon CopyCurrentCursorIcon() { CURSORINFO ci = new CURSORINFO(); ci.cbSize = Marshal.SizeOf(typeof(CURSORINFO)); if (!GetCursorInfo(ref ci)) return null; if (ci.flags != CURSOR_SHOWING) return null; IntPtr hCopy = CopyIcon(ci.hCursor); if (hCopy == IntPtr.Zero) return null; // 用 Icon.FromHandle 只是过渡,需要再克隆一次才能释放原句柄 using (Icon temp = Icon.FromHandle(hCopy)) { return (Icon)temp.Clone(); } }

原理说明:Icon.FromHandle不会接管句柄生命周期,using释放temp时并不会真正销毁hCopy,所以必须执行DestroyIcon(hCopy),或者通过Clone()得到独立托管副本。我一般会再包一层 try/finally,在 finally 里确保DestroyIcon,防止异常路径漏释放。

参数层面,CURSOR_SHOWING的值是 1,如果鼠标指针处于隐藏状态(比如某些全屏游戏中光标被隐藏),GetCursorInfo仍然返回 TRUE 但flags会是 0,此时应当直接返回 null,而不是继续绘制一个过期的指针。

3. 抓取并绘制鼠标形状:实时预览与无边框窗口实现

3.1 从 API 到位图:把图标画到内存画布

API 只能把图标画在HDC(设备上下文)上,所以要拿最终像素,需要创建一个内存位图(Bitmap+Graphics.GetHdc),让ImageList_Draw往这个 DC 上画,画完再GetHdc释放,回到Bitmap对象。这样你就能把鼠标形状作为一个普通Image使用,比如显示在 PictureBox、保存成文件或与桌面截图合并。

public static Bitmap DrawCursorToBitmap(IntPtr hCursor, int width, int height) { Bitmap bmp = new Bitmap(width, height, PixelFormat.Format32bppArgb); using (Graphics g = Graphics.FromImage(bmp)) { IntPtr hdc = g.GetHdc(); IntPtr imageList = ImageList_Create(width, height, ILC_COLOR32 | ILC_MASK, 1, 0); ImageList_AddIcon(imageList, hCursor); bool drawn = ImageList_Draw(imageList, 0, hdc, 0, 0, ILD_TRANSPARENT); ImageList_Destroy(imageList); g.ReleaseHdc(hdc); if (!drawn) return null; } return bmp; }

说明:GetHdc/ReleaseHdc必须成对出现,ImageList_Draw画完后立刻Destroy掉 imageList,位图对象则持有已经拷贝的像素数据,之后可以任意使用。width、height建议严格按照系统图标大小取,一般是从SystemInformation.CursorSize获得,默认 32×32,DPI 缩放后可能变成不同值。

3.2 实时预览窗口:无边框、置顶、穿透

抓取鼠标形状经常要做一个「跟随预览」,也就是一个小窗口跟着鼠标走,里面显示当前指针的样子。直接做一个普通Form会导致鼠标移入窗体时形状刷新出现波动甚至死循环,所以正确做法是做一个完全不接收鼠标事件的无边框置顶窗体,靠SetWindowLong加WS_EX_TRANSPARENT | WS_EX_LAYERED实现鼠标穿透和透明背景。

protected override CreateParams CreateParams { get { CreateParams cp = base.CreateParams; cp.ExStyle |= 0x00000020; // WS_EX_TRANSPARENT:鼠标穿透 cp.ExStyle |= 0x00080000; // WS_EX_LAYERED:分层窗口 return cp; } }

这个窗体的BackColor设成全透明色(比如Color.Lime),TransparencyKey设成同样的颜色,鼠标形状就画在OnPaint里。因为窗口本身不参与鼠标事件,Cursor.Position读取的坐标永远是真实桌面坐标,不会因为窗口覆盖而读到窗体内部坐标。

绘制逻辑要点:OnPaint里不要每次都重新GetCursorInfo,因为Paint事件频率可能远高于鼠标状态变化,常见做法是开一个 15ms 的定时器,在定时器里判断鼠标位置和形状是否变化,变化了才Invalidate()——而不是让窗口实时重绘。

3.3 参数与边界:热区坐标怎么用

鼠标形状不只是图像,还有一个「热点」(hotspot),也就是指针左上角到真正触发点击的点的偏移。比如箭头指针热点在左上角(0,0),而 I 型文本指针热点在中间偏左。做预览跟随的时候,窗体左上角坐标应该等于鼠标坐标 - 热点偏移,这样图像显示出来才是「看起来正好被手指点在目标上」。

POINT point; GetCursorPos(ref point); CURSORINFO ci = new CURSORINFO(); ci.cbSize = Marshal.SizeOf(typeof(CURSORINFO)); GetCursorInfo(ref ci); // 从图标信息中读取热点 ICONINFO iconInfo; GetIconInfo(ci.hCursor, out iconInfo); int hotX = iconInfo.xHotspot; int hotY = iconInfo.yHotspot; previewForm.Location = new Point(point.X - hotX, point.Y - hotY);

注意:GetIconInfo返回的ICONINFO里还包含hbmMask和hbmColor两个位图句柄,必须用DeleteObject释放,否则每抓一次泄漏两个 GDI 对象,这个坑放在第 5 章展开。

4. 把抓到的形状落地:保存 PNG、透明通道与命中热区

4.1 保存为透明 PNG 文件

抓到的鼠标形状经常需要存成素材,比如给文档配图、做教学素材、做自动化测试的报告截图。直接bmp.Save("cursor.png", ImageFormat.Png)就能得到透明底 PNG,但前提是位图像素格式必须是Format32bppArgb,且绘制时用的是ImageList_Draw带掩码模式,如果绘制时错误地用了普通DrawIcon,保存出来的图片背景是黑色或白色。

using (Bitmap bmp = DrawCursorToBitmap(cursorHandle, iconSize.Width, iconSize.Height)) { bmp.Save(@"D:\cursor_snapshot.png", ImageFormat.Png); }

如果最终交付的是 ICO 文件,则在保存前用Icon.FromHandle转为Icon对象再Save,但 ICO 保存需要多尺寸支持,只能保存为单尺寸,适合做静态素材。我用下来觉得 PNG 更实用,因为可以直接被文档或其他程序读取。

4.2 用鼠标形状做「状态识别」

除了可视化,抓取鼠标形状还有一个实际用途:通过形状变化判断系统或应用当前的状态。比如页面加载中时鼠标变成沙漏(WaitCursor),输入文字时变成 I 型,拖拽时变成移动十字箭头。抓取形状后可以优化成两个判断维度:

  • 形状是否发生变化:比较当前抓到的图标哈希值与上一次的差异。
  • 属于哪一类状态:用模板匹配或特征值比对。

特征值比对最简单的做法不是图像匹配,而是取热区坐标和当前句柄的 ID:GetCursorInfo返回的hCursor虽然每次可能不同,但同一形状的图标句柄在系统内通常复用。做一个字典映射句柄到状态名即可:

private static string InferCursorState(IntPtr hCursor) { if (hCursor == waitCursorHandle) return "Busy/Wait"; if (hCursor == arrowCursorHandle) return "Normal/Select"; if (hCursor == textCursorHandle) return "Editing/Text"; return "Unknown/Custom"; }

这个方案的局限是自定义光标(比如某些软件自己设置的指针)不在这三个句柄里,需要额外记录首次出现的未知句柄并标记为Custom。工业级做法是在此基础上再做一次图标灰度哈希比对,两个维度结合判断。我对这类需求的建议是:先做句柄 ID 映射,命中率低再上像素比对,不要一开始就上图像识别,性能和复杂度都不划算。

4.3 把鼠标形状叠加到截图上

录屏或截图工具里最常见的落地是把鼠标形状与桌面截图合成。关键点是:截图要在鼠标事件捕获之后立刻截屏,然后按鼠标坐标 - 热点偏移把图标画上去。为什么不直接截屏等系统画?因为硬件光标可能不在 GDI 层面渲染,等截完再画是稳定可控的。

Rectangle bounds = Screen.PrimaryScreen.Bounds; using (Bitmap screenshot = new Bitmap(bounds.Width, bounds.Height)) using (Graphics g = Graphics.FromImage(screenshot)) { g.CopyFromScreen(bounds.Location, Point.Empty, bounds.Size); Point mouse = GetCursorPosition(); IntPtr hCursor = GetCurrentCursorHandle(); using (Bitmap cursorBmp = DrawCursorToBitmap(hCursor, iconSize.Width, iconSize.Height)) { g.DrawImage(cursorBmp, mouse.X - hotspot.X, mouse.Y - hotspot.Y); } screenshot.Save("frame_with_cursor.png", ImageFormat.Png); }

注意合成时热区偏移必须独立计算,不能直接用Cursor.Position作为绘制原点,否则鼠标箭头看起来会「悬空」在坐标点右下侧,视觉上明显偏。我第一次做这个合成时没处理热区,截图里鼠标永远点在目标右侧 10 像素左右,看起来极其别扭,后来才意识到是所有箭头都有热点补偿。

5. 抓取鼠标形状避坑:句柄失效、DPI 缩放与性能损耗

5.1 现象:绘制出的鼠标图标是旧的

明明刚刚把鼠标从箭头切换到了沙漏,抓取绘制出来的却还是箭头。原因有两类:第一类是自定义光标,系统在切换形状时不会立即更新全局光标句柄,而是有一个延迟,大约几十毫秒到一两百毫秒;第二类是应用自己调用了Cursor.Current设到一个私有光标,GetCursorInfo拿到的是那个私有句柄,但句柄与你预期的系统标准句柄不是一个值。

解决:轮询线程加一个去抖时间段,比如发现形状变化后等 80ms 再抓一次,取最后一次结果作为当前状态。窗口应用内设置的光标,优先用应用层的Cursor.Current,全局抓取会慢半拍。

5.2 现象:高 DPI 缩放下图标变小或变大

在 150% 缩放(96 DPI 基础上 ×1.5)下,SystemInformation.CursorSize返回的尺寸是 48×48 还是 32×32 取决于进程是否声明 PerMonitorV2 感知。如果程序没有声明 DPI 感知,系统会帮你把窗口内 32×32 的图标拉伸成 48×48 显示,但抓出来的位图还是 32×32,拉伸后模糊成一团。

解决:程序启动时在app.manifest里声明PerMonitorV2(dpiAwareness设为PerMonitorV2),并用GetDpiForWindow拿当前窗口真实 DPI,按size = 32 * dpi / 96计算图标绘制尺寸。不声明 DPI 感知,后续做坐标换算也会出问题,比如鼠标坐标比实际位置偏移一点。

<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings"> PerMonitorV2 </dpiAwareness>

5.3 现象:长时间运行后 GDI 对象数暴涨

抓取循环里每次调用GetIconInfo后没有释放hbmMask和hbmColor,或者CopyIcon得到的句柄没有配对DestroyIcon,程序跑 30 分钟后任务管理器里 GDI 对象数从 80 涨到几千,最终绘制卡顿甚至系统警告。这是 GDI/User 对象泄漏的典型症状。

解决:严格遵守「谁打开谁释放」原则。GetIconInfo拿到的两个位图句柄必须DeleteObject,CopyIcon的句柄必须DestroyIcon。可以写一个try/finally结构,或者把整个绘制过程包进using风格的自定义类,在Dispose里统一清理。

ICONINFO info = new ICONINFO(); bool ok = GetIconInfo(ci.hCursor, out info); try { // 使用 info.hbmColor / info.hbmMask } finally { if (info.hbmColor != IntPtr.Zero) DeleteObject(info.hbmColor); if (info.hbmMask != IntPtr.Zero) DeleteObject(info.hbmMask); }

5.4 现象:ImageList_Draw 画出来是黑底

有些机器上绘制出来的鼠标形状周围有一圈黑色方块,而不是透明背景。这是ImageList_Create的像素格式与目标 DC 不匹配导致的:如果目标 DC 是 16 位色而 ImageList 用 32 位色创建,透明通道会失效变成黑色;另外ILD_TRANSPARENT依赖掩码,掩码位图没有正确加载时也会出现黑底。

解决:强制让目标Bitmap使用Format32bppArgb,并且ImageList_Create用ILC_COLOR32 | ILC_MASK;绘图时把rasterOperation参数传ILD_TRANSPARENT | ILD_BLEND25也可以缓解一些老显卡上的黑色边缘,但核心还是位深匹配。

5.5 现象:鼠标移动时预览窗口闪烁

预览窗口跟随鼠标移动,OnPaint里直接画图标,发现在快速移动时窗体边缘闪烁、图标残影严重。原因是窗口没有双缓冲,且鼠标位置更新在 UI 线程每帧触发Invalidate,重绘跟不上移动速度。

解决:给窗体重写DoubleBuffered = true或者用BufferedGraphics先绘制到内存缓冲区再一次 BitBlt 到窗口;同时不要每个 1ms 都移动窗体位置,而是等Cursor.Position变化超过两个像素再更新位置,把刷新率压到 60Hz 左右即可,肉眼无差别。

6. 进阶:把鼠标形状识别成「状态信号」:轮询降频与事件触发

走到这一步,你已经能正确拿到鼠标形状、画到位图、存成文件了。接下来更高级的玩法,是把「鼠标形状」变成一个可被程序感知的信号源。为什么要这么做?因为很多自动化脚本靠坐标点击和截屏来判断程序状态,但页面是否在加载、软件是否卡死,最直接的信号就是鼠标形状是否变成了沙漏或不可用状态。

首先要解决的是轮询降频。GetCursorInfo虽然轻量,也不能在 UI 线程里 1ms 一次死循环,否则窗口消息被阻塞,画面和交互都会受影响。我一般用一个后台Timer线程,间隔 100ms 轮询一次,检测到形状变化时触发一个自定义事件,UI 线程订阅这个事件做响应。这个设计把高频轮询与 UI 刷新解耦,也避免了窗体重绘造成的额外开销。

public class CursorShapeMonitor : IDisposable { private readonly System.Timers.Timer _timer; private IntPtr _lastHandle; private string _lastState; public event EventHandler<CursorShapeChangedEventArgs> ShapeChanged; public CursorShapeMonitor(double intervalMs = 100) { _timer = new System.Timers.Timer(intervalMs); _timer.Elapsed += (s, e) => Poll(); } private void Poll() { CURSORINFO ci = new CURSORINFO(); ci.cbSize = Marshal.SizeOf(typeof(CURSORINFO)); if (!GetCursorInfo(ref ci)) return; IntPtr hCursor = ci.hCursor; if (ci.flags != 1 || hCursor == _lastHandle) return; _lastHandle = hCursor; string state = MapHandleToState(hCursor); _lastState = state; ShapeChanged?.Invoke(this, new CursorShapeChangedEventArgs(state)); } // ... }

参数说明:intervalMs含义是轮询间隔。100ms 是折中:太短 CPU 占用高,太长会漏掉中间一闪而过的光标状态。_lastHandle缓存是为了避免重复通知;但这里有个问题,hCursor是系统全局句柄,某些系统上层应用会重新加载同样式光标导致句柄变化,从而误触发一次事件——所以更稳的缓存键是「形状图像哈希」或「句柄 + 状态字符串」二元组。我在实际项目中就遇到过一次误报:某软件启动时把光标切回「规格箭头」但句柄和系统默认句柄不同,导致监控端连续触发了多次「形状恢复正常」事件。解法很简单,把MapHandleToState返回的字符串也一起缓存,字符串相同就不触发。

事件触发比轮询好在哪?它天然支持多路订阅。你可以在屏幕录制工具里订阅它做状态标注,在自动化测试框架里订阅它做等待条件,比如「等到光标变回箭头才认为页面加载完成」。我做过一个模拟项目,用这个方式替代固定Thread.Sleep(3000),测试稳定性提升明显——加载快时不需要傻等,加载慢时也不会过早点击。

再进一步,你可以把鼠标形状与屏幕区域联动。比如只在鼠标位于某一块固定区域时启用状态监控,因为很多业务界面同一个光标在不同区域代表不同状态。联动方式是用GetCursorPos配合区域 Rect 做过滤,命中了再触发事件,否则不触发。

关于热点偏移,在进阶场景里还有一个用途:当光标状态为Move(十字箭头)时,热点通常是中心,这时如果你要做拖拽动画演示,可以直接用热点坐标定位「鼠标按住的位置」,比从图像轮廓计算中心点要准得多。这是一个很巧但很少被提到的技巧。

作为收尾的习惯,我现在每写一个抓取鼠标形状的功能,都会强制走一遍「句柄复制 → GetIconInfo 释放 → DeleteObject → DestroyIcon → DPI 声明」五步检查表。这几步看着琐碎,但少任何一步,程序要么画不出来,要么长时间运行后突然崩溃。希望这份实战笔记能把你在 C# 抓取鼠标形状这条路上的坑提前填平,让你把精力留给真正有价值的业务逻辑。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询