C++实现Windows屏幕水印:GDI分层窗口防泄密技术详解

📅 2026/7/25 6:01:43
C++实现Windows屏幕水印:GDI分层窗口防泄密技术详解
1. 项目概述为什么需要屏幕水印最近在做一个内部工具的开发涉及到一些敏感信息的屏幕共享和演示。为了防止在演示过程中屏幕内容被截屏或录屏后无限制地传播我们决定给这个工具加上一个“屏幕水印”功能。简单来说就是在整个桌面上以半透明、重复平铺的方式显示一些标识信息比如用户名、工号、时间戳等。这样即使屏幕内容被泄露也能追溯到源头。这个需求听起来简单但真要自己动手用C实现一个性能好、兼容性强的屏幕水印里头的门道还真不少。它不像在普通应用窗口里画个图那么简单你需要直接操作整个屏幕的图形缓冲区还要确保不影响其他应用的正常显示和操作。网上相关的完整、可用的代码片段不多大多只是提个思路。所以我花了些时间把从原理到实现的完整路径都走通了在这里把核心的技术点、踩过的坑和最终的解决方案分享出来。这个项目适合有一定C和Windows编程基础的开发者特别是那些需要开发安全审计、远程协助、防泄密相关工具的朋友。通过这个项目你不仅能学会如何操作屏幕DC设备上下文还能深入理解Windows图形子系统GDI/GDI的一些底层机制。2. 核心思路与技术选型实现屏幕水印本质上是在系统最顶层的“画布”上作画。我们不能干扰每个独立应用程序的绘制而是要在它们都画完之后再在上面叠加一层我们自己的内容。在Windows平台上有几种主流的技术路径可以选择。2.1 方案对比GDI, GDI, DirectX 与 Desktop Duplication API首先我们得决定用什么图形API来“画”这个水印。GDI (Graphics Device Interface)这是Windows最经典、最底层的2D图形接口。它的优势是极其轻量系统原生支持兼容性最好。对于绘制文本、简单图形和位图块来说性能足够。我们最终选择的就是GDI因为它足够简单直接无需引入庞大的运行时库适合这种“系统级”的绘制任务。GDI可以看作是GDI的一个增强版提供了更丰富的功能如抗锯齿、渐变填充、更复杂的路径绘制等。但它比GDI重对于需要极高刷新率比如水印要动态变化的场景性能可能成为瓶颈。我们的水印主要是静态或低频更新的文本GDI完全够用。DirectX (Direct2D/DirectWrite)这是现代Windows应用尤其是游戏和高性能UI的首选。它利用GPU硬件加速性能最强效果也最好支持亚像素渲染、高级抗锯齿等。但它的缺点是复杂度高需要处理大量的COM接口和资源管理对于一个小型水印工具来说有点“杀鸡用牛刀”。而且要确保在所有Windows版本和显卡驱动下稳定工作需要更多的测试。Windows Desktop Duplication API这是Windows 8以后引入的用于高效捕获整个桌面图像流的API。它本身不用于绘制但如果你需要先捕获桌面再与水印合成最后再输出到某个地方比如虚拟显示器这个API效率最高。但我们的需求是“实时叠加显示”而不是“捕获-处理-再输出”所以这个API并不直接适用。选择依据经过权衡我们选择了纯GDI方案。核心原因是目标单一、要求稳定、资源占用最低。水印不需要华丽的特效只需要清晰、稳定地显示在屏幕最顶层。GDI作为系统核心组件其稳定性和兼容性是最佳的。2.2 实现原理分层窗口与屏幕DC确定了使用GDI后下一个问题是怎么画。你不能简单地创建一个普通窗口然后画东西因为那样会被其他全屏窗口比如游戏、视频覆盖。这里的关键技术是“分层窗口”。一个设置为WS_EX_LAYERED扩展样式的窗口被称为分层窗口。Windows系统会为它维护一个独立的位图缓冲区。它的核心魔力在于透明度与混合可以轻松设置整个窗口的透明度Alpha通道实现半透效果。无视Z-Order的顶级显示通过SetWindowPos配合HWND_TOPMOST可以让窗口始终位于最顶层并且关键点不会抢夺焦点不会干扰用户的键盘鼠标操作。高性能更新更新窗口内容时只需更新其内部的位图缓冲区然后通知系统系统会以最优的方式将其与其他窗口合成最终画面。我们的策略就是创建一个覆盖整个屏幕、无边框、透明背景、顶级显示的分层窗口。然后在这个窗口的客户区使用GDI函数绘制我们想要的水印文本或图形。那么如何获取整个屏幕的大小和位置呢这就用到了“屏幕设备上下文DC”。通过GetDC(NULL)可以获取整个屏幕虚拟屏幕在多显示器情况下的DC。结合GetSystemMetrics或GetDeviceCaps函数我们能得到屏幕的分辨率从而设置我们的水印窗口大小。3. 详细实现步骤与代码解析下面我将分步拆解实现过程并提供关键代码。我们使用Win32 API和C标准库来完成。3.1 创建分层水印窗口首先我们需要注册窗口类并创建窗口。#include windows.h #include string #include vector // 全局变量 HINSTANCE g_hInst; HWND g_hwndWatermark nullptr; const wchar_t CLASS_NAME[] LScreenWatermarkClass; std::wstring g_watermarkText L内部资料 - 张三 - 2024-05-27; // 窗口过程函数 LRESULT CALLBACK WindowProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam); int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow) { g_hInst hInstance; // 1. 注册窗口类 WNDCLASSEX wc {}; wc.cbSize sizeof(WNDCLASSEX); wc.lpfnWndProc WindowProc; wc.hInstance hInstance; wc.lpszClassName CLASS_NAME; wc.hCursor LoadCursor(nullptr, IDC_ARROW); // 重要设置为箭头光标避免隐藏 if (!RegisterClassEx(wc)) { MessageBox(nullptr, L窗口类注册失败, L错误, MB_ICONERROR); return 0; } // 2. 获取屏幕尺寸考虑多显示器 int screenWidth GetSystemMetrics(SM_CXVIRTUALSCREEN); int screenHeight GetSystemMetrics(SM_CYVIRTUALSCREEN); int screenLeft GetSystemMetrics(SM_XVIRTUALSCREEN); int screenTop GetSystemMetrics(SM_YVIRTUALSCREEN); // 3. 创建窗口 // 关键样式无边框、最顶层、分层窗口、透明背景、禁用鼠标输入 g_hwndWatermark CreateWindowEx( WS_EX_LAYERED | WS_EX_TRANSPARENT | WS_EX_TOOLWINDOW | WS_EX_TOPMOST | WS_EX_NOACTIVATE, CLASS_NAME, L屏幕水印, WS_POPUP, // 无边框弹出窗口 screenLeft, screenTop, screenWidth, screenHeight, // 覆盖整个虚拟屏幕 nullptr, nullptr, hInstance, nullptr ); if (!g_hwndWatermark) { MessageBox(nullptr, L窗口创建失败, L错误, MB_ICONERROR); return 0; } // 4. 初始设置分层窗口属性完全透明后续由UpdateLayeredWindow绘制 SetLayeredWindowAttributes(g_hwndWatermark, 0, 0, LWA_ALPHA); // 5. 显示窗口不激活 ShowWindow(g_hwndWatermark, SW_SHOWNA); UpdateWindow(g_hwndWatermark); // 6. 进入消息循环 MSG msg {}; while (GetMessage(msg, nullptr, 0, 0)) { TranslateMessage(msg); DispatchMessage(msg); } return 0; }关键点解析WS_EX_LAYERED: 核心样式启用分层窗口。WS_EX_TRANSPARENT: 使窗口对鼠标点击“透明”。鼠标事件会穿透这个窗口落到下面的窗口上。这是实现“水印不干扰操作”的关键WS_EX_TOOLWINDOW: 使窗口不显示在任务栏。WS_EX_TOPMOST: 窗口始终置顶。WS_EX_NOACTIVATE: 窗口被点击时不会激活不会抢夺焦点。WS_POPUP: 创建一个无边框窗口。SM_CXVIRTUALSCREEN: 获取所有显示器组成的虚拟屏幕的总宽度和起始位置完美支持多显示器。3.2 绘制水印内容创建窗口后我们需要在窗口上绘制水印。我们不在WM_PAINT中绘制而是直接使用UpdateLayeredWindowAPI它专为分层窗口的高效更新而设计。我们在窗口创建后和需要更新水印时比如文本变化调用一个绘制函数。void UpdateWatermark() { if (!g_hwndWatermark) return; // 1. 获取屏幕尺寸 int screenWidth GetSystemMetrics(SM_CXVIRTUALSCREEN); int screenHeight GetSystemMetrics(SM_CYVIRTUALSCREEN); // 2. 创建内存DC和兼容位图作为绘制画布 HDC hdcScreen GetDC(nullptr); HDC hdcMem CreateCompatibleDC(hdcScreen); HBITMAP hBitmap CreateCompatibleBitmap(hdcScreen, screenWidth, screenHeight); SelectObject(hdcMem, hBitmap); // 3. 初始化画布为完全透明 BLENDFUNCTION blend { AC_SRC_OVER, 0, 180, AC_SRC_ALPHA }; // 全局透明度180/255 ≈ 70% RECT rect { 0, 0, screenWidth, screenHeight }; HBRUSH hBrush (HBRUSH)GetStockObject(BLACK_BRUSH); FillRect(hdcMem, rect, hBrush); // 4. 设置GDI绘图属性 SetBkMode(hdcMem, TRANSPARENT); // 文本背景透明 SetTextColor(hdcMem, RGB(255, 100, 100)); // 水印文字颜色浅红色 // 5. 计算水印字体和布局 int fontSize 48; // 字体大小 int textHeight fontSize; int textWidth 0; HFONT hFont CreateFont(fontSize, 0, 0, 0, FW_NORMAL, FALSE, FALSE, FALSE, DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, DEFAULT_QUALITY, DEFAULT_PITCH | FF_DONTCARE, L微软雅黑); HFONT hOldFont (HFONT)SelectObject(hdcMem, hFont); // 计算单个水印文本块的宽度 SIZE textSize; GetTextExtentPoint32(hdcMem, g_watermarkText.c_str(), g_watermarkText.length(), textSize); textWidth textSize.cx; int spacing 100; // 水印块之间的间隔 // 6. 平铺绘制水印文本 for (int y 0; y screenHeight; y textHeight spacing) { for (int x 0; x screenWidth; x textWidth spacing) { // 添加一些随机偏移避免过于死板也增加去除水印的难度 int offsetX (rand() % 20) - 10; int offsetY (rand() % 20) - 10; TextOut(hdcMem, x offsetX, y offsetY, g_watermarkText.c_str(), g_watermarkText.length()); } } // 7. 可选绘制一个倾斜的水印更防篡改 LOGFONT lf {}; lf.lfHeight 80; lf.lfEscapement lf.lfOrientation 450; // 旋转45度单位是0.1度45045度 wcscpy_s(lf.lfFaceName, LArial); HFONT hRotatedFont CreateFontIndirect(lf); SelectObject(hdcMem, hRotatedFont); SetTextColor(hdcMem, RGB(100, 100, 255)); // 在屏幕中心绘制一个大而倾斜的水印 std::wstring centerMark LCONFIDENTIAL; GetTextExtentPoint32(hdcMem, centerMark.c_str(), centerMark.length(), textSize); TextOut(hdcMem, (screenWidth - textSize.cx) / 2, (screenHeight - textSize.cy) / 2, centerMark.c_str(), centerMark.length()); // 8. 恢复旧字体清理资源 SelectObject(hdcMem, hOldFont); DeleteObject(hFont); DeleteObject(hRotatedFont); // 9. 更新分层窗口 POINT ptSrc { 0, 0 }; POINT ptDst { GetSystemMetrics(SM_XVIRTUALSCREEN), GetSystemMetrics(SM_YVIRTUALSCREEN) }; SIZE sizeWnd { screenWidth, screenHeight }; UpdateLayeredWindow(g_hwndWatermark, hdcScreen, ptDst, sizeWnd, hdcMem, ptSrc, RGB(0, 0, 0), blend, ULW_ALPHA); // 10. 释放资源 ReleaseDC(nullptr, hdcScreen); DeleteDC(hdcMem); DeleteObject(hBitmap); }关键点解析BLENDFUNCTION: 定义了混合方式。AlphaFormat AC_SRC_ALPHA表示使用源位图的Alpha通道。SourceConstantAlpha 180是全局透明度值越小越透明。SetBkMode(hdcMem, TRANSPARENT):至关重要。这确保我们绘制的文本背景是透明的否则文本会带有一个不透明的背景色块。平铺算法通过双层循环在屏幕画布上按计算好的间距textWidth spacing和textHeight spacing重复绘制水印文本。随机偏移(rand() % 20) - 10给每个水印块一个小的随机位置偏移。这有两个好处一是让水印看起来更自然不那么“机械”二是增加了后期通过简单图像处理如克隆图章去除水印的难度。旋转字体通过创建LOGFONT并设置lfEscapement来旋转字体。在屏幕中央绘制一个大的、倾斜的、颜色不同的水印能极大地增强防泄密效果即使截图后裁剪边缘中心水印依然存在。UpdateLayeredWindow: 这是将我们内存DChdcMem中绘制好的内容更新到分层窗口的关键函数。它一次性完成位图传输和Alpha混合。3.3 窗口过程与动态更新窗口过程函数负责处理基本的窗口消息。对于水印窗口我们只需要处理极少的消息。LRESULT CALLBACK WindowProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam) { switch (uMsg) { case WM_CREATE: // 窗口创建时初始化绘制一次水印 UpdateWatermark(); // 可以在这里设置一个定时器用于动态更新水印如时间戳 // SetTimer(hwnd, 1, 1000, nullptr); // 每秒更新一次 break; case WM_TIMER: if (wParam 1) { // 更新水印文本中的时间 SYSTEMTIME st; GetLocalTime(st); wchar_t timeStr[64]; swprintf_s(timeStr, L内部资料 - 张三 - %04d/%02d/%02d %02d:%02d:%02d, st.wYear, st.wMonth, st.wDay, st.wHour, st.wMinute, st.wSecond); g_watermarkText timeStr; UpdateWatermark(); // 重绘 } break; case WM_DESTROY: // KillTimer(hwnd, 1); // 如果用了定时器记得销毁 PostQuitMessage(0); break; case WM_ERASEBKGND: return 1; // 直接返回1告知系统我们已经处理了背景擦除防止闪烁 default: return DefWindowProc(hwnd, uMsg, wParam, lParam); } return 0; }动态水印如果你需要水印中的时间戳每秒更新可以启用WM_CREATE中的SetTimer和WM_TIMER处理逻辑。这样水印文本会包含实时时间泄露的截图可以精确到秒级溯源。4. 进阶优化与疑难问题排查一个基础的水印功能已经实现了但在实际部署中你会遇到各种问题。下面是我在开发和测试中总结的几个关键点和解决方案。4.1 性能优化避免全局重绘我们的UpdateWatermark函数每次都会重新绘制整个屏幕大小的位图。如果水印是静态的这没问题只在启动时绘制一次。但如果水印需要动态更新比如每秒刷新时间频繁的全屏重绘会带来不必要的CPU和GPU负载。优化策略双缓冲与局部更新创建固定大小的水印单元位图预先创建一个包含单个水印文本如“张三-2024/05/27”的、带透明通道的小位图。在内存中合成最终位图在UpdateWatermark中不再用GDI重复绘制每一个文本而是将预先做好的水印单元位图使用AlphaBlend函数以“贴图”的方式平铺到屏幕大小的内存位图上。AlphaBlend是GDI中效率较高的位图混合函数。局部更新如果只有部分水印内容变化比如只有中心的时间在变可以只更新变化区域对应的那一小块内存位图然后再调用UpdateLayeredWindow。不过UpdateLayeredWindow本身效率很高对于每秒一次的全屏更新现代电脑的CPU通常也绰绰有余。优化应视实际性能 profiling 结果而定。4.2 多显示器与DPI缩放适配这是一个非常容易踩坑的地方。在高DPI显示器或多显示器不同缩放比例环境下直接使用GetSystemMetrics(SM_CXSCREEN)获取的可能是逻辑像素而CreateCompatibleBitmap需要的是物理像素。解决方案使用DPI感知API#include shellscalingapi.h #pragma comment(lib, Shcore.lib) // 链接Shcore.lib // 在WinMain开始处声明为DPI感知 SetProcessDpiAwareness(PROCESS_PER_MONITOR_DPI_AWARE); // 获取屏幕物理尺寸和位置 int screenWidth GetSystemMetrics(SM_CXVIRTUALSCREEN); int screenHeight GetSystemMetrics(SM_CYVIRTUALSCREEN); int screenLeft GetSystemMetrics(SM_XVIRTUALSCREEN); int screenTop GetSystemMetrics(SM_YVIRTUALSCREEN); // 创建内存位图时确保使用物理像素 HDC hdcScreen GetDC(nullptr); int physicalWidth screenWidth; // SM_CXVIRTUALSCREEN 在DPI感知后通常返回物理像素 int physicalHeight screenHeight; // 但更严谨的做法是从屏幕DC获取 // physicalWidth GetDeviceCaps(hdcScreen, HORZRES); // physicalHeight GetDeviceCaps(hdcScreen, VERTRES); HBITMAP hBitmap CreateCompatibleBitmap(hdcScreen, physicalWidth, physicalHeight);字体大小在CreateFont时字体高度参数lfHeight是逻辑单位。在高DPI下你可能需要根据DPI缩放因子来调整这个值否则字体看起来会很小。可以通过GetDpiForWindow获取窗口DPI然后进行换算。4.3 常见问题与排查技巧水印不显示或闪烁检查窗口样式确保创建窗口时包含了WS_EX_LAYERED。检查UpdateLayeredWindow调用确保传入的BLENDFUNCTION参数正确特别是AlphaFormat和SourceConstantAlpha。如果SourceConstantAlpha为0窗口完全透明。检查绘制内容确保内存位图hBitmap被成功创建并绘制了非完全透明的内容。可以在绘制后暂时用FillRect填充一个半透明的纯色如灰色测试位图本身是否有内容。处理WM_ERASEBKGND在窗口过程中处理WM_ERASEBKGND消息并直接返回1可以防止系统在绘制前擦除窗口背景避免潜在闪烁。水印干扰鼠标操作点击无效确认WS_EX_TRANSPARENT样式这是实现鼠标穿透的核心。如果没有这个样式水印窗口会拦截所有鼠标消息。检查窗口区域确保没有通过SetWindowRgn设置一个非矩形的窗口区域这可能会影响穿透效果。水印在某些全屏应用游戏、视频下消失这是正常现象。一些全屏应用尤其是DirectX独占全屏模式的应用会接管整个图形输出此时基于GDI的顶层窗口会被覆盖。这是Windows图形系统层级决定的。部分解决方案可以尝试将水印窗口的父窗口设置为HWND_DESKTOP桌面窗口并确保WS_EX_TOPMOST生效。但对于真正的独占全屏应用没有完美的软件方案。硬件级的解决方案如通过显卡驱动或特定硬件不在本文讨论范围。GDI资源泄漏GDI对象HDC,HBITMAP,HFONT,HBRUSH是系统全局资源有上限约10000个。必须成对创建和删除。黄金法则对于GetDC必须用ReleaseDC释放。对于CreateCompatibleDC,CreateFont,CreateCompatibleBitmap等创建的对象必须用DeleteDC或DeleteObject释放。在UpdateWatermark函数的最后我们清晰地释放了所有创建的GDI对象。杀毒软件误报直接操作屏幕、创建全局顶层窗口的程序行为上类似一些屏幕录制或恶意软件可能会被某些杀毒软件标记。解决方法是为你的程序添加数字签名。在程序说明中明确其功能。如果用于公司内部可以将程序路径添加到杀毒软件的白名单中。5. 扩展思路与安全增强基础功能实现后我们可以考虑一些增强功能让这个水印工具更加强大和实用。5.1 水印内容动态化与个性化除了时间水印内容可以更加丰富系统信息加入计算机名、IP地址内网、当前登录的Windows用户名。进程信息显示当前前台活动窗口的标题或进程名这对于监控特定软件的使用情况很有用。HWND hForeground GetForegroundWindow(); if (hForeground) { wchar_t title[256]; GetWindowText(hForeground, title, 256); // 将 title 加入到水印文本中 }唯一标识符每次启动生成一个随机序列号并记录到日志中。这样即使同一用户不同次会话的水印也不同便于追踪。5.2 抗去除与视觉干扰设计水印的目的是增加泄露者去除水印的成本和难度。多角度平铺除了水平平铺可以增加一组旋转了15度或30度的水印文本交错叠加使得简单的仿制图章工具难以处理。颜色和透明度随机化让每个水印块的颜色在相近色系内和透明度有细微差别。嵌入不可见信息数字水印这是更高级的技术。可以在水印的像素中通过轻微调整某些像素的亮度或颜色值人眼难以察觉编码进二进制信息如用户ID、时间。即使截图后经过裁剪、压缩通过特定的解码算法依然能提取出信息。这需要更复杂的图像处理算法。5.3 配置化与外部控制一个成熟的工具应该支持配置配置文件使用INI、JSON或XML文件来配置水印文本、字体、颜色、大小、旋转角度、透明度、是否启用时间戳等。命令行参数支持通过命令行启动并传入用户名、任务ID等参数。进程间通信开发一个简单的控制台或GUI控制程序通过命名管道、共享内存或Socket向水印主进程发送命令动态改变水印内容或隐藏/显示水印窗口而无需重启程序。实现一个屏幕水印从原理上看是“在屏幕上画东西”但深入进去涉及到Windows窗口机制、图形系统、资源管理、DPI感知、性能优化等多个方面。上面提供的代码框架是一个稳定可用的起点你可以根据自己的需求在上面添加更多功能。最后再次强调GDI对象的创建和销毁一定要配对进行这是保证程序长期稳定运行不崩溃的基础。在实际项目中最好将GDI资源的获取和释放封装到RAII类中利用C的析构函数自动管理能有效避免资源泄漏问题。