前段时间被一位朋友拉去看他写的一个小工具,界面里有个“开始扫描”的按钮,他想把按钮做成带图标的样子,结果折腾了一下午:按钮上的图要么不显示,要么显示出来是一团黑,要么点了没反应。他用的就是最朴素的 Win32 API 加 C 语言,没有任何界面库。这个问题在 Win32 里其实非常典型——系统根本没有给你一个叫“图片按钮”的现成控件,一切都要自己拼出来。这篇就把这套东西从头到尾拆一遍:怎么把一张指定图片加载进内存、怎么把它画到一个真正的 BUTTON 控件上、怎么让它在悬停和按下时有反馈、怎么处理高 DPI 下的缩放,以及我在这条路上踩过的几个坑。无论你是刚接触 Win32 想练手,还是在维护一个十几年前的老界面需要加个图标按钮,下面的内容都能直接抄去用。
1. Win32 里为什么找不到一个叫“图片按钮”的现成控件
刚接触 Win32 的人常有一个误解,觉得CreateWindowEx的第一个参数填个"BUTTON"就应该万事大吉,剩下的属性靠样式位堆上去就行。结果翻遍winuser.h里的BS_开头的宏,能找到BS_BITMAP、BS_ICON,兴冲冲试一把,发现图标和文字挤在一起,位置还调不动,尺寸一改就变形。这不是你姿势不对,而是这套控件的设计年代决定的——它出生的时候,界面上的按钮就是灰色方块加一行字,图片按钮根本不在设计目标里。
BS_BITMAP的语义是“按钮表面显示一张位图”,它会用位图去替换整个按钮的绘制,但按钮的边框、焦点框、按下时的凹陷效果全部不生效,你得到的就是一张贴上去的图,看起来完全不像按钮。更麻烦的是它只认HBITMAP,而且尺寸跟随按钮,一旦按钮尺寸和图片尺寸不匹配就直接裁剪。所以真正做图片按钮的人,基本都绕开了这个样式位。
1.1 三条能做出来的路线,以及它们各自的天花板
把 Win32 里所有能实现“图片按钮”的路子捋一遍,其实就三条,它们的能力边界差别很大,值得先摆在桌面上对比。
| 路线 | 核心做法 | 优点 | 天花板 |
|---|---|---|---|
| 静态控件伪装 | STATIC+SS_BITMAP/SS_ICON+SS_NOTIFY,处理STN_CLICKED | 代码最少,十几行能跑 | 没有按下态、没有焦点框、键盘不可达、切图要手动STM_SETIMAGE |
| 自绘按钮 | BUTTON+BS_OWNERDRAW,父窗口处理WM_DRAWITEM | 键盘、焦点、BN_CLICKED通知全部免费 | 只能画在自己的矩形里,超出部分会被裁 |
| 完全自绘窗口 | 注册自己的窗口类,WM_PAINT全权处理 | 想怎么画怎么画,圆角、阴影、动画都行 | 键盘导航、无障碍、Tab 顺序全部要自己实现 |
第一条路线最容易被新手选中,因为搜出来的示例代码大多是这个。它确实能跑,但做出来的东西本质是个“会响应点击的图片”,不是按钮。你按 Tab 键跳不到它上面,按空格不会触发,鼠标按下时它没有任何视觉反馈,用户点下去心里是没底的。放在工具类软件里勉强能接受,放在稍微正式一点的界面上就显得很粗糙。
第三条路线是给做自定义皮肤框架的人准备的,代价是你得自己接管WM_KEYDOWN、自己维护焦点链、自己处理WM_TABSTOP的语义,还要考虑屏幕阅读器能不能识别。为一个按钮付这个代价不划算。
1.2 我最后选了 BS_OWNERDRAW 的三个理由
中间那条路是我这些年一直用的。BS_OWNERDRAW的本质是:按钮的行为还是系统的,按钮的长相归你。系统负责接收鼠标消息、维护按下状态、处理键盘空格、在状态变化时发BN_CLICKED给父窗口;你只需要在WM_DRAWITEM里把像素画上去。
第一个理由是状态位现成。WM_DRAWITEM送来的DRAWITEMSTRUCT里有个itemState字段,按下、获得焦点、被禁用这些状态系统已经帮你算好了,你不用自己追踪一堆布尔变量。这一点在后面第 3 章会详细展开,它省掉的心智负担比想象中大。
第二个理由是和现有布局不冲突。BS_OWNERDRAW的按钮依然是个普通子窗口,MoveWindow、SetWindowPos、放在WS_EX_CLIENTEDGE的容器里,行为和其他控件完全一致。老项目里做局部改造时,这一点很关键——你不需要动外围的布局代码。
第三个理由是绘制时机可控。按钮什么时候需要重画,由系统通过WM_DRAWITEM通知你;你想主动重画,一个InvalidateRect就够。悬停高亮这类效果,靠的就是这个机制。
提示:
BS_OWNERDRAW只能和BUTTON类一起用。如果你在STATIC上写这个样式,它不会报错,但什么也不会发生,这个坑我见人踩过不止一次。
2. 图片进内存:BMP、PNG 与“C 语言到底能不能用 GDI+”这件事
选好了绘制路线,下一个问题就是图片怎么进内存。这一步是整个流程里最容易卡住的地方,因为答案取决于你的图片是什么格式。标题里写的是“显示指定图片”,实际工程里这个“指定”可能是 BMP,也可能是带透明通道的 PNG,两者的处理方式完全不在一个量级上。
2.1 LoadImage 只能搞定 BMP,PNG 得换工具
LoadImage是 Win32 里最直接的图片加载函数,签名里那个fuLoad参数可以填LR_LOADFROMFILE从磁盘读,也可以填MAKEINTRESOURCE从资源里读。但它内部走的是OleLoadPicture的老路径,实际支持的格式只有 BMP、ICO、CUR 这几种。你给它一个 PNG 文件路径,返回值是NULL,GetLastError()给你一个ERROR_BAD_FORMAT之类的错误码,然后就没了。
我见过不少人试图靠改文件扩展名骗过去,把logo.png改名成logo.bmp,当然不会成功——格式判断看的是文件头,不是后缀名。
所以只要你的图片是 PNG、JPG、GIF 中的任何一种,就必须引入额外的解码器。Win32 平台上能用的就两套:GDI+ 和 WIC(Windows Imaging Component)。
2.2 GDI+ 的类封装是 C++,所以 .c 要改名 .cpp
GDI+ 是最省事的选择。它的解码器覆盖了常见格式,接口也简单,Image::FromFile一行就能把 PNG 读进来,透明通道完整保留。但这里有一个绕不过去的坎:GDI+ 的头文件用 C++ 类做了封装,Gdiplus::Image、Gdiplus::Graphics这些全是类,纯 C 编译器根本过不了。
所以项目标题里虽然说“C 语言”,实际落地时几乎所有人的做法都是:把源文件后缀从.c改成.cpp,但代码风格依然保持纯 C 的过程式写法——全局静态函数、switch-case的消息循环、不使用任何 STL 和类继承。编译出来的东西和 C 没有任何区别,只是借用了 C++ 的语法来通过头文件检查。
这个取舍我认为完全合理。硬要用纯 C 调 GDI+ 的 C 接口层(GdipCreateImageFromFile那一套Gdip*前缀的扁平 API),代码会变得非常难读,而且官方文档和示例几乎全是 C++ 风格,出问题时很难找到参考。
2.3 坚持纯 C 的话:WIC 这条路怎么走
如果项目有硬性约束必须保持.c后缀,那就只能走 WIC。WIC 是一套 COM 接口,纯 C 完全可用,流程大致是这样:
CoInitializeEx初始化 COM,注意线程模型要和你的窗口线程一致,UI 线程用COINIT_APARTMENTTHREADED。CoCreateInstance创建CLSID_WICImagingFactory。CreateDecoderFromFilename拿到解码器,GetFrame拿到帧。CreateFormatConverter把帧统一转成GUID_WICPixelFormat32bppBGRA,这一步骤解决不同 PNG 的位深差异。CopyPixels把像素数据拷进你自己申请的一块缓冲区。- 用
CreateDIBSection建一个 32 位的 DIB,把缓冲区memcpy进去,得到一个可以直接BitBlt的HBITMAP。
这条路代码量大概是 GDI+ 方案的三到四倍,但它有个额外好处:你拿到的是原始像素,想做什么处理(比如按状态染色、生成灰色禁用态)都比 GDI+ 方便。取舍看你的项目约束,如果只是做个演示,我建议还是改后缀用 GDI+。
2.4 图片从文件读还是打进资源
两种方式都能用,但适用场景不一样。
从文件读(LR_LOADFROMFILE或Image::FromFile)的好处是换图不用重新编译,适合图标皮肤这类需要用户自定义的东西。代价是你得处理路径问题:相对路径是相对于当前工作目录,不是 exe 所在目录。用户从桌面快捷方式启动、或者从别的程序里ShellExecute调用,工作目录都可能不是你预期的那个,结果就是图片莫名其妙加载失败。稳妥做法是用GetModuleFileName拿到 exe 的完整路径,截掉文件名部分,再拼上图片的相对路径。
打进资源的好处是单文件分发,不会出现“少了个 png 就白屏”的尴尬。做法是在.rc文件里写一行:
IDB_BTN_NORMAL PNG "res\\btn_normal.png"然后在代码里用FindResource+LoadResource+LockResource拿到内存指针,再配合IStream(SHCreateMemStream或CreateStreamOnHGlobal)喂给Gdiplus::Image的构造函数。LoadImage走资源那条路只支持 BMP,PNG 不行,这个限制要知道。
我自己的习惯是:正式发布的工具用资源,开发调试阶段用文件,这样可以边改图边看效果,不用反复编译。
3. 自绘一次按钮:WM_DRAWITEM 里的状态位与双缓冲
图片加载进来之后,真正的工作才开始。WM_DRAWITEM这个通知消息是自绘按钮的全部入口,它送来的DRAWITEMSTRUCT结构体里有几样东西你必须吃透,否则画出来的东西要么位置不对,要么状态不同步。
3.1 itemState 那几个位到底代表什么
itemState是一个位掩码,常用的几个位含义如下:
| 位 | 值 | 含义 | 实际用途 |
|---|---|---|---|
ODS_SELECTED | 0x0001 | 按钮处于按下状态 | 切换到按下态贴图 |
ODS_DISABLED | 0x0004 | 按钮被禁用 | 画灰色版本或降低不透明度 |
ODS_FOCUS | 0x0010 | 按钮拥有键盘焦点 | 画焦点虚线框 |
ODS_DEFAULT | 0x0020 | 是默认按钮 | 加一圈高亮边框 |
ODS_NOFOCUSRECT | 0x0200 | 系统要求不要画焦点框 | 尊重它,跳过焦点框绘制 |
这里有个细节值得单独说:ODS_SELECTED在两种情况下都会置位,一是鼠标左键在按钮范围内按下,二是按钮获得焦点时按下空格键。这意味着你的绘制代码不需要区分输入来源,键盘和鼠标会自动共用同一套视觉反馈。这是BS_OWNERDRAW相对自绘窗口最大的优势之一。
另一个细节是ODS_FOCUS和ODS_NOFOCUSRECT的组合。系统在用户用鼠标点击时会主动置上ODS_NOFOCUSRECT,意思是“这次别画焦点框,因为用户是用鼠标操作的,焦点框会显得很吵”。如果你无视这个位、硬要每次都画,界面上就会出现那种点一下冒一个虚线框的廉价感。判断顺序应该是先看ODS_FOCUS,再看有没有ODS_NOFOCUSRECT,两个条件都满足才画。
3.2 为什么必须双缓冲,内存DC怎么建
直接往DRAWITEMSTRUCT::hDC上画会闪。原因很直白:这个 DC 是直接对应屏幕的,你的每一次DrawImage、FillRect都会立刻反映到屏幕上,用户能看到绘制过程被一帧一帧地刷出来,尤其是图片较大的时候,那种从上往下“拉窗帘”的效果非常明显。
解决办法是经典的双缓冲:在内存里建一块和按钮等大的画布,所有绘制先在内存画布上完成,最后一次性BitBlt到屏幕。这样用户看到的永远是一个完整的、已经画好的结果。
建内存画布有两种写法,CreateCompatibleBitmap和CreateDIBSection。我强烈推荐后者,原因在于像素格式可控。CreateCompatibleBitmap创建出来的位图格式由目标 DC 决定,可能是 16 位色,可能是 24 位,你没法预知;而CreateDIBSection可以强制指定 32 位BI_RGB,配合 GDI+ 绘制时抗锯齿效果稳定得多。
创建 DIB 的关键参数是biHeight,填负数表示这是一个自上而下的位图,第一行像素在缓冲区开头。这一点很重要,因为 GDI+ 的坐标系是自上而下的,如果你填正数(自下而上),DrawImage画出来的图会是上下颠倒的。这个坑我在早期项目里踩过,当时排查了半天以为是图片本身的问题。
3.3 等比缩放与居中留白
按钮的尺寸和图片的尺寸几乎不可能刚好一致。直接拉伸会导致图片变形,所以正确做法是算出等比缩放系数,取宽高两个方向上的较小值,缩放后居中放置。
int iw = (int)pImg->GetWidth(); int ih = (int)pImg->GetHeight(); float fx = (float)w / (float)iw; float fy = (float)h / (float)ih; float fs = fx < fy ? fx : fy; /* 取小的那个,保证不越界 */ int dw = (int)(iw * fs); int dh = (int)(ih * fs); int dx = (w - dw) / 2; int dy = (h - dh) / 2; gfx.DrawImage(pImg, Rect(dx, dy, dw, dh));这里fx < fy ? fx : fy用三元不用min,是因为windows.h里定义了min/max宏,和 C++ 标准库的同名函数打架是 Win32 项目里的老问题,绕开最省事。
缩放质量靠两个设置保证:SetInterpolationMode(InterpolationModeHighQualityBicubic)处理缩放插值,SetPixelOffsetMode(PixelOffsetModeHighQuality)处理像素对齐。少了后面这一句,缩小后的图片边缘会出现半像素偏移,看起来像被轻微模糊了一层。这两个设置是配套的,只开一个效果会打折扣。
4. 让按钮“活”起来:悬停、按下、焦点与出界判定
静态的图贴上去了,接下来是交互反馈。这块内容看着琐碎,但它决定了用户觉得这个按钮是“活的”还是“死的”。
4.1 悬停状态:GWLP_WNDPROC 子类化 + TrackMouseEvent
BS_OWNERDRAW按钮的默认窗口过程只关心按下和弹起,它压根不告诉你鼠标什么时候移进来了。想知道悬停状态,只能自己截获按钮的WM_MOUSEMOVE。
标准做法是子类化:在创建按钮之后,用SetWindowLongPtr把按钮的窗口过程换成自己的,在里面处理完需要的消息,剩下的转发给原来的过程。
g_pOldBtnProc = (WNDPROC)SetWindowLongPtrW(hButton, GWLP_WNDPROC, (LONG_PTR)BtnSubclassProc);我不建议用SetWindowSubclass(comctl32 那套),因为它依赖 comctl32 v6 的 side-by-side 装配,如果项目没有配置 manifest,运行时会找不到符号。SetWindowLongPtr是老办法,但胜在兼容性无死角。
拿到 `WM_M