恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
C# Winform仿QQ截图工具实战:PInvoke、BitBlt与选区标注
首页
资讯中心
/
C# Winform仿QQ截图工具实战:PInvoke、BitBlt与选区标注
C# Winform仿QQ截图工具实战:PInvoke、BitBlt与选区标注
发布时间:2026/9/16 1:11:53
简介这套 C# Winform 仿 QQ 截图源码面向有一定 Winform 基础、希望实现屏幕捕获与简易标注功能的桌面开发者。工程演示了 PInvoke 调用 user32.dll 与 gdi32.dll 完成桌面取图并围绕 MouseDown、MouseMove、MouseUp 实现拖拽选区的交互逻辑同时附带绘制工具、热键注册等扩展模块适合作为截图工具入门及二次开发参考。压缩包共 70 个文件包含 41 个 C# 源码文件、9 个资源文件、9 个图标及解决方案与工程配置文件核心类如 CaptureImageTool、DrawToolsControl、HotKey 等均分模块组织整包仅 130KB轻量便于阅读。已有 295 人在线学习浏览。通过源码可以清楚看到从创建兼容 DC、BitBlt 截屏到位图保存的完整链路也能直接复用其矩形选区绘制、橡皮擦与文字标注等操作管理方式对理解 Winform 图像处理和 Win32 API 互操作很有帮助。1. 仿QQ截图难点不在截屏而在“反馈”与“编辑”链路很多人第一次做截图工具时都以为最大的坎是怎么把屏幕抓下来。实际上BitBlt三行代码就能把整个桌面拷进Bitmap真正的工程难点全在交互用户按住鼠标拖出选区时覆盖层不能闪烁多显示器下选区坐标不能错位截完图后还要有一排顺手可用的标注工具。这套CaptureImageToolDemo_CSharp正是把这条链路完整串起来的一个 Winform 样例里面既有 PInvoke 抓屏也有自绘工具栏、颜色选择器、热键注册。对于想给内部工具加上截图功能的开发者或者打算把截图做成独立小产品的 C# 工程师这份代码的拆解价值比“屏幕抓取”本身高得多。2. PInvoke 与 BitBlt先把屏幕抓到内存里2.1 为什么不用 Graphics.CopyFromScreen 而要碰 PInvokeSystem.Drawing里其实已经提供了Graphics.CopyFromScreen它内部就是调用BitBlt大多数单屏截屏场景下直接用这个就够了。但仿 QQ 截图的工具有两个地方让CopyFromScreen不太顺手一是你需要拿到“当前整个虚拟桌面”的原始 DC尤其当副屏在主屏左边负坐标时CopyFromScreen的坐标参数需要自己换算二是后续如果要配合DrawToBitmap、鼠标光标捕获、或者用方向键微调选区直接拥有 HDC 比再来一层封装更灵活。这个项目里已经把GetDC、CreateCompatibleDC、BitBlt等 API 都封装好了与其再包装一层不如直接读它。另外CopyFromScreen在启用了 DWM 合成的时候理论上也是通过 GDI 抓取但在某些远程桌面或高 DPI 环境会出现延迟或缩放错位。采用 PInvoke 方式所有坐标和尺寸都由我们自己控制排查起来也简单。下面我把这套调用具象化。2.2 需要引入的 Win32 函数及其作用先看这个项目在NativeMethods.cs里 import 了哪些函数核心围绕三个动作拿设备上下文、创建兼容内存画布、执行位块传送。我用表格列出来函数DLL作用关键参数GetDC(IntPtr hwnd)user32.dll获取窗口或屏幕的设备上下文hwnd 为IntPtr.Zero时指向全屏返回 HDCReleaseDC(IntPtr hwnd, IntPtr hdc)user32.dll释放 DC避免 GDI 句柄泄漏hwnd 与 GetDC 对应CreateCompatibleDC(IntPtr hdc)gdi32.dll创建与指定 DC 兼容的内存 DC用于离屏绘制CreateCompatibleBitmap(IntPtr hdc, int w, int h)gdi32.dll创建与 DC 兼容的位图宽高按目标区域SelectObject(IntPtr hdc, IntPtr obj)gdi32.dll把位图等 GDI 对象选入 DC返回旧对象用后恢复BitBlt(...)gdi32.dll从源 DC 复制像素块到目标 DC最后一个参数是光栅操作码DeleteDC(IntPtr hdc)gdi32.dll删除内存 DC与CreateCompatibleDC配对DeleteObject(IntPtr obj)gdi32.dll删除 GDI 对象删除位图这里要特别注意 DC 和 GDI 对象的生命周期。GetDC拿到的屏幕 DC 不能自己删除必须还回去CreateCompatibleDC创建的内存 DC 用完后要DeleteDCCreateCompatibleBitmap创建的位图在用Image.FromHbitmap转成托管Bitmap之后原 HBITMAP 句柄可以删。顺序是先把旧位图 Select 回去再删新位图最后删 DC否则可能造成 GDI 资源泄漏运行一段时间后整个屏幕花掉。2.3 ScreenCapture 关键代码与参数说明项目中CaptureImageTool的抓屏核心可以简化为这样一个ScreenCapture类public class ScreenCapture { [DllImport(user32.dll)] static extern IntPtr GetDC(IntPtr hwnd); [DllImport(user32.dll)] static extern int ReleaseDC(IntPtr hwnd, IntPtr hdc); [DllImport(gdi32.dll)] static extern IntPtr CreateCompatibleDC(IntPtr hdc); [DllImport(gdi32.dll)] static extern bool DeleteDC(IntPtr hdc); [DllImport(gdi32.dll)] static extern IntPtr CreateCompatibleBitmap(IntPtr hdc, int w, int h); [DllImport(gdi32.dll)] static extern IntPtr SelectObject(IntPtr hdc, IntPtr obj); [DllImport(gdi32.dll)] static extern bool DeleteObject(IntPtr obj); [DllImport(gdi32.dll)] static extern bool BitBlt(IntPtr hdcDest, int xDest, int yDest, int w, int h, IntPtr hdcSrc, int xSrc, int ySrc, uint rop); const int SRCCOPY 0x00CC0020; public static Bitmap Capture(Rectangle region) { IntPtr screenDc GetDC(IntPtr.Zero); IntPtr memDc CreateCompatibleDC(screenDc); IntPtr bitmap CreateCompatibleBitmap(screenDc, region.Width, region.Height); IntPtr oldBitmap SelectObject(memDc, bitmap); BitBlt(memDc, 0, 0, region.Width, region.Height, screenDc, region.X, region.Y, SRCCOPY); Bitmap result Image.FromHbitmap(bitmap); SelectObject(memDc, oldBitmap); DeleteObject(bitmap); DeleteDC(memDc); ReleaseDC(IntPtr.Zero, screenDc); return result; } }这段代码的逻辑是先从GetDC(IntPtr.Zero)拿到整个屏幕的 DC这个屏幕 DC 指的是桌面能覆盖所有显示器。再创建一块和屏幕兼容的内存 DC 和位图把内存 DC 作为画布中转BitBlt把屏幕 DC 中region指定区域的像素直接拷到内存位图里最后用Image.FromHbitmap把 GDI 位图句柄转成 C# 的Bitmap对象。参数上要注意几点。BitBlt的xSrc/ySrc是源 DC 里的坐标所以把region.X和region.Y传进去意味着可以截取虚拟屏幕任意位置包括负坐标区域Width/Height是选区的宽高不一定是整个屏幕。SRCCOPY表示直接覆盖复制不做透明合成。如果以后想做“截图时包含鼠标光标”还得在这个基础上再调GetCursorInfo和DrawIcon这里不展开。还有一个容易忽略的点为什么用CreateCompatibleBitmap而不是直接new Bitmap(Width, Height)因为直接新建的位图 RGB 格式可能和屏幕 DC 不一致BitBlt时会在某些显卡驱动下出现逐像素颜色偏移或性能下降。兼容位图能保证格式完全匹配。3. 选区交互从 MouseDown 到 MouseUp 的矩形绘制与双缓冲3.1 鼠标事件驱动的选区状态机抓屏之后用户需要在覆盖层上拖出选区。这个覆盖层实际上是一个全屏、无边框、置顶的 Winform 窗体它先在OnPaint里把截图画出来然后监听鼠标事件。项目里的做法可以归纳成一个三态状态机None等待按下鼠标Selecting正在拖拽矩形随鼠标更新Selected已经释放鼠标等待确认或编辑鼠标按下时记录startPoint移动时更新endPoint并触发Invalidate()抬起时把矩形固定下来。关键事件如下private Point startPoint, endPoint; private CaptureState state CaptureState.None; protected override void OnMouseDown(MouseEventArgs e) { base.OnMouseDown(e); if (e.Button ! MouseButtons.Left) return; state CaptureState.Selecting; startPoint e.Location; endPoint e.Location; Invalidate(); } protected override void OnMouseMove(MouseEventArgs e) { base.OnMouseMove(e); if (state ! CaptureState.Selecting) return; endPoint e.Location; Invalidate(); } protected override void OnMouseUp(MouseEventArgs e) { base.OnMouseUp(e); if (state ! CaptureState.Selecting) return; state CaptureState.Selected; // 这里从矩形区域调用 ScreenCapture.Capture }逻辑说明e.Location是鼠标相对于覆盖层窗体的坐标由于覆盖层左上角就是虚拟屏幕的左上角所以这个点直接当作屏幕坐标用。在MouseUp里调用上一章的ScreenCapture.Capture(GetSelectionRect())就能得到选区图像。这里有个容易被忽略的点如果用户从右下往左上拖矩形坐标为负宽高需要做归一化。用一个方法把startPoint和endPoint转换成正常Rectangle同时把宽度和高度取绝对值。项目里的RegionHelper和GraphicsPathHelper就是做这类几何运算的。3.2 双缓冲刷新解决拖拽残影如果直接在OnPaint里画背景再画选区拖拽时会看到明显的闪烁和残影。原因是 Winform 默认先在背景上擦除再触发OnPaint一擦一画之间就闪了。方案是启用双缓冲把整个绘制过程先画到内存缓冲区再一次拷到屏幕。这个项目在窗体构造函数里设置了SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer, true);然后OnPaint里按顺序绘制截图背景、半透明遮罩、选区矩形、选区边框、尺寸提示文字。半透明遮罩是 QQ 截图那种“选区外变暗”的效果使用Color.FromArgb(80, 0, 0, 0)填充。protected override void OnPaint(PaintEventArgs e) { e.Graphics.DrawImage(_screenImage, Point.Empty); // 背景 using (var brush new SolidBrush(Color.FromArgb(80, 0, 0, 0))) { Rectangle sel GetSelectionRect(); if (state CaptureState.Selecting || state CaptureState.Selected) { // 画四个矩形区域把选区外遮住 e.Graphics.FillRectangle(brush, new Rectangle(0, 0, Width, sel.Top)); e.Graphics.FillRectangle(brush, new Rectangle(0, sel.Bottom, Width, Height - sel.Bottom)); e.Graphics.FillRectangle(brush, new Rectangle(0, sel.Top, sel.Left, sel.Height)); e.Graphics.FillRectangle(brush, new Rectangle(sel.Right, sel.Top, Width - sel.Right, sel.Height)); } else { e.Graphics.FillRectangle(brush, DisplayRectangle); } } if (state CaptureState.Selecting || state CaptureState.Selected) { Rectangle sel GetSelectionRect(); using (var pen new Pen(Color.DeepSkyBlue, 1f)) { e.Graphics.DrawRectangle(pen, sel); } } }参数说明Color.FromArgb(80, 0, 0, 0)的 80 是透明度值越小遮罩越淡。四个FillRectangle分别覆盖选区上、下、左、右四个区域因为选区矩形本身不需要遮罩。DrawRectangle的sel需要是归一化后的矩形而且注意 GDI 的DrawRectangle会向内部缩进一个像素所以绘制边框时经常用new Rectangle(sel.X, sel.Y, sel.Width - 1, sel.Height - 1)来避免右边和下边被截断。这里还要提一下双缓冲的代价选区越大每次Invalidate重绘的开销越大。如果觉得拖拽不够跟手可以在OnMouseMove里只更新改变的区域用Invalidate(oldRect)合并新矩形减少重绘面积。但实际用下来1920x1080 下全屏重绘也能轻松跑到 60fps这一步优化可以放到后面再做。3.3 坐标换算虚拟屏幕与物理屏幕多显示器是截图工具绕不开的坑。Windows 把整个桌面区域称为“虚拟屏幕”主屏的左上角不一定是 (0,0)。比如副屏放在主屏左边时副屏的坐标是负数这时全屏覆盖窗体的 Location 也会变成负数。如果直接用Screen.PrimaryScreen.Bounds计算选区的坐标就会错位。正确做法是用SystemInformation.VirtualScreen获取整个虚拟屏幕的边界然后让覆盖窗体覆盖这个区域Rectangle virtualScreen SystemInformation.VirtualScreen; this.Bounds virtualScreen;这样e.Location天然就是相对于虚拟屏幕的坐标。再算真实截屏区域时不需要额外的偏移。如果做窗口跟随或者放大镜预览才会需要这个virtualScreen.Location换算。比如在MouseMove里显示“选区尺寸”文字可以直接用sel.Width和sel.Height不需要关心它在哪个屏幕。还有一种情况是 DPI 缩放。当系统缩放不是 100% 时VirtualScreen返回的是逻辑像素BitBlt需要的是物理像素。如果使用 PInvoke 截图建议在程序入口调用SetProcessDPIAware()来禁用 DPI 虚拟化否则截图区域会变小。这是项目中容易被忽略但在高分辨率显示器上必踩的一步。也就是说选区的矩形区域来自鼠标逻辑坐标而 BitBlt 的坐标最终要映射到物理像素很多“截图偏小”的问题追踪到底都出在这里。4. 仿QQ截图的工具栏绘制对象管理与颜色选择4.1 从 DrawToolsControl 到 OperateManager标注对象的生命周期截图完接下来是标注。这个项目里的工具栏是DrawToolsControl它继承ToolStrip上面放了画笔、矩形、椭圆、文字、颜色等按钮。注意它不是一个普通控件而是通过自定义渲染器DrawToolsDockStyle和ToolStripRendererEx画成 QQ 那种浅灰扁平样式。标注对象的管理放在OperateManager里。OperateManager维护一个ListOperateObject每一个OperateObject对应一个标注元素比如一条直线、一个矩形框或一段文字。OperateObject持有OperateType枚举类型表示工具类型、Rectangle区域、Color、PenWidth等。绘制时DrawToolsControl通知OperateManager切换当前操作类型鼠标在截图区域操作时OperateManager负责把新对象加入列表并触发重绘。这个结构把“UI 事件”和“绘制数据”分离了后续做撤销重做非常方便。撤销时只需要从OperateManager的对象列表里移除最后一个OperateObject而不是撤销绘制操作本身。我一般会在这里再包一层_undoStack和_redoStack每次提交新标注时把当前列表副本压栈。对这个小项目来说直接保存对象列表也能达到同样效果。代码上核心是public enum OperateType { None, Pen, Rectangle, Ellipse, Arrow, Text } public class OperateObject { public OperateType Type { get; set; } public Rectangle Bounds { get; set; } public Color Color { get; set; } public float PenWidth { get; set; } public string Text { get; set; } public ListPoint Points { get; set; } // 画笔的轨迹 } public class OperateManager { public ListOperateObject Objects { get; } new(); public void Add(OperateObject obj) { Objects.Add(obj); } public void RemoveLast() { if (Objects.Count 0) Objects.RemoveAt(Objects.Count - 1); } }ObservableCollection在这里不是必须的但方便后续把列表绑定到ListBox做图层列表。如果不做图层直接用ListOperateObject加Invalidate即可。参数说明Bounds对矩形、椭圆、文字有意义Points只对画笔有意义这样同一个类可以承载所有标注不会因为“文字不需要 Points”而出现一堆 null 判断。4.2 实现画笔、矩形与文字标注用户选择工具后鼠标的交互逻辑与选区那套类似但不需要全屏遮罩而是在一个PictureBox或者截图结果显示控件上操作。绘制画笔时MouseDown新建一个OperateObject并把当前点加入PointsMouseMove如果还在按住状态就继续加新点同时调用Invalidate重绘绘制矩形时MouseMove更新Bounds绘制文字时MouseDown弹出一个输入框把用户输入的字符串存到Text。关键代码片段private void Canvas_MouseMove(object sender, MouseEventArgs e) { if (_current null) return; if (_current.Type OperateType.Pen) { _current.Points.Add(e.Location); canvas.Invalidate(); } else if (_current.Type OperateType.Rectangle || _current.Type OperateType.Ellipse) { _current.Bounds Rectangle.FromLTRB( _startPoint.X, _startPoint.Y, e.X, e.Y); canvas.Invalidate(); } }之后在OnPaint里遍历OperateManager.Objects根据Type画出不同内容。Pen直接画折线Rectangle画矩形框Text用DrawString。这里有一个容易出问题的点Rectangle.FromLTRB对反向拖拽会自动交换左右边界吗不会它只是简单把第一个参数作为 Left。所以还是需要像第三章那样做矩形归一化或者直接用new Rectangle(Math.Min(start.X, e.X), Math.Min(start.Y, e.Y), Math.Abs(e.X - start.X), Math.Abs(e.Y - start.Y))。撤销快捷键也简单在窗体上重写ProcessCmdKey监听 CtrlZ然后调用OperateManager.RemoveLast()。这一步可以让标注体验立刻接近 QQ 截图。还有文字标注的字体设置建议在DrawTextData里存一份Font否则每次绘制都从系统中性字体创建会导致不同用户电脑上文字大小不一致。绘制文字时还要记得把TextRenderingHint设为AntiAlias否则文字边缘的锯齿非常明显这个细节在做截图工具时几乎必踩。4.3 颜色选择器与界面美化仿QQ工具栏的细节工具栏上要能方便地切换颜色。项目里有一个ColorSelector控件点击色块弹出下拉面板再配合ColorLabel显示当前值。这里最值得借鉴的是它的交互色块不是普通Button而是重写了OnPaint用当前颜色填充并画一个圆角边框选中时边框变深。这个思路比用ColorDialog更贴合截图工具的使用频率——不需要每次都弹系统对话框。如果你想把工具栏做得更像 QQ重点在ToolStripProfessionalRenderer的颜色表。项目里的ProfessionalCaptureImageToolColorTable就是干这个的我提取出几个关键属性属性作用仿QQ建议值ToolStripBorder工具栏外边框#D1D5DAToolStripGradientBegin顶部渐变开始色#F7F8FAToolStripGradientEnd底部渐变结束色#EBEDF0ButtonSelectedGradientBegin鼠标悬停背景#E4E9F2ButtonPressedGradientBegin按下背景#D0D7E5设置方法很简单在构造函数里var renderer new ToolStripProfessionalRenderer(new ProfessionalCaptureImageToolColorTable()); toolStrip.Renderer renderer;注意继承ProfessionalColorTable时需要把不使用的MenuStrip、StatusStrip相关属性也一并实现否则某些版本下仍会使用默认蓝色系统主题。这类界面美化的坑在 Winform 里很多尤其是当你的工具栏里还有ToolStripSplitButton时下拉箭头的绘制逻辑很容易错位。遇到这种情况建议像项目一样单独画一个ColorSelector而不是塞进ToolStrip可控性会高很多。另外如果代码里引用了原项目ToolStripRendererEx.cs要记得该类在System.Drawing.Drawing2D的GraphicsPath基础上做了圆角处理它依赖GraphicsPathHelper两者要一起拷贝。5. 全局热键、保存格式与剪贴板集成5.1 用 RegisterHotKey 呼出截图QQ 截图的招牌是热键唤起。项目里HotKey.cs底层就是RegisterHotKey。注册CtrlAltA窗口在后台也能收到消息[DllImport(user32.dll)] static extern bool RegisterHotKey(IntPtr hWnd, int id, uint fsModifiers, uint vk); protected override void WndProc(ref Message m) { if (m.Msg 0x0312) // WM_HOTKEY { if (m.WParam.ToInt32() HOTKEY_ID) { ShowCaptureOverlay(); } } base.WndProc(ref m); }0x0312是 WM_HOTKEYid用于区分多个热键。注意关闭窗体时要调用UnregisterHotKey否则快捷键一直被占着。5.2 保存为 PNG 而不是 JPG标注后的截图可能包含透明信息JPEG 不支持透明通道保存后会把透明部分填黑。保存格式建议按扩展名区分using var sfd new SaveFileDialog(); sfd.Filter PNG|*.png|JPEG|*.jpg; if (sfd.ShowDialog() DialogResult.OK) { if (Path.GetExtension(sfd.FileName).Equals(.jpg, StringComparison.OrdinalIgnoreCase)) { using var bmp new Bitmap(canvas.Width, canvas.Height); canvas.DrawToBitmap(bmp, canvas.ClientRectangle); bmp.Save(sfd.FileName, ImageFormat.Jpeg); } else { canvas.Image.Save(sfd.FileName, ImageFormat.Png); } }DrawToBitmap会把标注图层和截图背景合成到一张不透明图上避免 JPEG 变黑。如果不是必须支持 JPG干脆只给 PNG 一个选项。5.3 截图完成直接进剪贴板截完图自动复制到剪贴板是 QQ 截图的一个习惯。一行代码就能做Clipboard.SetImage(resultBitmap);注意Clipboard.SetImage要求当前线程是 STA 单线程模型Winform 主线程默认就是 STA。如果是在后台线程或非 UI 线程调用需要包一层Thread并设置SetApartmentState(ApartmentState.STA)否则会静默失败。热键唤起、拖拽选区、标注、确认后进剪贴板或者保存这几个步骤已经覆盖了仿 QQ 截图工具的核心体验。实际落地时还可以在MouseUp之后自动隐藏覆盖窗体等用户按回车确认截图按 ESC 取消这样才能避免误操作后还要走一遍关闭流程。本文还有配套的精品资源点击获取