☰
C#截屏工具实战:BitBlt、多屏DPI与GDI句柄回收
2026/10/24 4:26:09 网站建设 项目流程

简介:面向Windows桌面开发初学者与需要快速搭建截屏工具的开发者,这份C#截屏功能示例项目围绕屏幕捕获与图像处理展开,覆盖全屏截图、自定义区域框选、截图上线条标注、图片本地保存以及上传至服务器等核心环节,并涉及Graphics、Bitmap、Pen、鼠标消息监听与HttpClient网络请求等知识点,对截图区域坐标换算、图像格式选择、文件读写与请求体封装等细节均有涉及。压缩包共36个文件、约820KB,以11个cs源码文件和resx/resources资源文件为主体,辅以sln解决方案、csproj项目配置、exe可执行文件及少量config、pdb调试文件,结构上包含窗体界面、截图裁剪、图像操作与程序入口等模块,便于对照理解完整工程组织。已有1662人学习,适合作为C#图形编程与Windows桌面应用实践案例,也能为有经验者扩展自定义截屏工具提供起点。

1. 截图这件小事:C# 截屏工具真正的难点在哪

一周前,同事让我写一个 C# 截屏小工具:按下快捷键后能框选区域,截完直接进入剪贴板,顺手保存到指定文件夹,最好还能对敏感区域打码。现成软件我也试过几个,要么必须装客户端,要么没法嵌进我们自己的值班流程。最后我决定自己实现——几十行 P/Invoke,加一个透明窗体,编译出一个 exe,复制到任何机器都能跑,不依赖外部组件。好处很明显;坏处是所有细节都得自己处理:多屏坐标、高分屏缩放、GDI 句柄回收,一个都不能漏。下面按我拆解过的这个项目,从技术选型一路讲到二次开发。

2. 技术路线与工程骨架:GDI+、BitBlt、WGC 到底选哪个

动手之前,先把“用什么抓屏幕”定下来。这一步选错,后面所有代码都要重写,而且换方案的成本比你想象的高——不是替换一个方法那么简单,选区覆盖层、坐标换算、保存链路都跟抓取方式绑在一起。

2.1 四种抓取方式的取舍

C# 在 Windows 上抓屏,主流就四条路,我按实际踩过的顺序列一下。

第一条是Graphics.CopyFromScreen,System.Drawing 自带,一行代码就能出图。它的内部实现就是 BitBlt,但每次调用都新建一个Graphics对象、拿一次屏幕 DC,你没机会控制 ROP 码,也没法决定 DC 什么时候释放。写 Demo 够用,集成进常驻程序就容易出问题。

第二条是直接 P/InvokeBitBlt,自己管GetDC/CreateCompatibleDC/SelectObject这套 GDI 流程。这是我最终选的方案:代码量比第一条多二十行,但每一次句柄创建都有对应的释放点,出问题能查。缺点也很明确——抓不到鼠标指针,抓不到受保护内容(部分播放器、带 DRM 的窗口),遇到独占全屏基本只能抓到黑画面。

第三条是PrintWindow,可以抓被其他窗口遮挡的目标窗口,配合PW_RENDERFULLCONTENT = 0x2对部分 Direct3D 渲染的窗口也有效。它解决的是“抓某个窗口”而不是“抓某块屏幕”,跟框选截图是两条业务线,我一般把它放在另一个功能入口里。

第四条是 Win10 1803 之后提供的Windows.Graphics.Capture,走 DWM 合成器,能抓到绝大多数内容,包括一部分 BitBlt 抓不到的画面。代价是要建 D3D11 设备、处理帧池、订阅GraphicsCaptureItem.Closed事件,代码量直接翻几倍。要不要上,看你的容忍度。

方案代码量能否抓遮挡窗口能否抓硬件加速画面适用场景
CopyFromScreen极低否部分一次性 Demo、脚本
BitBlt + P/Invoke低否部分常规桌面截图、轻量常驻工具
PrintWindow中是部分(需 0x2 标志)单窗口抓取
Windows.Graphics.Capture高是多数录屏、需要抓现代渲染内容的场景

项目的骨架就是围绕第二条路搭的,同时留了一个接口,方便后面换成第四条。

2.2 目录结构与分层

