
简介这是一款基于C# WinForms开发的电脑截图小工具面向桌面工具开发初学者及有自定义截图需求的开发者解决鼠标交互式截图中的选区定位、大小调整与图像保存等问题。资源围绕拖拽选区的核心逻辑展开包含当前鼠标坐标跟踪、移动选框、八个控制点调整选区范围以及限定鼠标活动区域等典型操作完整演示了从按下鼠标到绘制半透明选区、再释放截图的实现思路。压缩包共50个文件大小仅377KB内含18个C#源码文件、5个资源文件及resx配置项并附可直接运行的exe、pdb调试符号和VS解决方案工程便于对照学习与二次开发。目前已获521人学习下载。工程按MainCut主窗体、CutPicSet设置面板、HelpDoc帮助文档、CloseBox自定义按钮等模块划分同时包含ImageHelper图像辅助类和Program入口可帮助读者快速掌握WinForms窗体布局、事件绑定与截屏保存的完整流程适合作为课程设计或桌面工具开发的参考范例。1. 从 PrintScreen 到自己写截图工具的几步很多人觉得截图没必要自己造轮子PrintScreen 加剪贴板已经能应付大多数场景。可一旦出现“定时每隔 5 分钟截一张全屏”“只截第二块屏”“在程序跑起来之后 3 秒再截右键菜单”这类需求系统自带能力和微信截图全都对不上。用 C# 做桌面端小工具WinForms 几百行代码就能覆盖全屏、区域拖选、定时保存和窗口识别而且方便集成到 c# 上位机里做现场画面留档。本文按照一个截图工具最实际的开发路径展开先写最小可运行的全屏截图再补区域选择与遮罩交互然后是定时采集和 DPI 换算最后落到延迟截图和指定窗口获取。每段代码都按“能直接放进项目跑”的标准来写同时也把为什么这样选讲清楚。2. 全屏截图最小实现CopyFromScreen 与 Graphics 的配合2.1 一段能跑起来的 C# 全屏截图代码截图工具最核心的 API 是Graphics.CopyFromScreen。它负责把桌面画布上的指定矩形区域直接复制到位图对象上属于 GDI 层面的操作跟具体窗体控件无关。下面是最小可用的完整实现适用于 WinForms 项目也适用于有消息循环的桌面程序using System; using System.Drawing; using System.Drawing.Imaging; using System.Windows.Forms; public static class ScreenShooter { public static Bitmap CaptureFullScreen() { var screen Screen.PrimaryScreen; var bounds screen.Bounds; var bmp new Bitmap(bounds.Width, bounds.Height, PixelFormat.Format32bppArgb); using (var g Graphics.FromImage(bmp)) { g.CopyFromScreen(bounds.X, bounds.Y, 0, 0, bounds.Size); } return bmp; } public static void CaptureToFile(string directory, string prefix) { using var bmp CaptureFullScreen(); var filename ${prefix}_{DateTime.Now:yyyyMMdd_HHmmss_fff}.png; bmp.Save(System.IO.Path.Combine(directory, filename), ImageFormat.Png); } }这段代码里需要注意几处参数。CopyFromScreen的签名是CopyFromScreen(int sourceX, int sourceY, int destX, int destY, Size blockRegionSize)其中sourceX和sourceY是屏幕坐标系里的起点destX和destY是目标位图上的起点blockRegionSize是要复制的宽高。因为主屏的Bounds.Location通常是(0,0)所以直接把bounds.X和bounds.Y传进去不会发生偏移。位图用PixelFormat.Format32bppArgb是推荐做法。很多机器上屏幕颜色深度是 32 位真彩如果你用默认的Format32bppRgb个别显卡驱动下会出现颜色值整体偏暗或 Alpha 通道异常。文件名里加了毫秒fff因为截图工具经常被做成定时任务一秒钟内可能要保存多张只精确到秒会互相覆盖。当前 .NET 版本选择上这代码同时适用于 .NET 6/8 和 .NET Framework 4.8如果你的运行环境还没有升级到新 SDK直接建一个旧框架项目也能编过。2.2 保存与剪贴板PNG、JPEG 和粘贴位图全屏截图拿到Bitmap之后第二个决策是往哪里输出。常见做法是保存文件加复制剪贴板两件事都做给用户两种使用路径。保存成文件时格式选择建议按这个表格来判断格式特点适用场景PNG无损文字边缘清晰体积适中界面截图、报 Bug 附件、留档JPEG有损色块边缘会糊照片类画面、不关心细节时BMP无压缩体积大临时中转不推荐落地保存我个人在工具里给用户的默认选项只有 PNG 和 JPEG 两个。原因是截图内容通常包含文字和 UI 线条JPEG 会在白底黑字周围产生明显振铃噪点而 PNG 不存在这个问题。剪贴板输出用一行Clipboard.SetImage(bmp)即可复制后直接在聊天窗口粘贴。public static void CaptureToClipboard() { using var bmp CaptureFullScreen(); Clipboard.SetImage(bmp); }这里有一个容易踩的坑Clipboard.SetImage要求当前线程处于 STASingle-Threaded Apartment模式。WinForms 主线程默认就是 STA但如果你把这段代码放在后台Task.Run里执行会直接抛ThreadStateException。处理办法是不在后台线程操作剪贴板改为通过Control.BeginInvoke扔回 UI 线程。2.3 为什么不用 Control.DrawToBitmap 截屏新上手的人容易想到Control.DrawToBitmap毕竟它同样能把内容画到位图里而且调用更简单。但这个方法在实际使用中有两个硬伤。第一它只能绘制当前进程内的控件树其他进程的窗口、桌面壁纸、右键菜单都不在画布范围内截出来往往是黑块。第二对硬件加速渲染的内容比如 WPF 窗口、DirectX 渲染区、视频播放画面DrawToBitmap经常抓不到实际像素只能得到空白或者花屏。CopyFromScreen走的是桌面合成结果Desktop Window Manager 已经把所有窗口绘制到了一张完整桌面图上因此它截到的是用户真正看到的画面。这个特性也是它最大的边界它必须运行在“有交互桌面的会话”里。如果用小工具的人把它做成 Windows 服务或计划任务在 Session 0 里跑CopyFromScreen会截到黑屏或报Win32Exception。这属于系统权限模型的问题不是 API 本身能绕开的。3. 区域截图与交互选区遮罩窗体与鼠标状态机3.1 用无边框置顶窗体搭一个全屏遮罩区域截图比全屏截图多了一个交互环节用户要先在屏幕上拖出一个矩形工具才知道复制哪个区域。常见做法是启动一个覆盖整个屏幕的遮罩窗体背景压暗鼠标框选的区域保持高亮。这个窗体本身不工作它只负责两件事收集鼠标坐标把选区和周围遮罩画出来。public class OverlayForm : Form { private Point _startPoint; private Point _currentPoint; private bool _isSelecting; public OverlayForm(Rectangle bounds) { FormBorderStyle FormBorderStyle.None; Bounds bounds; StartPosition FormStartPosition.Manual; TopMost true; DoubleBuffered true; ShowInTaskbar false; } protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); using (var brush new SolidBrush(Color.FromArgb(80, 0, 0, 0))) { e.Graphics.FillRectangle(brush, ClientRectangle); } if (!_isSelecting _startPoint Point.Empty) return; var rect GetSelectionRectangle(); using var pen new Pen(Color.Red, 2); var innerRect rect; innerRect.Inflate(-1, -1); e.Graphics.DrawRectangle(pen, innerRect); } private Rectangle GetSelectionRectangle() { int x Math.Min(_startPoint.X, _currentPoint.X); int y Math.Min(_startPoint.Y, _currentPoint.Y); int w Math.Abs(_startPoint.X - _currentPoint.X); int h Math.Abs(_startPoint.Y - _currentPoint.Y); return new Rectangle(x, y, w, h); } }遮罩窗体有几个关键属性要一起设置FormBorderStyle.None去边框TopMost保证它压在所有窗口之上DoubleBuffered减少拖选时的闪烁。需要特别留意的是WS_EX_NOACTIVATE扩展样式它让遮罩窗体不会抢走当前程序的激活状态。如果缺少这个设置用户一点遮罩原窗口的焦点菜单就可能自动收起你要截的东西就消失了。重写CreateParams增加这个样式即可实现protected override CreateParams CreateParams { get { var cp base.CreateParams; cp.ExStyle | 0x08000000; // WS_EX_NOACTIVATE return cp; } }3.2 鼠标按下、拖动、抬起三个状态的坐标记录拖选过程本质是三个鼠标事件组成的简单状态机。按下左键记录起点移动时更新终点并刷新画面抬起后判断选区是否有效。这段逻辑写进OverlayForm里protected override void OnMouseDown(MouseEventArgs e) { if (e.Button ! MouseButtons.Left) return; _startPoint e.Location; _currentPoint e.Location; _isSelecting true; Capture true; } protected override void OnMouseMove(MouseEventArgs e) { if (!_isSelecting) return; _currentPoint e.Location; Invalidate(GetSelectionRectangle()); } protected override void OnMouseUp(MouseEventArgs e) { if (e.Button ! MouseButtons.Left || !_isSelecting) return; _isSelecting false; var rect GetSelectionRectangle(); if (rect.Width 5 rect.Height 5) DialogResult DialogResult.OK; else Close(); }这里把鼠标捕捉Capture true很重要。默认情况下鼠标按下后拖出窗体边界控件会停止接收MouseMove拖选就断了。设置Capture之后即使鼠标移出屏幕窗体依然能收到事件。选区的宽高阈值取 5 像素避免用户误点击造成一个 1x1 的无效区域。这一段建议使用局部刷新而不是每次都Invalidate()全窗体。因为遮罩窗体的ClientRectangle可能覆盖整块大屏全量刷新在低配置机器上会掉帧。片段刷新时直接Invalidate(GetSelectionRectangle())让系统只重绘画面前后两个矩形覆盖的区域。3.3 取消、确认与 DPI 坐标换算WS_EX_NOACTIVATE带来的副作用是窗体拿不到键盘焦点Esc 取消快捷键在常见实现里会失效。我一般处理方式是在鼠标按下时主动调用一次Focus()或Activate()让焦点回到遮罩窗体然后在OnKeyDown里判断Keys.Escape直接关闭。因为用户按下鼠标和按下 Esc 很少在同一帧发生焦点抢占的副作用可以忽略。区域截图真正麻烦的是高 DPI 下的坐标换算。在 150% 缩放比例的机器上如果不做任何处理鼠标事件的Location是逻辑像素即 WinForms 缩放后的坐标而CopyFromScreen要求的是物理像素坐标。两者在 100% 缩放下相等超过 100% 时直接截出来的区域会有偏移。处理方式是先声明进程 DPI 感知再按DeviceDpi / 96.0换算private Point ToPhysicalPoint(Point logical) { double factor DeviceDpi / 96.0; return new Point((int)(logical.X * factor), (int)(logical.Y * factor)); }获得物理坐标后截取区域的代码就很简单了using var bmp new Bitmap(rect.Width, rect.Height, PixelFormat.Format32bppArgb); using (var g Graphics.FromImage(bmp)) { g.CopyFromScreen(rect.X, rect.Y, 0, 0, rect.Size); }4. 定时连续截图与数据流避免界面卡顿的刷新策略4.1 Task.Delay 与 Stopwatch 校正定时漂移定时截图是 c# 上位机场景里很常见的附加功能比如监控程序界面每隔一分钟留档一张。很多人的初版实现是用System.Windows.Forms.Timer事件里放截图和保存。这个写法在截图偶发一次时看不出问题一旦截图间隔缩短到一秒以内UI 线程被文件编码占住整个窗体就开始像 c# 循环数据采集时 UI 刷新卡顿那样掉帧。正确做法是用后台线程循环把截图和保存从 UI 线程里摘出去。private async Task RunCaptureLoop(int intervalSeconds, CancellationToken ct) { var stopwatch System.Diagnostics.Stopwatch.StartNew(); while (!ct.IsCancellationRequested) { var nextTime DateTime.Now.AddSeconds(intervalSeconds); await Task.Run(() { ScreenShooter.CaptureToFile(_saveDirectory, monitor); }, ct); var remaining nextTime - DateTime.Now; if (remaining TimeSpan.Zero) { await Task.Delay(remaining, ct); } } }这里不用Task.Delay(intervalSeconds)是因为单纯的固定延时存在累积漂移。假设截图本身耗时 80 毫秒每轮循环都要先截图再延时实际间隔会变成 interval 0.08 秒跑一百轮之后整体节奏会越来越慢。上面的写法用nextTime把下一次执行钉在绝对时间点上每次循环末尾只补充这一轮剩余的时间误差不累积。Task.Delay(remaining, ct)在CancellationToken取消时会抛OperationCanceledException所以调用方要用try/catch包住整个循环或者直接放外层等待取消。这个小细节不处理程序退出时会有未捕获异常飘出来。4.2 后台截图结果回 UI 线程显示定时任务保存文件的同时通常还要在界面上显示“最近一张截图”的缩略图。保存文件能在线程池里做但PictureBox.Image赋值必须在 UI 线程执行。正确姿势是BeginInvoke把委托丢回 UI 线程await Task.Run(() { var thumb Bitmap.FromFile(latestFile); this.BeginInvoke(new Action(() { pictureBox.Image?.Dispose(); pictureBox.Image thumb; })); });这里有一个容易写错的细节。直接pictureBox.Image new Bitmap(file)很容易出现上一个图像对象没释放的问题每张图都造成一次 GDI 句柄泄露。所以更新前先Dispose()旧对象。如果截图频率高比如一秒一张连续BeginInvoke会把 UI 线程的待处理任务队列堆满画面还是卡。这种情况我一般把缩略图更新节流到每秒最多两次或者只在缩略图被用户看到时才刷新。截图采集是高频任务UI 展示是低频任务两者之间允许一定延迟才符合实际需求。4.3 多显示器与 SystemInformation.VirtualScreen主屏不是全部。工具放在双屏机器上时Screen.PrimaryScreen.Bounds只覆盖主屏副屏内容完全截不到。要覆盖所有屏幕直接读取SystemInformation.VirtualScreen就能拿到底层系统合并出来的虚拟屏幕矩形var allBounds SystemInformation.VirtualScreen; using var bmp new Bitmap(allBounds.Width, allBounds.Height); using (var g Graphics.FromImage(bmp)) { g.CopyFromScreen(allBounds.X, allBounds.Y, 0, 0, allBounds.Size); }注意虚拟屏幕的坐标可能是负数。副屏在主屏左侧时VirtualScreen.X是负值CopyFromScreen里必须把这个负坐标传进去而不是从 0 开始。截某一特定屏幕时则直接取对应Screen.Bounds的 X/Y 作为源点。混合 DPI 的多屏环境更麻烦。Windows 的 PerMonitorV2 模式下一个屏幕是 150%另一个屏幕是 100%两边的Bounds返回的是物理像素而鼠标坐标在不同屏幕之间切换时逻辑坐标会变化。所以区域截图的换算逻辑必须做到每个屏幕独立计算靠Screen.FromPoint找到鼠标所在屏幕再取它的DeviceDpi不能在全屏工具里只存一个全局 DPI 系数。这个坑在截图工具支持副屏后几乎必踩建议在验证时专门找一台混合缩放的笔记本接显示器来测。5. 截图工具实用化延迟截图、窗口识别与辅助技巧5.1 延迟 3 秒截打开中的菜单截右键菜单和下拉框这类展开后马上消失的 UI最常用的是延迟截图。做法是在截屏前先弹一个小的倒计时窗体把操作者的注意力引到一个固定位置数到零再执行截图public static async Task CaptureWithDelayAsync(int seconds) { await Task.Delay(TimeSpan.FromSeconds(seconds)); ScreenShooter.CaptureToFile(_saveDirectory, delay); }如果延迟期间遮罩上没有提示用户会以为程序卡死。我一般弹一个置顶的小窗体显示剩余秒数倒计时结束后关掉它再截。这里建议把倒计时窗体的Opacity设成 0.6保留背景可见性这样用户能边等边准备菜单状态。5.2 用 Win32 API 定位指定窗口做定点截图比全屏截图更精准的操作是“只截记事本”或“只截某个工控组态窗口”。这类需求用FindWindow加GetWindowRect两个 Win32 API 就能拿到目标窗口在屏幕上的像素矩形[DllImport(user32.dll, CharSet CharSet.Unicode)] private static extern IntPtr FindWindow(string lpClassName, string lpWindowName); [DllImport(user32.dll)] private static extern bool GetWindowRect(IntPtr hWnd, out RECT rect);调用方传窗口标题进去再拿返回的RECT去调截图逻辑。有一点要注意GetWindowRect返回的是窗口外框矩形包含标题栏和边框如果只需要客户区需要再调ClientToScreen转换客户区坐标。目标窗口最小化时GetWindowRect返回的矩形仍在任务栏里直接截会得到错位画面使用前先判断IsIconic(hWnd)是否为假。5.3 被遮挡窗口的截取方向CopyFromScreen截的是屏幕合成结果目标窗口被其他窗口挡住时截到的就是遮挡后的样子。要穿透遮挡层抓窗口自身内容可以用PrintWindowAPI它直接向窗口发送WM_PRINT消息让窗口自己绘制到位图。实际上很多现代渲染引擎WPF、DirectX对PrintWindow支持并不完整经常出现黑色或空白结果。真正稳定的方案是把目标窗口SetForegroundWindow置顶等几百毫秒再截屏。这个做法虽然笨但对 99% 的 WinForms 和原生 Win32 程序都可靠。本文还有配套的精品资源点击获取