C#截屏实战:从GDI到BitBlt,解决DPI缩放与光标捕获的完整方案
2026/9/7 9:46:00 网站建设 项目流程

简介:这是一份基于C#开发的Windows截屏功能示例项目,适合刚接触桌面应用开发或希望实现屏幕捕获、区域裁剪、图像标注与上传等功能的C#学习者。项目围绕System.Drawing命名空间,覆盖全屏截图、鼠标拖拽选取自定义区域、Graphics对象绘制线条注释、Bitmap保存JPEG/PNG,以及通过HttpClient将图片上传至服务器等完整流程,具备清晰的模块划分和可扩展结构。压缩包共36个文件,核心代码集中在若干cs源文件中,同时包含sln工程配置、resx资源文件、exe可执行程序及dll依赖,便于直接打开运行或二次修改,整体大小约820KB。资源已有1600余人学习使用,既可作为初学者理解C#图形处理、事件监听与网络通信的实战参考,也可作为开发者快速定制截屏工具的基础框架。 作为一个常年跟 Windows 桌面应用打交道的人,我做过不少截屏功能:有给上位机做画面记录用的,有做远程协助时传输画面用的,还有专门截取某个窗口内容用于自动化测试的。C# 里实现截屏,看起来就是调几个 API 的事,但真正落地时会遇到 DPI 缩放、光标捕获、窗口遮挡、性能这些坑。这篇就把我常用的几种方案和实战经验整理出来,希望能帮到正在做类似功能的你。

1. 截屏功能的需求场景与整体设计思路

先聊需求。截屏这事,在不同项目里的用法完全不一样,我简单归纳一下:

  • 上位机画面存档:工业现场需要定期保存当前界面状态,用于追溯或告警记录。
  • 远程协助/画面共享:抓取当前屏幕帧,压缩后发送给另一端。
  • 自动化测试:在指定窗口上执行操作后,截取画面用于断言比对。
  • 系统监控:定时截取桌面,分析是否存在异常弹窗或卡顿画面。

不同场景对截屏的要求不同:有的只要能截到就行,有的要截特定窗口,有的必须带上鼠标光标,还有的要保证在后台也能截到完整画面。所以我的建议是:不要一开始就写死一种方案,先把项目对“截什么区域、是否含光标、是否需要后台窗口内容、多高的频率”这几个问题定下来,再选实现方式。

整体设计上,C# 里截屏无非两条路线:

  1. 托管代码直接调用System.DrawingGraphics.CopyFromScreen,适合简单全屏或区域截取。
  2. 通过 P/Invoke 调用 Win32 GDI 函数(BitBltPrintWindowGetDC等),灵活性和底层控制力更强,能解决光标、窗口遮挡等问题。

我的一个习惯是:先做一个小而稳的核心截屏类,暴露一个Capture(Rectangle region)方法,返回Bitmap,上层再封装“保存文件”“定时截屏”“压缩编码”等逻辑。这样后续换方案时不用动业务代码,只需要改内部实现。

2. 三种主流截屏方案对比与选型

2.1 Graphics.CopyFromScreen:最简单但不够灵活

这是最容易上手的方式,核心代码只有几行:

using System.Drawing; using System.Drawing.Imaging; public Bitmap CaptureScreen() { Rectangle bounds = Screen.PrimaryScreen.Bounds; Bitmap bmp = new Bitmap(bounds.Width, bounds.Height, PixelFormat.Format32bppArgb); using (Graphics g = Graphics.FromImage(bmp)) { g.CopyFromScreen(bounds.X, bounds.Y, 0, 0, bounds.Size); } return bmp; }

它的本质是调用 GDI 的BitBlt,从屏幕设备上下文中复制像素到目标 Bitmap。优点是代码量极小、不需要额外声明复杂的 API;缺点是它只能截取屏幕当前显示的内容,无法截取被其他窗口遮挡住的窗口画面,也无法默认带上鼠标光标,而且在多显示器、高 DPI 场景下坐标容易算错。

我一般只在“快速验证功能”时用它,生产环境我更倾向于下面两种。

2.2 BitBlt + 鼠标光标:底层可靠方案

要解决“光标能不能一起截进去”这个问题,就得用BitBlt配合GetIconInfoDrawIcon来实现。BitBlt是 Win32 GDI 里一个非常经典的位图传输函数,它把源设备上下文中的一块矩形像素复制到目标设备上下文中。C# 里通过 P/Invoke 调用它,可以获得比CopyFromScreen更细粒度的控制。

