
简介这是一份面向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转图像的 APICursor类只暴露了位置和热区Cursor.Current拿到的也不是一个可以直接绘制成 PNG 的Icon。Windows 系统把鼠标指针当作一个图标资源由 user32.dll 管理每个指针有一个HCURSOR句柄。要拿到它需要调用GetCursorInfo或者CopyCursor这类底层函数。我最早做这个功能的时候试图用Icon.FromHandle(Cursor.Current.Handle)直接转结果画出来的图要么是空的要么是只有一个黑点。原因很简单Cursor.Handle不是标准图标句柄而且指针图标内部除了颜色层还有掩码层AND mask XOR maskFromHandle不会帮你正确地处理掩码合成。这个实例里你需要自己走一遍完整链路获取全局指针信息 → 复制句柄 → 用 ImageList 合成绘制。2.2 核心 API 全景与调用顺序整个抓取流程涉及三个 DLLuser32.dll拿句柄、comctl32.dllImageList 绘制、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 返回 FALSEILC_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设备上下文上所以要拿最终像素需要创建一个内存位图BitmapGraphics.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×32DPI 缩放后可能变成不同值。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.LimeTransparencyKey设成同样的颜色鼠标形状就画在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 型拖拽时变成移动十字箭头。抓取形状后可以优化成两个判断维度形状是否发生变化比较当前抓到的图标哈希值与上一次的差异。属于哪一类状态用模板匹配或特征值比对。特征值比对最简单的做法不是图像匹配而是取热区坐标和当前句柄的 IDGetCursorInfo返回的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里声明PerMonitorV2dpiAwareness设为PerMonitorV2并用GetDpiForWindow拿当前窗口真实 DPI按size 32 * dpi / 96计算图标绘制尺寸。不声明 DPI 感知后续做坐标换算也会出问题比如鼠标坐标比实际位置偏移一点。dpiAwareness xmlnshttp://schemas.microsoft.com/SMI/2016/WindowsSettings PerMonitorV2 /dpiAwareness5.3 现象长时间运行后 GDI 对象数暴涨抓取循环里每次调用GetIconInfo后没有释放hbmMask和hbmColor或者CopyIcon得到的句柄没有配对DestroyIcon程序跑 30 分钟后任务管理器里 GDI 对象数从 80 涨到几千最终绘制卡顿甚至系统警告。这是 GDI/User 对象泄漏的典型症状。解决严格遵守「谁打开谁释放」原则。GetIconInfo拿到的两个位图句柄必须DeleteObjectCopyIcon的句柄必须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 EventHandlerCursorShapeChangedEventArgs 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# 抓取鼠标形状这条路上的坑提前填平让你把精力留给真正有价值的业务逻辑。本文还有配套的精品资源点击获取