简介:C#实现的Windows截屏功能源码,面向Windows桌面开发初学者及需要集成截屏能力的开发者。项目借助系统绘图类库中的位图、画布等对象完成屏幕图像捕获,先获取屏幕尺寸并创建同尺寸位图,再用画布绘制方法将屏幕内容复制到位图;支持鼠标拖拽自定义区域截图,监听鼠标移动消息计算选区矩形,并可使用画笔绘制线条做标注。生成的图片能保存为JPEG或PNG格式,同时通过HTTP客户端示例演示了上传图片到服务器的流程。资源包共36个文件,以C#源码为核心,辅以资源文件、可执行程序、调试文件,另含解决方案、配置文件与动态库引用,压缩后仅820KB,工程结构清晰。目前已有1660人学习下载。项目包含主窗体、剪切窗体、模式窗口等多个窗体及图像处理类,直观展示选区、绘制、保存与网络请求的完整衔接,适合逐段学习图形编程和网络通信技巧,也方便在此基础上扩展出个性化截屏工具。
1. C# 截屏工具的日常:一个桌面小脚本能解决什么
需要每天上午十点把内部报表看板的某个区域截图存档,还要按日期命名、传到共享文件夹。手动截图一个月之后,某开发者实在忍不了,用 C# 写了一个常驻托盘的截屏小工具:支持全屏、区域、窗口三种方式,注册全局热键,保存成 PNG/JPEG,也可以直接复制到剪贴板,交互方式类似常见截屏软件。这个项目把 Windows 截屏要用到的关键技术点都串了起来:GDI+ 的 CopyFromScreen、全屏遮罩窗体、Win32 热键、剪贴板和 DPI 处理。如果你正在用 C# 做桌面工具,或者想给自己的业务系统加一个“截图上报”功能,这份源码可以直接抄作业,按自己的需求改改就能用。
2. 架构与技术选型:为什么是 C# + GDI+,程序怎么跑起来
2.1 三种截屏方案,为什么先选 CopyFromScreen
Windows 平台上做截屏,我通常会在三种方案里选。第一种是Graphics.CopyFromScreen,这是 .NET 里最直接的 API,从屏幕设备上下文直接拷贝一块矩形区域到位图,适合固定桌面、全屏、窗口可见区域的截图。第二种是PrintWindow,通过给目标窗口发送 WM_PRINT 消息,让窗口自己把内容画到指定的 HDC 上,适合窗口被其他窗口遮挡时也能截到完整内容的场景。第三种是 DXGI Desktop Duplication,直接复制桌面纹理,性能最好,但代码复杂度明显更高,适合录屏和高帧率采集。
这个项目选 CopyFromScreen 不是因为它最强,而是因为它对 90% 的桌面截图场景足够稳定,代码量只有几十行。对于“每天定时截一块区域存下来”这种需求,没必要把桌面采集那套搬进来。技术栈就是 C# + .NET Framework 4.8 + Windows Forms,System.Drawing 负责图像处理,System.Windows.Forms 提供窗体和剪贴板。工程默认用 .NET Framework 4.8,原因是 Windows Forms 在这个版本下最成熟,不依赖额外的运行时;如果换成 .NET 6/8,需要处理 System.Drawing.Common 在非 Windows 平台下的兼容问题,Windows 上问题不大。
2.2 工程结构与主流程:截图动作的完整链路
拿到源码包之后,先看目录结构,整个工程的布局是这样的:
ScreenCapDemo/ ├── ScreenCapDemo.sln ├── ScreenCapDemo/ │ ├── App.config │ ├── Program.cs │ ├── MainForm.cs │ ├── MainForm.Designer.cs │ ├── HotKeyManager.cs │ ├── ScreenCapture.cs │ ├── RegionSelector.cs │ ├── SaveHelper.cs │ └── Properties/ │ ├── AssemblyInfo.cs │ └── Settings.settings主流程非常直接:程序启动后Program.cs创建主窗体但默认隐藏,MainForm里注册全局热键并准备系统托盘图标。用户按下热键时,HotKeyManager通过 Win32 回调把事件抛给MainForm,此时根据当前模式来决定动作:区域截图会先弹出一个全屏遮罩窗体RegionSelector,用户在遮罩上拖拽出矩形区域;全屏模式直接用Screen.PrimaryScreen.Bounds拿到整个屏幕的矩形;窗口模式则通过鼠标取点定位窗口句柄,再换算成屏幕坐标。拿到矩形后,ScreenCapture.CaptureRegion执行Graphics.CopyFromScreen,把像素拷贝到位图。最后SaveHelper负责写文件或放到剪贴板。
这个设计里每个类职责都很单一,ScreenCapture不关心 UI,RegionSelector不关心图像编码,SaveHelper只负责持久化。后续如果想把截屏能力集成到别的程序,直接调用ScreenCapture.CaptureRegion就够了。
2.3 快速跑起来:从打开工程到按下第一个热键
假设你机器上已经装了 Visual Studio 2022 或者较新的 .NET SDK,双击ScreenCapDemo.sln,在解决方案管理器里把启动项目设为 ScreenCapDemo,然后直接 F5。第一次运行会看到右下角任务栏出现一个托盘图标,主窗口是隐藏的。默认热键是Ctrl + Alt + A,按下之后屏幕会蒙上一层半透明遮罩,鼠标拖拽选出要截的区域,松开后图片会自动保存到系统图片目录的 Screenshots 文件夹,文件名带时间戳,比如screenshot_20250601_143512.png。
如果想立刻该改保存位置,不用动代码,直接打开App.config,把SaveFolder的值改成你想要的路径,比如D:\Work\Captures,重新运行即可。路径支持环境变量写法,代码内部会调用Environment.ExpandEnvironmentVariables做展开。如果热键跟系统或其他软件冲突,把HotKey配置项改成类似CtrlAlt+Shift+A这样的组合再重启程序。第一次跑建议先用全屏模式验证环境,再做区域截图的联动调试。
3. 核心模块拆解:一个截图请求从热键到落盘的完整链路
3.1 ScreenCapture:最核心的几十行代码
所有截屏逻辑最终都收敛到这一个方法上。核心代码看起来很短,但每个细节都有讲究:
using System; using System.Drawing; using System.Drawing.Imaging; using System.Windows.Forms; namespace ScreenCapDemo { public static class ScreenCapture { public static Bitmap CaptureRegion(Rectangle region) { if (region.IsEmpty) throw new ArgumentException("截图区域不能为空", nameof(region)); var bmp = new Bitmap(region.Width, region.Height, PixelFormat.Format32bppArgb); using (var g = Graphics.FromImage(bmp)) { g.CopyFromScreen( region.Location, Point.Empty, region.Size, CopyPixelOperation.SourceCopy); } return bmp; } public static Bitmap CaptureFullScreen() { var bounds = Screen.PrimaryScreen?.Bounds ?? throw new InvalidOperationException("当前没有可用的屏幕"); return CaptureRegion(bounds); } } }CaptureRegion接收一个Rectangle,这个矩形用的是屏幕坐标,左上角为原点,向右向下递增。方法先做空值检查,然后创建等宽高的位图,再用 Graphics.Draw 的方式把屏幕像素拷贝到位图。PixelFormat.Format32bppArgb是为了统一颜色格式,避免系统在缩放或进行图片处理时因为格式不一致出现颜色偏差。g.CopyFromScreen有四个关键参数:第一个region.Location是屏幕源区域的左上角坐标,第二个Point.Empty是目标位图的写入起点,第三个region.Size是拷贝的宽高,最后一个CopyPixelOperation.SourceCopy表示直接覆盖而不是混合。
调用方拿到Bitmap之后必须自己负责释放,这里故意不写using,因为截图结果要跨方法传递,由最终持有者决定什么时候释放。一个小习惯是:谁创建位图,谁就不负责销毁,销毁责任交给消费方,避免“提前释放导致后续画图异常”这类问题。
3.2 区域选择:全屏遮罩窗体是怎么交互的
区域截图的交互不能靠主窗体,需要一个独立的全屏窗体盖住桌面,让用户在上面拖拽出一个矩形。RegionSelector 的核心实现思路是:窗体设置FormBorderStyle.None、Bounds = Screen.PrimaryScreen.Bounds、TopMost = true、WindowState = Maximized,然后通过鼠标事件记录起止坐标,重绘时把选区外的区域涂半透明黑,选区内部保持透明。
public partial class RegionSelector : Form { private Point _start; private Rectangle _rect; public Rectangle SelectedRegion { get; private set; } public RegionSelector() { SetStyle(ControlStyles.OptimizedDoubleBuffer | ControlStyles.AllPaintingInWmPaint, true); FormBorderStyle = FormBorderStyle.None; TopMost = true; ShowInTaskbar = false; Cursor = Cursors.Cross; } protected override void OnMouseDown(MouseEventArgs e) { _start = e.Location; _rect = Rectangle.Empty; base.OnMouseDown(e); } protected override void OnMouseMove(MouseEventArgs e) { if (e.Button == MouseButtons.Left) { int x = Math.Min(_start.X, e.X); int y = Math.Min(_start.Y, e.Y); int w = Math.Abs(e.X - _start.X); int h = Math.Abs(e.Y - _start.Y); _rect = new Rectangle(x, y, w, h); Invalidate(); } base.OnMouseMove(e); } protected override void OnMouseUp(MouseEventArgs e) { if (_rect.Width > 2 && _rect.Height > 2) { SelectedRegion = _rect; DialogResult = DialogResult.OK; } base.OnMouseUp(e); } protected override void OnPaint(PaintEventArgs e) { var bounds = ClientRectangle; var fillBrush = new SolidBrush(Color.FromArgb(100, Color.Black)); if (_rect.IsEmpty) { e.Graphics.FillRectangle(fillBrush, bounds); } else { var outside = new Region(bounds); outside.Exclude(_rect); e.Graphics.FillRegion(fillBrush, outside); e.Graphics.DrawRectangle(Pens.Red, _rect); } fillBrush.Dispose(); } }OnMouseMove里的计算逻辑很直白,Math.Min保证无论从哪个方向拖拽,矩形坐标都归一化成左上角原点的表示,宽高取绝对值,这样最后拿到的_rect才能直接传给ScreenCapture.CaptureRegion。OnPaint里用Region做“选区外部排除”,比手动计算四个矩形要边界安全得多,不会有缝隙或重叠。拖拽过程中调用Invalidate()触发重绘,配合双缓冲可以避免闪烁。释放遮罩前千万记得把窗体隐藏,不然CopyFromScreen会把遮罩和红色边框一起截进去,这一条在避坑章节详细讲。
窗口模式截图比区域模式多一个步骤:先让遮罩消失,然后在鼠标位置调用WindowFromPoint取到窗口句柄,再用GetWindowRect拿到窗口矩形,最后把这个矩形当作区域交给CaptureRegion。注意GetWindowRect拿到的矩形包含窗口阴影和边框,如果只想截客户区,需要再调GetClientRect加ClientToScreen换算。
3.3 保存、剪贴板与定时截图:结果怎么落到磁盘
截图结果拿到后,保存逻辑集中在SaveHelper。它负责两件事:按配置生成文件路径,以及处理剪贴板复制。这里把保存和复制分开,用户按Ctrl+S存文件,按Ctrl+C直接复制到剪贴板,互不干扰。
public static class SaveHelper { public static string GeneratePath(string folder, ImageFormat format) { string ext = format.Equals(ImageFormat.Png) ? "png" : "jpg"; string fileName = DateTime.Now.ToString("yyyyMMdd_HHmmss_fff") + "." + ext; string fullFolder = Environment.ExpandEnvironmentVariables(folder); Directory.CreateDirectory(fullFolder); return Path.Combine(fullFolder, fileName); } public static string SaveBitmap(Bitmap bmp, ImageFormat format, string folder) { string path = GeneratePath(folder, format); bmp.Save(path, format); return path; } public static void CopyToClipboard(Bitmap bmp) { Clipboard.SetImage(bmp); // 复制成功后顺便保存一份临时文件,防止剪贴板数据在程序退出后失效 } }DateTime.Now.ToString("yyyyMMdd_HHmmss_fff")里的毫秒位fff很重要,不带上它,两次连续截图间隔小于 1 秒时会出现文件名冲突。Environment.ExpandEnvironmentVariables负责把配置文件里的%USERPROFILE%展开成真实路径,这是新手最容易漏的一步。Directory.CreateDirectory是幂等操作,目录不存在就创建,存在则不报错,可以放心调用。
定时截图是另一个隐藏入口。在托盘菜单里勾选“定时截图”后,主窗体启动一个System.Windows.Forms.Timer,把Interval设为配置里的秒数乘以 1000,然后在Tick事件里调用CaptureFullScreen并保存。定时截图一般配全屏模式,如果配合区域截图,需要提前把区域坐标写死,或者存一个“上次使用区域”的持久化文件,下次直接复用,这比每次弹遮罩要顺手得多。
4. 配置与运行:改哪里、怎么跑、失败时看什么
4.1 App.config 里到底能改什么
整个程序不提供设置界面,所有参数集中在App.config,这样做的好处是部署到别的机器时不用重新编译。配置结构分成三块:热键、保存策略、截屏行为。典型配置如下:
<configuration> <appSettings> <add key="SaveFolder" value="%USERPROFILE%\Pictures\Screenshots" /> <add key="HotKey" value="CtrlAlt+A" /> <add key="DefaultFormat" value="PNG" /> <add key="RegionMode" value="Region" /> <add key="TimerIntervalSeconds" value="0" /> <add key="IncludeCursor" value="false" /> <add key="MinWidth" value="2" /> <add key="MinHeight" value="2" /> </appSettings> <startup> <supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.8" /> </startup> </configuration>HotKey的值解析规则是:用CtrlAlt、CtrlShift、AltShift这类组合作为前缀,加号后面是大写字母键。解析代码里维护一个字典,把字符串映射到 Win32 的修饰键常量MOD_CONTROL|MOD_ALT和虚拟键码(uint)Keys.A。DefaultFormat支持 PNG 和 JPEG,两种格式对应的ImageFormat实例不同,保存路径里的扩展名也会跟着变。TimerIntervalSeconds设为 0 表示不启用定时任务,改成 5 就是每 5 秒自动截一次全屏,这对电脑监控类需求很有用。
改配置之后有两种生效方式:改完直接重启程序,或者在托盘菜单里点“重载配置”,后者内部其实还是重新读取一次ConfigurationManager.AppSettings,再把全局的单例配置对象替换成新实例。这个细节比每次都读配置更高效,因为截屏路径和热键注册不希望在每次截图时都重新解析。
4.2 命令行参数与系统托盘:不弹窗口的常驻方案
托盘程序最忌讳的就是主窗口弹出来打断用户操作。MainForm的构造函数里有一个关键动作:this.WindowState = FormWindowState.Minimized; this.ShowInTaskbar = false;,这样程序启动后只在NotifyIcon上显示一个气泡提示“截屏工具已启动,按 Ctrl+Alt+A 截图”。
命令行参数我预留了几个调试入口:
ScreenCapDemo.exe --once ScreenCapDemo.exe --savefolder D:\tmp ScreenCapDemo.exe --region 0,0,1920,1080--once表示启动后立即执行一次截图然后退出,适合在任务计划程序里配合定时任务使用;--region后面跟四个数字,逗号分隔,表示 x、y、宽、高,这样不用弹遮罩直接截指定区域。实现方式是Program.cs里先解析Environment.GetCommandLineArgs(),把参数填到一个启动配置对象里,再决定是否跳过托盘流程直接进入截图逻辑。这里没有做太过复杂的参数校验,数字解析失败就落回默认配置,保证程序不会因为命令行写错而崩溃。
4.3 日志和错误处理:失败时不要只给用户一个黑盒子
截屏失败时最怕程序默默吞掉异常,用户只看到没反应还以为是热键没触发。我习惯在Program.cs里挂全局异常钩子,同时给托盘菜单加一个“查看日志”入口。日志文件写到%LOCALAPPDATA%\ScreenCapDemo\logs\error.log,每行记录带时间戳的异常信息。热键触发失败会记录热键 id、修饰键和虚拟键码,保存失败会记录路径和完整堆栈。关键位置不用日志框架,直接File.AppendAllLines就够了,避免引第三方依赖。
运行中经常出问题的还有两类:一类是剪贴板被其他程序占用,Clipboard.SetImage抛ExternalException;另一类是没有权限写指定的保存目录。剪贴板异常的处理方式是捕获后重试一次,再失败就只保存文件不复制剪贴板,并且提示用户“复制失败,文件已保存到默认目录”。保存目录异常则在初始化阶段就做一次写测试,程序启动时生成一个 1x1 像素的测试图片再删除,如果写不了直接托盘气泡提醒,不要把问题拖到用户真正截图那一刻。
5. 避坑与排查:DPI 偏移、句柄泄漏、热键冲突与黑屏
5.1 DPI 缩放导致的截屏偏移,看起来是玄学其实有规律
现象:在一块 150% 缩放的屏幕上,用遮罩精确选了一个 100x100 的区域,保存出来的图片却是 150x150,而且内容整体向右下偏移一大截。原因:进程默认不是 DPI-Aware,系统会把传入CopyFromScreen的坐标和尺寸按照缩放比例重新解释,导致区域被放大。解决:程序入口处调用SetProcessDPIAware,告诉系统“我自己处理缩放”,之后再传入的坐标就都是物理像素。
[DllImport("user32.dll")] private static extern bool SetProcessDPIAware(); [STAThread] static void Main() { SetProcessDPIAware(); Application.EnableVisualStyles(); Application.Run(new MainForm()); }另外注意,Screen.PrimaryScreen.Bounds在 DPI-Aware 和 DPI-Unaware 两种模式下返回的值不一样,这也解释了为什么同一个工程在 100% 缩放的机器上一切正常,换到高分辨率笔记本上就翻车。血泪经验是:任何涉及屏幕坐标的 Windows 程序,都必须在程序入口做这一行调用,别指望用户自己去改兼容性设置。
5.2 句柄泄漏,跑一天后内存暴涨甚至报 OutOfMemoryException
现象:定时截屏跑了几个小时之后,内存占用一路爬升,最终弹出一个“创建 Graphics 对象失败”的异常。原因:在循环里创建了大量Bitmap和Graphics对象,没有及时调用Dispose()释放,GDI+ 句柄池被耗尽。解决:每次截图只创建一张位图,用完立刻释放,保存和剪贴板都做完之后,调用bmp.Dispose()。在定时截图的Tick事件里,建议用try-finally保证异常时也能释放,或者用using包住整个处理流程。
还要注意一个不明显的坑:把Bitmap对象塞进剪贴板之后,剪贴板不会立即释放这个位图的句柄,它在剪贴板内容被替换或程序退出前都可能占着资源。所以复制到剪贴板后不要马上Dispose,可以等到下次截图时再处理,或者直接复制一个MemoryStream序列化的副本到剪贴板。实战中我通常是“保存文件和复制剪贴板二选一,优先保存,剪贴板交给系统管理”。
5.3 全局热键注册失败,组合键没反应也不报错
现象:Ctrl+Alt+A毫无反应,托盘菜单的“测试热键”功能却能正常截图,说明 UI 逻辑没问题,只是热键没注册成功。原因:该组合键被其他程序占用,或者RegisterHotKey的句柄、id、参数设置不对,函数返回 false。解决:RegisterHotKey声明时加上SetLastError = true,注册失败时立刻检查返回值和错误码。常见错误码是 1409,表示热键已经被占用,这种情况需要自动降级到备选组合键,或提示用户改配置。注册时hWnd要传主窗体的句柄,id 是程序内唯一即可,用来区分不同热键。
5.4 把遮罩窗体截进图里,选区中间有半透明黑块
现象:区域截图保存出来的图片里,有一块颜色变暗的区域,红框也印在图片上,刚好是被拖拽选区的位置。原因:CopyFromScreen执行时遮罩窗体还停留在屏幕上,桌面上实际是一片“半透明黑 + 红框”的画面,截图把整个桌面都拍下来自然带上了遮罩。解决:在开始截图前先隐藏遮罩窗体,等桌面重绘完成后再拷贝。我一般会在RegionSelector的确认事件里先执行this.Hide(),然后调一个Application.DoEvents()或者Thread.Sleep(50),确保屏上不再残留窗体痕迹,再调用CaptureRegion。不要把这 50 毫秒省掉,尤其有些屏幕刷新率低,隐藏窗体到画面真正刷新之间有明显延迟。
5.5 游戏、视频播放器截出来是黑屏或空白
现象:截全屏视频播放区域或游戏画面时,图片是黑色、空白或者内容缺失。原因:视频播放器和多数游戏都用了硬件加速覆盖层或 DirectX 独占模式,CopyFromScreen走的 GDI 合成通道拿不到覆盖层的内容,只能看到桌面背景。解决:这个坑在 GDI+ 方案里无解,需要换成 DXGI Desktop Duplication 或者 PrintWindow 绕过合成。PrintWindow 对某些视频窗口有效,但它要求目标窗口不处于硬件独占模式。做项目定位时要想清楚:如果你的目标用户主要是浏览器页面和办公软件,CopyFromScreen 完全够用;如果要做游戏直播、录屏工具,一开始就放弃这个方案,直接上桌面复制 API。
6. 二次开发:把截屏能力抽象成可接入的服务
接下来是工程实践部分。如果不想只做一个托盘工具,而是把截屏能力塞进现有的业务系统,比如考勤打卡附带人脸截图、客服工单附带报错截图,我建议先做两个改造:定义一个ICaptureSource接口,把 CopyFromScreen 和 PrintWindow 分别封装成实现;把热键、托盘、遮罩这些 UI 相关的东西从核心逻辑里彻底剥离。
public interface ICaptureSource { Bitmap Capture(Rectangle region); bool IsSupported { get; } } public class GdiCaptureSource : ICaptureSource { public Bitmap Capture(Rectangle region) => ScreenCapture.CaptureRegion(region); public bool IsSupported => true; } public class PrintWindowCaptureSource : ICaptureSource { public Bitmap Capture(Rectangle region) { // 通过窗口句柄获取目标窗口的屏幕坐标区间 IntPtr hWnd = WindowFromPoint(new Point(region.X, region.Y)); if (hWnd == IntPtr.Zero) return null; return PrintWindowHelper.CaptureWindow(hWnd); } public bool IsSupported => true; }这样原来的CaptureRegion变成具体实现,上层服务只需依赖接口。做定时任务时,这个接口尤其有价值:你可以为同一个业务系统写一个“区域截图 + 水印 + 上传”的流水线,截图模块只是其中一个步骤。验证方法也简单,写一个单元测试工程,分别用 100% 和 150% 缩放的虚拟机跑一遍针对ICaptureSource的矩形边界测试,把 DPI Awareness 初始化、对象释放和热键冲突三项列成回归清单。从那以后我每次接截屏功能,都强制先过一遍多显示器加缩放回归列表,再把用例补进自动化,这套流程帮我省下很多来自测试小姐姐的“截图又偏了”的反馈。希望帮到你。
本文还有配套的精品资源,点击获取