C#抓取鼠标形状:从GetCursorInfo句柄到位图绘制全解析
简介面向C#初学者的Windows API调用实例以抓取并显示鼠标当前形状为载体完整演示P/Invoke与系统资源管理。压缩包共21个文件大小仅26KB其中包含6个cs源码、3个exe可执行程序、2个resx资源文件以及pdb调试信息覆盖窗体设计、Program入口、配置与缓存等工程结构便于直接打开运行或对照研读已有305人学习下载。通过这份实例读者能获得完整可编译的工程代码和可执行程序深入理解GetCursorInfo函数、CURSORINFO结构体、定时器刷新机制以及DllImport引入、句柄转图标、资源释放等关键操作。实例清晰展示了如何通过P/Invoke调用user32.dll解析cbSize、flags、hCursor、ptScreenPos等字段并在UI中实时呈现光标变化对准备接触底层交互或需要实现自定义光标显示的开发者而言这是一条清晰、可复现的参考路径也可作为后续扩展鼠标状态监听、多屏坐标处理等功能的基础。1. 用 C# 抓取鼠标形状先分清你要的是“这张图”还是“这个形状”写录屏工具时最容易被忽略的细节就是光标——画面里少了那支箭头回放时总感觉哪里不对做 UI 自动化断言时想确认系统是不是进入了等待状态最直接的信号就是从箭头变成了转圈远程协助场景中对方的光标样式还需要实时还原到本端。C# 抓取鼠标形状 要解决的就是这件事从系统手里拿到当前那只光标的句柄再把句柄转成可保存、可重绘、可对比的位图。这跟截屏是两码事截屏拿到的只是像素光标自身的形状定义、热点位置、透明通道都是散的。这里走的方向很明确Win32 API 的 GetCursorInfo 拿句柄GDI 绘制成 PNG适合 Windows 桌面端工具、自动化测试脚本和录屏类项目去落地。2. 先看懂光标的三个事实句柄、热点和绘制尺寸2.1 光标句柄系统只认图标资源不认文件名在 Windows 里光标本质上就是图标的一种。它可能是 .cur 静态光标文件也可能是 .ani 动画光标还可能是编译进某个 DLL 的光标资源。系统不会给你一条“当前光标.png”的路径让你直接读文件你能拿到的只是一个 IntPtr 句柄代表系统内部维护的那个图标对象。所以“抓取鼠标形状”的第一步不是找文件而是向系统申请这个句柄。这个句柄本身不带坐标信息也不保证在你手上一直有效——光标随时会变鼠标移进文本框变成 I 形进入程序等待状态变成沙漏原句柄会被系统释放。因此拿到句柄后需要尽快处理处理完还要负责释放。常见做法是组合两个 APIGetCursorInfo取当前光标的句柄和屏幕坐标CopyIcon复制一份句柄避免原句柄在光标切换后失效。这两个 API 都在 user32.dll 里C# 里通过 P/Invoke 调用。P/Invoke 是 .NET 调 Win32 原生函数的桥梁声明一次就能像普通方法一样用代价是自己定义结构体和常量这也是很多人第一次写会卡住的地方。2.2 GetCursorInfo 的结构flags、句柄和屏幕坐标GetCursorInfo 需要传入一个 CURSORINFO 结构体关键字段有四个。cbSize 是结构体大小调用前必须手动赋好flags 表示光标显示状态值为 1 代表当前正在显示0 代表隐藏hCursor 是当前光标句柄ptScreenPos 是光标在屏幕上的坐标。这段结构体看着简单但有个容易忽略的点ptScreenPos 到底是光标左上角还是热点坐标会随系统版本和 DPI 设置产生差异。大部分情况下它给的是热点坐标也就是鼠标箭头尖那个点但也有驱动改写过它的情况。后面避坑章节我会专门拿一条来讲这里先记住字段含义。最小调用代码如下[StructLayout(LayoutKind.Sequential)] public struct POINT { public int X; public int Y; } [StructLayout(LayoutKind.Sequential)] public struct CURSORINFO { public int cbSize; public int flags; public IntPtr hCursor; public POINT ptScreenPos; } [DllImport(user32.dll)] public static extern bool GetCursorInfo(ref CURSORINFO pci); // 调用前必须把 cbSize 赋成结构体真实大小 CURSORINFO ci new CURSORINFO(); ci.cbSize Marshal.SizeOf(typeof(CURSORINFO)); bool ok GetCursorInfo(ref ci); if (ok ci.flags 1 ci.hCursor ! IntPtr.Zero) { // 此时 ci.hCursor 和 ci.ptScreenPos 可用 }注意 cbSize 如果不赋值API 直接返回 false这是刚上手的人踩得最多的坑比后面的图像转换更靠前。另外flags 1 是显示中的状态如果系统正处在文本输入的光标隐藏期hCursor 可能真是零这时候强行用句柄做后续操作只会拿到空图。2.3 从句柄到位图的路径选择CopyIcon Icon.FromHandle 还是 GetIconInfo拿到句柄后转图像有两条常见路径。路径一CopyIcon 复制句柄Icon.FromHandle 包一层再用 Graphics.DrawIcon 画到位图上。这条路径代码最短适合绝大多数静态光标也是本文主要采用的方式。IntPtr hCopy CopyIcon(ci.hCursor); using (Icon icon Icon.FromHandle(hCopy)) { using (Bitmap bmp new Bitmap(icon.Width, icon.Height, PixelFormat.Format32bppArgb)) { using (Graphics g Graphics.FromImage(bmp)) { g.Clear(Color.Transparent); g.DrawIcon(icon, 0, 0); } bmp.Save(cursor.png, ImageFormat.Png); } } DestroyIcon(hCopy);这条路径有个关键约束Icon.FromHandle 只是借用句柄它不拥有所有权所以用完必须手动调 DestroyIcon。如果漏掉这一步每抓一次就漏一个 GDI 对象长时间运行的程序最后会卡到画不出东西。路径二CopyIcon 之后调 GetIconInfo把句柄拆成掩码位图 hbmMask 和颜色位图 hbmColor再自己逐像素合并。这套写法适合对透明通道有严格要求的场景比如做图像像素级对比但代码量会翻倍。我的习惯是先用路径一只有当 DrawIcon 画出来的目标外观不一致时才切到路径二。先立住这个认知句柄不一定有文件名、GetCursorInfo 是最稳的入口、转图像有两条路径。接下来直接给可复现的工程代码。3. 用 GetCursorInfo 抓当前光标并保存 PNG最小可运行实现3.1 先搭工程WinForms 项目与引用注意点创建 Windows 桌面工程后要确认两件事。一是目标框架要带 -windows 后缀二是工程要启用 Windows Forms 支持。用命令行创建时推荐 dotnet new winforms它会自带 System.Drawing 引用和 Windows 桌面框架省去手动配 NuGet 的麻烦。如果你的工程是从普通控制台项目改的记得检查 csproj 里的 TargetFramework。以 .NET 8 为例应该写 net8.0-windows并且加上 true 。如果框架没带 -windows 后缀运行到 DllImport user32 时不会直接报错但 System.Drawing 的内部初始化可能抛 PlatformNotSupportedException。一个容易绕弯路的地方有人会去手动安装 System.Drawing.Common 的 NuGet 包然后发现 Windows 上根本不需要装了反而可能因为版本不一致出现类型冲突。WinForms 工程模板里已经带好了不用额外折腾。3.2 完整代码抓取、绘制、保存把 Win32 声明和抓取逻辑放在一个静态类里方便整个工程复用。这里我写了 CursorCaptureHelper对外只暴露一个方法 CaptureCurrentCursor调用方拿到 Bitmap 后自己决定保存还是处理。using System.Drawing; using System.Drawing.Imaging; using System.Runtime.InteropServices; public static class CursorCaptureHelper { [StructLayout(LayoutKind.Sequential)] public struct POINT { public int X; public int Y; } [StructLayout(LayoutKind.Sequential)] public struct CURSORINFO { public int cbSize; public int flags; public IntPtr hCursor; public POINT ptScreenPos; } [DllImport(user32.dll)] public static extern bool GetCursorInfo(ref CURSORINFO pci); [DllImport(user32.dll)] public static extern IntPtr CopyIcon(IntPtr hIcon); [DllImport(user32.dll)] public static extern bool DestroyIcon(IntPtr hIcon); public const int CURSOR_SHOWING 0x00000001; public static Bitmap CaptureCurrentCursor() { CURSORINFO ci new CURSORINFO(); ci.cbSize Marshal.SizeOf(typeof(CURSORINFO)); if (!GetCursorInfo(ref ci)) return null; if ((ci.flags CURSOR_SHOWING) 0 || ci.hCursor IntPtr.Zero) return null; // 复制句柄防止光标在后续绘制过程中切换导致句柄失效 IntPtr hCopy CopyIcon(ci.hCursor); if (hCopy IntPtr.Zero) return null; try { using (Icon icon Icon.FromHandle(hCopy)) { Bitmap bmp new Bitmap(icon.Width, icon.Height, PixelFormat.Format32bppArgb); using (Graphics g Graphics.FromImage(bmp)) { g.Clear(Color.Transparent); g.DrawIcon(icon, 0, 0); } return bmp; } } finally { DestroyIcon(hCopy); } } }这段代码的核心逻辑是先查光标显示状态再复制句柄复制成功后才进入绘制。finally 里销毁 hCopy 是必须的它能保证即使 DrawIcon 抛了异常GDI 句柄也不会泄漏。返回的 Bitmap 由调用方负责 Dispose如果调用方只 Save 不 Dispose内存也一样会慢慢涨。调用方保存为 PNG 的写法Bitmap bmp CursorCaptureHelper.CaptureCurrentCursor(); if (bmp ! null) { string name cursor_ DateTime.Now.ToString(HHmmssfff) .png; bmp.Save(name, ImageFormat.Png); bmp.Dispose(); } else { Console.WriteLine(当前光标不在显示状态或系统暂不返回句柄); }文件名里带了时间戳方便连续抓取时不会互相覆盖。Save 之后马上 Dispose因为位图对象占用的是非托管资源GC 不一定会及时回收。3.3 参数说明与三种调用方式CaptureCurrentCursor 方法里没有对外暴露参数但内部有几个值值得你按场景调整。第一个是 CURSORINFO.cbSize。它必须等于 Marshal.SizeOf(typeof(CURSORINFO))这个值在不同平台编译下是确定的不要硬编码一个数字否则换到 64 位进程可能出错。第二个是 flags 的掩码判断。我用了 0x00000001 去判断显示中状态但注意有些系统返回的 flags 可能是多个位的组合直接用 1 判断不够稳用按位与更安全。第三个是 Bitmap 的像素格式。Format32bppArgb 是必须写死的如果偷懒用默认格式DrawIcon 画出来的透明区域可能变成黑色背景。调用方式常见有三种第一种是按钮触发适合调试时手工抓一张看效果。按钮 Click 事件里调一次 CaptureCurrentCursor然后显示在 PictureBox 里或者保存成文件。第二种是定时器轮询适合做光标变化监控。用 System.Windows.Forms.TimerInterval 设成 100 毫秒每次 Tick 里抓一次跟上一次的结果对比句柄或哈希。第三种是结合 Windows 消息循环的延迟抓取。比如刚检测到用户点击鼠标先 Sleep 50 毫秒再抓让系统完成光标切换避免抓到切换中间态。这条看着有点玄学但在某些渲染延迟较高的远程桌面环境里很管用。三种方式底层都是同一个方法差异只在调用时机。按你的使用场景选就行不必在架构上纠结。4. 抓预置光标、算热点、做轮询追踪从“抓一次”到“一直抓”4.1 用 IDC_ 常量抓系统预置的鼠标形状有些需求不需要抓“当前光标”而是想拿到某个标准光标的长相。比如做教程截图要统一用系统默认的箭头做测试报告要把等待光标和手形光标单独导出来。这时用 LoadCursor 传入系统预置的 IDC_ 常量比等用户把鼠标移过去触发状态再抓要主动得多。[DllImport(user32.dll)] public static extern IntPtr LoadCursor(IntPtr hInstance, int lpCursorName); public const int IDC_ARROW 32512; public const int IDC_IBEAM 32513; public const int IDC_WAIT 32514; public const int IDC_CROSS 32515; public const int IDC_HAND 32649; public const int IDC_SIZEALL 32646; public static Bitmap CaptureSystemCursor(int cursorId) { // hInstance 传 IntPtr.Zero 表示取系统预置光标 IntPtr hCursor LoadCursor(IntPtr.Zero, cursorId); if (hCursor IntPtr.Zero) return null; // LoadCursor 返回的是系统共享句柄不能调用 DestroyIcon using (Icon icon Icon.FromHandle(hCursor)) { Bitmap bmp new Bitmap(icon.Width, icon.Height, PixelFormat.Format32bppArgb); using (Graphics g Graphics.FromImage(bmp)) { g.Clear(Color.Transparent); g.DrawIcon(icon, 0, 0); } return bmp; } }这段代码里特别注意LoadCursor 返回的句柄属于系统共享资源不要调用 DestroyIcon否则会把系统图标句柄释放掉轻则当前进程绘图异常重则影响整个会话的图标资源。与 GetCursorInfo 场景不同这里只有 CopyIcon 出来的句柄才需要手动销毁。4.2 热点坐标让光标尖角精准对齐屏幕坐标鼠标光标有一个热点概念。箭头光标的尖角、I 形光标的中间横线这些点才是光标向系统上报位置的参考点。抓到的光标图像左上角不是坐标原点把图像叠到屏幕画布上时热点要对应回鼠标的屏幕坐标否则叠加出来的效果就是光标尖角离实际点击位置偏了几像素甚至十几像素。获取热点需要 GetIconInfo。它返回一个 ICONINFO 结构体里面有 xHotspot 和 yHotspot 两个字段。[StructLayout(LayoutKind.Sequential)] public struct ICONINFO { public bool fIcon; public int xHotspot; public int yHotspot; public IntPtr hbmMask; public IntPtr hbmColor; } [DllImport(user32.dll)] public static extern bool GetIconInfo(IntPtr hIcon, out ICONINFO piconinfo);拿到热点后叠加绘制时的计算方式ICONINFO ii; GetIconInfo(hCopy, out ii); // screenX/screenY 是 CURSORINFO.ptScreenPos 里的鼠标坐标 int drawX screenX - ii.xHotspot; int drawY screenY - ii.yHotspot;这里有个细节GetIconInfo 需要传入的是图标句柄不能是 LoadCursor 返回的共享句柄也不能是 CopyIcon 复制出来的句柄之外的东西。一般流程是先用 CopyIcon 复制当前光标句柄然后用复制出来的副本去调 GetIconInfo用完后销毁副本。如果你的叠加画布和 GetCursorInfo 的坐标处在同一 DPI 环境下热点可以直接用。如果跨越了 DPI 边界比如从主屏拖到副屏且缩放率不一致热点值本身可能不需要缩放但坐标点要先换算这个很难一句话说死最好在目标环境里实测一下。4.3 用定时器轮询实现对光标变化的追踪全局鼠标钩子能做到很高精度的光标追踪但实现成本高容易因为消息阻塞把进程搞乱。对大多数 C# 桌面工具来说定时器轮询已经够用100 毫秒一次能覆盖绝大多数光标切换场景。IntPtr _lastCursorHandle IntPtr.Zero; System.Windows.Forms.Timer timer new System.Windows.Forms.Timer(); timer.Interval 100; timer.Tick (s, e) { CURSORINFO ci new CURSORINFO(); ci.cbSize Marshal.SizeOf(typeof(CURSORINFO)); if (GetCursorInfo(ref ci) ci.hCursor ! _lastCursorHandle) { _lastCursorHandle ci.hCursor; using (Bitmap bmp CursorCaptureHelper.CaptureCurrentCursor()) { // 光标句柄发生变化更新预览或记录日志 } } }; timer.Start();第一次执行 Tick 时_lastCursorHandle 初始为零所以一定会触发一次抓取。后续如果光标一直没变句柄相同就不会重复抓图节省开销。把 Interval 调到 50 能更快响应但会让消息队列多出不少 Tick 事件调到 200 以上则可能漏掉持续时间很短的沙漏状态。如果确实需要高频追踪正确做法是换 WH_MOUSE_LL 全局钩子但那个要在独立线程里处理消息循环还要处理回调里的耗时操作复杂度比定时器高一个量级。我的建议是先评估你的业务是不是真的需要 50ms 以内的变化感知大多数场景不需要别为了完美主义把维护成本拉上去。5. C# 抓取鼠标形状的踩坑清单五个高频翻车点5.1 光标句柄拿不到系统明明显示着箭头hCursor 却是零现象GetCursorInfo 返回 true但 hCursor 是 IntPtr.Zero或者 flags 不是 1。原因最常见的是光标被系统临时隐藏。触摸屏输入时系统会隐藏光标某些应用主动把 Cursor.Current 设成了 Cursor.None游戏里的锁光标模式也会导致光标不显示。还有一种情况是 cbSize 没赋值API 返回 false被误判成“拿不到句柄”。解决先检查 cbSize 是否用 Marshal.SizeOf 赋值了。确认没问题后按位与判断 flags当 flags 不包含显示位时直接返回空结果不要继续往绘制流程走。区分这两种情况能省下很多排查时间。5.2 抓出来的图边缘发黑、透明信息丢了现象DrawIcon 画完后保存成 PNG背景是黑色方块或者箭头边缘有黑边。原因位图的像素格式没有设成 32bppArgb。默认的 Format24bppRgb 不支持透明通道黑边其实是掩码位图里未被正确处理的区域。解决创建 Bitmap 时把 PixelFormat.Format32bppArgb 写死画之前用 Clear(Color.Transparent) 清一遍。如果光标本身是老式 4 位掩码格式DrawIcon 内部会自动做掩码转透明不用额外处理。只有当你发现 DrawIcon 对某些特殊自定义光标画出来完全不对时才需要切到 GetIconInfo 逐像素合并。5.3 高分屏下抓到的光标位置偏移半个光标现象把光标图叠加到截图上时箭头尖角离鼠标的实际点击点差一截在边界上差得更多。原因GetCursorInfo 返回的 ptScreenPos 按物理屏幕坐标计算而 DrawIcon 或 Graphics 绘制时用的尺寸是逻辑像素。DPI 缩放率不是 100% 时这两套坐标系不能直接混用。解决录制或截屏的合成层面统一用物理像素坐标。最简单的方法是把叠加画布的 Graphics 关掉所有变换在原始像素坐标系里直接 DrawImage。还有一个更稳定的做法不再依赖 CURSORINFO 里的坐标做叠加而是用 GetCursorPos 单独再取一次坐标并且把热点坐标作为后续绘制的偏移基准。5.4 动画光标只能拿到第一帧现象系统正在显示转圈等待动画抓出来的图却是静止的圈。原因Icon.FromHandle 配合 DrawIcon 对 .ani 动画光标只绘制默认帧不会跟随动画更新。解决如果你的产品只要求“录下来的光标是那个形状”静态帧完全够用不用纠结。如果一定要当前帧连续抓两张对比差异取变化后的那张代价是要多做一次图像哈希比对。目前没有更简单的公开办法能在系统层面“冻结”动画光标当前帧。5.5 句柄泄漏一次抓取漏掉一个 GDI 对象现象程序长时间运行后任务管理器里 GDI 对象数不断上涨最后报 OutOfMemoryException。原因Icon.FromHandle 不持有句柄所有权CopyIcon 复制出来的句柄没人负责销毁。只用 using 包 Icon 是不够的因为 Icon 不会销毁一个它没创建的句柄。解决每次 CopyIcon 之后必须成对出现 DestroyIcon。我习惯把 CopyIcon 放在 try 的上一层把 DestroyIcon 放在 finally 里这样无论中间哪一步异常句柄都能被释放。另外LoadCursor 返回的系统共享句柄不要调用 DestroyIcon这条和 CopyIcon 的销毁规则是相反的别记混。6. 进阶技巧记录光标轨迹并做回放叠加6.1 记录时间戳与光标变化数据做录屏或操作回放时与其每帧都去抓光标图像不如只在光标句柄变化时抓一次图其他时间只记录坐标和时间戳。这样回放时可以从事件列表里最近邻取到对应的光标快照。定义一个事件类来承载每条记录public class CursorEvent { public DateTime Time; public IntPtr Handle; public int X; public int Y; public Bitmap Snapshot; }轮询时先比较句柄句柄相同就只更新坐标句柄变化才重新抓取图像并赋值给 Snapshot。这个技巧能把一次两小时的录制里几百张光标图压缩到几十张因为用户大部分时间光标根本不变。6.2 回放时按时间戳把光标画到画布上回放合成的关键方法是 DrawCursorFrame它根据时间戳决定当前帧要不要画出光标并根据热点或中心点计算绘制位置。void DrawCursorFrame(Graphics g, CursorEvent evt, TimeSpan elapsed) { // 超过记录时间范围就不画 if (evt.Time DateTime.Now) return; // 如果记录过热点这里用热点坐标没有热点就退化成居中 int hotX evt.X; int hotY evt.Y; int dx hotX - evt.Snapshot.Width / 2; int dy hotY - evt.Snapshot.Height / 2; g.DrawImage(evt.Snapshot, dx, dy, evt.Snapshot.Width, evt.Snapshot.Height); }把这段调用放在录屏编码之前一个标准的循环如下using (Bitmap combined new Bitmap(frame.Width, frame.Height)) { using (Graphics g Graphics.FromImage(combined)) { g.DrawImage(frame, 0, 0); CursorEvent ne GetNearestEvent(elapsed); if (ne ! null) DrawCursorFrame(g, ne, elapsed); } // combined 交给编码器 }GetNearestEvent 按时间戳做二分查找回放帧率与录制帧率不一致时取最近一条光标就不会比画面快半拍。这个方案适合把光标轨迹叠加进已有录屏也适合拿来做自动化的鼠标路径可视化不依赖任何第三方图像库。以前我写这类功能总觉得越复杂越专业结果在句柄销毁上翻过车GDI 对象涨到顶直接把进程拖死。现在每次抓取只记住一条铁律复制出来的句柄必须销毁热点必须跟图一起存。这套流程看着简单但能稳定跑几个通宵就说明它值得长期用。希望帮到你。本文还有配套的精品资源点击获取