news 2026/11/5 6:20:26

MFC仿聊天截图工具:全局热键、多屏抓取与GDI双缓冲

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
MFC仿聊天截图工具:全局热键、多屏抓取与GDI双缓冲

简介:面向具备 C++ 基础、希望用 MFC 开发 Windows 桌面小工具的开发者,这份工程源码复现了 QQ 风格的截图程序,可作为窗口管理、消息映射与 GDI+ 绘图的实践样本。压缩包共 43 个文件、约 80.54MB,其中 h 与 cpp 为各功能类的实现,rc、ico、png、jpg 构成对话框与按钮素材,sln、vcxproj、filters 提供工程配置,Release 目录还附带已编译的 exe 与 pdb 调试文件,结构接近真实项目而非零散片段。核心模块包含跟随鼠标的放大取景窗口、透明覆盖窗口上的矩形选区拖拽与缩放、CFileException 等异常的捕获与提示,以及把截图位图转为 CF_DIB 写入剪切板的完整流程。已有 477 人学习,适合按模块拆解源码、对照复现功能,或作为课程设计与自学的参考实现。

1. 一个截图工具,本质是窗口与位图的博弈

去年帮一个内部工单系统做改造,用户抱怨最多的不是功能少,而是"描述问题"那栏——没人愿意打字说清屏幕上哪块出了错。后来在原有的 MFC 老工程里塞进一个仿聊天软件的截图工具,按下热键、框选、画个箭头、直接贴进输入框,整个过程两秒结束,反馈质量肉眼可见地变了。

这份代码干的事很具体:全局热键唤起、多显示器全屏抓取、拖拽选区、放大镜取色、矩形/椭圆/箭头/画笔/文字/马赛克六种标注、撤销重做、保存文件或直接进剪贴板。它适合两类人——手里有 MFC 项目想加截图能力、又不愿意为此引入 Qt 或前端技术栈的;以及想看清 GDI 双缓冲、分层窗口、鼠标状态机这些"老派但扎实"的写法究竟怎么落地的人。代码量不大,但坑一个不少,下面按我拆解的顺序讲。

2. 工程骨架:对话框、覆盖窗口、工具条的三层分工

很多人第一反应是"主对话框上放个按钮,点了就截屏"。这么写出来的东西只能截自己窗口,一旦跨窗口、跨显示器就崩,因为你的绘图上下文始终绑在自家 HWND 上。正确的拆法是把"触发"和"执行"彻底分开:常驻的只有一个隐形的主对话框,真正的截图发生在一个临时的全屏覆盖窗口里。

2.1 目录结构与类的职责边界

先看目录。这套结构是我调整过两版之后定下来的,核心原则是"抓屏、交互、标注"三者互不依赖,任何一层都能单独替换。

ScreenShot/ ├── ScreenShot.sln ├── ScreenShot/ │ ├── ScreenShot.cpp/.h 应用入口,InitInstance 里隐藏主窗口 │ ├── ScreenShotDlg.cpp/.h 主对话框,只负责热键与托盘图标 │ ├── ShotWnd.cpp/.h 全屏覆盖窗口,抓屏/遮罩/选区 │ ├── ToolBarWnd.cpp/.h 浮动工具条,跟随选区移动 │ ├── Overlay/ │ │ ├── AnnoBase.h 标注对象基类 │ │ ├── AnnoRect.cpp/.h 矩形、椭圆 │ │ ├── AnnoArrow.cpp/.h 箭头 │ │ ├── AnnoPen.cpp/.h 自由画笔 │ │ ├── AnnoText.cpp/.h 文字 │ │ └── AnnoMosaic.cpp/.h 马赛克 │ ├── Core/ │ │ ├── ScreenCapture.cpp/.h 抓屏与多显示器拼合 │ │ ├── BitmapUtil.cpp/.h HBITMAP / CImage 互转 │ │ ├── HotKey.cpp/.h RegisterHotKey 封装 │ │ └── Clipboard.cpp/.h 写 CF_DIB │ ├── res/ │ └── ScreenShot.rc
类职责生命周期
CScreenShotDlg常驻,注册全局热键、托盘、退出随进程
CShotWnd一次截图会话,抓屏、遮罩、选区、标注绘制每次截图创建销毁
CToolBarWnd工具按钮、颜色选择、保存/复制/取消跟随CShotWnd
CAnnoBase派生族单个标注对象的绘制与命中测试由撤销栈持有
CScreenCapture纯函数式抓屏,无窗口依赖静态调用

分清楚之后,好处立刻显现:想加"滚动截图",只需要在CScreenCapture里加一个循环拼接的函数,CShotWnd完全不用动;想换一套工具条皮肤,CToolBarWnd单独替换即可。

2.2 程序入口:主对话框为什么必须藏起来

