简介:这是一份基于C#的Windows截屏工具源码包,面向正在学习WinForms桌面开发、图形处理或网络通信的初中级开发者。程序覆盖全屏截取、自定义区域框选、截图画线标记、本地保存以及通过HttpClient上传服务器等完整流程,能帮助读者理解System.Drawing命名空间下Graphics、Bitmap与Pen的实际用法。资源共36个文件,以.cs源码为主,并包含.resx资源、.config配置、.exe可执行程序、.sln解决方案等类型,压缩包仅820KB,结构精简,便于快速打开研读。核心模块包括Form1主窗体、FrmCut截图区域选择、ImageOperate图像操作等,代码分层清晰,适合作为自定义截屏工具的改造起点。项目已有1655人学习下载,对需要动手实践C#桌面应用和网络上传功能的读者来说,具有不错的参考价值。 说实话,做Windows桌面开发的人,迟早都会碰上“截屏”这个需求。不管是做C#上位机、远程协助工具、教学演示软件,还是自动化测试脚本,截屏几乎是个绕不开的基础功能。我最早接触这个需求是给车间做一套设备监控上位机,客户要求异常报警时自动截取当前画面存档,当时网上资料少,硬是踩了一堆坑才把功能做稳定。这篇就把我用C#实现Windows截屏功能的完整思路、核心代码和排查经验整理出来,给正准备做类似功能的朋友一个参考。
1. 方案选型:为什么我首选 CopyFromScreen
实现Windows截屏,方案其实不止一种。我梳理下来,主流的有三种:GDI+的Graphics.CopyFromScreen、Windows Graphics Capture API、以及DirectX的后台缓冲截取。很多人一上来就选最复杂的,其实完全没必要。
1.1 三种主流截屏方案对比
先看一张我整理的对比表,帮你快速建立选型认知:
| 方案 | 调用难度 | 性能表现 | 兼容性 | 适用场景 |
|---|---|---|---|---|
| GDI+ CopyFromScreen | 低,几行代码搞定 | 中等,适合低频截屏 | 极好,Win7到Win11都行 | 一般业务系统、上位机、工具软件 |
| Graphics Capture API | 中高,需要处理异步流程 | 高,支持高帧率 | 仅Win10 1803+ | 录屏、高帧率截取、窗口捕获 |
| DirectX后台缓冲 | 高,需要了解DX渲染管线 | 最高,截取游戏画面 | 取决于实现方式 | 游戏截屏、特殊渲染场景 |
1.2 为什么CopyFromScreen是大多数项目的“最优解”
多数业务系统的截屏需求,频率都不会太高,基本是用户点一下截一张,或者报警时截一张。这种场景下,CopyFromScreen的效率和稳定性已经绰绰有余。Graphics Capture虽然性能好,但API设计复杂,对老系统的兼容性差,在小工具项目里属于过度设计。
更重要的是,CopyFromScreen不受显卡渲染模式的影响,不需要处理GPU资源,逻辑简单可靠。它背后的原理是:直接从屏幕设备上下文(DC)中获取像素数据,将其复制到内存Bitmap中。你不需要理解底层显卡怎么工作,只要知道“屏幕现在长什么样,我就能拿到什么”就够了。
提示:如果你的项目需要连续截屏做录屏,或者需要捕获被遮挡的特定窗口,再考虑升级到Graphics Capture或PrintWindow方案。普通场景,CopyFromScreen就是那个最省心的选择。
2. 核心实操:从零实现基础截屏
选定了方案,下面直接看代码。先声明环境:Visual Studio 2022,.NET 8.0,Windows 11。如果你用的还是.NET Framework 4.7.2,代码也完全兼容,不需要任何修改。
2.1 最简单的全屏截屏代码
新建一个WinForms项目,放一个按钮,双击进入事件,核心代码就三行:
private void btnCapture_Click(object sender, EventArgs e) { // 获取主屏幕的分辨率范围 Rectangle bounds = Screen.PrimaryScreen.Bounds; // 创建一块与屏幕大小一致的内存位图 using (Bitmap bmp = new Bitmap(bounds.Width, bounds.Height)) { // 从位图创建Graphics对象 using (Graphics g = Graphics.FromImage(bmp)) { // 把屏幕内容复制到位图 g.CopyFromScreen(bounds.X, bounds.Y, 0, 0, bounds.Size); } // 保存到桌面 bmp.Save(@"C:\Users\Public\Pictures\screenshot.png", ImageFormat.Png); } }这里有个细节值得说明:CopyFromScreen的前两个参数是源坐标,即从屏幕的哪个点开始复制;中间两个参数是目标坐标,即复制到位图的哪个位置;最后一个参数是复制的宽高。理解了这个参数含义,后面做区域截屏就很容易了。
另外,为什么用using包裹Bitmap和Graphics?因为Bitmap里存的是位图句柄和内存,如果不用using释放,长时间运行的程序内存会持续涨,最终GDI对象耗尽导致截屏失败甚至屏幕闪烁。这是个非常重要的好习惯。
2.2 区域截屏和带鼠标光标的实现
区域截屏其实就是调整CopyFromScreen的参数。比如用户框选了一个矩形,你想截取左上角为(200, 150)、宽800、高600的区域,那么源坐标就是(200, 150),目标坐标还是(0, 0),复制尺寸是(800, 600)。
再来说个高频需求:截屏时包含鼠标光标。默认的CopyFromScreen是不带光标的,需要手动绘制。你需要在截屏后获取光标当前位置,再把光标图标画上去:
using System.Runtime.InteropServices; // 定义光标位置结构体 [StructLayout(LayoutKind.Sequential)] private struct POINT { public int X; public int Y; } // 引入Win32 API获取光标位置 [DllImport("user32.dll")] private static extern bool GetCursorPos(ref POINT point); public Bitmap CaptureScreenWithCursor() { // 截取全屏虚拟屏幕 Rectangle bounds = SystemInformation.VirtualScreen; Bitmap bmp = new Bitmap(bounds.Width, bounds.Height); using (Graphics g = Graphics.FromImage(bmp)) { g.CopyFromScreen(bounds.X, bounds.Y, 0, 0, bounds.Size); // 获取当前光标位置 POINT p = new POINT(); GetCursorPos(ref p); // 将系统默认光标绘制到位图上 using (Icon cursorIcon = Cursor.Current != null ? Cursor.Current : Cursors.Default) { cursorIcon.Draw(g, new Rectangle(p.X - bounds.X, p.Y - bounds.Y, cursorIcon.Width, cursorIcon.Height)); } } return bmp; }这里有个关键点:我用了SystemInformation.VirtualScreen而不是Screen.PrimaryScreen.Bounds。因为VirtualScreen返回的是所有显示器组成的虚拟矩形。如果你的电脑接了双屏,用PrimaryScreen只能截到主屏,而VirtualScreen能截到全部显示器。坐标上,副屏在主屏左侧时,VirtualScreen的X坐标可能是负数,所以绘制光标时做了p.X - bounds.X的偏移修正,这个细节很容易被忽略。
2.3 高DPI缩放下坐标失真的处理
说到坐标,就绕不开DPI缩放问题。在Win10/Win11上,如果系统缩放率是125%或150%,而你的程序没有声明DPI感知,那么Screen.PrimaryScreen.Bounds拿到的分辨率其实是虚拟分辨率(比如实际2560x1440,程序看到的是1920x1080),截出来的图就会模糊,鼠标光标位置也会偏移。
解决方法:在程序入口处声明DPI感知。在Program.cs中,Application.Run之前加一行:
[DllImport("user32.dll")] private static extern bool SetProcessDPIAware();[STAThread] static void Main() { SetProcessDPIAware(); // 告诉系统本程序已处理DPI缩放 Application.EnableVisualStyles(); Application.SetCompatibleTextRenderingDefault(false); Application.Run(new MainForm()); }加了这行代码后,Windows会把真实的物理像素坐标传给程序,截屏就不会虚,光标位置也准了。
注意:如果你的程序里有大量写死的UI尺寸,开启DPI感知可能导致界面变小或者布局错乱。这是历史遗留问题,没有一劳永逸的解法,只能在启用后逐个界面调整布局。不过对于工具类程序,利大于弊。
3. 进阶玩法:定时截屏与自动化场景
基础功能搞定后,很多人会发现“单次截屏”不够用。常见的进阶需求是:定期自动截屏,或者在特定事件触发时抓取屏幕,比如扫码枪扫到条码后自动截屏存档。
3.1 用Timer实现定时自动截屏
定时截屏最简单的实现是用System.Windows.Forms.Timer。把它拖到窗体上,设置Interval为5000毫秒,Tick事件里调用截屏方法即可:
private void timer_Tick(object sender, EventArgs e) { string fileName = $"screenshot_{DateTime.Now:yyyyMMdd_HHmmss}.png"; string path = Path.Combine(timestampFolder, fileName); using (Bitmap bmp = CaptureScreenWithCursor()) { bmp.Save(path, ImageFormat.Png); } }这里有个实际项目里发现的问题:频繁截全屏时,每次截屏耗时大约在50到150毫秒(取决于分辨率和机器性能)。如果定时间隔过短,比如500毫秒,会导致CPU占用偏高,甚至在截屏瞬间画面掉帧。建议间隔不低于2秒,除非你有明确的帧率需求。我后来写着色器调试工具时,把间隔设在了1000毫秒,任务管理器里CPU占用还在可控范围内,但笔记本风扇能明显听到声音。
如果需要更稳定的定时精度(比如自动化测试要精确控制截图时间点),建议用System.Threading.Timer或System.Timers.Timer,它们走的是线程池,不受UI消息阻塞影响。
3.2 长截屏(滚动截屏)的思路
电脑长截屏这个需求,最近被讨论得很多。Windows端的浏览器、文档阅读器,虽然自带的网页长截图功能,但很多老旧软件没有。业务上,我做过的设备运行日志报表,就要求把整个Scrollable列表一次性截成一张长图。
核心思路是:不用一次截全屏,而是截取窗口可见区域后,滚动窗口再截,最后拼接。步骤大致如下:
- 截取当前窗口可见区域,保存第一张。
- 调用
SendMessage给窗口发送WM_VSCROLL消息,每次滚动一行或一屏。 - 等窗口内容刷新稳定后,继续截取下一屏。
- 重复滚动和截取,直到滚动条到达底部。
- 把所有部分按滚动顺序纵向拼接成一张长图。
有一个执行细节:滚动和截图之间需要等待一段时间,让窗口完成重绘。滚动消息发出后立即截图,大概率会截到尚未更新的区域,导致拼接处出现空白。我用的是Thread.Sleep(150)加Application.DoEvents()的组合,实测对大部分WinForms/WPF窗口有效。但遇到用WebView或GPU加速渲染的界面时,这种方案不稳定,因为后台渲染不在窗口DC的掌握里。
拼接时用Graphics.DrawImage把每张图按位置绘制到一大张Bitmap上,坐标上加上之前已截取的总高度即可。这套逻辑不复杂,但边界情况多,光是调试首行、末行的重复或者缺失就花了我两天时间。建议先在一两个目标应用上验证可行,再决定要不要通用化。
4. 上位机与工业场景的特殊适配
热词里反复出现“C#上位机”和“工业级”,我就多聊几句工业环境下的截屏经验。这些场景跟普通桌面工具不一样,有很多隐藏坑。
4.1 截屏作为报警证据:抓取时机与文件策略
工业上位机里,报警和截屏往往是并发的。我在设备监控软件里的做法是:开启一个后台看门狗线程,监听PLC传来的报警信号。信号触发后,立即调用截屏方法,将当前画面保存到D盘固定目录,并以报警类型和时间戳作为文件名。
这里有个反直觉的经验:报警触发瞬间立即截屏,截到的可能不是报警画面。因为画面渲染需要时间,信号到达程序时,界面可能还没完成刷新。我给系统加了80毫秒的延迟:
// 报警信号到达后,先等界面刷新完成 await Task.Delay(80); // 再执行截屏为什么是80毫秒?我实测过,WinForms的Control.Invalidate触发的WM_PAINT消息在消息队列中排队的平均延迟大约为30-60毫秒,加上CPU调度波动,80毫秒是个保险值。太短会截到旧帧,太长可能错过精彩画面。
图片文件名建议增加PLC的报警编号,方便事后追溯。文件格式首选PNG,无损且体积适中。BMP太大,JPG有损会糊掉小字。如果一天能存好几个G的图片,再考虑加个简单的按日期分目录和定期清理策略。
4.2 扫码枪触发截屏的实现方式
热词里有“C#扫码枪触发事件”,这其实是个挺经典的需求。工业场景里,产品扫码后要把外观照片存档,实现方式分两种。
方式一:扫码枪模拟键盘输入,程序监听输入触发
大部分USB扫码枪默认走HID协议,扫描后像键盘一样输入一串字符,并附带回车。程序侧只需在窗体上监听键盘事件,判断回车前累积的字符串是否符合条码规则:
private StringBuilder barcodeBuffer = new StringBuilder(); protected override void OnKeyPress(KeyPressEventArgs e) { base.OnKeyPress(e); if (e.KeyChar == (char)13) // 回车表示扫码结束 { string barcode = barcodeBuffer.ToString(); if (barcode.StartsWith("SD")) // 校验条码前缀 { TakeScreenshotAsProductRecord(barcode); } barcodeBuffer.Clear(); } else { barcodeBuffer.Append(e.KeyChar); } }方式二:扫码枪串口通讯,程序接收串口数据
有些工程类扫码枪或固定式读码器走RS232串口。通过System.IO.Ports.SerialPort接收数据,收到完整条码后触发截屏。这个方案的关键是处理好串口分包问题——一次接收事件可能只到了半个条码,需要拼接。
4.3 截屏转数据:把趋势图变成结构化数据
热词里有一条“走势图截屏后转化为数据”,这属于图像处理领域了。我之前给车间做过一个简单版本:截取实时趋势图区域后,分析图表像素,把彩色曲线提取成坐标点序列。
基本思路分成四步:
- 图像预处理:去掉网格线和背景色,只保留曲线像素。具体做法是遍历每个像素,判断颜色是否落在曲线颜色的RGB范围内。
- 单像素化:同一列可能有多个像素属于曲线,取最上面的点(或所有点的平均值),得到每个X坐标对应的Y坐标。
- 坐标转换:根据图表区域的像素尺寸和Y轴量程,把像素坐标映射为实际物理值。
- 异常过滤:剔除因为画面抖动产生的离散噪点。
这一步我用的是纯C#写的图像处理代码,没引入OpenCV(那时候团队对引入C++库有顾虑)。实际效果还算可用,但处理倾斜或模糊的截屏时会出问题。如果你们想做得更稳,建议直接用OpenCVSharp或PaddleOCR,在工业场景里稳定压倒一切,能用成熟库就不自己造轮子。
5. 常见问题与排查技巧实录
最后这部分是压轴的。截屏功能看起来简单,实际部署到别人机器上,各种稀奇古怪的问题都会冒出来。我把自己踩过的坑按出现频率从高到低整理成一份排查表。
| 现象 | 根本原因 | 解决方案 |
|---|---|---|
| 截屏保存后是黑屏 | 屏幕内容由GPU硬件加速渲染(如视频、某些WebView),GDI无法读取 | 换用PrintWindow(针对窗口)或Graphics Capture API(针对全屏) |
| 截屏分辨率模糊 | 程序未声明DPI感知,拿到的分辨率是缩放后的虚拟分辨率 | 在Main入口调用SetProcessDPIAware() |
| 光标位置偏移 | 双屏坐标未做偏移修正,或DPI不匹配 | 使用VirtualScreen坐标做相对定位 |
| 截屏偶发失败,报参数错误 | 屏幕分辨率发生变化(如远程桌面连接、显示器切换) | 截屏前重新获取屏幕Bounds,并try-catch重试 |
| 程序长时间运行内存涨满 | Bitmap和Graphics对象未释放 | 统一用using包裹,或finally里Dispose |
| 远程桌面会话中截屏异常 | RDP会话的屏幕DC与本地不同 | 检测会话状态,必要时提示用户切回本地 |
5.1 窗口被遮挡也能截到:PrintWindow
大多数场景用CopyFromScreen没问题,但如果用户开了别的窗口挡住了你要截的窗口,截出来的是最上层的画面。想要“透视”截取特定窗口——即使它被遮挡——可以用PrintWindow这个API:
[DllImport("user32.dll")] public static extern bool PrintWindow(IntPtr hWnd, IntPtr hdcBlt, uint nFlags); public Bitmap CaptureWindow(IntPtr hWnd) { RECT rect; GetWindowRect(hWnd, out rect); int width = rect.Right - rect.Left; int height = rect.Bottom - rect.Top; Bitmap bmp = new Bitmap(width, height); using (Graphics g = Graphics.FromImage(bmp)) { IntPtr hdc = g.GetHdc(); try { // 第二个参数传2(PW_RENDERFULLCONTENT),可在Win10+上截到完整内容 PrintWindow(hWnd, hdc, 2); } finally { g.ReleaseHdc(hdc); } } return bmp; }这个方法能调用窗口自己的绘制逻辑把内容“画”到内存DC里,所以被遮挡也能拿到画面。但注意:某些硬件加速渲染的窗口(如DirectX游戏、WebGL页面),PrintWindow也会失效,返回的图片是一片空白或纯色。这算是个已知限制。
5.2 截屏黑屏不是玄学,是渲染路径差异
做这个功能时最难排查的就是“我在自己电脑上一切正常,到了客户电脑上截图全黑”。后来发现问题集中在两类窗口上:视频播放器和WebView。它们默认走GPU加速,窗口内容由显卡合成,GDI/PrintWindow根本读不到。
解决方案有两种。优先推荐Graphics Capture API,它是Win10 1803后微软主推的截屏接口,能捕获GPU渲染的画面,但代码量比CopyFromScreen多不少,还要处理异步回调流程。如果你的目标平台都是Win10 1803以上,建议抽时间迁移。如果必须兼容Win7,那只能退而求其次:让用户在软件设置里勾选“启动时禁用硬件加速”,或者提示用户把系统显示设置改为“让Windows决定缩放”,在业务上规避问题。
5.3 性能优化与内存释放实践
截屏功能的性能瓶颈主要在像素拷贝和图片编码上。CopyFromScreen本身很快,一张1920x1080的图约耗时30-50毫秒,瓶颈集中在Bitmap.Save时PNG编码,大概要100毫秒以上。如果你在高频场景下截屏(比如每秒截一次),建议:截屏和保存放到后台线程,不要阻塞UI线程,否则窗口会假死。
内存方面,Bitmap是典型的非托管资源。长截图拼接时,如果一次拼接了50张1920x1080的图,内存会瞬间冲到300MB以上。我的处理策略是:拼一张就释放一张的原始图,并且用GC.Collect()在合适的时候手动回收。当然,更彻底的办法是直接操作像素数组拼接,避免创建过多中间对象,但代码复杂度会上升,看你的实际需求取舍。
最后再分享几个小技巧
做了这些年截屏功能,我个人的经验是:不要迷信高级API,稳、准、快才是这个功能的终极目标。CopyFromScreen看着简陋,但它跨平台(Win7到Win11)、零依赖、代码量少,最适合做成通用工具函数供整个项目里到处调用。
给大家一个扩展方向:现在Windows 11上系统自带的截图工具就做得很好,但C#的应用场景从来不是跟系统工具比功能,而是把截屏嵌入到业务流程里。你可以想想自己的业务中,哪些环节需要“证据留存”,哪些操作需要“事后追溯”,把它们和截屏结合起来,往往是个很有价值的功能点。
最后提醒一句:用了Graphics对象的代码,写完一定要回头看一眼有没有用using包住。GDI对象泄露的问题,排查起来比逻辑错误痛苦十倍。装了GDIView工具,程序跑一段时间看GDI对象数量曲线,陡增的话,就老老实实回去找没释放的Bitmap吧。
本文还有配套的精品资源,点击获取