我把它拆成四层:抓取、交互、服务、界面。抓取层只负责“给一块矩形,返回一张 Bitmap”,不关心谁来调用;交互层是一个无边框透明窗体,只负责收集鼠标拉出来的矩形;服务层管快捷键、保存、托盘常驻。

ScreenCapture/ ├─ ScreenCapture.csproj ├─ app.manifest # DPI 感知声明,必须放在这里 ├─ Program.cs # 入口,STAThread ├─ Capture/ │ ├─ IScreenCapturer.cs # 抽象:Rectangle -> Bitmap │ ├─ GdiScreenCapturer.cs # BitBlt 实现 │ └─ NativeMethods.cs # 所有 P/Invoke 集中在这 ├─ Forms/ │ ├─ MainForm.cs # 主窗口 / 托盘菜单 │ └─ OverlayForm.cs # 选区覆盖层 ├─ Services/ │ ├─ HotkeyService.cs # RegisterHotKey 封装 │ ├─ SaveService.cs # 落盘 + 剪贴板 │ └─ TrayService.cs # 托盘图标与菜单 └─ Assets/ └─ app.ico

把 P/Invoke 全部收进NativeMethods.cs是个小习惯,但收益不小:出问题时只需要看这一个文件,不用在十几个窗体里翻DllImport。抽象接口则是为了可测试——IScreenCapturer可以塞一个返回纯色 Bitmap 的假实现,UI 逻辑就能脱离真实屏幕跑单元测试。