截图工具的常态是"平时不存在,热键按下才出现"。如果主对话框可见,它会出现在你自己的截图里,那就闹笑话了。所以InitInstance里创建了对话框但不显示,消息循环靠DoModal转,同时把全局热键挂在它身上。

BOOL CScreenShotApp::InitInstance() { CWinApp::InitInstance(); // 高 DPI 感知必须在任何窗口创建之前声明,否则后面全是错位 ::SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); CScreenShotDlg dlg; m_pMainWnd = &dlg; // 热键 id 固定为 0x1001,组合键 Ctrl+Alt+A if (!::RegisterHotKey(dlg.GetSafeHwnd(), HOTKEY_ID_SHOT, MOD_CONTROL | MOD_ALT, 'A')) { // 注册失败一般是组合键被别的软件占了,必须提示,不能静默 AfxMessageBox(_T("热键注册失败,可能已被其他程序占用,请在设置里更换")); } dlg.DoModal(); // 窗口本身不 ShowWindow,只借它的消息泵 ::UnregisterHotKey(dlg.GetSafeHwnd(), HOTKEY_ID_SHOT); return FALSE; // 返回 FALSE 让 MFC 直接退出,不再进默认循环 }

几个参数值得说明。MOD_CONTROL | MOD_ALT是修饰键掩码,'A'是虚拟键码,用字符常量比VK_A直观。HOTKEY_ID_SHOT是自定义的整型 id,在OnHotKey里靠它区分是哪个热键触发的。DoModal而不是Create,是因为对话框不显示时Create之后还得自己写消息循环,DoModal直接把RunModalLoop借来用了,省事。最后return FALSE,告诉 MFC "别再去创建主窗口了"。

热键消息处理里只做一件事:创建覆盖窗口。这里有个血泪经验——不要在热键回调里直接BitBlt,因为热键触发的那一瞬间,用户可能正在拖动别的窗口,画面还没稳定,抓出来是残影。常见做法是PostMessage一个自定义消息,等当前消息队列清空、界面刷完了再抓。

// ScreenShotDlg.cpp void CScreenShotDlg::OnHotKey(UINT nHotKeyId, UINT, UINT) { if (nHotKeyId != HOTKEY_ID_SHOT) return; // 抛到消息队列尾部,等界面稳定再抓,避免抓到窗口重绘的中间态 PostMessage(WM_START_CAPTURE, 0, 0); } LRESULT CScreenShotDlg::OnStartCapture(WPARAM, LPARAM) { CShotWnd* pWnd = new CShotWnd(); // 覆盖窗口自己 delete 自己 if (!pWnd->Start()) { delete pWnd; } return 0; }

new出来自己管生命周期看着粗暴,但覆盖窗口天生是"一次性"的,用栈对象反而要处理它跟工具条的父子关系。约定好CShotWnd::EndSession()里DestroyWindow(),PostNcDestroy()里delete this,这条链就干净了。

2.3 覆盖窗口的创建参数,每一个都有讲究

这个窗口要盖住所有显示器、不能被 Alt+Tab 切出来、没有标题栏、鼠标是十字。参数写错一个,体验就崩一半。

BOOL CShotWnd::Start() { // 虚拟屏幕坐标:多显示器时左边那台可能是负坐标,不能用主屏的 0,0 CRect rcVirtual( ::GetSystemMetrics(SM_XVIRTUALSCREEN), ::GetSystemMetrics(SM_YVIRTUALSCREEN), ::GetSystemMetrics(SM_XVIRTUALSCREEN) + ::GetSystemMetrics(SM_CXVIRTUALSCREEN), ::GetSystemMetrics(SM_YVIRTUALSCREEN) + ::GetSystemMetrics(SM_CYVIRTUALSCREEN)); LPCTSTR cls = ::AfxRegisterWndClass( CS_HREDRAW | CS_VREDRAW, ::LoadCursor(NULL, IDC_CROSS), // 十字光标 NULL, NULL); // WS_EX_TOOLWINDOW 让它不出现在 Alt+Tab 里,WS_EX_TOPMOST 保证盖住任务栏 if (!CreateEx(WS_EX_TOPMOST | WS_EX_TOOLWINDOW, cls, _T("ShotCover"), WS_POPUP, rcVirtual, NULL, 0, NULL)) return FALSE; m_nW = rcVirtual.Width(); m_nH = rcVirtual.Height(); // 抓屏必须在窗口显示之前完成,否则抓到的画面里会包含这个窗口自己 if (!CScreenCapture::GrabVirtualScreen(m_imgScreen)) return FALSE; ShowWindow(SW_SHOW); SetForegroundWindow(); // 抢焦点,否则收不到键盘 ESC 取消 return TRUE; }

SM_XVIRTUALSCREEN系列指标是老 API,但比EnumDisplayMonitors省事,一次性拿到整个虚拟桌面的原点与尺寸。如果主屏在右、副屏在左,SM_XVIRTUALSCREEN会是负数,这是必须处理的,否则副屏上根本看不到遮罩。

WS_EX_TOOLWINDOW这个样式容易被忽略,但它决定了两件事:窗口不出现在 Alt+Tab 列表,以及不显示在任务栏。少了它,用户按 Alt+Tab 切回来时会看到一个叫 "ShotCover" 的怪东西。

还有一个顺序问题:抓屏一定要在ShowWindow之前。如果先显示再抓屏,BitBlt会把你自己的遮罩窗口也拷进去,形成"画中画"。我第一版就犯过这个错,调试了半小时才反应过来。

2.4 三层的消息链路

把三者的流转说清楚:热键落在CScreenShotDlg,它PostMessage创建CShotWnd;CShotWnd在鼠标松开、选区确定后创建CToolBarWnd,并把当前选区的屏幕坐标传过去;工具条上的每个按钮点击后回调CShotWnd的SetCurrentTool(),改变的是CShotWnd内部的状态机枚举,而不是工具条自己的状态。

enum ShotTool { TOOL_SELECT = 0, // 选择/移动选区 TOOL_RECT, // 矩形标注 TOOL_ELLIPSE, TOOL_ARROW, TOOL_PEN, TOOL_TEXT, TOOL_MOSAIC };

工具条不认识标注对象,它只认识枚举,CShotWnd::OnToolChanged(ShotTool t)收到后更新m_eCurTool并把光标换成对应的形状。状态只存一份,是保证"工具条高亮"和"实际绘制行为"永远一致的前提。见过不少新手版本把工具状态在两边各存一份,结果点快了就出现按钮亮着但画出来是上一种图形。

3. 抓屏与选区:一次 BitBlt 之后的所有交互

全屏图抓下来只是起点,真正占工作量的是一层遮罩、一个选框、八个控制点,以及它们之间的实时响应。这块是做截图工具最容易被低估的部分,写好了才有"丝滑"的感觉。

3.1 多显示器下的一次性全屏抓取

抓屏代码本身只有十几行,但坐标和位深处理不对,后面全是麻烦。

BOOL CScreenCapture::GrabVirtualScreen(CImage& out) { HDC hScreen = ::GetDC(NULL); // NULL = 整个屏幕,而非某个窗口 if (!hScreen) return FALSE; const int x = ::GetSystemMetrics(SM_XVIRTUALSCREEN); const int y = ::GetSystemMetrics(SM_YVIRTUALSCREEN); const int w = ::GetSystemMetrics(SM_CXVIRTUALSCREEN); const int h = ::GetSystemMetrics(SM_CYVIRTUALSCREEN); if (w <= 0 || h <= 0) { ::ReleaseDC(NULL, hScreen); return FALSE; } HDC hMem = ::CreateCompatibleDC(hScreen); HBITMAP hBmp = ::CreateCompatibleBitmap(hScreen, w, h); HGDIOBJ hOld = ::SelectObject(hMem, hBmp); // 源坐标传 x/y,推送坐标从 0,0 开始,多屏时这才是正确姿势 // CAPTUREBLT 让分层窗口(比如某些带阴影的界面)也能被抓到 ::BitBlt(hMem, 0, 0, w, h, hScreen, x, y, SRCCOPY | CAPTUREBLT); ::SelectObject(hMem, hOld); ::DeleteDC(hMem); ::ReleaseDC(NULL, hScreen); out.Attach(hBmp); // CImage 接管所有权,析构时自动 DeleteObject return TRUE; }

参数逐个说:GetDC(NULL)拿的是整个屏幕的 DC,不是桌面窗口的;CreateCompatibleBitmap必须用屏幕 DC 来创建,如果用内存 DC 创建,你会得到一个 1bpp 的单色位图,存出来全黑白——这是个经典陷阱。BitBlt的源坐标传x, y,目标坐标传0, 0,意思是"从虚拟桌面左上角开始拷 w×h 那么大一块,放到我的位图左上角",这样副屏内容才不会被裁掉。CAPTUREBLT这个标志加与不加差别不大,但加了能抓到一部分分层窗口,代价是偶尔会有闪烁残留,可以按需取舍。

CImage::Attach之后不要再去DeleteObject(hBmp),CImage析构会做,重复删除会直接崩。

3.2 遮罩与选区高亮:三段绘制

画面效果是"整屏压暗,选中的区域保持原样"。实现上就是把原图贴三次:先整屏贴一遍做底,再全屏叠一层半透明黑,最后把选区那一小块原图再贴一次盖回去。

void CShotWnd::OnPaint() { CPaintDC dc(this); // 双缓冲:所有绘制先落在内存 DC,最后一次性吐到屏幕 CDC mem; CBitmap bmpBuf; mem.CreateCompatibleDC(&dc); bmpBuf.CreateCompatibleBitmap(&dc, m_nW, m_nH); CBitmap* pOldBmp = mem.SelectObject(&bmpBuf); CDC memSrc; memSrc.CreateCompatibleDC(&dc); CBitmap* pOldSrc = memSrc.SelectObject(&m_bmpScreen); // 3.1 抓到的整屏图 // 第一遍:铺原图 mem.BitBlt(0, 0, m_nW, m_nH, &memSrc, 0, 0, SRCCOPY); // 第二遍:整个屏幕压暗,用 50% 点阵刷代替 AlphaBlend,省掉 GDI+ 依赖 CRect rcAll(0, 0, m_nW, m_nH); mem.FillRect(rcAll, &m_brMask); // m_brMask 是 2x2 黑白点阵刷 // 第三遍:选区还原成原始亮度 if (!m_rcSel.IsRectEmpty()) { mem.BitBlt(m_rcSel.left, m_rcSel.top, m_rcSel.Width(), m_rcSel.Height(), &memSrc, m_rcSel.left, m_rcSel.top, SRCCOPY); DrawSelectionBorder(&mem); // 1px 虚线框 + 四角控制点 } dc.BitBlt(0, 0, m_nW, m_nH, &mem, 0, 0, SRCCOPY); memSrc.SelectObject(pOldSrc); mem.SelectObject(pOldBmp); }

半透明遮罩这里有两种常见做法。第一种是引 GDI+,Graphics::FromHDC之后FillRectangle一个 alpha=100 的黑色画刷,效果最正,代价是工程里要多链gdiplus.lib并初始化GdiplusStartup。第二种是这里用的,CreatePatternBrush造一个 2×2 的位图(两个黑两个白),铺上去在视觉上就是 50% 灰,老 GDI 项目里非常常见,代码量小、不引额外库,缺点是不能调成 30% 或 70%,只能靠改点阵密度凑。我一般先用第二种,产品经理非要"再淡一点"的时候再换 GDI+。

双缓冲在这儿是必须的,否则拖动选区时整屏会疯狂闪烁,原因和解决在第 5 章细说。

3.3 鼠标状态机:按下、拖动、归一化

选区的交互不能只靠一个m_bDragging布尔量,得有明确的状态区分,否则"从右下往左上拖"和"拖到一半松手"这类边界很容易翻车。

void CShotWnd::OnLButtonDown(UINT nFlags, CPoint point) { if (!m_rcSel.IsRectEmpty() && HitTestHandles(point) > 0) { // 命中八个控制点之一,进入"调整尺寸"状态而不是重新框选 m_eDrag = DRAG_RESIZE; m_nHitHandle = HitTestHandles(point); m_rcSelBeforeDrag = m_rcSel; } else { m_eDrag = DRAG_SELECTING; m_ptStart = point; m_ptCur = point; m_rcSel.SetRectEmpty(); } SetCapture(); // 鼠标移出窗口外也能收到 WM_MOUSEMOVE CWnd::OnLButtonDown(nFlags, point); } void CShotWnd::OnMouseMove(UINT nFlags, CPoint point) { if (m_eDrag == DRAG_SELECTING) { m_ptCur = point; m_rcSel.SetRect(m_ptStart, m_ptCur); m_rcSel.NormalizeRect(); // 从右下往左上拖时把 left/right 换回来 Invalidate(FALSE); // FALSE = 不擦背景,配合 OnEraseBkgnd 防闪 } else if (m_eDrag == DRAG_RESIZE) { ResizeSelectionByHandle(m_nHitHandle, point); Invalidate(FALSE); } // 放大镜与取色跟手,不受拖拽状态影响 UpdateMagnifier(point); CWnd::OnMouseMove(nFlags, point); } void CShotWnd::OnLButtonUp(UINT nFlags, CPoint point) { if (m_eDrag == DRAG_SELECTING && m_rcSel.Width() > 2 && m_rcSel.Height() > 2) { ShowToolBar(m_rcSel); // 选区成型,工具条贴到右下角 } m_eDrag = DRAG_NONE; ReleaseCapture(); // 必须配对,否则别的窗口收不到鼠标 CWnd::OnLButtonUp(nFlags, point); }

SetCapture/ReleaseCapture这一对是选区能"拖出窗口边界"的关键,漏了ReleaseCapture的话,整个系统都会觉得鼠标被你的程序按住了,这是很严重的 bug。NormalizeRect处理反向拖拽,CRect::SetRect(左上, 右下)在m_ptStart.x > m_ptCur.x时会产生负宽度,不归一化后面Width()直接返回负数。

Invalidate(FALSE)的参数决定"要不要擦背景",传TRUE会先发WM_ERASEBKGND再发WM_PAINT,两次绘制之间有一段纯背景色,视觉上就是闪。这块必须配OnEraseBkgnd直接返回TRUE,在第 5 章会再展开。

3.4 八个控制点与放大镜

控制点就是选区的四角和四边中点,每个点是一个 5×5 的小方块。命中测试反过来写更清楚:先算每个手柄的中心,再判断鼠标到中心的距离是否在阈值内。

int CShotWnd::HitTestHandles(CPoint pt) const { if (m_rcSel.IsRectEmpty()) return 0; const int cx = (m_rcSel.left + m_rcSel.right) / 2; const int cy = (m_rcSel.top + m_rcSel.bottom) / 2; // 顺序与枚举 HANDLE_NW..HANDLE_W 一致,1 开始,0 留给"未命中" const CPoint pts[8] = { { m_rcSel.left, m_rcSel.top }, { cx, m_rcSel.top }, { m_rcSel.right, m_rcSel.top }, { m_rcSel.right, cy }, { m_rcSel.right, m_rcSel.bottom }, { cx, m_rcSel.bottom }, { m_rcSel.left, m_rcSel.bottom }, { m_rcSel.left, cy } }; for (int i = 0; i < 8; ++i) { CRect rcHit(pts[i].x - 4, pts[i].y - 4, pts[i].x + 4, pts[i].y + 4); if (rcHit.PtInRect(pt)) return i + 1; } return 0; }

放大镜是另一个加分项:在鼠标右下方画一个 120×90 的小方块,把鼠标周围 20×15 像素的区域按 6 倍放大贴进去,再在中间画个十字定位当前像素,旁边标上RGB(12, 34, 56)的十六进制色值。数据直接从m_imgScreen用GetPixel取就行,因为这里一天也就取几百次,性能完全够。真要做像素级的批量处理(比如马赛克),才需要下沉到 DIB 指针层面。

4. 标注与工具条:让截图从"能看"到"能说清"

到这一步,用户已经框出了一块区域,接下来是画东西。这块的设计核心只有一条:所有标注都要能被撤销,且撤销的粒度是"一次完整的绘制动作",而不是"一个像素点"。

4.1 用多态管住所有标注对象

每种图形都实现同一套接口,绘制循环就变成一次遍历,加新图形只要新增一个派生类,OnPaint一行都不用改。

// AnnoBase.h class CAnnoBase { public: virtual ~CAnnoBase() {} // 把自身画到目标 DC 上,坐标已经是图片局部坐标 virtual void Draw(CDC* pDC) = 0; // 判断某个点是否落在图形上,用于"选中后拖动或删除" virtual BOOL HitTest(CPoint pt) const { return FALSE; } // 包围盒,用于脏矩形重绘 virtual CRect GetBounds() const = 0; void SetStyle(COLORREF clr, int nWidth) { m_clr = clr; m_nWidth = nWidth; } protected: COLORREF m_clr = RGB(230, 30, 30); // 默认红,醒目 int m_nWidth = 2; // 线宽像素 };

撤销栈就是一个std::vector<std::shared_ptr<CAnnoBase>>:

void CShotWnd::CommitAnnotation(std::shared_ptr<CAnnoBase> sp) { m_vecAnno.push_back(sp); m_vecRedo.clear(); // 新操作让重做链失效,标准行为 RefreshCanvas(); } void CShotWnd::Undo() { if (m_vecAnno.empty()) return; m_vecRedo.push_back(m_vecAnno.back()); m_vecAnno.pop_back(); RefreshCanvas(); }

用shared_ptr而不是裸指针,是因为撤销后图形对象可能还挂在重做栈里,两边共享同一个实例,手动delete会重复释放。这一条我在别的项目里踩过,排查了半天才发现是撤销和重做各 delete 了一次。

RefreshCanvas()的流程是:把原整屏图贴到临时 DC → 遍历m_vecAnno调Draw→ 把这个临时 DC 整块贴回屏幕。整块重绘比算脏矩形的代码短得多,而且以现在的屏幕尺寸完全感知不到卡顿,我一般先上整块重绘,真卡了再优化。

4.2 箭头与画笔:几何计算都在这十几行里

箭头是标注里使用频率最高的,画法是一条主线加两条翼线,翼线角度相对主线偏转 ±150 度。

void CAnnoArrow::Draw(CDC* pDC) { CPen pen(PS_SOLID, m_nWidth, m_clr); CPen* pOld = pDC->SelectObject(&pen); pDC->MoveTo(m_ptFrom); pDC->LineTo(m_ptTo); const double PI = 3.14159265358979323846; // atan2 用 y 在前、x 在后,返回主线与 x 轴的夹角 const double ang = atan2((double)(m_ptTo.y - m_ptFrom.y), (double)(m_ptTo.x - m_ptFrom.x)); // 箭头长度跟线宽挂钩,粗线配小箭头会显得很怪 const int len = max(12, m_nWidth * 6); CPoint p1(m_ptTo.x + (int)(len * cos(ang + PI * 5.0 / 6.0)), m_ptTo.y + (int)(len * sin(ang + PI * 5.0 / 6.0))); CPoint p2(m_ptTo.x + (int)(len * cos(ang - PI * 5.0 / 6.0)), m_ptTo.y + (int)(len * sin(ang - PI * 5.0 / 6.0))); pDC->MoveTo(m_ptTo); pDC->LineTo(p1); pDC->MoveTo(m_ptTo); pDC->LineTo(p2); pDC->SelectObject(pOld); }

PI * 5 / 6就是 150 度,两条翼线张开 60 度,这个角度画出来最像手绘。max(12, m_nWidth * 6)是个经验值,线宽 2 时箭头 12 像素,线宽 6 时箭头 36 像素,比例看着顺眼。角度用double算完再转int,如果全程用整型角度,画出来的箭头会有明显的锯齿错位。

自由画笔相对简单,WM_MOUSEMOVE里每隔一定距离记一个点,最后连成Polyline。关键是要做重采样——鼠标移动快的时候两点间距可能有几十像素,直接连线会变成折线。常见做法是两点距离超过 3 像素才记录,然后交给Polyline顺滑连接;讲究一点可以用二次贝塞尔插值,但一般来说前者够用。

void CAnnoPen::AddPoint(CPoint pt) { if (!m_vecPt.empty()) { CPoint& last = m_vecPt.back(); const int dx = pt.x - last.x, dy = pt.y - last.y; if (dx * dx + dy * dy < 9) return; // 距离小于 3px 直接丢弃 } m_vecPt.push_back(pt); }

4.3 马赛克与文字:两个最容易做糙的地方

马赛克的常见错误是"随机取块内某个像素填满整块",效果噪点很重,看着像坏了。正确的做法是取块内平均值。

void CAnnoMosaic::ApplyTo(CImage& img, const CRect& rc) { const int BLOCK = 8; // 块越大越糊,8 是隐私遮挡的常用值 for (int y = rc.top; y < rc.bottom; y += BLOCK) { for (int x = rc.left; x < rc.right; x += BLOCK) { const int xEnd = min(x + BLOCK, rc.right); const int yEnd = min(y + BLOCK, rc.bottom); long r = 0, g = 0, b = 0; int n = 0; for (int j = y; j < yEnd; ++j) for (int i = x; i < xEnd; ++i) { const COLORREF c = img.GetPixel(i, j); r += GetRValue(c); g += GetGValue(c); b += GetBValue(c); ++n; } const COLORREF avg = RGB(r / n, g / n, b / n); for (int j = y; j < yEnd; ++j) for (int i = x; i < xEnd; ++i) img.SetPixel(i, j, avg); } } }

GetPixel/SetPixel是 GDI 里最慢的一类调用,一块 400×300 的马赛克区域就是接近两万次调用,肉眼能感觉到卡。真要优化,用CImage::GetBits()拿到 DIB 指针直接按行偏移读写,速度能快一个数量级。我一般的做法是:拖拽过程中只画个半透明的方块做预览,鼠标松开才真正执行ApplyTo,这样用户感知不到延迟。

文字标注的难点不在绘制,在输入。用CreateFont配DrawText画是没问题的,但用户得能打字、能改字号。省事的做法是弹一个无边框的CEdit贴在选区上,失去焦点时把内容转成一个CAnnoText对象提交。字号要跟着屏幕 DPI 缩放,否则在 4K 屏上打出来的字小得看不见。

void CAnnoText::Draw(CDC* pDC) { // 字号按当前 DPI 换算,96 是 100% 缩放的基准值 const int nHeight = -MulDiv(m_nFontSize, GetDeviceCaps(pDC->m_hDC, LOGPIXELSY), 72); CFont font; font.CreateFont(nHeight, 0, 0, 0, FW_BOLD, FALSE, FALSE, 0, DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, CLEARTYPE_QUALITY, DEFAULT_PITCH | FF_SWISS, _T("微软雅黑")); CFont* pOldFont = pDC->SelectObject(&font); const int nOldMode = pDC->SetBkMode(TRANSPARENT); const COLORREF clrOld = pDC->SetTextColor(m_clr); CRect rc = m_rcText; pDC->DrawText(m_strText, rc, DT_LEFT | DT_TOP | DT_NOCLIP | DT_SINGLELINE); pDC->SetTextColor(clrOld); pDC->SetBkMode(nOldMode); pDC->SelectObject(pOldFont); }

MulDiv(fontSize, LOGPIXELSY, 72)是把磅值转成像素高度的标准公式,-nHeight的负号表示"按字符高度而非单元格高度"指定,这样字看起来才不会偏大。CLEARTYPE_QUALITY在小字号上比ANTIALIASED_QUALITY清晰。SetBkMode(TRANSPARENT)不能省,否则文字后面会带一块白底。

4.4 保存文件与写剪贴板

保存到文件用CImage::Save一行搞定,格式由扩展名决定:

BOOL CShotWnd::SaveToFile(LPCTSTR lpszPath) { CImage img; ComposeFinalImage(img); // 原图 + 所有标注合成到一起 const HRESULT hr = img.Save(lpszPath); // .png / .jpg / .bmp 都认识 return SUCCEEDED(hr); }

进剪贴板就得多花点心思。用CF_BITMAP贴出去最省事,但跨进程不可靠,粘到某些文档编辑器里会变成一片黑。稳妥的是CF_DIB,并且转成 24 位、正向高度。

BOOL CClipboard::PutImage(CWnd* pOwner, CImage& img) { if (!::OpenClipboard(pOwner->GetSafeHwnd())) return FALSE; ::EmptyClipboard(); // 统一转 24 位,避免接收端不认 32 位带 alpha 的位图 CImage dib; dib.Create(img.GetWidth(), img.GetHeight(), 24); HDC hdc = dib.GetDC(); img.Draw(hdc, 0, 0, dib.GetWidth(), dib.GetHeight(), 0, 0, img.GetWidth(), img.GetHeight()); dib.ReleaseDC(); BITMAPINFOHEADER bih = { 0 }; bih.biSize = sizeof(BITMAPINFOHEADER); bih.biWidth = dib.GetWidth(); bih.biHeight = dib.GetHeight(); // 正数 = 自底向上,CF_DIB 的标准布局 bih.biPlanes = 1; bih.biBitCount = 24; bih.biCompression = BI_RGB; const int nStride = ((bih.biWidth * 24 + 31) / 32) * 4; // 4 字节对齐 bih.biSizeImage = nStride * bih.biHeight; const DWORD dwTotal = sizeof(BITMAPINFOHEADER) + bih.biSizeImage; HGLOBAL hMem = ::GlobalAlloc(GMEM_MOVEABLE, dwTotal); if (!hMem) { ::CloseClipboard(); return FALSE; } BYTE* pBuf = (BYTE*)::GlobalLock(hMem); memcpy(pBuf, &bih, sizeof(BITMAPINFOHEADER)); HDC hdcDib = dib.GetDC(); ::GetDIBits(hdcDib, (HBITMAP)dib, 0, bih.biHeight, pBuf + sizeof(BITMAPINFOHEADER), (BITMAPINFO*)&bih, DIB_RGB_COLORS); dib.ReleaseDC(); ::GlobalUnlock(hMem); ::SetClipboardData(CF_DIB, hMem); // 成功后所有权归系统,不能再 GlobalFree ::CloseClipboard(); return TRUE; }

nStride那一行是 DIB 的硬性要求:每行字节数必须是 4 的倍数,不够要补齐。算错会导致整张图向右斜切。SetClipboardData之后hMem的所有权转移给系统,接着调GlobalFree会直接崩,这个坑很经典。

5. 避坑与排查:黑屏、错位、句柄泄漏的五次翻车

这套代码功能不难,难在 Windows 图形栈的边角料特别多。下面五条是我实际调试记录里挑出来的,每条都值得在写第一行代码之前先看一眼。

5.1 高 DPI 下只截到屏幕左上角

现象:在 4K 屏 + 150% 缩放的机器上,截出来的图只有左上角一小块是有效内容,其余全黑,尺寸也不对。

原因:进程没有声明 DPI 感知,系统会给一个"虚拟化"的坐标空间。物理分辨率 3840×2160 会被谎报成 2560×1440,BitBlt按 2560×1440 去拷,可位图的实际内容是按物理像素排布的,多出来的部分就是黑的。

解决:在InitInstance最早的位置调用SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2),或者直接在.manifest里写<dpiAware>true/PM</dpiAware>。注意这个函数必须在使用任何 GDI 或创建窗口之前调,中途改会不生效。改成 Per-Monitor V2 之后,所有坐标都是物理像素,你还要自己处理"工具条在高分屏上图标变小"的问题,那是另一回事。

5.2 拖动选区时整屏疯狂闪烁

现象:按住鼠标拖框,整个屏幕一闪一闪,像老式显示器刷新率不足。

原因:Invalidate(TRUE)会先发WM_ERASEBKGND再发WM_PAINT,两次之间那一段显示的是窗口背景色。再加上没有双缓冲,每次擦完立即画,就形成了明显的明暗跳变。

解决:三件事一起做。OnEraseBkgnd直接return TRUE表示"背景我自己擦";Invalidate(FALSE)不触发背景擦除;OnPaint里所有绘制先落内存 DC,最后一次性BitBlt到屏幕。

BOOL CShotWnd::OnEraseBkgnd(CDC*) { return TRUE; // 阻止系统擦背景,全部交给 OnPaint 的双缓冲处理 }

5.3 连续截几十次之后句柄数暴涨、程序卡死

现象:任务管理器里 GDI 对象数只增不减,用一段时间后整个程序无响应。

原因:三种资源泄漏同时存在。GetDC(NULL)拿了没ReleaseDC,CreateCompatibleDC创建了没DeleteDC,SelectObject选进去的位图没选回原对象就删了 DC。最后一种最隐蔽,因为DeleteDC不会报错,但被选中的位图会一直挂着。

解决:给每个 GDI 资源都配对写,或者干脆用 RAII 包一层。我的习惯是写一个极简的CDCGuard,构造时CreateCompatibleDC,析构时DeleteDC,中间强制 "先 SelectObject 回原对象再析构"。

class CDCGuard { public: explicit CDCGuard(HDC hRef) : m_hdc(::CreateCompatibleDC(hRef)) {} ~CDCGuard() { if (m_hdc) ::DeleteDC(m_hdc); } HDC Get() const { return m_hdc; } void Detach() { m_hdc = NULL; } private: HDC m_hdc; CDCGuard(const CDCGuard&); CDCGuard& operator=(const CDCGuard&); };

排查手段很简单:跑十次截图,在任务管理器里加两列 "GDI 对象" 和 "用户对象",如果数字持续上涨就说明有泄漏,然后对着GetDC/CreateCompatibleDC/CreatePen这几个关键字逐个检查配对。

5.4 复制粘贴到某些文档里图片变黑

现象:程序里复制一切正常,粘到某类文档编辑器里整张图全黑。

原因:用了CF_BITMAP,而HBITMAP是跟设备上下文和调色板绑定的,跨进程时目标程序拿不到原来那套上下文,只能按默认的 1bpp 或者错误调色板解释。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/5 6:19:04

Mamba硬件感知并行关联扫描:原理、CUDA实现与显存优化

序列建模做到一定规模&#xff0c;谁都绕不开一个老问题&#xff1a;想让模型记住更长的上下文&#xff0c;又不想让显存和时延跟着一起爆炸。Mamba 这套状态空间模型之所以在工程圈里被反复讨论&#xff0c;很大一部分原因不在于它的公式多优雅&#xff0c;而在于它配套的那套…

作者头像 李华
网站建设 2026/11/5 6:18:58

国内加速Docker拉取Weaviate镜像的解决方案

1. 问题现象与背景分析最近在部署Weaviate向量数据库时&#xff0c;发现直接运行docker pull weaviate命令在国内网络环境下频繁出现超时和失败。这个问题其实反映了国内开发者使用Docker Hub时的典型痛点——镜像拉取速度慢、成功率低。Weaviate作为一款开源的AI原生数据库&am…

作者头像 李华
网站建设 2026/11/5 6:18:57

CPU型号为何只有i3/i5/i7/i9?命名分层与编号留白逻辑

有人给我留言&#xff0c;问了一个特别较真的问题&#xff1a;CPU 型号里那一串 i3、i5、i7、i9&#xff0c;为什么清一色全是奇数&#xff0c;i4、i6、i8 到底去哪儿了&#xff1f;这问题乍一听像是抬杠&#xff0c;但你真去翻型号库、翻命名规则文档&#xff0c;会发现它其实…

作者头像 李华
网站建设 2026/11/5 6:18:06

多代理协作内核ACTP协议与资源调度算法解析

1. 项目背景与核心价值cccc这个多代理协作内核的出现&#xff0c;本质上是为了解决现代工程协作中的三个核心痛点&#xff1a;信息孤岛、决策延迟和资源错配。我在参与大型基建项目时深有体会——当设计院、施工方和监理单位各自为政时&#xff0c;光是图纸版本混乱就能导致数月…

作者头像 李华
网站建设 2026/11/5 6:18:05

MC9S12DG128 ADC采样不稳定根因与实战调优指南

简介&#xff1a;本资源是面向嵌入式初学者与高校电类专业学生的MC9S12DG128单片机AD采集实战项目&#xff0c;聚焦模拟信号采集、量化处理与实时可视化这一典型嵌入式开发闭环。实验基于飞思卡尔S12系列经典芯片&#xff0c;完整实现8位多通道AD转换配置、中断驱动数据读取&am…

作者头像 李华
网站建设 2026/11/5 6:18:05

libevent网络库在Linux高并发开发中的实践指南

1. 为什么选择libevent&#xff1f;在Linux服务器开发中&#xff0c;网络编程是基础能力。传统的select/poll模型存在性能瓶颈&#xff0c;而直接使用epoll又需要处理复杂的平台兼容性问题。libevent作为高性能事件通知库&#xff0c;封装了不同操作系统的事件机制&#xff08;…

作者头像 李华