简介:面向仍在 VC6.0 下维护 C++/MFC 项目的开发者,这份压缩包围绕 PNG 图片加载与透明化处理,提供一套可编译的 GDI+ 集成示例,适合需要兼容老工具链、实现图片 Alpha 通道绘制、颜色矩阵调整或窗口透明效果的读者参考。包内共 70 个文件,以 34 个 GDI+ 头文件、8 个 PNG 素材、3 个 cpp 源码及 dsp/dsw 工程文件为主,并含 lib 静态库、rc 资源、exe 与调试中间文件,压缩包约 4.73MB。已有 717 人学习下载。示例工程用 MFC 对话框时钟演示 PNG 加载、GDI+ 初始化、透明度绘制与分层窗口思路,配套头文件、库和素材较完整,便于对照理解图像绘制、颜色矩阵及资源释放流程,也可作为老版本 VC 图像处理项目的改造起点。资源也提示 VC6.0 对现代图像格式支持有限,若需更丰富的图形 API 可考虑升级工具链,但现有示例仍可用于旧项目维护、课程实验与兼容性验证。
1. 老工程里要贴 PNG,光靠 GDI 是画不出来的
VC6.0 的 MFC 工程里要贴一张带透明通道的 PNG,LoadBitmap只认 BMP,TransparentBlt只能抠一种颜色键,边上一圈锯齿怎么调都不干净——这套 VC++ PNG透明处理 的需求,几乎每个维护老项目的人都撞过。这个工程给出的解法是绕开 GDI 的位图加载,用 GDI+ 把 PNG 解成 32 位 ARGB,再转成预乘 alpha 的 DIB Section,交给 AlphaBlend 按每个像素的 alpha 混合。适合两类人:手里压着不能升级编译器的老工程、要加图标皮肤和异形界面;以及想弄清 PNG 透明在 Win32 这一层到底怎么落地的开发者。下面从环境配置一路讲到能改成自己工程里用的类。
2. 技术选型与环境落地:把 GDI+ 塞进 VC6 工程
2.1 三条路的取舍:GDI+、libpng 自己解析、纯色键透明
VC6 自带的 GDI 是个纯栅格接口,LoadImage、LoadBitmap、CreateBitmapFromResource这一套只认 BMP 和 DIB,PNG 文件里那套基于 zlib 压缩的块结构它根本不解析;CreateCompatibleBitmap出来的默认是设备相关位图,和 alpha 通道没有关系。所以「显示 PNG」这件事在老工程里从来不是调用一个 API 的问题,而是要先把带 alpha 的像素数据搬进一块 GDI 能认的内存里,再自己找机会混合。常见的三条路,我一般按「改动量 vs 最终效果」来排:
第一条是 GDI+。它自带 PNG 编解码器,Gdiplus::Bitmap::FromFile一行就能把文件读进来,LockBits拿到的就是每像素 32 位的 ARGB 数据,半透明、圆角、阴影全都能保下来。代价是工程里要多引一个 SDK 头文件和gdiplus.lib,运行时依赖 Gdiplus.dll(2000 以后的系统基本都有)。这条路的坑集中在两处:VC6 太老,头文件需要打补丁;AlphaBlend 要的是预乘 alpha,而 GDI+ 给的是非预乘,中间得自己转一手。
第二条是 libpng 加 zlib 自己解析。控制力最强,甚至可以只解 IDAT 块做流式加载,但代价是 VC6 编译第三方库要先处理一堆 C 编译器的警告和类型差异,还要自己写颜色类型转换、隔行扫描处理、CRC 校验。对于「我就想在对话框上贴个图标」的需求,这是明显的过度设计。除非项目本身要做图片编辑器或要处理超大图,我一般不走这条。
第三条是继续用色键透明,也就是TransparentBlt。它只认一种颜色当透明色,PNG 里那些 30%、50% 的羽化边缘和投影会被直接拍成实心块,边缘还会留一圈底色。它真正适合的场景是 8 位索引色图标、像素风素材,或者目标机器上连 Gdiplus.dll 都不保证存在。工程选的是第一条路加第三条路兜底:主绘制走 AlphaBlend,个别老素材走 TransparentBlt,两条路径共用一个 DIB 句柄。
2.2 VC6 里配置 GDI+ 的三处改动
VC6 的安装介质里不带 GDI+ 的开发包,得单独准备一份包含gdiplus.h、gdiplus.lib的头文件和库,放进工程目录或者加到 VC6 的搜索路径里:Tools → Options → Directories 里把 include 和 lib 两条路径补上,或者在 Project Settings → Link 页的 Object/library modules 里直接写gdiplus.lib msimg32.lib ole32.lib。前者一次配置所有工程受益,后者跟着工程走、换台机器不会丢,我一般两个都做,免得同事拉下代码后编译不过。
真正的麻烦在头文件顺序。VC6 的 Windows SDK 里没有ULONG_PTR这个类型(它是后来的平台 SDK 才补进来的),而gdiplus.h里到处在用它;同时gdiplus.h的部分内联代码依赖windows.h里windef.h定义的min/max宏。所以包含顺序不能乱:
// stdafx.h —— 顺序错了就是一堆 C2061/C2065,别随意调整 #include <windows.h> // 必须最先,gdiplus.h 依赖里面的基础类型和 min/max 宏 #include <objidl.h> // IStream,后面从内存流加载资源 PNG 要用 #ifndef ULONG_PTR_DEFINED typedef unsigned long ULONG_PTR; // VC6 没有这个类型,GDI+ 头文件里到处在用 #define ULONG_PTR_DEFINED #endif #include <gdiplus.h> // 放在 windows.h 之后,否则 min/max 找不到 // 用 pragma 链接,省得每台机器手动配 Link 页 #pragma comment(lib, "gdiplus.lib") #pragma comment(lib, "msimg32.lib") // AlphaBlend / TransparentBlt 在这里 #pragma comment(lib, "ole32.lib") // CreateStreamOnHGlobal 在这里这里有三点值得说清楚。typedef unsigned long ULONG_PTR;在 32 位下是对的,因为指针就是 4 字节;如果哪天要编 64 位,这行必须改成条件编译,但 VC6 本身也编不了 64 位,所以工程里就按 32 位写死。#pragma comment(lib, ...)是 MSVC 特有的,比在设置页里加库名更不容易漏。最后,using namespace Gdiplus;不要写进 stdafx.h,放在具体实现的 .cpp 里就行,因为 GDI+ 的Rect、Color、Graphics这些名字和 MFC 里的一些符号容易撞,头文件里展开命名空间会连累所有包含它的编译单元。
2.3 GDI+ 生命周期:别在 WndProc 里反复 Startup
GDI+ 要求进程内先GdiplusStartup再使用任何接口,退出前GdiplusShutdown,而且这个配对必须是全局的、只做一次。我见过最典型的翻车写法是在OnPaint里 Startup、函数结束 Shutdown,短时间看不出问题,窗口一拖动就开始掉帧,因为每次 Startup 都会创建内部的后台线程和编解码器表。正确做法是把它收进一个 RAII 类,跟着CWinApp的生命周期走:
// GdiplusInit.h class CGdiplusInit { public: CGdiplusInit(); ~CGdiplusInit(); BOOL IsOk() const { return m_bOk; } private: ULONG_PTR m_nToken; // Startup 返回的令牌,Shutdown 时要原样传回去 BOOL m_bOk; }; // GdiplusInit.cpp #include "stdafx.h" #include "GdiplusInit.h" CGdiplusInit::CGdiplusInit() : m_nToken(0), m_bOk(FALSE) { Gdiplus::GdiplusStartupInput input; // 默认值:GdiplusVersion=1, DebugEventCallback=NULL, // SuppressBackgroundThread=FALSE, SuppressExternalCodecs=FALSE Gdiplus::Status st = Gdiplus::GdiplusStartup(&m_nToken, &input, NULL); m_bOk = (st == Gdiplus::Ok); } CGdiplusInit::~CGdiplusInit() { if (m_bOk && m_nToken != 0) Gdiplus::GdiplusShutdown(m_nToken); }参数里唯一可能需要动的是SuppressBackgroundThread:设成 TRUE 之后 GDI+ 不启后台线程,图像解码变成同步的,某些需要显示进度的场景会用到,但普通的界面贴图保持默认 FALSE 就行。这个类怎么用,取决于工程是对话框还是文档视图:对话框程序一般在OnInitDialog的第一行创建成成员对象,OnDestroy时自然析构;MFC 单文档程序放进CWinApp::InitInstance的开头,配一个成员指针,在ExitInstance里 delete。
顺序上有一条不能踩的红线:所有Gdiplus::Bitmap、Graphics对象必须在GdiplusShutdown之前析构完。如果图省事把图片对象声明成全局变量或静态变量,它们的析构发生在ExitInstance之后,Shutdown 已经跑过了,退出时就会崩在某个 DLL 的清理代码里,用调试器看是一堆看不懂的调用栈,这就是典型的黑匣子故障。我一般会把图片资源挂在窗口类或 App 类上,跟着窗口销毁,退出路径清晰可见。
3. 加载链路拆解:从 PNG 文件到带预乘 alpha 的 DIB
3.1 第一步:FromFile 打开文件,顺手把格式统一成 32bppARGB
Gdiplus::Bitmap::FromFile是整条链路的入口,第二个参数是「是否使用嵌入的颜色管理信息」,一般传 FALSE,除非素材真的带了 ICC Profile 需要还原。返回值一定要判两次:指针是否为 NULL,以及GetLastStatus()是不是Ok,因为文件不存在、扩展名对但内容损坏、GDI+ 没初始化,这三种情况返回的表现不一样,只看指针会漏。
Gdiplus::Bitmap* png = Gdiplus::Bitmap::FromFile(L"skin\\btn_ok.png", FALSE); if (png == NULL || png->GetLastStatus() != Gdiplus::Ok) { delete png; // 注意:FromFile 失败时也可能返回非 NULL,必须 delete return FALSE; } UINT nW = png->GetWidth(); UINT nH = png->GetHeight(); Gdiplus::PixelFormat fmt = png->GetPixelFormat();GetPixelFormat()拿到的值很关键。PNG 有灰度、调色板、24 位 RGB、32 位 RGBA 等好几种颜色类型,GDI+ 解出来之后格式不一定是你想要的 32bppARGB;如果后面LockBits时按 32bppARGB 去锁一张 24 位的图,LockBits会返回InvalidParameter,或者更糟——自动转成 32bpp 但其实没有 alpha 通道。稳妥的做法是先统一格式,再进后续流程:
// 不管源 PNG 是什么格式,先转成一张标准的 32bppARGB 画布 Gdiplus::Bitmap* pArgb = new Gdiplus::Bitmap(nW, nH, PixelFormat32bppARGB); { Gdiplus::Graphics g(pArgb); // SourceCopy 表示直接覆盖像素,不做任何混合,避免透明区域被叠加两次 g.SetCompositingMode(Gdiplus::CompositingModeSourceCopy); g.DrawImage(png, 0, 0, nW, nH); } delete png;这里用CompositingModeSourceCopy而不是默认的SourceOver是有原因的:新建的画布是全透明的,用 SourceOver 绘制时,源图的半透明像素会和底色(黑)做一次混合,本来 alpha=128 的像素颜色会被压暗,最后显示出来整张图偏黑。这个现象在深色背景的素材上特别明显,很多人以为是 AlphaBlend 用错了,其实是这一步就错了。
3.2 第二步:LockBits 取原始像素,盯紧 Stride
Bitmap::LockBits把图像像素锁进一块内存,BitmapData里三个字段要记住:Scan0是起始指针,Stride是每一行的字节数,Width、Height是像素尺寸。坑在Stride:GDI+ 返回的 Stride 可能大于Width * 4,因为它按 4 字节对齐甚至更大粒度对齐,如果直接memcpy(Scan0, pDst, Width * Height * 4),图像会整体错位成一堆斜条。
Gdiplus::Rect rc(0, 0, nW, nH); Gdiplus::BitmapData bd; if (pArgb->LockBits(&rc, Gdiplus::ImageLockModeRead, PixelFormat32bppARGB, &bd) != Gdiplus::Ok) { return FALSE; } BYTE* pSrcBase = (BYTE*)bd.Scan0; // 按行处理,每次加 bd.Stride // 用完必须 UnlockBits,否则这块内存一直被占着,且 Bitmap 无法释放内存布局也要心里有数。GDI+ 的PixelFormat32bppARGB在小端机器上的实际字节序是 B、G、R、A,而 Windows 32 位 DIB 的BI_RGB布局也是 B、G、R、A,两者一致,所以通道顺序不需要交换。这是整个流程里最容易被过度设计的地方,网上不少代码会写三段交换 R 和 B 的循环,其实多此一举,白白慢一倍。真正必须做的是下一步的预乘。
3.3 第三步:建 32 位 DIB Section 并做预乘
GDI 的位图分两种:设备相关位图(DDB)和设备无关位图(DIB)。DDB 是CreateCompatibleBitmap出来的,像素格式内部由驱动决定,拿不到可靠的 32 位 alpha;DIB Section 用CreateDIBSection创建,会同时返回一个像素内存指针,格式自己指定,这才是 alpha 能落地的地方。
BITMAPINFO bmi; ZeroMemory(&bmi, sizeof(BITMAPINFO)); bmi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); bmi.bmiHeader.biWidth = nW; bmi.bmiHeader.biHeight = -(LONG)nH; // 负高度 = 自上而下,行序和 GDI+ 一致 bmi.bmiHeader.biPlanes = 1; bmi.bmiHeader.biBitCount = 32; bmi.bmiHeader.biCompression = BI_RGB; void* pBits = NULL; // 第一个参数传 NULL 在 BI_RGB 下是合法的,不需要屏幕 DC HBITMAP hDib = ::CreateDIBSection(NULL, &bmi, DIB_RGB_COLORS, &pBits, NULL, 0); if (hDib == NULL || pBits == NULL) { pArgb->UnlockBits(&bd); return FALSE; }biHeight写成负数是刻意为之:GDI+ 的Scan0指向第一行像素(自上而下),而正高度 DIB 的第一行在图像底部(自下而上),写成负数能省掉一次整图翻转,行序直接对齐。这个细节不写也不会报错,但你会看到图像上下颠倒。
接下来是预乘。预乘 alpha 的意思是每个像素的 R、G、B 已经乘过自己的 alpha 值,范围从 0 到 alpha。AlphaBlend 使用AC_SRC_ALPHA时要求源位图就是这种格式,它不再做乘法,直接做加法和移位;如果传入的是非预乘数据,相当于把颜色当成已经乘过的结果,半透明区域的颜色会被整体放大,表现出来就是圆角外的阴影发白发亮。
for (UINT y = 0; y < nH; ++y) { const BYTE* pSrc = pSrcBase + y * bd.Stride; // 源:按 Stride 走 BYTE* pDst = (BYTE*)pBits + y * nW * 4; // 目标:DIB 行宽就是 nW*4 for (UINT x = 0; x < nW; ++x) { BYTE b = pSrc[0]; BYTE g = pSrc[1]; BYTE r = pSrc[2]; BYTE a = pSrc[3]; // +127 是四舍五入,纯粹用 /255 会让半透明区域整体偏暗一点点 pDst[0] = (BYTE)((b * a + 127) / 255); pDst[1] = (BYTE)((g * a + 127) / 255); pDst[2] = (BYTE)((r * a + 127) / 255); pDst[3] = a; // alpha 本身保持不变 pSrc += 4; pDst += 4; } } pArgb->UnlockBits(&bd);这段循环是整个透明效果的地基。a为 0 时三个颜色分量都归零,AlphaBlend 加目标色后画面不变,完全透明;a为 255 时颜色原样保留,完全不透明;中间值就按比例混合。除法用/255而不是位移,是因为 255 不是 2 的幂,>>8会让结果偏小,白色区域会发灰。大图用这个循环会有点慢(一张 1024×1024 大约百万次迭代,VC6 不开优化也能在几十毫秒内跑完),所以只在加载时做一次,绘制时直接复用句柄,不要每帧重算。
3.4 资源内嵌 PNG 的加载:CreateStreamOnHGlobal 走内存流
把 PNG 当外部文件放在 exe 旁边,部署时最容易丢。工程里更常见的做法是把 PNG 编译进资源,用自定义资源类型(比如PNG)而不是BITMAP,因为后者会被 RC 编译器尝试解析,PNG 的二进制内容会被改坏。资源加载后要转成IStream才能喂给Bitmap::FromStream,这一步最省事的写法是用CreateStreamOnHGlobal:
HRSRC hRes = ::FindResource(hInst, MAKEINTRESOURCE(IDR_PNG_LOGO), _T("PNG")); if (hRes == NULL) return FALSE; DWORD dwSize = ::SizeofResource(hInst, hRes); HGLOBAL hData = ::LoadResource(hInst, hRes); LPVOID pData = ::LockResource(hData); // 必须拷贝一份到可移动内存,资源内存是只读的,IStream 需要可写句柄 HGLOBAL hMem = ::GlobalAlloc(GMEM_MOVEABLE, dwSize); if (hMem == NULL) return FALSE; void* pDst = ::GlobalLock(hMem); memcpy(pDst, pData, dwSize); ::GlobalUnlock(hMem); IStream* pStream = NULL; // 第二个参数 TRUE 表示流释放时自动 GlobalFree 这块内存 if (FAILED(::CreateStreamOnHGlobal(hMem, TRUE, &pStream))) { ::GlobalFree(hMem); return FALSE; } Gdiplus::Bitmap* pBmp = Gdiplus::Bitmap::FromStream(pStream, FALSE); pStream->Release(); // 流可以被释放,Bitmap 内部已经拷贝了解码后的数据一个常被误解的点:CreateStreamOnHGlobal不需要先CoInitialize,它是 ole32 里少数不依赖 COM 初始化的函数,所以对话框程序直接调用没问题。但Bitmap::FromStream依赖 GDI+ 已经 Startup,顺序不能反。另外注意LoadResource之后没有对应的FreeResource——在 Win32 下资源是映射进进程空间的,不需要释放,写了反而是错的(16 位时代才需要)。
4. 透明绘制:AlphaBlend、TransparentBlt 与异形窗口
4.1 AlphaBlend 的参数表与调用姿势
到这一步,手上已经有一个 32 位预乘的 DIB 句柄,绘制就只剩调用AlphaBlend一件事。这个函数的参数多,但真正会传错的只有最后那个BLENDFUNCTION:
| 参数 | 含义 | 常用取值 |
|---|---|---|
| hdcDest | 目标 DC | 窗口或双缓冲内存 DC |
| xoriginDest / yoriginDest | 目标左上角坐标 | 按布局算 |
| wDest / hDest | 目标宽高 | 等于源尺寸时不缩放 |
| hdcSrc | 源 DC,必须已 SelectObject 那张 DIB | 独立的兼容 DC |
| xoriginSrc / yoriginSrc | 源图起始坐标 | 通常是 0, 0 |
| wSrc / hSrc | 源图区域宽高 | 图像原始尺寸 |
| ftn.BlendOp | 混合方式 | AC_SRC_OVER |
| ftn.BlendFlags | 保留字段 | 必须为 0 |
| ftn.SourceConstantAlpha | 整体不透明度 0-255 | 255;做淡入淡出时改这里 |
| ftn.AlphaFormat | 来源 alpha 方式 | AC_SRC_ALPHA(每像素),0(不用) |
调用姿势上,最容易漏的是SelectObject和还原。把 DIB 选进一个兼容 DC 之后,函数结束要选回原来的对象,否则这个 DC 一直占着那张位图,后面想重建或释放都会失败:
void DrawPngAlpha(HDC hdcDst, int x, int y, int w, int h, HBITMAP hDib, int nSrcW, int nSrcH) { HDC hdcSrc = ::CreateCompatibleDC(hdcDst); HGDIOBJ hOld = ::SelectObject(hdcSrc, hDib); BLENDFUNCTION bf; bf.BlendOp = AC_SRC_OVER; bf.BlendFlags = 0; bf.SourceConstantAlpha = 255; bf.AlphaFormat = AC_SRC_ALPHA; // 源位图必须是预乘过的 32bpp // 缩放时用 HALFTONE,否则放大后的边缘是硬锯齿 ::SetStretchBltMode(hdcDst, HALFTONE); ::SetBrushOrgEx(hdcDst, 0, 0, NULL); ::AlphaBlend(hdcDst, x, y, w, h, hdcSrc, 0, 0, nSrcW, nSrcH, bf); ::SelectObject(hdcSrc, hOld); ::DeleteDC(hdcSrc); }SourceConstantAlpha这个参数值得单独说:它和每像素 alpha 是相乘关系,最终 alpha = 像素 alpha × 常量 / 255。所以做按钮的禁用态、弹出窗口淡入、鼠标悬停高亮,都不用另外准备一套图,改这个值就行。工程里做整体淡入时就是拿一个定时器把它从 0 加到 255,每帧重绘一次。
4.2 TransparentBlt 的适用边界
TransparentBlt在msimg32.lib里,接口形态和StretchBlt几乎一样,多一个颜色键参数:
// 最后一个参数是"被视为完全透明"的颜色键,这里用常见的洋红 ::TransparentBlt(hdcDst, x, y, w, h, hdcSrc, 0, 0, nSrcW, nSrcH, RGB(255, 0, 255));它的能力边界必须说清楚:只有「完全透明」和「完全不透明」两档,没有中间值。PNG 里那些 alpha 在 1 到 254 之间的羽化像素,要么被当成不透明画出来(边缘留下脏色),要么被当成透明抠掉(边缘变成硬切)。所以它只适合两类素材:8 位索引色的老图标,以及本来就是硬边缘的像素风图形。工程里保留它是因为有些老素材是别的系统导出的,边缘做了颜色键处理,用 AlphaBlend 反而会把色键颜色画出来。
4.3 双缓冲:把闪烁压下去
只要贴图,就一定会碰到闪烁。原因不是 AlphaBlend 慢,而是默认的 WM_ERASEBKGND 会用背景刷把客户区刷成一片底色,然后 WM_PAINT 再贴一次,中间这段时间用户能看到。解决办法是两步:第一步在OnEraseBkgnd里直接返回 TRUE,声明背景由自己负责;第二步在OnPaint里先把所有内容画到内存 DC,最后一次性BitBlt到屏幕。
BOOL CMyView::OnEraseBkgnd(CDC* pDC) { return TRUE; // 背景已经在 OnPaint 的双缓冲里画过了,这里不要再擦 } void CMyView::OnPaint() { CPaintDC dc(this); CRect rc; GetClientRect(&rc); CDC memDC; memDC.CreateCompatibleDC(&dc); CBitmap bmpBuf; bmpBuf.CreateCompatibleBitmap(&dc, rc.Width(), rc.Height()); CBitmap* pOldBmp = memDC.SelectObject(&bmpBuf); // 1. 先铺背景 memDC.FillSolidRect(&rc, RGB(240, 240, 240)); // 2. 再贴 PNG DrawPngAlpha(memDC.GetSafeHdc(), 20, 20, m_nPngW, m_nPngH, m_hPngDib, m_nPngW, m_nPngH); // 3. 一次性贴回屏幕 dc.BitBlt(0, 0, rc.Width(), rc.Height(), &memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBmp); }注意双缓冲里的CreateCompatibleBitmap(&dc, ...)建的是 DDB,它没有 alpha 通道,但没关系——它只是背景
本文还有配套的精品资源,点击获取