Windows GDI屏幕绘图实战:程序化绘制鼠标光标与透明处理
2026/9/24 6:48:58 网站建设 项目流程

在实际 Windows 桌面应用开发或自动化测试脚本编写中,有时会遇到一个特殊需求:需要在屏幕的特定位置,甚至随机位置,绘制一个鼠标光标。这并非简单地移动物理鼠标,而是通过程序在屏幕上“画”出一个光标图像。例如,在开发远程桌面指示器、屏幕标注工具、录制操作教程视频,或者模拟用户交互进行自动化测试时,这种能力就变得非常有用。对于刚接触 Windows 图形设备接口(GDI)的开发者来说,如何直接操作屏幕像素来绘制光标,是一个既有趣又实用的挑战。

本文将带你深入 GDI 的核心,一步步实现一个在屏幕任意位置(包括随机位置)绘制鼠标光标图案的程序。我们将从理解 GDI 屏幕绘图的基本原理开始,到获取光标位图、处理透明背景,最后完成在指定坐标的绘制。无论你是想为你的工具增加一个视觉指示器,还是学习 GDI 的底层绘图操作,这篇文章都将提供清晰的路径和可运行的代码示例。

1. 理解 GDI 与屏幕绘图的基本原理

在 Windows 系统中,图形设备接口(Graphics Device Interface, GDI)是一套用于处理图形输出的核心 API。它负责在屏幕、打印机等输出设备上绘制图形、文本和图像。当我们谈论“在屏幕上画东西”时,在 Windows 编程语境下,通常就是指通过 GDI 函数直接向屏幕的设备上下文(Device Context, DC)进行绘制。

1.1 设备上下文(DC)与屏幕 DC

设备上下文可以理解为一个画布,它关联了具体的输出设备(如显示器)以及一套绘图属性(如画笔、画刷、字体)。要对屏幕进行绘图,首先需要获取屏幕的设备上下文。

HDC hdcScreen = GetDC(NULL); // 获取整个屏幕的 DC

这行代码是屏幕绘图的起点。GetDC(NULL)返回的是整个屏幕(桌面)的设备上下文句柄。通过这个句柄,我们就可以调用各种 GDI 函数(如TextOut,Rectangle,BitBlt)在屏幕上直接作图。操作完成后,必须释放这个 DC,否则会造成资源泄漏。

ReleaseDC(NULL, hdcScreen);

1.2 光标(Cursor)的本质

系统鼠标光标并不是一个简单的位图,它是一个带有热点(Hot Spot)信息的特殊资源。热点定义了光标的“点击点”,例如箭头光标的热点通常在尖端。当我们通过 GDI 绘制光标时,通常只关心其视觉部分——即一个带有透明或反色区域的小位图。

Windows 提供了GetCursorInfo函数来获取当前系统光标的信息,包括其句柄、位置和状态。我们可以从这个句柄中提取出光标的图像数据(位图),用于后续的绘制。

1.3 挑战:透明与覆盖

直接在屏幕上绘制图像,最大的挑战是处理透明背景和避免永久性破坏桌面内容。GDI 的BitBlt函数支持多种光栅操作码(ROP),其中SRCCOPY是直接覆盖,SRCANDSRCPAINT等可以用于实现简单的透明效果(模拟掩码)。但对于带有 Alpha 通道(逐像素透明度)的现代光标,则需要使用 GDI+ 或更新版本的 API。为了保持教程的通用性和简洁性,我们将使用经典的掩码法来处理标准光标的透明效果。

2. 环境准备与项目配置

我们将使用 C++ 和原生 Win32 API 来实现这个功能。你不需要复杂的 IDE,一个简单的文本编辑器和命令行编译器即可。