这个方案的典型场景是做录屏或截图工具,用户希望截屏时鼠标指针的形状也保留下来。实现时需要先截取屏幕背景,再用GetCursorPos拿到光标位置,同时用GetIconInfo获取当前鼠标指针的位图和热点位置,最后用DrawIcon把光标画到图像上。我把完整代码放在下一节讲。

2.3 PrintWindow:后台窗口也能截

如果需求是“窗口被遮挡了,但我还是要截到这个窗口的完整内容”,BitBltCopyFromScreen都无能为力。这时候要用PrintWindow,它向窗口发送WM_PRINTWM_PRINTCLIENT消息,让窗口自己把内容绘制到指定的设备上下文里。

PrintWindow的缺点是某些窗口(特别是用了硬件加速的 DirectX 应用)不会响应这个绘制请求,截出来是黑屏或空白。这种情况下可以尝试设置PW_RENDERFULLCONTENT标志(Windows 8.1 以后支持),或者退而求其次用BitBlt截取窗口在屏幕上的可见区域。

2.4 三方案对比速查

方案截取整屏截取区域包含光标后台被遮挡窗口实现复杂度
CopyFromScreen支持支持不支持不支持
BitBlt+DrawIcon支持支持支持不支持
PrintWindow间接支持间接支持不支持支持中高

我的选型原则是:默认用 BitBlt 作为底座,既能控制光标又足够稳定;遇到需要截后台窗口时,再在核心类里增加 PrintWindow 分支。

3. 完整实现:一个可复用的截屏核心类

下面是我实际项目里用的一套代码,包含了整屏截取、指定区域截取、包含鼠标光标截取和定时保存。你可以把它直接拿过去改改用。

3.1 引入所需命名空间与 P/Invoke 声明

