ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C#截屏实战:从GDI到BitBlt,解决DPI缩放与光标捕获的完整方案

C#截屏实战:从GDI到BitBlt,解决DPI缩放与光标捕获的完整方案 简介这是一份基于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# 里截屏无非两条路线托管代码直接调用System.Drawing的Graphics.CopyFromScreen适合简单全屏或区域截取。通过 P/Invoke 调用 Win32 GDI 函数BitBlt、PrintWindow、GetDC等灵活性和底层控制力更强能解决光标、窗口遮挡等问题。我的一个习惯是先做一个小而稳的核心截屏类暴露一个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配合GetIconInfo和DrawIcon来实现。BitBlt是 Win32 GDI 里一个非常经典的位图传输函数它把源设备上下文中的一块矩形像素复制到目标设备上下文中。C# 里通过 P/Invoke 调用它可以获得比CopyFromScreen更细粒度的控制。这个方案的典型场景是做录屏或截图工具用户希望截屏时鼠标指针的形状也保留下来。实现时需要先截取屏幕背景再用GetCursorPos拿到光标位置同时用GetIconInfo获取当前鼠标指针的位图和热点位置最后用DrawIcon把光标画到图像上。我把完整代码放在下一节讲。2.3 PrintWindow后台窗口也能截如果需求是“窗口被遮挡了但我还是要截到这个窗口的完整内容”BitBlt和CopyFromScreen都无能为力。这时候要用PrintWindow它向窗口发送WM_PRINT或WM_PRINTCLIENT消息让窗口自己把内容绘制到指定的设备上下文里。PrintWindow的缺点是某些窗口特别是用了硬件加速的 DirectX 应用不会响应这个绘制请求截出来是黑屏或空白。这种情况下可以尝试设置PW_RENDERFULLCONTENT标志Windows 8.1 以后支持或者退而求其次用BitBlt截取窗口在屏幕上的可见区域。2.4 三方案对比速查方案截取整屏截取区域包含光标后台被遮挡窗口实现复杂度CopyFromScreen支持支持不支持不支持低BitBltDrawIcon支持支持支持不支持中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; } }这里有几个注意点BitBlt的最后一个参数rop填0x00CC0020也就是SRCCOPY意思是直接把源像素复制到目标区域。GetDC(IntPtr.Zero)获取的是整个屏幕的设备上下文它适用于所有显示器组合成的虚拟桌面如果你只想截主屏可以用Screen.PrimaryScreen.Bounds来限定。结构体布局必须用[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才表示光标当前是显示状态否则可能是隐藏状态不需要绘制。xHotspot和yHotspot是光标热点的位置意思是鼠标实际点击位置在图标内的偏移量。如果不减去它画出来的光标会偏。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操作的是物理像素两者不一致就会导致截出来的图区域偏移或者只有一部分。处理办法有两个在项目清单文件里声明 PerMonitorV2 DPI 感知application xmlnsurn:schemas-microsoft-com:asm.v3 windowsSettings dpiAwareness xmlnshttp://schemas.microsoft.com/SMI/2016/WindowsSettingsPerMonitorV2/dpiAwareness /windowsSettings /application通过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 帧会暴露两个问题Bitmap 和 Graphics 对象没有释放内存暴涨。因为Bitmap底层占的是非托管内存必须调用Dispose()。我见过有人边截屏边内存上涨最后程序直接 OOM。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把当前时间和进程名画在图片角落这种“盖章式”记录在排查问题和追溯数据时特别管用比单独存一个日志文件直观得多。我做过的好几个项目最后都是靠截图角落上的时间戳定位到了问题发生的精确时刻。本文还有配套的精品资源点击获取
返回列表