2.1 开发环境要求

  • 操作系统: Windows 7 及以上版本(理论上 Windows XP 也可运行,但建议使用 Win7+)。
  • 编译器: 支持 C++ 和 Win32 API 的编译器。推荐使用:
    • Visual Studio(2015 或更高版本):创建空的 “Windows 桌面应用程序” 项目。
    • MinGW-w64: 如果你喜欢轻量级环境,可以使用g++配合-lgdi32参数编译。
  • 知识准备: 基础的 C++ 语法知识,以及对 Windows 消息循环有初步了解。

2.2 创建 Win32 项目与基础代码框架

以 Visual Studio 为例,创建一个新的 “Windows 桌面应用程序” 项目,命名为 “DrawCursor”。系统会生成一个带有WinMain和窗口过程的模板。为了专注于 GDI 绘图,我们可以简化它,甚至创建一个控制台应用程序,但链接必要的库。

以下是程序的主干结构,它不创建可见窗口,而是直接执行绘图逻辑后退出:

#include <windows.h> #include <stdlib.h> // 用于 rand() #include <time.h> // 用于 srand() // 函数声明 void DrawCursorAtPoint(HDC hdc, int x, int y); HBITMAP GetCursorBitmap(HCURSOR hCursor); int main() { // 初始化随机数种子 srand((unsigned int)time(NULL)); // 1. 获取屏幕尺寸,用于生成随机坐标 int screenWidth = GetSystemMetrics(SM_CXSCREEN); int screenHeight = GetSystemMetrics(SM_CYSCREEN); // 2. 生成随机坐标 (示例:生成5个随机位置) for (int i = 0; i < 5; ++i) { int randX = rand() % screenWidth; int randY = rand() % screenHeight; // 3. 获取屏幕DC HDC hdcScreen = GetDC(NULL); if (hdcScreen) { // 4. 在随机位置绘制光标 DrawCursorAtPoint(hdcScreen, randX, randY); // 5. 释放屏幕DC ReleaseDC(NULL, hdcScreen); } // 等待一小段时间,让绘制效果可见(非必需) Sleep(500); } return 0; } // 后续将在这里实现 DrawCursorAtPoint 和 GetCursorBitmap

这个main函数完成了流程控制:获取屏幕大小,生成随机坐标,获取屏幕 DC,调用绘图函数,最后释放资源。Sleep(500)是为了让每次绘制在屏幕上停留半秒,方便观察。在实际工具中,你可能需要更复杂的逻辑来控制绘制的时机和生命周期。

2.3 关键头文件与库

确保你的项目包含了正确的头文件并链接了库:

  • 头文件:<windows.h>包含了绝大多数 Win32 API 的定义。
  • 库文件:gdi32.libuser32.lib是必须链接的。在 Visual Studio 的项目属性中,通常在 “链接器 -> 输入 -> 附加依赖项” 中添加。对于命令行编译(如 MinGW),使用-lgdi32 -luser32参数。

3. 核心功能实现:捕获与绘制光标位图

现在实现最核心的两个函数:GetCursorBitmap用于从光标句柄中提取位图,DrawCursorAtPoint用于在指定设备上下文的位置绘制该位图。

3.1 获取当前系统光标位图

我们不能直接绘制一个光标句柄到任意位置,需要先将其转换为一个标准的位图(HBITMAP)。这涉及到图标(ICON)信息结构的操作。