public interface IScreenCapturer { // bounds 使用物理像素坐标,调用方负责换算 Bitmap Capture(Rectangle bounds); }

参数只有一个矩形,但注释里必须写清“物理像素”四个字,后面所有坐标错位的问题都源自这里没说清。

2.3 目标框架与 DPI 清单

框架版本我选net48。理由很实际:目标机器是内网环境,装 .NET 8 桌面运行时要走审批,而 .NET Framework 4.8 在 Win10 以后是系统自带的。如果你的环境没这个限制,net8.0-windows配<UseWindowsForms>true</UseWindowsForms>也没问题,代码一行不用改。

平台目标建议x64,或者AnyCPU但把Prefer32Bit关掉。32 位进程在超大虚拟桌面(比如三块 4K 屏横向拼接)上抓全屏,CreateCompatibleBitmap有概率直接失败,这个毛病排查起来挺费劲。

真正的关键在app.manifest:

<?xml version="1.0" encoding="utf-8"?> <assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1"> <application xmlns="urn:schemas-microsoft-com:asm.v3"> <windowsSettings> <!-- 老系统认这个:true/pm 等于 Per-Monitor --> <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware> <!-- Win10 1703+ 认这个,PerMonitorV2 才能拿到正确的物理像素 --> <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness> </windowsSettings> </application> </assembly>

两行都要写,不是冗余。dpiAware是给老系统兜底的,dpiAwareness才是新系统真正读的那条。只写后者,在某些环境下会被忽略;只写前者,Win10 上拿到的仍然是 V1 行为,跨屏拖动窗口时不会重新计算缩放。

注意:清单文件必须在项目文件里通过<ApplicationManifest>app.manifest</ApplicationManifest>显式指定,光把文件丢进目录不会生效。

3. 核心模块拆解:一次截图从触发到落盘的完整链路

这条链路上有四个环节:算出要抓多大一块、把像素抠出来、让用户框选、把结果送出去。每一环都有一两个必须处理好的细节。

3.1 虚拟桌面尺寸与多屏坐标

单屏时代Screen.PrimaryScreen.Bounds就够了,多屏就得算虚拟桌面。Win32 提供了四个指标:

[DllImport("user32.dll")] private static extern int GetSystemMetrics(int nIndex); private const int SM_XVIRTUALSCREEN = 76; private const int SM_YVIRTUALSCREEN = 77; private const int SM_CXVIRTUALSCREEN = 78; private const int SM_CYVIRTUALSCREEN = 79; public static Rectangle GetVirtualScreenBounds() { int x = GetSystemMetrics(SM_XVIRTUALSCREEN); int y = GetSystemMetrics(SM_YVIRTUALSCREEN); int w = GetSystemMetrics(SM_CXVIRTUALSCREEN); int h = GetSystemMetrics(SM_CYVIRTUALSCREEN); return new Rectangle(x, y, w, h); }

这段代码本身很短,但有两个很容易忽略的点。第一,x和y可能是负数——副屏摆在主屏左边时,虚拟桌面起点就是负坐标,我见过有人在里面套了个Math.Abs,结果左屏截图全部偏移。第二,返回值的单位取决于进程的 DPI 感知级别,只有声明了 PerMonitorV2,这里拿到的才是物理像素,否则是经过系统缩放后的逻辑坐标,跟BitBlt要的参数对不上。

如果需要逐屏处理,用Screen.AllScreens遍历,每个Screen.Bounds同样是物理像素。混合 DPI 环境(一块 4K 一块 1080p)下,我建议给每块屏单独创建一个覆盖层窗体,而不是搞一个横跨整个虚拟桌面的大窗体——WinForms 的窗体只能有一个 DPI 上下文,跨越两块不同缩放的屏幕时会被系统按其中一块拉伸,选区坐标立刻错位。

3.2 BitBlt 抓像素并封装成 Bitmap

这是整个项目的核心,四十行不到,但每个finally都不能省。

internal static class NativeMethods { [DllImport("user32.dll", SetLastError = true)] public static extern IntPtr GetDC(IntPtr hWnd); [DllImport("user32.dll")] public static extern int ReleaseDC(IntPtr hWnd, IntPtr hDC); [DllImport("gdi32.dll", SetLastError = true)] public static extern IntPtr CreateCompatibleDC(IntPtr hdc); [DllImport("gdi32.dll", SetLastError = true)] public static extern IntPtr CreateCompatibleBitmap(IntPtr hdc, int width, int height); [DllImport("gdi32.dll")] public static extern IntPtr SelectObject(IntPtr hdc, IntPtr hObject); [DllImport("gdi32.dll", SetLastError = true)] public static extern bool BitBlt(IntPtr hdcDest, int xDest, int yDest, int width, int height, IntPtr hdcSrc, int xSrc, int ySrc, int rop); [DllImport("gdi32.dll")] public static extern bool DeleteObject(IntPtr hObject); [DllImport("gdi32.dll")] public static extern bool DeleteDC(IntPtr hdc); public const int SRCCOPY = 0x00CC0020; // 直接拷贝源像素 public const int CAPTUREBLT = 0x40000000; // 把分层窗口一起抓进来 }

SRCCOPY是最常用的光栅操作码,语义就是“原样复制”;CAPTUREBLT是个附加位,带上它才能把带透明效果的窗口层一并捕获,不带的话这类窗口在截图里会变成透明空洞。两个值按位或之后传给rop参数。

抓取实现:

public sealed class GdiScreenCapturer : IScreenCapturer { public Bitmap Capture(Rectangle bounds) { if (bounds.Width <= 0 || bounds.Height <= 0) throw new ArgumentException("截取区域不能为空", nameof(bounds)); IntPtr screenDc = NativeMethods.GetDC(IntPtr.Zero); // 整个屏幕的 DC IntPtr memDc = IntPtr.Zero; IntPtr hBitmap = IntPtr.Zero; IntPtr oldObj = IntPtr.Zero; try { memDc = NativeMethods.CreateCompatibleDC(screenDc); hBitmap = NativeMethods.CreateCompatibleBitmap(screenDc, bounds.Width, bounds.Height); oldObj = NativeMethods.SelectObject(memDc, hBitmap); // 把位图选进内存 DC bool ok = NativeMethods.BitBlt( memDc, 0, 0, bounds.Width, bounds.Height, screenDc, bounds.X, bounds.Y, NativeMethods.SRCCOPY | NativeMethods.CAPTUREBLT); if (!ok) throw new Win32Exception(Marshal.GetLastWin32Error()); // FromHbitmap 会复制一份像素,之后才能安全销毁原句柄 return Image.FromHbitmap(hBitmap); } finally { // 释放顺序必须反过来:先还原旧对象,再删位图,最后删 DC if (oldObj != IntPtr.Zero) NativeMethods.SelectObject(memDc, oldObj); if (hBitmap != IntPtr.Zero) NativeMethods.DeleteObject(hBitmap); if (memDc != IntPtr.Zero) NativeMethods.DeleteDC(memDc); NativeMethods.ReleaseDC(IntPtr.Zero, screenDc); } } }

逐段说一下。GetDC(IntPtr.Zero)拿的是整个屏幕的设备上下文,传IntPtr.Zero表示“整个屏幕”而不是某个窗口句柄。CreateCompatibleDC建一个内存 DC,它本身没有画布,必须靠SelectObject塞一张位图进去才能画。CreateCompatibleBitmap的宽高就是你要抓的区域大小,这里传的是从屏幕 DC 兼容出来的格式,通常是 32 位色。

BitBlt的九个参数可以分成三组:前三组是目标(内存 DC、目标左上角 0,0、宽高),然后一组是源(屏幕 DC、源左上角bounds.X/Y),最后是操作码。源坐标直接用虚拟桌面的绝对坐标,所以负坐标在这里是合法的。

最后Image.FromHbitmap会把非托管位图复制成托管Bitmap,复制完成后原句柄就可以删了。返回对象的像素格式一般是Format32bppRgb,如果要带 alpha 通道,得自己再套一层Format32bppArgb的 Bitmap 重绘一遍,除非你的业务需要透明背景,否则不必。

注意:finally里的释放顺序不能乱。如果先把memDc删了再去DeleteObject(hBitmap),位图会被 DC 持有而泄漏,任务管理器里 GDI 对象数会一路涨。

3.3 半透明选区覆盖层

覆盖层要解决的问题是:让用户看到屏幕,同时知道自己在选哪里。我的做法是三件事——窗体铺满整块屏、背景半透明、选中的区域从窗体 Region 里挖掉。

public sealed class OverlayForm : Form { private Point _start; private Rectangle _current; private bool _dragging; public Rectangle SelectedRegion => _current; public OverlayForm(Screen screen) { FormBorderStyle = FormBorderStyle.None; StartPosition = FormStartPosition.Manual; Bounds = screen.Bounds; // 用物理像素,和抓取层保持一致 TopMost = true; ShowInTaskbar = false; DoubleBuffered = true; BackColor = Color.Black; Opacity = 0.35; // 整个窗体半透明,相当于蒙一层灰 Cursor = Cursors.Cross; } protected override void OnMouseDown(MouseEventArgs e) { _dragging = true; _start = e.Location; // 窗体客户区坐标,相对本屏左上角 _current = Rectangle.Empty; base.OnMouseDown(e); } protected override void OnMouseMove(MouseEventArgs e) { if (_dragging) { _current = Rectangle.FromLTRB( Math.Min(_start.X, e.X), Math.Min(_start.Y, e.Y), Math.Max(_start.X, e.X), Math.Max(_start.Y, e.Y)); // 关键:把选区从 Region 里排除,这块就完全透明,能看见真实桌面 var region = new Region(ClientRectangle); if (_current.Width > 0 && _current.Height > 0) region.Exclude(_current); Region old = Region; Region = region; old?.Dispose(); // 不手动释放会一直累积 GDI 区域句柄 Invalidate(); } base.OnMouseMove(e); } protected override void OnMouseUp(MouseEventArgs e) { _dragging = false; DialogResult = _current.Width > 4 && _current.Height > 4 ? DialogResult.OK : DialogResult.Cancel; Close(); } protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); if (_current.Width <= 0) return; using (var pen = new Pen(Color.DeepSkyBlue, 2)) e.Graphics.DrawRectangle(pen, _current); } }

Opacity是窗体级属性,作用于整个窗口;Region决定了窗口的可见形状。两者组合起来正好是“半透明遮罩 + 选区透明”的效果,比在OnPaint里手动画遮罩、再用CompositingMode.SourceCopy挖洞要干净得多。

Bounds = screen.Bounds用的是屏幕物理坐标,窗体内部鼠标事件给的是客户区相对坐标。因为窗体刚好铺满一整块屏,这个相对坐标加上screen.Bounds.X/Y就是绝对坐标——换算只需要一行加法,放在调用方做。

_current.Width > 4这个判断不是矫情。手一抖点一下,不设阈值就会截出一张 1×1 的图,后面保存、贴图全是麻烦。四像素是我试出来的手感值,你也可以改。

3.4 保存与剪贴板

拿到 Bitmap 之后分两条路:一条进文件,一条进剪贴板。

public static class SaveService { public static string SavePng(Bitmap bmp, string folder) { Directory.CreateDirectory(folder); // 目录不存在会直接抛异常 string name = $"cap_{DateTime.Now:yyyyMMdd_HHmmss_fff}.png"; string path = Path.Combine(folder, name); bmp.Save(path, ImageFormat.Png); // PNG 无损,适合留档 return path; } public static void CopyToClipboard(Bitmap bmp) { // SetImage 内部会复制像素,调用后原 Bitmap 可以放心 Dispose Clipboard.SetImage(bmp); } }

文件名里带毫秒fff是为了连续截图不互相覆盖。我一开始用HHmmss,结果快速按两下快捷键,第二张直接把第一张覆盖了,查了半天才发现不是保存失败。

Clipboard.SetImage必须在 STA 线程调用,WinForms 主线程默认就是 STA(前提是Program.cs上标了[STAThread])。如果你在后台线程拿到图要贴进去,别直接调,用Invoke扔回 UI 线程,否则会抛ThreadStateException。

至于bmp.Save的格式,PNG 体积大但无损,适合存档;JPEG 可以传质量参数,适合发给别人的场景:

// 90 表示质量百分比,值越低文件越小、锯齿越明显 ImageCodecInfo jpg = ImageCodecInfo.GetImageEncoders() .First(c => c.FormatID == ImageFormat.Jpeg.Guid); using (var ep = new EncoderParameters(1)) { ep.Param[0] = new EncoderParameter(Encoder.Quality, 90L); bmp.Save(path, jpg, ep); }

EncoderParameters实现了IDisposable,忘了using同样会泄漏,只是没那么直观。

4. 编译运行与全局快捷键:把项目跑起来

前面讲的是内部实现,这一章讲怎么让它在你的机器上真正运行起来,以及怎么让它在后台待命。

4.1 环境准备与编译

需要的东西不多:Visual Studio 2022(或者只装 Build Tools),.NET Framework 4.8 开发包,没了。不需要任何第三方 NuGet 包,这一点是这个项目最省心的地方。

# 用 dotnet CLI 也能编译,net48 项目同样支持 dotnet restore ScreenCapture.sln dotnet build ScreenCapture.sln -c Release # 产物默认在 bin/Release/net48/ScreenCapture.exe # 单文件分发:把 exe 和 app.manifest 一起拷走即可(manifest 已嵌入)

如果是 Visual Studio,直接打开.sln,把ScreenCapture设为启动项目,F5就行。第一次跑建议先用默认保存路径,也就是“图片”目录,等流程跑通了再改配置。

配置文件我用最简单的方式——同目录下的config.json,用System.Text.Json或者Newtonsoft.Json都行。放三个字段就够:SaveFolder、Hotkey、AutoCopyToClipboard。之所以放外部文件而不是 app.config,是因为运维同事改路径的时候不想重编译。

{ "SaveFolder": "D:\\shots", "Hotkey": "Ctrl+Alt+A", "AutoCopyToClipboard": true }

4.2 第一次截图的验收清单

跑起来之后别急着交付,按这个顺序验一遍,能提前发现八成问题。

第一步,主屏截全屏,看尺寸。用图片查看器打开,右键属性里看像素宽高是否等于你的屏幕分辨率。如果只有一半,说明 DPI 感知没生效,回去检查清单文件有没有被真正编译进去——可以用 Resource Hacker 之类的工具打开 exe 确认。

第二步,把窗口拖到副屏,再截一次。如果副屏在主屏左侧,重点看坐标是不是负数导致的偏移。

第三步,开一个带透明效果的窗口(比如某些播放器悬浮窗),截一次看有没有空洞。有空洞就把CAPTUREBLT加上——上面的代码已经加了,这一步是验证它确实起作用。

第四步,连续快速截十次,同时打开任务管理器,切到“详细信息”页,右键列头把“GDI 对象”和“用户对象”加进去,观察这两个数字。正常情况下它们会在一个小范围里波动然后回落;如果一路单调上涨,就是有句柄没释放。

第五步,改一次配置里的保存路径,重启程序,确认新路径生效。

4.3 全局快捷键与托盘常驻

截图工具不能要求用户先切窗口再按键,所以必须是全局热键。RegisterHotKey是标准做法:

private const int WM_HOTKEY = 0x0312; private const int HOTKEY_ID = 0x9000; private const uint MOD_ALT = 0x0001; private const uint MOD_CONTROL = 0x0002; private const uint MOD_NOREPEAT = 0x4000; // 按住不放不连发 private const uint VK_A = 0x41; [DllImport("user32.dll", SetLastError = true)] private static extern bool RegisterHotKey(IntPtr hWnd, int id, uint fsModifiers, uint vk); [DllImport("user32.dll", SetLastError = true)] private static extern bool UnregisterHotKey(IntPtr hWnd, int id); protected override void OnHandleCreated(EventArgs e) { base.OnHandleCreated(e); // Handle 必须已创建,否则注册无效 if (!RegisterHotKey(Handle, HOTKEY_ID, MOD_CONTROL | MOD_ALT | MOD_NOREPEAT, VK_A)) { int err = Marshal.GetLastWin32Error(); // 1409 = ERROR_HOTKEY_ALREADY_REGISTERED,说明被别的程序占了 MessageBox.Show($"快捷键注册失败,错误码 {err}"); } } protected override void WndProc(ref Message m) { if (m.Msg == WM_HOTKEY && m.WParam.ToInt32() == HOTKEY_ID) { // 不要在 WndProc 里做重活;先隐藏主窗体,再触发截图 Hide(); BeginInvoke(new Action(StartCapture)); } base.WndProc(ref m); } protected override void OnFormClosing(FormClosingEventArgs e) { UnregisterHotKey(Handle, HOTKEY_ID); // 不注销,进程退出后句柄才回收 base.OnFormClosing(e); }

fsModifiers里MOD_NOREPEAT是个很实用的修饰位,不加的话按住快捷键会连续触发,一秒弹出十几个覆盖层。HOTKEY_ID只是个标识,同一个窗口内唯一即可,收到WM_HOTKEY时靠m.WParam区分是哪个热键。

托盘常驻用NotifyIcon加一个ContextMenuStrip就够,菜单项三个:立即截图、打开保存目录、退出。注意退出要走Application.Exit(),直接Environment.Exit会跳过OnFormClosing,热键就注销不掉了。我见过不止一个人在这上面翻车,表现为改完代码重新运行,快捷键一直提示被占用。

5. 常见问题排查:黑屏、错位、内存涨和高分屏

这一章是我实际遇到的五个坑,按“现象 → 原因 → 解决”记下来,遇到类似的可以直接对号。

5.1 画面类问题

截出来全黑,但屏幕上明明有内容。

原因是BitBlt抓的是 GDI 绘制的桌面表面,而部分程序(尤其是走硬件加速的播放器、某些全屏应用、带内容保护标记的窗口)把画面直接交给显卡合成,GDI 层拿不到数据,只能拿到一片黑。解决办法是先判断业务场景:如果只是常规桌面截图,可以尝试给BitBlt加上CAPTUREBLT;如果目标确实是这类窗口,就换PrintWindow并传PW_RENDERFULLCONTENT = 0x2,或者干脆上Windows.Graphics.Capture。我一般把三者做成策略链,前一个返回全黑就自动降级到下一个。

多屏环境下截图位置偏移,右侧多出一块黑的或者少截一段。

原因是坐标系用错了。常见错误是用Screen.PrimaryScreen.Bounds当整个虚拟桌面,副屏内容自然抓不到;另一种是副屏在主屏左侧时,代码里对负坐标做了绝对值处理。解决方法是统一用GetSystemMetrics那四个指标算虚拟桌面,并且约定全流程只用物理像素坐标,不要在中间任何环节转换成逻辑坐标。

5.2 资源与性能类问题

连续截几十次之后,任务管理器里 GDI 对象数只涨不跌,程序越来越卡。

原因是有 GDI 句柄没释放。最常见的三个位置:Bitmap没Dispose、Graphics没using、OverlayForm里反复给Region赋值但没释放旧的。解决的问题是把所有非托管资源创建都放进try/finally,托管资源一律using;排查时打开任务管理器的 GDI 对象列,截一次看涨几个,涨了就在那条链路上逐个加Dispose。

保存时报“GDI+ 中发生一般性错误”。

这是 GDI+ 里最没有信息量的异常,原因通常跟权限、路径或文件占用有关,跟画面内容无关。三个高发场景:目标目录不存在(bmp.Save不会自动建目录);文件名重复导致文件被上一张图的对象占用;保存到需要管理员权限的系统目录。解决办法是在保存前Directory.CreateDirectory,文件名带毫秒避免撞名,路径默认落到用户目录下,不要往Program Files写。

5.3 高分屏与分发类问题

4K 屏上截图尺寸只有实际的一半,画面还发虚。

原因是进程没有声明 DPI 感知,系统把所有坐标和位图都按缩放比例虚拟化了一遍,你请求 3840×2160,实际拿到 1920×1080 的拉伸结果。解决办法是在app.manifest里同时写dpiAware和dpiAwareness两个节点,并且确认项目文件里配了<ApplicationManifest>。不想改清单也可以在Main开头调用SetProcessDpiAwarenessContext,但这必须在使用任何窗口之前完成,晚一步就无效。

在自己机器上一切正常,拷到别人电脑上双击没反应。

先看对方有没有装 .NET Framework 4.8——Win7 和部分未更新的 Win10 是没有的。另外x64编译的 exe 在 32 位系统上跑不了。解决办法是把目标框架降到net48并在文档里写清系统要求,或者直接发AnyCPU版本并关闭Prefer32Bit。我一般还会在Program.Main里包一层全局异常捕获,把未处理异常写进同目录的日志文件,不然对方只会说“打不开”,你什么都查不到。

6. 二次开发:打码、定时、批注三条改造路线

拿到这个项目之后,最常见的改造需求就三类,我按改动量从小到大排。

改造方向改动位置工作量
选区打码SaveService之前插一个图像处理步骤半天
定时截图新增Scheduler,复用IScreenCapturer一天
截图批注扩展OverlayForm,加箭头/文字工具两三天

打码是最容易见效的。不需要引入任何图像库,把选区里每个 N×N 的像素块统一刷成块中心色就行:

public static void Pixelate(Bitmap bmp, Rectangle area, int block = 12) { area.Intersect(new Rectangle(0, 0, bmp.Width, bmp.Height)); // 防止越界 using (var g = Graphics.FromImage(bmp)) // Graphics 提到循环外 using (var brush = new SolidBrush(Color.Black)) { for (int y = area.Top; y < area.Bottom; y += block) for (int x = area.Left; x < area.Right; x += block) { int cx = Math.Min(x + block / 2, bmp.Width - 1); int cy = Math.Min(y + block / 2, bmp.Height - 1); brush.Color = bmp.GetPixel(cx, cy); g.FillRectangle(brush, x, y, Math.Min(block, bmp.Width - x), Math.Min(block, bmp.Height - y)); } } }

两个细节值得留意。area.Intersect是为了防止选区贴到屏幕边缘时FillRectangle越界抛出异常;Graphics放在循环外是关键,放里面的话每画一个块就要创建一个 GDI+ 对象,一张 4K 图能把内存打满,这个坑我踩过一次就再没忘。

定时截图更简单,System.Threading.Timer每 N 秒调一次IScreenCapturer.Capture,注意回调跑在线程池线程上,碰 UI 必须Invoke。批注功能改动最大,思路是把覆盖层的状态从“一个矩形”扩展成“矩形 + 若干图形对象列表”,OnPaint里遍历绘制,鼠标事件根据当前工具类型分派。

我现在每次给这类工具加新功能,都会先把IScreenCapturer的假实现跑一遍,用一张纯色 Bitmap 把整条链路走通,再上真屏测试。因为截图类问题一旦出现,你很难分清是抓取失败、坐标错位还是保存出错,而假实现能把后两个变量直接排除掉。这个习惯是从一次排查了三小时、最后发现只是路径没建目录的经历里留下来的。希望帮到你。

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

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

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

立即咨询