简介:这是一款基于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要求当前线程处于 STA(Single-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 程序都可靠。
本文还有配套的精品资源,点击获取