using System; using System.Drawing; using System.Drawing.Imaging; using System.Runtime.InteropServices; public class ScreenCapturer { // 获取当前鼠标光标的位置 [DllImport("user32.dll")] private static extern bool GetCursorPos(out POINT lpPoint); // 获取当前光标图标的信息 [DllImport("user32.dll")] private static extern bool GetIconInfo(IntPtr hIcon, out ICONINFO piconinfo); // 获取当前系统的光标句柄 [DllImport("user32.dll")] private static extern IntPtr CopyIcon(IntPtr hIcon); // 销毁图标句柄 [DllImport("user32.dll")] private static extern bool DestroyIcon(IntPtr hIcon); // 源DC/目标DC与位图数据转换 [DllImport("gdi32.dll")] private static extern bool BitBlt(IntPtr hdcDest, int xDest, int yDest, int wDest, int hDest, IntPtr hdcSrc, int xSrc, int ySrc, int rop); [DllImport("user32.dll")] private static extern IntPtr GetDC(IntPtr hwnd); [DllImport("user32.dll")] private static extern int ReleaseDC(IntPtr hwnd, IntPtr hdc); [StructLayout(LayoutKind.Sequential)] public struct POINT { public int X; public int Y; } [StructLayout(LayoutKind.Sequential)] public struct ICONINFO { public bool fIcon; public int xHotspot; public int yHotspot; public IntPtr hbmMask; public IntPtr hbmColor; } }

这里有几个注意点:

  1. BitBlt的最后一个参数rop0x00CC0020,也就是SRCCOPY,意思是直接把源像素复制到目标区域。
  2. GetDC(IntPtr.Zero)获取的是整个屏幕的设备上下文,它适用于所有显示器组合成的虚拟桌面;如果你只想截主屏,可以用Screen.PrimaryScreen.Bounds来限定。
  3. 结构体布局必须用[StructLayout(LayoutKind.Sequential)]保证内存排列与 Win32 一致,否则数据错乱。

3.2 整屏与指定区域截取实现

public Bitmap Capture(Rectangle region) { IntPtr hdcSrc = GetDC(IntPtr.Zero); Bitmap bmp = new Bitmap(region.Width, region.Height, PixelFormat.Format32bppArgb); using (Graphics g = Graphics.FromImage(bmp)) { IntPtr hdcDest = g.GetHdc(); BitBlt(hdcDest, 0, 0, region.Width, region.Height, hdcSrc, region.X, region.Y, 0x00CC0020); g.ReleaseHdc(hdcDest); } ReleaseDC(IntPtr.Zero, hdcSrc); return bmp; } public Bitmap CaptureFullScreen(bool includeCursor = false) { Rectangle bounds = GetVirtualScreenBounds(); Bitmap bmp = Capture(bounds); if (includeCursor) { DrawCursor(bmp); } return bmp; }

GetVirtualScreenBounds()要考虑多显示器场景,把所有屏幕的Bounds合并成一个矩形:

private Rectangle GetVirtualScreenBounds() { Rectangle bounds = Rectangle.Empty; foreach (var screen in Screen.AllScreens) { bounds = Rectangle.Union(bounds, screen.Bounds); } return bounds; }

如果不做这一步,多显示器环境下只能截到主屏,容易踩坑。

3.3 将鼠标光标画到截图上

这部分是“截屏怎么包括鼠标”这个需求的核心实现:

private void DrawCursor(Bitmap bmp) { CURSORINFO cursorInfo = new CURSORINFO(); cursorInfo.cbSize = Marshal.SizeOf(typeof(CURSORINFO)); if (!GetCursorInfo(ref cursorInfo)) return; if (cursorInfo.flags != 0x00000001) return; // CURSOR_SHOWING IntPtr hIcon = CopyIcon(cursorInfo.hCursor); if (hIcon == IntPtr.Zero) return; try { ICONINFO iconInfo; if (GetIconInfo(hIcon, out iconInfo)) { int x = cursorInfo.ptScreenPos.X - iconInfo.xHotspot; int y = cursorInfo.ptScreenPos.Y - iconInfo.yHotspot; using (Graphics g = Graphics.FromImage(bmp)) { g.DrawIcon(Icon.FromHandle(hIcon), x, y); } } } finally { DestroyIcon(hIcon); } }

需要补充两个结构体声明:

[StructLayout(LayoutKind.Sequential)] public struct CURSORINFO { public int cbSize; public int flags; public IntPtr hCursor; public POINT ptScreenPos; } [DllImport("user32.dll")] private static extern bool GetCursorInfo(ref CURSORINFO pci);

这里说明几个关键点:

  • flags等于0x00000001CURSOR_SHOWING)才表示光标当前是显示状态,否则可能是隐藏状态,不需要绘制。
  • xHotspotyHotspot是光标热点的位置,意思是鼠标实际点击位置在图标内的偏移量。如果不减去它,画出来的光标会偏。
  • CopyIcon是为了复制一份光标句柄,防止使用过程中被系统释放,用完记得DestroyIcon释放。

3.4 定时截屏并保存到文件

实际项目中经常需要“间隔几秒截一张图”的功能,我一般用一个System.Threading.Timer或者BackgroundWorker来做,避免阻塞 UI 线程:

public class ScreenRecorder : IDisposable { private ScreenCapturer _capturer = new ScreenCapturer(); private Timer _timer; private string _outputDir; public void Start(int intervalMilliseconds, string outputDir) { _outputDir = outputDir; if (!Directory.Exists(_outputDir)) { Directory.CreateDirectory(_outputDir); } _timer = new Timer(_ => { var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss_fff"); var bmp = _capturer.CaptureFullScreen(includeCursor: true); var path = Path.Combine(_outputDir, $"screen_{timestamp}.png"); bmp.Save(path, ImageFormat.Png); bmp.Dispose(); }, null, 0, intervalMilliseconds); } public void Dispose() { _timer?.Dispose(); } }

保存格式我建议用 PNG,因为屏幕截图通常是文字和色块,PNG 无损压缩在同类内容下体积控制很好,而 JPEG 反而会在文字边缘出现明显噪声。如果对体积敏感,可以考虑 PNG 后再用System.IO.Compression压缩,或者转换成 PNG-8。

4. 实操中遇到的坑与排查心得

4.1 高DPI缩放导致截屏区域偏移

这是我在 Win10/Win11 上踩过最频繁的坑。系统显示缩放设置为 125% 或 150% 时,很多程序被系统“虚拟化”了,Screen.Bounds返回的坐标是逻辑像素,而BitBlt操作的是物理像素,两者不一致就会导致截出来的图区域偏移或者只有一部分。

处理办法有两个:

  1. 在项目清单文件里声明 PerMonitorV2 DPI 感知:
<application xmlns="urn:schemas-microsoft-com:asm.v3"> <windowsSettings> <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness> </windowsSettings> </application>
  1. 通过GetDpiForWindow动态获取当前 DPI,对坐标做换算。

实际中我在工具类里直接用 P/Invoke 方式判断进程是否支持 DPI 感知,如果不支持就自动调整区域坐标,保证在不同终端上行为一致。

4.2 截取窗口时遇到黑屏

PrintWindow截取窗口,如果目标窗口是 DirectX 硬件加速渲染(游戏、视频播放器、某些高版本地图控件),截出来经常是黑屏。此时可以尝试添加PW_RENDERFULLCONTENT标志,原型是 0x00000002:

[DllImport("user32.dll")] private static extern bool PrintWindow(IntPtr hwnd, IntPtr hdcBlt, uint nFlags); // 调用时 PrintWindow(hwnd, hdcDest, 0x00000002);

如果加了标志还是黑屏,那就只能退化成“截取窗口当前可见区域”,这个方案至少能保证用户看到什么就截到什么。还有一种极端情况,窗口被最小化时PrintWindow也拿不到内容,我会先通过ShowWindow临时恢复窗口再截,不过这种操作要谨慎,容易干扰用户。

4.3 频繁截屏时的性能与泄漏问题

截屏这个动作看起来不重,但高频执行(比如每秒 10 帧)会暴露两个问题:

  1. Bitmap 和 Graphics 对象没有释放,内存暴涨。因为Bitmap底层占的是非托管内存,必须调用Dispose()。我见过有人边截屏边内存上涨,最后程序直接 OOM。
  2. GDI 句柄泄漏。每调用一次GetDC就要对应一次ReleaseDC,每CopyIcon一次就要DestroyIcon,一旦泄漏,任务管理器里的 GDI 对象数会一直往上走,最终导致画面绘制异常。

我的经验是写一个简单的封装,把 Bitmap 的创建和释放都用using包裹,同时在开发环境用性能监视器观察 GDI 对象数量变化,一旦发现持续上升就能及时定位。

4.4 常见问题速查表

问题现象排查方向
截屏区域偏移截出来的图不是想要的区域检查 DPI 感知设置,检查多显示器坐标合并
截屏黑屏窗口内容是黑的或空白换 PrintWindow,加 PW_RENDERFULLCONTENT,检查是否硬件加速
鼠标指针缺失截图中没有光标检查 DrawCursor 调用,检查 flags 是否为 CURSOR_SHOWING
内存稳步上涨程序长时间运行后内存变大检查 Bitmap/Graphics 是否 Dispose,检查 GDI 句柄是否泄漏
多屏只能截主屏副屏幕内容不在截图里使用 System.Windows.Forms.Screen.AllScreens 合并边界
截屏偶尔延迟高频截屏时卡顿避免同步保存文件,先放队列后异步写盘

还有一个容易被忽略的点:如果程序以管理员权限运行,截屏时某些安全桌面(比如 UAC 提示界面)是截不到的,这是系统保护机制,强迫不得。

5. 延伸:截屏在自动化测试与上位机中的应用

如果你的需求不止于“截一张图”,而要把截屏用于自动化测试或上位机联动,有几个方向可以参考。

5.1 用于自动化测试的窗口截取

我做 UI 自动化时经常要判断“当前窗口是否显示了某个状态”。可以先通过FindWindow找到窗口句柄,再用PrintWindow截取窗口内容,最后用Bitmap.GetPixel或哈希比对来判断画面是否发生变化。这里要注意:GetPixel在逐像素比对时性能很差,我一般会先缩小图像尺寸,再用不安全的LockBits方式把像素拷贝到字节数组里批量比较。

5.2 上位机里的截屏触发场景

在工业上位机项目中,截屏功能经常和扫码枪事件、报警事件绑定。比如扫码枪触发一个事件时,需要立即抓取当前画面,并把条码信息和截图一起保存到数据库。这种对实时性要求高的场景,我的做法是提前初始化一块可复用的 Bitmap,避免每次触发时重新分配内存,同时把截图保存放到独立线程,不阻塞扫码枪的事件处理链路。

另外,如果你的上位机需要对接海康或其他工业相机,通常相机会自带 SDK 采集图像,而截屏功能主要负责记录上位机软件本身的操作界面,两者互为补充,用途不同,不要混为一谈。

6. 我的经验总结

先说明一下:网上很多截屏教程只会教CopyFromScreen那几行代码,但实际项目里几乎必然会碰到 DPI、光标、黑屏这三座大山。我的建议是,把前面这套基于 BitBlt 的封装类当成你的基础工具,再按需加 PrintWindow 分支,基本能覆盖九成需求。

最后分享一个小技巧:如果你需要在截屏图片上标注时间或来源,可以在保存前用Graphics.DrawString把当前时间和进程名画在图片角落,这种“盖章式”记录在排查问题和追溯数据时特别管用,比单独存一个日志文件直观得多。我做过的好几个项目,最后都是靠截图角落上的时间戳定位到了问题发生的精确时刻。

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

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

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

立即咨询