HBITMAP GetCursorBitmap(HCURSOR hCursor) { ICONINFO iconInfo = { 0 }; if (!GetIconInfo(hCursor, &iconInfo)) { return NULL; } // 注意:GetIconInfo 创建了位图(hbmMask, hbmColor),我们需要负责删除它们 // 但我们返回的是 hbmColor 的副本,所以先保存句柄,最后清理原始的。 HBITMAP hBitmapToReturn = NULL; // 通常,彩色光标信息在 hbmColor 中,单色光标信息在 hbmMask 中。 // 我们优先使用彩色位图。 if (iconInfo.hbmColor) { // 复制彩色位图以返回 HDC hdcScreen = GetDC(NULL); HDC hdcMem = CreateCompatibleDC(hdcScreen); hBitmapToReturn = CreateCompatibleBitmap(hdcScreen, GetSystemMetrics(SM_CXCURSOR), GetSystemMetrics(SM_CYCURSOR)); SelectObject(hdcMem, hBitmapToReturn); // 将原始彩色位图绘制到我们的副本中 HDC hdcSrc = CreateCompatibleDC(hdcScreen); SelectObject(hdcSrc, iconInfo.hbmColor); BitBlt(hdcMem, 0, 0, GetSystemMetrics(SM_CXCURSOR), GetSystemMetrics(SM_CYCURSOR), hdcSrc, 0, 0, SRCCOPY); DeleteDC(hdcSrc); DeleteDC(hdcMem); ReleaseDC(NULL, hdcScreen); // 删除原始位图(由 GetIconInfo 创建) DeleteObject(iconInfo.hbmColor); } // 如果没有彩色位图,则使用掩码位图(单色,需要特殊处理透明,本例简化处理) else if (iconInfo.hbmMask) { // 简化处理:同样复制掩码位图。实际应用中,需要结合掩码实现透明。 HDC hdcScreen = GetDC(NULL); HDC hdcMem = CreateCompatibleDC(hdcScreen); hBitmapToReturn = CreateCompatibleBitmap(hdcScreen, GetSystemMetrics(SM_CXCURSOR), GetSystemMetrics(SM_CYCURSOR)); SelectObject(hdcMem, hBitmapToReturn); HDC hdcSrc = CreateCompatibleDC(hdcScreen); SelectObject(hdcSrc, iconInfo.hbmMask); // 注意:单色位图直接复制可能显示异常,这里仅为示例。 BitBlt(hdcMem, 0, 0, GetSystemMetrics(SM_CXCURSOR), GetSystemMetrics(SM_CYCURSOR), hdcSrc, 0, 0, SRCCOPY); DeleteDC(hdcSrc); DeleteDC(hdcMem); ReleaseDC(NULL, hdcScreen); DeleteObject(iconInfo.hbmMask); } // 清理另一个位图句柄(如果存在且未被使用) if (iconInfo.hbmMask && iconInfo.hbmMask != (HBITMAP)iconInfo.hbmColor) { DeleteObject(iconInfo.hbmMask); } // hbmColor 已在上面被删除,这里不需要再操作 return hBitmapToReturn; }

关键点解释:

  1. GetIconInfo: 这个函数获取光标(或图标)的信息,并创建两个位图:hbmMask(单色掩码)和hbmColor(彩色位图)。调用者必须负责删除这两个位图对象,否则会造成资源泄漏。
  2. 资源管理: 代码中在复制了需要的位图数据后,立即使用DeleteObject删除了GetIconInfo创建的原始位图。我们返回的是新创建的位图副本。
  3. 单色光标: 对于传统的单色光标(如箭头、I型光束),其形状信息完全存储在hbmMask中。完整的透明绘制需要将hbmMask分为 AND 掩码和 XOR 掩码两部分,并使用BitBlt两次操作(SRCANDSRCPAINT)来实现。为了简化示例,我们直接复制了掩码位图,这在实际中可能显示为黑白块。生产代码需要处理这部分逻辑。

3.2 在指定位置绘制光标位图

有了光标位图后,我们就可以在屏幕 DC 的任意位置绘制它了。为了处理透明效果,我们实现一个支持掩码的绘制函数。

void DrawCursorAtPoint(HDC hdcDest, int x, int y) { // 1. 获取当前系统光标 CURSORINFO cursorInfo = { 0 }; cursorInfo.cbSize = sizeof(CURSORINFO); if (!GetCursorInfo(&cursorInfo)) { return; } // 2. 从光标句柄获取位图 HBITMAP hCursorBmp = GetCursorBitmap(cursorInfo.hCursor); if (!hCursorBmp) { return; } // 3. 获取光标标准尺寸 int cursorWidth = GetSystemMetrics(SM_CXCURSOR); int cursorHeight = GetSystemMetrics(SM_CYCURSOR); // 4. 创建内存 DC 用于操作位图 HDC hdcMem = CreateCompatibleDC(hdcDest); SelectObject(hdcMem, hCursorBmp); // 5. 关键:实现透明绘制(模拟掩码法) // 假设我们获取的是已经处理好透明信息的彩色位图(例如32位带Alpha)。 // 对于简单的掩码光标,需要更复杂的步骤(AND, THEN XOR)。 // 此处使用 SRCCOPY 直接绘制,适用于某些情况,但不是真正的透明。 // 更正确的方法是使用 `TransparentBlt` (需要 `msimg32.lib`) 或 GDI+。 BitBlt(hdcDest, x, y, cursorWidth, cursorHeight, hdcMem, 0, 0, SRCCOPY); // 6. 清理资源 DeleteDC(hdcMem); DeleteObject(hCursorBmp); }

绘制逻辑说明:

  1. GetCursorInfo: 获取当前系统光标的实时信息,包括其句柄hCursor
  2. 调用GetCursorBitmap将光标句柄转换为我们可以操作的HBITMAP
  3. 使用CreateCompatibleDC创建一个与目标 DC(屏幕)兼容的内存 DC,并将位图选入其中。这是 GDI 操作位图的标准步骤。
  4. BitBlt执行实际的像素传输。这里使用了SRCCOPY操作码,意味着直接将源位图的像素复制到目标位置,覆盖原有内容。这不是真正的透明绘制
  5. 最后,务必删除创建的内存 DC 和位图对象,释放资源。

3.3 实现真正的透明绘制(掩码法)

对于不支持 Alpha 通道的旧式光标,需要使用其掩码信息进行两次BitBlt操作来实现透明效果。这需要从GetIconInfo获取的hbmMask中分离出 AND 和 XOR 掩码。以下是改进后的DrawCursorAtPoint函数的核心部分:

// ... 获取 cursorInfo 和 iconInfo ... // 假设 iconInfo.hbmMask 包含 AND 和 XOR 掩码(高度是光标的两倍) if (iconInfo.hbmMask && !iconInfo.hbmColor) { // 单色光标 int maskHeight = cursorHeight * 2; // 掩码位图高度是光标的两倍 HDC hdcMask = CreateCompatibleDC(hdcDest); SelectObject(hdcMask, iconInfo.hbmMask); // 第一次 Blt: AND 操作 (SRCAND),将目标区域与掩码的 AND 部分进行“与”操作 // AND 掩码是 hbmMask 的上半部分(白色区域透明,黑色区域保留背景) BitBlt(hdcDest, x, y, cursorWidth, cursorHeight, hdcMask, 0, 0, SRCAND); // 第二次 Blt: XOR 操作 (SRCPAINT),将目标区域与掩码的 XOR 部分进行“或”操作 // XOR 掩码是 hbmMask 的下半部分(黑色区域透明,白色区域绘制光标形状) BitBlt(hdcDest, x, y, cursorWidth, cursorHeight, hdcMask, 0, cursorHeight, SRCPAINT); DeleteDC(hdcMask); DeleteObject(iconInfo.hbmMask); } else if (iconInfo.hbmColor) { // 彩色光标 - 使用更简单的透明方法或 AlphaBlend HDC hdcColor = CreateCompatibleDC(hdcDest); SelectObject(hdcColor, iconInfo.hbmColor); // 对于32位带Alpha的位图,应使用 AlphaBlend (GDI+) 或 UpdateLayeredWindow BitBlt(hdcDest, x, y, cursorWidth, cursorHeight, hdcColor, 0, 0, SRCCOPY); DeleteDC(hdcColor); DeleteObject(iconInfo.hbmColor); }

掩码法原理:

  • AND 掩码: 定义了哪些像素是透明的。白色(1)表示透明(目标像素不变),黑色(0)表示不透明(目标像素被清除为黑色)。
  • XOR 掩码: 定义了光标的具体形状和颜色。与经过 AND 操作后的目标区域进行 XOR 运算,最终显示出光标。
  • 这种两步法在早期 Windows 中广泛用于实现透明光标和图标。

4. 整合与运行:在随机位置绘制光标

现在,将上述函数整合到最初的main函数框架中。完整的程序流程如下:

  1. 初始化随机数种子。
  2. 获取屏幕分辨率。
  3. 循环数次(例如5次): a. 生成屏幕范围内的随机坐标 (x, y)。 b. 调用GetDC(NULL)获取屏幕 DC。 c. 调用DrawCursorAtPoint(hdcScreen, x, y)在该位置绘制光标。 d. 释放屏幕 DC。 e. 短暂休眠,以便观察。
  4. 程序结束。

运行效果:编译并运行程序,你会看到系统光标(例如箭头)的图案在屏幕的五个随机位置快速闪现(每次停留约0.5秒)。请注意,这是“绘制”上去的图像,不会影响物理鼠标的实际位置和操作。

5. 常见问题、错误排查与优化

在实际编码和运行过程中,你可能会遇到以下问题:

5.1 编译与链接错误

错误现象可能原因解决方案
undefined reference to ‘GetCursorInfo’等链接错误未链接必要的 Windows 库。在项目设置或编译命令中添加-luser32 -lgdi32(MinGW)或在 VS 的附加依赖项中添加user32.lib; gdi32.lib
error C2065: ‘CURSORINFO’ : undeclared identifier开发环境较旧,或未定义WINVER等宏。确保#include <windows.h>在最前面。对于较新 API,在包含头文件前定义#define WINVER 0x0500(或更高,如0x0601对应 Win7)。
程序编译成功但运行后无任何效果程序可能瞬间执行完毕退出。main函数末尾或循环内添加Sleepgetchar()暂停程序,以便观察绘制效果。

5.2 运行时问题与排查

问题现象可能原因检查与处理建议
绘制出的光标是黑色方块或不透明方块1. 位图复制逻辑错误。
2. 透明处理未生效(使用了SRCCOPY且位图背景非黑)。
3. 获取到的hbmColor为空,且hbmMask处理不当。
1. 调试GetCursorBitmap,检查GetIconInfo的返回值以及hbmColorhbmMask哪个有效。
2. 对于单色光标,务必使用AND 后 XOR的两步掩码法绘制。
3. 尝试使用DrawIconDrawIconExAPI 直接绘制图标,它们内部处理了透明。
绘制位置严重偏移未考虑光标热点(Hot Spot)。GetIconInfo返回的iconInfo.xHotspoticonInfo.yHotspot是热点相对于光标图像左上角的偏移。在调用绘制函数时,对坐标进行修正:drawX = targetX - iconInfo.xHotspot; drawY = targetY - iconInfo.yHotspot;
绘制操作破坏了桌面背景,留下残影绘制方式是完全覆盖(SRCCOPY),且程序没有负责“擦除”。如果目的是临时指示,可以考虑先保存目标区域的屏幕截图,绘制光标,显示一段时间后再恢复原图。或者使用分层窗口(Layered Window)技术,它支持 Alpha 混合且不影响底层窗口。
在多显示器系统中,绘制位置不正确GetSystemMetrics(SM_CXSCREEN)可能只返回主显示器分辨率。GetDC(NULL)获取的是整个虚拟屏幕的 DC。使用GetSystemMetrics(SM_CXVIRTUALSCREEN)SM_CYVIRTUALSCREEN获取整个虚拟屏幕的大小。对于具体显示器坐标,需要用到多显示器 API(EnumDisplayMonitors)。

5.3 代码优化与最佳实践

  1. 资源泄漏预防:GDI 对象(HDC,HBITMAP,HPEN等)是稀缺资源。务必保证每一个CreateCompatibleDC,CreateBitmap等都有对应的DeleteDCDeleteObject。使用 RAII(资源获取即初始化)思想封装这些句柄是 C++ 中的好习惯。
  2. 错误检查:所有 Win32 API 调用(如GetCursorInfo,GetIconInfo,BitBlt)都应检查返回值,并在失败时进行适当处理(记录日志、使用默认值、优雅退出)。
  3. 性能考虑:频繁调用GetDC(NULL)和进行屏幕绘制是相对耗时的操作。避免在 tight loop 中这样做。如果需要在屏幕上持续显示一个自定义光标,考虑使用一个无边框、透明的顶级窗口,并在其窗口过程中绘制。
  4. 使用更现代的 API
    • DrawIconEx:这个函数可以直接在指定的 DC 上绘制图标或光标,并自动处理透明和热点。在很多情况下,它比手动操作位图更简单可靠。
    // 更简单的绘制方法 DrawIconEx(hdcScreen, x, y, cursorInfo.hCursor, 0, 0, 0, NULL, DI_NORMAL);
    • GDI+:对于需要复杂 Alpha 混合、抗锯齿或高质量图像缩放的情况,GDI+ 是更好的选择。它提供了Graphics类和Image类,能更好地处理带 Alpha 通道的 PNG 等图像格式。
    • Direct2D:对于高性能、高质量的 2D 图形渲染,尤其是在现代 UI 应用中,Direct2D 是微软推荐的替代 GDI 的方案。

6. 扩展方向与实际应用

掌握了在屏幕上绘制光标的基本方法后,你可以将其应用到更丰富的场景中:

  1. 屏幕标注与演示工具:在屏幕共享或教学时,绘制一个醒目的、自定义形状的光标(如圆圈、箭头)来指示重点。
  2. 自动化测试与脚本可视化:在运行自动化测试脚本时,在屏幕上绘制一个“虚拟鼠标”来实时显示脚本当前的操作位置,增强可观测性。
  3. 远程协助指示器:在远程控制软件中,除了控制端的光标,可以在被控端屏幕上绘制一个额外的、半透明的光标来指示控制者的关注点。
  4. 自定义光标系统:完全接管系统的光标绘制,实现全局的、皮肤化的光标效果(这需要更底层的钩子技术,如SetWindowsHookEx设置WH_GETMESSAGEWH_MOUSE_LL钩子,并配合SetCursor)。
  5. 游戏与交互应用:在 DirectX 或 OpenGL 渲染的游戏/应用中,如果需要绘制一个独立的、不受系统光标限制的指示器,也可以借鉴此思路,在渲染层叠加一个光标纹理。

关于“Kali 没有鼠标光标”的延伸思考:这个搜索热词反映的是在 Kali Linux 等无图形界面或纯命令行环境下,鼠标光标不显示的问题。这与我们在 Windows 下用 GDI 绘制光标是不同层面的问题。在 Linux 终端下,“光标”通常指文本插入点,由终端模拟器或图形服务器的鼠标指针驱动管理。如果遇到此类问题,应检查 X Window System 是否正常运行、鼠标驱动是否加载、输入设备配置是否正确。而本文的技术,则可以用于在 Windows 环境下,为某些全屏应用(如游戏、虚拟机)或远程会话,创建一个辅助的、可见的指针指示。

最后,请记住,直接向屏幕 DC 绘制是一种强大但“粗鲁”的方式,它会覆盖屏幕上的其他内容。在生产级工具中,应优先考虑使用分层窗口、透明窗口或 Hook 技术来实现更稳定、更友好、性能更好的屏幕绘制效果。本文提供的代码是一个教学起点,帮助你理解 GDI 屏幕操作的基本原理,在实际项目中请根据具体需求和约束进行设计和优化。

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

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

立即咨询