1. 需求拆解一个图片按钮到底要拆成几件事做 C 语言 WinAPI 的界面开发最容易被低估的就是放一张图上去这件小事。很多同行第一次听到图片按钮脑子里想的是加个按钮、贴张图、完事。真动手才知道Win32 原生控件里那个BUTTON根本不认图片你给它的文字它认识你给它的位图它不认识。我手上接过一个小工具纯 C 写的界面全是原生控件需求就一句话界面上放一个带图标的按钮点一下把指定的那张大图显示出来。听起来十分钟的活实际做下来踩了三四个坑。先把需求层层剥开。第一条按钮要显示图片这涉及控件绘制权的问题要么把绘制权从系统手里抢过来自己画owner-draw要么绕开按钮用别的控件伪装成按钮。第二条要能点这意味着必须有鼠标点击的消息进到你的窗口过程里很多新手用静态控件贴图图是出来了鼠标点上去毫无反应问题就出在少了一个扩展样式。第三条要显示指定的图片这里指定两个字信息量很大是编译时就固定的图还是运行时可以由用户选择、由配置决定的图前者可以塞进资源节后者必须走文件加载两者的代码路径完全不同。第四条图片格式LoadImage只吃 BMP、ICO、CUR你随手从设计那边拿到的 PNG 带透明通道它直接给你返回 NULL一点提醒都没有。这四条需求决定了整个方案的骨架。我在动手前习惯把这种看起来很小的功能写成一句话在原生窗口上创建一个能响应点击、能按状态切换外观、可加载外部或内嵌图片的自绘控件并在点击后把目标图片等比绘制到客户区。这句话写出来后面所有技术选型都是围绕它转的不会写着写着跑偏。还有一层容易被忽略的隐性需求这套东西得在不同缩放的显示器上看起来不糊。现在的机器 125%、150% 缩放是常态如果代码里所有尺寸都写死像素图片按钮在你本机很精致到同事的笔记本上要么小得像个点要么糊成一团马赛克。这个坑我在第一个版本就踩了后面专门用一节讲 DPI 的处理。准备工作不用太复杂一套能编译 C 的构建环境MSVC 或者 MinGW-w64 都行、Windows SDK 头文件、gdiplus.lib和msimg32.lib这两个链接库够了。代码本身不依赖任何第三方框架全程原生 API编译出来的 exe 体积小、没有运行时依赖这也是我坚持这个方案的主要原因——给别人的老机器跑扔一个 exe 过去就行不用装这装那。2. 方案取舍三条路线各自适合什么场景2.1 路线对比owner-draw、静态控件、纯自绘Win32 下做图片按钮说白了就三条路我全试过各有各的脾气。第一条BS_OWNERDRAW按钮。给按钮加上这个样式系统就不再自己画它而是给父窗口发一条WM_DRAWITEM消息把绘制权、按钮矩形、当前状态正常/按下/焦点/禁用一股脑塞进DRAWITEMSTRUCT结构体里交给你。你在里面爱画什么画什么画图、画渐变、画文字都行。这条路的好处是按钮的行为系统全包了键盘 Tab 焦点、空格键触发、BN_CLICKED通知、禁用态不响应点击这些都是现成的你只管外观。第二条STATIC控件 SS_BITMAPSS_NOTIFY。静态控件加SS_BITMAP样式再用STM_SETIMAGE把位图塞进去图片立刻显示。但静态控件默认是不接收鼠标消息的它会把WM_LBUTTONDOWN直接透传给父窗口必须补一个SS_NOTIFY点下去才会有一条STN_CLICKED通知发到父窗口。这条路最省代码五分钟就能跑出效果缺点是几乎没有状态反馈鼠标悬停没变化、按下去没变化、不能有焦点框视觉上就是个贴了图的方块用户根本不知道它能点。第三条完全自绘。不创建任何子控件直接在父窗口的WM_PAINT里算一个矩形区域WM_MOUSEMOVE、WM_LBUTTONDOWN、WM_LBUTTONUP里自己做命中测试。灵活度最高想加圆角、阴影、动画都随意代价是所有交互逻辑都得自己写包括鼠标移出时清掉悬停状态要靠TrackMouseEvent注册TME_LEAVE、点击时判断按下和抬起是不是在同一个区域内防止用户按下后拖出去再松开被误判为点击。我的实际选择是主按钮用路线一验证阶段用路线二特殊视觉效果才上路线三。理由很实在——图片按钮这种东西用户对它的期待就是一个按钮而不是一块会响应的图。既然期待是按钮那就让系统的按钮行为兜底我只负责外观这是投入产出比最高的分工。对比项BS_OWNERDRAW 按钮STATIC SS_BITMAP纯自绘区域上手成本中要理解 DRAWITEMSTRUCT低几行代码出效果高事件全自理键盘操作系统自带Tab/空格不支持需要自己实现悬停/按下态支持但要自己画不支持支持焦点框ODS_FOCUS直接给无自己画禁用态ODS_DISABLED直接给需手动改手动透明 PNG支持GDI 或 AlphaBlend半透明会丢支持适合场景标准功能按钮纯展示、临时验证定制化视觉注意给按钮加BS_OWNERDRAW之后WM_DRAWITEM是发给父窗口的不是发给按钮自己。新手常犯的错是把处理逻辑写在按钮的子类化过程里结果消息根本收不到。2.2 图片格式与加载库的选型逻辑图片格式这块是个分水岭。如果你的素材是 BMP那LoadImage一行就搞定参数填LR_LOADFROMFILE | LR_CREATEDIBSECTION从文件读填LR_DEFAULTCOLOR从资源读。但 BMP 的问题是体积大、不支持透明通道、设计师基本不会给你 BMP。现实里的素材九成是 PNG带 Alpha 通道。这时候有三条路GDI、WICWindows Imaging Component、stb_image 这类单头文件库。GDI 胜在 API 友好Bitmap::FromFile一行读进来Graphics::DrawImage一行画出去缩放、插值模式、抗锯齿都是现成参数代码量最少。缺点是必须初始化GdiplusStartup而且初始化失败的返回值经常被忽略导致后面所有调用静默失败图就是不显示你还查不出原因。WIC 更底层性能更好、没有全局状态但 COM 组件那一套初始化、枚举解码器、格式转换的样板代码能写一百多行对一个显示图片的小工具来说太重了。stb_image 是纯 C零依赖但只负责解码解码出来的像素你还得自己塞进CreateDIBSection透明通道要自己走AlphaBlend合成。我的判断标准很简单工具类程序、界面元素不多、追求开发速度就用 GDI。它对内存和性能的额外开销在几十个按钮的规模下完全感知不到换来的是代码量减半、透明通道开箱即用。3. 工程骨架与图片资源的两种放法3.1 最小可跑窗口的搭建在写按钮之前得先有个能承载它的窗口。Win32 的骨架万年不变注册窗口类、创建窗口、消息循环。这一步不需要写得多花哨但有几个细节建议一开始就定型后面省事。#include windows.h #include objidl.h #include gdiplus.h #pragma comment(lib, gdiplus.lib) #pragma comment(lib, msimg32.lib) #define IDC_IMG_BTN 1001 #define IDC_PIC_AREA 1002 static ULONG_PTR g_gdiplusToken 0; static Gdiplus::Image *g_btnImg NULL; static Gdiplus::Image *g_bigImg NULL; static BOOL g_showBig FALSE; LRESULT CALLBACK WndProc(HWND, UINT, WPARAM, LPARAM); int WINAPI WinMain(HINSTANCE hInst, HINSTANCE hPrev, LPSTR lpCmd, int nShow) { /* 第一步DPI 感知越早调用越好 */ SetProcessDPIAware(); /* 第二步GDI 初始化失败就别往下走了 */ Gdiplus::GdiplusStartupInput gsi; if (Gdiplus::GdiplusStartup(g_gdiplusToken, gsi, NULL) ! Gdiplus::Ok) return 1; WNDCLASSEXW wc { 0 }; wc.cbSize sizeof(wc); wc.style CS_HREDRAW | CS_VREDRAW; wc.lpfnWndProc WndProc; wc.hInstance hInst; wc.hCursor LoadCursorW(NULL, IDC_ARROW); wc.hbrBackground (HBRUSH)(COLOR_BTNFACE 1); wc.lpszClassName LImgBtnDemoWnd; if (!RegisterClassExW(wc)) return 2; HWND hWnd CreateWindowExW(0, wc.lpszClassName, L图片按钮示例, WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT, 640, 480, NULL, NULL, hInst, NULL); if (!hWnd) return 3; ShowWindow(hWnd, nShow); UpdateWindow(hWnd); MSG msg; while (GetMessageW(msg, NULL, 0, 0) 0) { TranslateMessage(msg); DispatchMessageW(msg); } Gdiplus::GdiplusShutdown(g_gdiplusToken); return (int)msg.wParam; }这段骨架里我特意做了两件看起来多余的事。一是把SetProcessDPIAware()放在最前面因为它必须在任何窗口创建之前调用才有效放到后面调等于白调。二是把 GDI 的初始化放在窗口创建之前、GdiplusShutdown放在消息循环之后保证整个程序生命周期内 GDI 都可用同时退出时干净收尾。很多人把GdiplusShutdown忘了程序退出时不会有明显报错但如果你的程序会被反复启动比如作为插件加载累积的 GDI 对象迟早出问题。窗口背景我用了COLOR_BTNFACE也就是系统的对话框底色而不是纯白。这么做是为了让按钮的图片区域和背景色差小一点后面处理透明通道时视觉上更自然。如果你要做深色主题这里换成自绘背景刷就行。3.2 资源嵌入还是外部文件按图片会不会变来决定图片从哪来这个问题在项目初期就要拍板因为它影响后面所有的加载代码。外部文件适合图片会变的场景换皮肤、换图标、用户自定义。好处是不用重新编译改个文件立刻生效调试阶段特别爽。坏处是路径问题程序一挪位置就找不到图。我一般这么处理/* 取 exe 所在目录拼出图片的绝对路径 */ static void GetImagePath(WCHAR *out, size_t cch, const WCHAR *name) { WCHAR dir[MAX_PATH] { 0 }; GetModuleFileNameW(NULL, dir, MAX_PATH); WCHAR *p wcsrchr(dir, L\\); if (p) *(p 1) L\0; _snwprintf_s(out, cch, _TRUNCATE, L%s%s, dir, name); }这段代码看着平平无奇但它是踩过坑的产物。我第一版用的是相对路径Lbtn.png本机跑得好好的发给别人直接空白。原因是相对路径的基准是进程的当前工作目录而不是 exe 所在目录。从资源管理器双击启动时工作目录恰好是 exe 目录看着没问题但从快捷方式启动、或者从别的程序拉起你的 exe 时工作目录完全可能是别的地方图就没了。用GetModuleFileNameW取到 exe 自己的完整路径再拼这个问题就绝迹了。资源嵌入适合图标这种固定不变的素材。做法是在.rc文件里写一行IDB_BTN_NORMAL PNG res\\btn_normal.png注意这里自定义了一个资源类型名PNG不是标准的BITMAP这是允许的——FindResource的类型参数可以填任意字符串。这么写的好处是 GDI 可以直接从原始 PNG 字节流解码不用经过LoadImage那套只认 BMP 的限制static Gdiplus::Image* LoadImageFromResource(HINSTANCE hInst, int resId, const WCHAR *type) { HRSRC hRes FindResourceW(hInst, MAKEINTRESOURCEW(resId), type); if (!hRes) return NULL; DWORD size SizeofResource(hInst, hRes); HGLOBAL hMem LoadResource(hInst, hRes); if (!hMem) return NULL; void *pData LockResource(hMem); if (!pData || size 0) return NULL; /* 关键把裸内存包成 IStreamGDI 认流不认指针 */ IStream *pStream SHCreateMemStream((const BYTE *)pData, size); if (!pStream) return NULL; Gdiplus::Image *img Gdiplus::Image::FromStream(pStream); pStream-Release(); return img; }这里的坑在于GDI 不接受裸内存指针它要的是一个 COM 流接口。SHCreateMemStream是最省事的包装方式需要链shlwapi.lib。用CreateStreamOnHGlobal也行但要自己分配全局内存、拷贝数据、再释放多三步。注意SHCreateMemStream创建的是只读流流对象用完必须Release但底层那块资源内存由系统管理不要去free。怎么选我的判断标准是按钮图标这类跟程序绑定的、几乎不会变的嵌进资源用户要看的大图、会随业务变化的走外部文件。资源嵌入的 exe 稍微大一点但只有一个文件发给别人不会漏图这个体验上的差别比那几百 KB 重要得多。3.3 GDI 初始化失败的几种典型表现GDI 初始化失败这事报错信息约等于没有。GdiplusStartup返回非Ok你去查文档得到的是一堆枚举值。我在实际项目里遇到过的失败原因主要有三类。第一类是没有链接gdiplus.lib编译能过、链接报未定义符号这个还算好的至少能发现。第二类是调用顺序错了比如在GdiplusStartup之前就new了一个Gdiplus::Image这时候对象构造会返回NULL但代码里没判空后面DrawImage传个空指针直接被吞掉画面上什么都不出来。第三类是多线程访问GDI 的对象不是线程安全的如果你在后台线程里解码图片、然后拿到 UI 线程去画偶尔会崩在莫名其妙的地方。我的做法是把初始化和加载包成一个函数失败就立刻弹个提示框退出绝不带病往下跑static BOOL InitGraphics(void) { Gdiplus::GdiplusStartupInput gsi; if (Gdiplus::GdiplusStartup(g_gdiplusToken, gsi, NULL) ! Gdiplus::Ok) { MessageBoxW(NULL, L图形库初始化失败, L错误, MB_ICONERROR); return FALSE; } return TRUE; }提示加载图片之后一定判空。Gdiplus::Image::FromFile在文件不存在、格式不支持、文件被占用时都会返回NULL不判空直接画程序不会崩但你会盯着一个空白的按钮怀疑人生。4. 核心实现从静态贴图到能点的图片按钮4.1 十分钟版本STATIC 控件贴图先说最快的验证路径用来确认环境和图片本身没问题别一上来就写自绘出了 bug 分不清是环境问题还是代码问题。/* 在 WM_CREATE 里 */ HWND hPic CreateWindowExW(0, LSTATIC, L, WS_CHILD | WS_VISIBLE | SS_BITMAP | SS_NOTIFY | SS_CENTERIMAGE, 20, 20, 160, 64, hWnd, (HMENU)IDC_IMG_BTN, hInst, NULL);拿到句柄之后把位图塞进去HBITMAP hbm (HBITMAP)LoadImageW(hInst, Lres\\btn.bmp, IMAGE_BITMAP, 0, 0, LR_LOADFROMFILE | LR_CREATEDIBSECTION); SendMessageW(hPic, STM_SETIMAGE, IMAGE_BITMAP, (LPARAM)hbm);这样图就出来了。点击怎么接静态控件的点击通知不是BN_CLICKED而是STN_CLICKED在父窗口的WM_COMMAND里这样判断case WM_COMMAND: if (LOWORD(wParam) IDC_IMG_BTN HIWORD(wParam) STN_CLICKED) { g_showBig !g_showBig; InvalidateRect(hWnd, NULL, TRUE); } break;STM_SETIMAGE之后系统会负责在控件销毁时释放位图但如果你自己换图旧的那张要自己DeleteObject否则每换一次泄漏一张位图的 GDI 句柄。这个版本的价值在于它五分钟就能告诉你图片路径对不对、格式支持不支持、点击消息通不通。等这三点都验证过了再替换成自绘按钮出问题就只可能是绘制代码本身。我强烈建议保留这一步别嫌麻烦。4.2 正式版本BS_OWNERDRAW 的四态绘制现在把静态控件换掉用真正的所有者绘制按钮。HWND hBtn CreateWindowExW(0, LBUTTON, L, WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_OWNERDRAW, Scale(20), Scale(20), Scale(160), Scale(64), hWnd, (HMENU)IDC_IMG_BTN, hInst, NULL);注意WS_TABSTOP这是让 Tab 键能切到它上面。BS_OWNERDRAW只在创建时给一次创建完再改样式不生效SetWindowLong改GWL_STYLE之后必须SetWindowPos带SWP_FRAMECHANGED才会重新计算而且很多情况下按钮的行为已经定型改了也没用。所以样式一定要在CreateWindowExW时就写全。真正的绘制逻辑在父窗口的WM_DRAWITEM里case WM_DRAWITEM: { LPDRAWITEMSTRUCT dis (LPDRAWITEMSTRUCT)lParam; if (dis-CtlID IDC_IMG_BTN dis-CtlType ODT_BUTTON) { DrawImageButton(dis); return TRUE; /* 必须返回 TRUE告诉系统我画完了 */ } } break;return TRUE这个细节不能省。返回FALSE系统会认为你没处理可能触发默认绘制出现重影或者闪烁。绘制函数本身要处理四件事背景、图片等比缩放居中、状态叠加、焦点框。#define MINV(a,b) ((a) (b) ? (a) : (b)) static void DrawImageButton(LPDRAWITEMSTRUCT dis) { RECT rc dis-rcItem; int cw rc.right - rc.left; int ch rc.bottom - rc.top; /* 双缓冲先画到内存 DC再一次性贴上去 */ HDC memDC CreateCompatibleDC(dis-hDC); HBITMAP buf CreateCompatibleBitmap(dis-hDC, cw, ch); HBITMAP old (HBITMAP)SelectObject(memDC, buf); /* 1. 背景 */ HBRUSH bg GetSysColorBrush(dis-itemState ODS_SELECTED ? COLOR_BTNSHADOW : COLOR_BTNFACE); FillRect(memDC, (RECT){ 0, 0, cw, ch }, bg); /* 2. 图片等比缩放居中 */ if (g_btnImg g_btnImg-GetLastStatus() Gdiplus::Ok) { Gdiplus::Graphics g(memDC); g.SetInterpolationMode(Gdiplus::InterpolationModeHighQualityBicubic); g.SetPixelOffsetMode(Gdiplus::PixelOffsetModeHighQuality); int iw (int)g_btnImg-GetWidth(); int ih (int)g_btnImg-GetHeight(); float s MINV((float)cw / iw, (float)ch / ih); int dw (int)(iw * s), dh (int)(ih * s); int dx (cw - dw) / 2, dy (ch - dh) / 2; /* 按下时整体下移 1 像素模拟物理按压 */ if (dis-itemState ODS_SELECTED) { dx 1; dy 1; } g.DrawImage(g_btnImg, dx, dy, dw, dh); /* 禁用态打灰 */ if (dis-itemState ODS_DISABLED) { Gdiplus::SolidBrush mask(Gdiplus::Color(140, 255, 255, 255)); g.FillRectangle(mask, 0, 0, cw, ch); } } /* 3. 焦点框 */ if (dis-itemState ODS_FOCUS) { RECT fr { 2, 2, cw - 2, ch - 2 }; DrawFocusRect(memDC, fr); } /* 4. 一次性提交 */ BitBlt(dis-hDC, rc.left, rc.top, cw, ch, memDC, 0, 0, SRCCOPY); SelectObject(memDC, old); DeleteObject(buf); DeleteDC(memDC); }关于ODS_SELECTED下移一像素这个处理看起来微不足道但用户对按下去有反馈的感知非常依赖它。纯靠背景色从BTNFACE变BTNSHADOW在很多显示器上对比度不够加一像素位移立刻就有按到了的感觉。需要说明的是BS_OWNERDRAW按钮不会自动产生悬停状态。DRAWITEMSTRUCT的itemState里有ODS_HOTLIGHT但只有带BS_FLAT或者特定主题时才会出现别指望它。要做悬停高亮得自己在父窗口的WM_MOUSEMOVE里判断鼠标坐标是不是落在按钮矩形内然后手动InvalidateRect那个按钮区域触发重绘。这是个典型的系统给了你一半、另一半自己补的场景。4.3 点击后显示指定图片客户区绘制的实现按钮点下去之后要显示大图这块的逻辑在WM_PAINT里。case WM_PAINT: { PAINTSTRUCT ps; HDC hdc BeginPaint(hWnd, ps); if (g_showBig g_bigImg) { RECT rc; GetClientRect(hWnd, rc); /* 给按钮留出上方空间 */ rc.top Scale(100); Gdiplus::Graphics g(hdc); g.SetInterpolationMode(Gdiplus::InterpolationModeHighQualityBicubic); int iw (int)g_bigImg-GetWidth(); int ih (int)g_bigImg-GetHeight(); int aw rc.right - rc.left; int ah rc.bottom - rc.top; float s MINV((float)aw / iw, (float)ah / ih); int dw (int)(iw * s), dh (int)(ih * s); int dx rc.left (aw - dw) / 2; int dy rc.top (ah - dh) / 2; g.DrawImage(g_bigImg, dx, dy, dw, dh); } EndPaint(hWnd, ps); }这里有个必须注意的点BeginPaint会自动清空更新区域但如果你在WM_PAINT里画了两块互不相干的内容一块在rc.top以上、一块以下那么当只有下半部分失效时上半部分不会重绘可能出现残留。稳妥的做法是把整个客户区都标脏InvalidateRect(hWnd, NULL, TRUE)对这个规模的程序来说性能完全不是问题。还有一个更隐蔽的坑当g_showBig从TRUE切回FALSE时上一次画的大图不会自己消失。因为WM_PAINT里直接BeginPaint之后没有任何背景填充旧内容还在。解决办法有两种一是在WM_PAINT开头用FillRect刷一遍背景二是改用WM_ERASEBKGND返回FALSE之后自己在WM_PAINT里全刷。我选前者逻辑最直白FillRect(hdc, ps.rcPaint, GetSysColorBrush(COLOR_BTNFACE));放在BeginPaint之后、绘制之前。别放在EndPaint之前那就把图盖掉了。5. 尺寸、DPI 与资源管理让程序在别人机器上也体面5.1 等比缩放的计算与 StretchBlt 的模式选择图片缩放这件事物理课上讲保持长宽比代码里就是一个min运算。设按钮可用宽高为cw × ch图片原始尺寸iw × ih横向缩放系数sx cw / iw纵向sy ch / ih取两者较小值s min(sx, sy)目标尺寸就是iw × s和ih × s然后居中偏移(cw - dw) / 2。取小值的逻辑是保证整张图完整放得下取大值会裁掉一部分。这个公式我几乎在每次做等比显示时都手写一遍因为它太短了封装反而麻烦。如果你不用 GDI 而走纯 GDI那就得用StretchBlt并且一定要先设拉伸模式SetStretchBltMode(hdc, HALFTONE); SetBrushOrgEx(hdc, 0, 0, NULL); /* HALFTONE 模式下必须重置刷子原点 */ StretchBlt(hdc, dx, dy, dw, dh, memDC, 0, 0, iw, ih, SRCCOPY);HALFTONE是四种模式里唯一做插值平滑的另外三种BLACKONWHITE、WHITEONBLACK、COLORONCOLOR都是简单删行删列的取整策略。不设的话缩小时图片会出现难看的锯齿和噪点。但HALFTONE有个副作用它会改变目标 DC 的刷子原点所以要补一句SetBrushOrgEx重置否则你在同一个 DC 里后续画的填充矩形会错位。这个坑非常隐蔽表现出来是填充区域无缘无故偏移了几个像素查半天查不出来。拉伸模式行为适用场景BLACKONWHITE缩小取最暗像素黑白文字、图标追求锐利WHITEONBLACK缩小取最亮像素反白文字COLORONCOLOR直接删行删列不要求质量、追求速度HALFTONE按目标像素块加权平均照片、带透明通道的图追求平滑显示照片的时候我个人习惯是缩小到 30% 以内用HALFTONE再往上缩会出现明显模糊那时候就得考虑用 GDI 的InterpolationModeHighQualityBicubic或者干脆预先算好缩略图缓存起来。5.2 高 DPI 下的模糊与尺寸换算这个必须单独说因为它是最容易露怯的地方。程序在自己机器上好看到别人 150% 缩放的屏幕上整个窗口被系统拉伸图糊得像隔了层毛玻璃。问题的根源是默认情况下系统认为你的程序不认识 DPI于是在高缩放环境下先按 96 DPI 渲染你的窗口再把整个窗口位图放大。文字和图片都糊。解法是在程序启动最早期声明自己认识 DPI然后所有硬编码尺寸乘上 DPI 比例static int Scale(int v) { HDC hdc GetDC(NULL); int dpi GetDeviceCaps(hdc, LOGPIXELSX); ReleaseDC(NULL, hdc); return MulDiv(v, dpi, 96); }MulDiv是个好用的 API它做的是v * dpi / 96并四舍五入比手写(v * dpi 48) / 96干净。为什么不能直接v * dpi / 96因为整数除法会截断连续多次换算之后累积误差会让界面元素一点点错位。MulDiv内部处理了舍入结果稳定。如果目标系统是 Windows 10 1703 及以上用SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)更好它支持多显示器不同 DPI窗口从 100% 屏拖到 150% 屏时能收到WM_DPICHANGED消息重新布局。但这个函数不是所有系统都有直接调用在旧系统上会链接失败或者运行失败。稳妥的写法是运行时动态取函数地址typedef BOOL (WINAPI *PFN_SetCtx)(DPI_AWARENESS_CONTEXT); HMODULE hUser32 GetModuleHandleW(Luser32.dll); PFN_SetCtx pfn (PFN_SetCtx)GetProcAddress(hUser32, SetProcessDpiAwarenessContext); if (pfn) { pfn(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); } else { SetProcessDPIAware(); /* 老系统兜底 */ }注意DPI 声明必须在创建任何窗口之前完成。放到WM_CREATE里调就晚了已经创建的窗口不会重新按新 DPI 布局。5.3 GDI 对象泄漏看不出来但会拖垮程序GDI 对象位图、DC、画刷、画笔是系统全局资源每个进程默认配额是一万个。听起来很多但如果你在WM_PAINT里创建 DC 和位图却忘了释放每次重绘漏一点窗口拖动几下、最小化再还原几次配额就见底了表现出来是界面突然变白、什么都不画而且没有任何报错。必须成对出现的几组创建释放CreateCompatibleDCDeleteDCCreateCompatibleBitmapDeleteObjectGetDC/BeginPaintReleaseDC/EndPaintCreateSolidBrushDeleteObjectLoadImage返回的位图DeleteObjectSelectObject换出的旧对象必须换回再删除原对象最后一条最容易错。SelectObject(hdc, newBmp)之后newBmp就被绑定在 DC 上了这时候你DeleteObject(newBmp)会失败函数返回NULL而且这个位图会一直挂在那儿。正确顺序是先SelectObject(hdc, oldBmp)把旧的换回去让newBmp脱离 DC再DeleteObject(newBmp)最后DeleteDC(hdc)。我在调试期会加一段检查代码跑完一轮绘制看 GDI 对象数有没有涨/* 仅调试用 */ DWORD cnt GetGuiResources(GetCurrentProcess(), GR_GDIOBJECTS);正常情况下稳定运行时这个数字应该在一个常数上下小幅波动。如果每重绘一次涨几个那就是有东西没释放慢慢排查。6. 常见问题与排查实录6.1 图片不显示、按钮点了没反应这两个是最高频的问题我按排查顺序整理一下思路。图片不显示先分清是加载失败还是绘制失败。在Gdiplus::Image::FromFile之后立刻判空并弹框或者把GetLastStatus()的返回值打出来能立刻区分。加载失败的话检查路径用绝对路径试一下排除工作目录问题、检查文件是不是被别的程序占用、检查文件名里有没有中文FromFile接受宽字符但如果你的字符串字面量是窄字符会乱码。加载成功但画面空白那就去看绘制代码DrawImage的坐标参数是不是负的、目标尺寸是不是被算成了 0原始图片宽高读到 0 的话缩放系数就是无穷大取整后变成 0画了个 0×0 的矩形当然什么都看不见。按钮点了没反应按三个层次查。第一层按钮创建时有没有加BS_OWNERDRAW没有的话系统走默认绘制WM_DRAWITEM根本不发。第二层父窗口有没有处理WM_DRAWITEM并且返回TRUE。第三层WM_COMMAND里判断BN_CLICKED时LOWORD(wParam)是不是对的控件 ID——如果创建按钮时hMenu参数传了空NULL那控件 ID 就是 0你拿任何 ID 去比都对不上。还有一个更绕的情况按钮被别的控件盖住了。Win32 里子控件的层叠顺序按创建顺序决定后创建的在上面。如果你先建按钮后建静态图片控件而且两者区域重叠点击就被上面的控件吃掉了。用SetWindowPos(hBtn, HWND_TOP, 0,0,0,0, SWP_NOMOVE|SWP_NOSIZE)提到最上层。6.2 闪烁、黑边与透明通道失效闪烁的成因很固定WM_ERASEBKGND先用背景刷涂一遍白WM_PAINT再画内容两次刷屏之间用户看到白的。解法有二一是绘制全部走内存 DC 双缓冲前面DrawImageButton里已经这么做了二是在WM_ERASEBKGND里直接return TRUE告诉系统背景我处理了你别管case WM_ERASEBKGND: return TRUE;注意这么写之后就必须在WM_PAINT里自己填背景否则残留会一直在。两者是配套的只改一个会出更奇怪的问题。透明 PNG 边缘有黑边或白边这是 GDI 的经典问题。Bitmap::FromFile读进来的 PNG 带 Alpha 通道但Graphics::DrawImage在把混合后的像素写回一个不支持 Alpha 的目标 DC时边缘的半透明像素会跟目标 DC 的黑色背景预乘出现一圈黑边。解法有两个一是确保目标 DC 已经有正确的背景色先用背景色填满再画图二是改用AlphaBlend手动控制混合BLENDFUNCTION bf { AC_SRC_OVER, 0, 255, AC_SRC_ALPHA }; AlphaBlend(hdcDst, dx, dy, dw, dh, memDC, 0, 0, iw, ih, bf);AlphaBlend要求源 DC 里是 32 位带 Alpha 的位图CreateDIBSection创建的 32 位位图Alpha 通道要自己填混合参数AC_SRC_ALPHA表示用源像素自己的 Alpha 值。这条路可控性最强但准备工作多。对大部分场景来说先填背景再DrawImage就够了黑边问题基本能压下去。6.3 常见问题速查表现象最可能的原因处理办法按钮上没图BS_OWNERDRAW没加创建时加样式创建后改不生效图出来了但点不动静态控件漏了SS_NOTIFY补上并在WM_COMMAND里接STN_CLICKEDWM_DRAWITEM不触发处理写在按钮子类里挪到父窗口过程图片加载返回NULL路径是相对路径用GetModuleFileNameW拼绝对路径PNG 读不进来用了LoadImage改用 GDI 或AlphaBlend路径窗口缩放时闪白没做双缓冲内存 DC 绘制 WM_ERASEBKGND返回 TRUE高 DPI 下模糊没声明 DPI 感知启动早期调SetProcessDPIAware拖几次窗口后界面变白GDI 对象泄漏检查DeleteDC/DeleteObject配对缩放后图片变形宽高各自独立缩放取min(sx, sy)保持比例边缘一圈黑边Alpha 预乘问题先填背景再绘制或用AlphaBlend7. 一些实际踩出来的经验代码写到这里功能就齐了剩下几条是我在不同项目里反复验证过的习惯算不上什么高深技巧但确实能省时间。第一个习惯先跑通再优化。上面那个STATIC SS_BITMAP的十分钟版本我每次做新界面都会保留它作为第一步哪怕最终肯定要换成自绘。因为图片加载、路径、格式、消息连通性这四件事是正交的混在一起调出了问题根本分不清是哪一层。先用最笨的方法把四件事各自验证一遍后面写自绘逻辑时脑子是清的。第二个习惯把控件 ID 和图片资源 ID 定义在一处。我见过不少人把1001、1002这种数字散落在代码各处改一个 ID 要全局搜索。集中成#define或者enum虽然老派但维护成本最低。第三个习惯测试三张图。一张正常尺寸、一张 4000 像素宽的超大图、一张 16×16 的小图标。超大图能测出你的缩放计算有没有溢出iw * s如果溢出了int会得到负数DrawImage直接不画小图标能测出你的居中是不是真的居中。我第一版就栽在这用 4000 像素宽的图算缩放系数中间某一步乘法溢出成负数图整个不显示排查了半天才想到是整数溢出改成float中间计算就对了。第四个习惯别把Gdiplus::Image当成员变量到处传指针。它的生命周期归你管delete之前任何一次误用都是崩溃。我现在的做法是全局只保留几个Image*在WM_CREATE里加载在WM_DESTROY里统一delete并置空中间所有绘制函数都判一次空再用。不优雅但稳。最后一个建议如果你的按钮以后可能要做圆角、悬停渐变动画、点击涟漪这类效果那从一开始就别用BS_OWNERDRAW直接上纯自绘。因为 owner-draw 按钮的绘制被限制在DRAWITEMSTRUCT给定的矩形里做超出矩形的效果比如外发光会被裁掉到时候还得重构。判断标准很简单外观需求是不是标准按钮的范畴是就用 owner-draw不是就自绘别中间反复横跳。
阅读完成 · 觉得有帮助?