你如果一路跟到第四篇,说明前面三篇里的GDI+初始化、基本图形绘制和简单文本输出都已经跑通了。这一回我们把剩下的硬骨头啃掉:画刷的高级用法、字体渲染的细节、图像加载与像素操作,以及做动画时绕不开的双缓冲和坐标变换。这些内容比单纯画线画矩形有意思得多,也是GDI+相对于老GDI最值回票价的地方。
这篇文章仍然保持“Win32汇编+MASM32”的路线,不会扯C++封装,所有调用都是GDI+扁平API,也就是那些带Gdip前缀的导出函数。新来的读者也不用担心,我会在关键位置把前几篇依赖的背景补一点,至少你能知道每个GDI+对象是怎么来的、怎么用、怎么释放。整篇读完,你应该能在一个纯汇编窗口程序里完成图片显示、文字水印、像素反色,以及一个不闪烁的动画演示。
1. 这一篇到底要补什么:GDI+的调用套路和状态管理
1.1 为什么Win32汇编里绕不开GDI+
很多人在Win32汇编里画界面,第一反应是使用GDI,毕竟TextOut、Rectangle、BitBlt这些函数简单直接。但一旦涉及到抗锯齿、半透明、渐变刷、PNG图片、任意角度旋转,GDI就会非常痛苦。老GDI的路径、区域、变换接口不是没有,但使用起来和现代绘图需求差得太远。GDI+之所以在早期被大量GUI程序采用,就是因为它把这些能力统一成一个相对好用的对象模型,而且底层是纯C接口,汇编可直接调用。
在Win32汇编里使用GDI+,首先要接受一个事实:没有C++的Graphics类、Pen类,所有操作都变成带Gdip前缀的全局函数,第一个参数通常是对象指针。我自己的经验是,把C++头文件里的成员函数和扁平API名字对应起来就顺了。比如C++里的graphics.DrawLine(pen, x1, y1, x2, y2),对应汇编里的GdipDrawLine(graphics, pen, x1, y1, x2, y2);graphics.DrawRectangle对应GdipDrawRectangleI。记住这个规律,查MSDN或者看gdiplus.inc时就不会迷路。
1.2 GDI+的核心对象与释放原则
GDI+的主要对象包括Graphics(绘图表面)、Pen(画笔)、Brush(画刷)、Font(字体)、Image/Bitmap(图像)、StringFormat(文本格式)、Matrix(变换矩阵)。和GDI句柄不同,这些都是指向内部结构体的指针,创建成功后你拿到的实际上是一个DWORD类型的地址。所有对象都有对应的创建和释放函数,名字规律非常明显:
GdipCreatePen1/GdipDeletePenGdipCreateSolidFill/GdipDeleteBrushGdipCreateFont/GdipDeleteFontGdipLoadImageFromFile/GdipDisposeImageGdipCreateFromHDC/GdipDeleteGraphics
释放顺序也有讲究。如果一个Graphics对象是在某个HDC上用GdipCreateFromHDC创建的,那么必须先把图形对象删掉,才能释放HDC。反过来,画刷和画笔可以在Graphics对象删除之前或者之后都行,只要保证每个对象都释放一次即可。我在实际调试中见过很多人只创建不释放,程序跑一会儿内存就飙上去,这个问题在纯汇编里比C++更容易忽略,因为汇编没有析构函数帮你兜底。
2. 初始化与反初始化:第四次聊还要再强调一遍
2.1 一个稳妥的startup/shutdown流程
GDI+全局初始化在系列前几篇应该已经写过,但我还是建议你把它封装成一个固定流程,避免每次新建工程都复制错。GdiplusStartup需要的输入结构体在MASM32的gdiplus.inc里有定义,只需要在数据段放一个初始化的结构体实例即可,四个字段分别是版本号、调试回调指针、是否抑制后台线程、是否抑制外部编码器。
.data gdiplusInput GdiplusStartupInput <1, 0, 0, 0> gdiplusToken DWORD ? pGraphics DWORD ?初始化代码放在WinMain里,或者在窗口创建成功之后,程序退出之前确保GdiplusShutdown被调用。一个比较稳的选择是在注册窗口类之后初始化,在收到WM_CLOSE或者主循环退出前反初始化。
invoke GdiplusStartup, ADDR gdiplusToken, ADDR gdiplusInput, NULL test eax, eax jnz start_failed ; 继续创建窗口,进入消息循环反初始化的时候只需要传入token,不传会直接产生未定义行为。GDI+内部对token做了引用管理,所以必须保存好这个值。我在一个工程里犯过错误,把gdiplusToken当成普通变量反复清零,导致退出时程序崩溃,后来才发现是GdiplusShutdown收不到正确的token。
2.2 初始化失败该怎么办
GdiplusStartup返回0表示成功,非0就是失败。失败原因常见的就几种:系统版本太老、GDI+DLL缺失、已经有一个不兼容的初始化状态。对现代Windows来说,前两种基本不会出现,但你仍然应该写一个错误分支,总好过黑屏后没人知道为什么。
如果初始化失败,所有后续的GdipCreate...调用都会返回错误状态。因为GDI+函数本身不会触发异常,只会安静地返回非0值,所以最直接的排查方法就是在每个关键调用后面检查eax。不要觉得啰嗦,在汇编时代这就是唯一的容错手段。我习惯在调试版里把所有非0返回值都记下来,看到哪个函数第一次返回值异常,基本就能定位到问题区域。
3. 画笔画刷与颜色:从“能画”到“画得漂亮”
3.1 实线、虚线、宽度和LineCap
GDI+的画笔比GDI的HPEN灵活,主要体现在宽度是浮点、默认带抗锯齿、可以设置虚线样式。创建画笔用GdipCreatePen1,参数分别是ARGB颜色、宽度(REAL类型)、单位,最后输出画笔指针。单位一般用UnitPixel,在枚举里值是2。如果宽度小于1,它不会变成1像素,而是按浮点宽度渲染,这一点和GDI不同。
.data hPen DWORD ? penWidth REAL4 2.5 .code invoke GdipCreatePen1, 0FF000000h, penWidth, 2, ADDR hPen设置虚线样式调用GdipSetPenDashStyle,传入DashStyle枚举值。典型的枚举:DashStyleSolid=0、DashStyleDash=1。如果只是想要一个虚线边框,在重用同一个画笔时记得在绘制完成后把样式恢复成实线,否则后续所有用这个画笔画的线都会是虚线。这个“状态残留”是新手最容易忽略的坑。
3.2 渐变画刷和透明色
GDI+最让人舒服的地方之一就是渐变刷,老GDI如果要实现渐变,要么用CreateDIBSection的逐像素填充,要么用FillRect加混合,麻烦到怀疑人生。GDI+里线性渐变刷用GdipCreateLineBrushI创建,这里I后缀表示用整数坐标的POINT结构作为起止点。两个颜色可以是任意ARGB值,包括带Alpha的。
.data ptStart GpPoint <10, 10> ptEnd GpPoint <200, 100> hBrush DWORD ? .code invoke GdipCreateLineBrushI, ADDR ptStart, ADDR ptEnd, 0FFFF0000h, 0FF0000FFh, ADDR hBrush invoke GdipFillRectangleI, pGraphics, hBrush, 10, 10, 190, 90透明色用ARGB表示,32位颜色值中高8位是Alpha。0x80FF0000就是不透明度50%的红色。很多人第一次用0xFF000000和0xFFFF0000混在一起时,会惊讶地发现颜色完全不对,因为ARGB不是GDI的COLORREF,0x00RRGGBB在GDI+里是完全透明的黑色。这个坑我踩过很久。
3.3 释放资源不是可选项
画刷和画笔创建后必须释放。具体对应关系如下表:
| 对象类型 | 创建函数 | 释放函数 |
|---|---|---|
| 画笔 | GdipCreatePen1 | GdipDeletePen |
| 纯色画刷 | GdipCreateSolidFill | GdipDeleteBrush |
| 渐变画刷 | GdipCreateLineBrushI | GdipDeleteBrush |
| 纹理画刷 | GdipCreateTextureBrush | GdipDeleteBrush |
在循环里创建对象尤其要小心。比如一个每帧改变颜色的动画,如果每帧都重新创建一支画笔,用完立刻释放,那没事;但如果创建后忘记释放,只要几十帧,内存占用就能看得见地往上涨。GDI+对这些对象有自己的缓存机制,不及时释放不只是浪费内存,还可能导致后续创建失败。
4. 字体与文本:让DrawString在汇编里不乱码
4.1 从CreateFont到GDI+的Font
在GDI里使用字体,无非是CreateFont配上SelectObject。GDI+的字体重心偏向了Unicode和布局,但流程并不复杂。你得先根据字体名创建一个FontFamily,再基于这个FontFamily创建真正的Font。字体名必须是宽字符,可以用英文字体名规避源代码编码问题。比如用“Microsoft YaHei”而不是“微软雅黑”,这样即使汇编器对源文件编码敏感,也不会出现乱码。
.data szFontName WCHAR "Microsoft YaHei", 0 hFamily DWORD ? hFont DWORD ? fFontSize REAL4 24.0 .code invoke GdipCreateFontFamilyFromName, ADDR szFontName, NULL, ADDR hFamily invoke GdipCreateFont, hFamily, fFontSize, 0, 2, ADDR hFontGdipCreateFont的第三个参数是字体样式,0是常规、1是粗体、2是斜体、4是下划线,可以组合。第四参数是单位,2仍然是UnitPixel。我不建议用CreateFont拿到HFONT再转GDI+字体,因为两者模型不同,转接过程中很容易丢失样式信息。
4.2 DrawString的坐标体系
文本输出函数GdipDrawString的参数比GDI的TextOut多得多:
invoke GdipDrawString, pGraphics, ADDR szHello, len, hFont, ADDR layoutRect, hFormat, hBrush其中layoutRect是一个RectF结构,包含四个REAL字段:X、Y、宽度、高度。GDI+把文本绘制限定在一个布局矩形内,配合StringFormat决定对齐方式。创建StringFormat使用GdipCreateStringFormat,设置水平对齐用GdipSetStringFormatAlign。注意,对齐枚举中0是靠近原点(左对齐),1是居中,2是右侧对齐。垂直对齐另用GdipSetStringFormatLineAlign控制。
.data layoutRect RectF <10.0, 10.0, 500.0, 200.0> hFormat DWORD ? .code invoke GdipCreateStringFormat, 0, 0, ADDR hFormat invoke GdipSetStringFormatAlign, hFormat, 1这里有两个细节。第一,GdipDrawString的第二个参数是宽字符串,如果是常量字符串可以直接用ADDR跳转,但字符串长度最好显式传入,不要传-1让GDI+自己算。第二,布局矩形用的单位是像素,不是DPI下的点。在程序里手动缩放窗口时,如果想保持字体大小不变,需要自己处理HI-DPI缩放,GDI+不会替你处理。
4.3 中文字体名和文本抗锯齿
如果一定要在源码里写中文字体名,必须确保.asm文件以合适的编码保存,并且汇编器能正确识别宽字符。MASM32对UTF-16的兼容性不是特别理想,所以我的习惯是能英文就英文。需要显示中文字符内容时,字符串本身依然需要宽字符格式。比如:
szHello WCHAR "你好,GDI+", 0汇编器会按照源文件编码处理,建议统一保存为UTF-8 with BOM,或者直接用Unicode。如果你在链接后发现文字变成乱码,先检查这个字符串在内存里的十六进制是否和预期一致,别急着怀疑GDI+。另外,文本抗锯齿通过GdipSetTextRenderingHint控制,在绘制文字前设置一次即可。枚举值里2是默认抗锯齿,4是ClearType,如果你对字体清晰度有要求,可以用4,但要注意在一些特殊背景上ClearType反而会有彩边。
5. 图像加载、保存和像素级操作
5.1 加载PNG/JPG:两行代码的事
GDI+加载图片的API非常简单,GdipLoadImageFromFile接收一个宽字符文件名,输出GpImage*。它支持的格式包括BMP、GIF、JPEG、PNG、TIFF。然后在Graphics上绘制时,GdipDrawImageRectI可以直接指定目标区域,相当于一次完成缩放和绘制。
.data szFileName WCHAR "demo.png", 0 pImage DWORD ? .code invoke GdipLoadImageFromFile, ADDR szFileName, ADDR pImage test eax, eax jnz load_failed invoke GdipDrawImageRectI, pGraphics, pImage, 0, 0, 400, 300这里有一个经验:加载图片后最好用GdipGetImageWidth和GdipGetImageHeight拿到原始宽高,再根据目标区域做等比缩放,避免图片被拉伸变形。如果你桌面是DPI缩放状态,逻辑坐标和物理像素会有偏差,先把当前窗口的缩放因子算好,再决定绘制尺寸。
5.2 用LockBits读写像素
如果不想只做整体缩放,而想直接处理像素,比如反色、亮度调整、加马赛克,那么必须把Bitmap锁进内存。GDI+提供了GdipBitmapLockBits,但很多人一看到BitmapData结构就发怵,其实它只是返回一块连续内存的地址、宽高、步长和像素格式。
.data bitmapData BitmapData <> lockRect GpRect <0, 0, 512, 512>调用时,锁定的矩形用GpRect,操作标志用ImageLockModeRead(值1)或ImageLockModeWrite(值2),想同时读写就把两个标志按位或,但读取和写入分开更清晰。像素格式通常固定用32位ARGB,因为这是GDI+内部最通用的格式,处理速度也最快。
invoke GdipBitmapLockBits, pBitmap, ADDR lockRect, 1, PixelFormat32bppARGB, ADDR bitmapData拿到Scan0之后,就可以用普通汇编指令遍历内存。Stride是每行扫描线的字节数,它可能比Width * 4大,因为GDI+会按4字节或者8字节边界对齐。遍历时不能用“宽度乘以4再乘高度”当作总长度线性走,否则会在行尾漏掉对齐填充字节,处理出来的图会斜。必须逐行按Stride跳转。
mov esi, bitmapData.Scan0 mov ecx, bitmapData.Height row_loop: push ecx mov ecx, bitmapData.Width mov edi, esi pixel_loop: ; <<< 对 [edi] 处的DWORD做像素处理 >>> add edi, 4 loop pixel_loop add esi, bitmapData.Stride pop ecx loop row_loop反色处理时,注意不能直接对整个DWORD取反,否则Alpha通道也被反转。正确做法是保留最高8位Alpha,只对低24位RGB取反:
mov eax, [edi] and eax, 0FF000000h mov edx, [edi] not edx and edx, 00FFFFFFh or eax, edx mov [edi], eax处理完成后,必须调用GdipBitmapUnlockBits把内存还给GDI+。不要直接删除Bitmap而不调用解锁,否则内部状态会混乱,严重时会崩溃。解锁之后,这个Bitmap才能继续参与绘制或者保存。
5.3 实际案例:给图片加水印并缩放
我建议你自己写一个简单的小模块:从磁盘加载一张图片,在内存中把它绘制到宽高缩放过的新Bitmap上,然后在右下角用半透明画刷绘制一行文字,最后把整个结果绘制到窗口客户区。这个综合练习能把你前面的知识全部串起来。
一个可行流程是这样的:先用GdipLoadImageFromFile获得图像,再用GdipCreateBitmapFromFile或GdipCreateBitmapFromGraphics创建目标Bitmap,拿到目标Bitmap的Graphics对象,调用GdipDrawImageRectI做缩放绘制,接下来设置一个带Alpha的实心画刷和字体,用GdipDrawString在右下角写字。整个过程最忌讳的是直接对原图操作,因为水印是不可逆的,如果原图是唯一资产,处理完就后悔了。正确做法是先加载原图到内存,然后复制一份再处理,最后释放原图对象。
6. 变换与双缓冲:动画是GDI+的加分项
6.1 用GdipTranslateTransform做平移旋转
GDI+的变换体系基于Matrix,但如果你只是做平移、缩放、旋转这些简单操作,完全可以不直接创建Matrix,而是用GdipTranslateTransform、GdipScaleTransform、GdipRotateTransform这些快捷函数。这三个函数作用于Graphics对象本身的World Transform,调用后所有后续绘制都会受到变换影响。
invoke GdipTranslateTransform, pGraphics, 100.0, 50.0 invoke GdipRotateTransform, pGraphics, 45.0旋转角度的单位是度,不是弧度。这里有个容易掉进去的坑:旋转是围绕原点进行的,不是围绕图形中心。你先把图形画在坐标原点附近,再旋转,最后通过平移把结果挪到目标位置,比直接尝试“绕中心旋转”省心得多。恢复变换时,用GdipResetTransform把World Transform重置为单位矩阵,否则后续绘制全部带偏移。
6.2 为什么GDI+直接画到HDC会闪
GDI+反锯齿渲染本来就比GDI慢,如果每帧在WM_PAINT里直接往窗口HDC上绘制,窗口缩放、移动、刷新时一定会闪。解决办法不是硬调消息循环,而是做双缓冲。
经典流程是:在WM_PAINT里先BeginPaint拿到窗口HDC,创建一个兼容内存DC,再创建一个兼容位图并选入内存DC,然后调用GdipCreateFromHDC在内存DC上创建Graphics对象。所有GDI+绘制都画到内存DC上,绘制完成后先GdipDeleteGraphics,再用BitBlt把内存DC整块拷贝到窗口HDC,最后清理位图和DC。
invoke CreateCompatibleDC, hdc mov hMemDC, eax invoke CreateCompatibleBitmap, hdc, winWidth, winHeight mov hBmp, eax invoke SelectObject, hMemDC, hBmp mov hOldBmp, eax invoke GdipCreateFromHDC, hMemDC, ADDR pMemGraphics ; 在这里绘制所有图形 invoke GdipDeleteGraphics, pMemGraphics invoke BitBlt, hdc, 0, 0, winWidth, winHeight, hMemDC, 0, 0, SRCCOPY invoke SelectObject, hMemDC, hOldBmp invoke DeleteObject, hBmp invoke DeleteDC, hMemDC invoke EndPaint, hWnd, ADDR ps这里必须强调一点:GdipCreateFromHDC之后,Graphics对象持有内存DC的引用,如果没调用GdipDeleteGraphics就调用DeleteDC,内存DC不会真正释放,等到下次创建DC时系统可能报“内存不足”。别问我怎么知道的,这是我用调试器翻遍错误代码才总结出来的。
6.3 定时器驱动的动画循环
动画循环最简单的驱动方式还是SetTimer。设置一个16毫秒的定时器,大约60FPS,然后在WM_TIMER里更新状态变量,调用InvalidateRect触发重绘。
invoke SetTimer, hWnd, 1, 16, NULLWM_TIMER里不要做复杂计算,更不要做图像解码。它的职责只是更新位置或角度,然后标记客户区无效。真正决定性能的是WM_PAINT里的渲染速度。如果某帧绘制时间超过16毫秒,画面自然会掉帧,这时可以适当降低定时器频率到30FPS,或者把抗锯齿关掉,等稳定后再开。
如果做复杂动画,比如同时旋转、缩放多个图形,建议用双缓冲配合GdipResetTransform,每帧开始重置变换,再按需要调用平移和旋转。绝不要在上一帧变换的基础上继续叠加变换,否则累计误差会以肉眼可见的速度让图形飞出去。
7. 常见问题与性能建议
7.1 速查表:GDI+调用失败的几种典型现象
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 画面完全空白 | GdipCreateFromHDC失败,Graphics指针无效 | 检查HDC是否有效,检查BeginPaint和EndPaint配对 |
| 颜色偏色或透明 | ARGB顺序写错,把0xFFRRGGBB写成0xRRGGBB | 检查颜色常量高8位,确保包含Alpha |
| 文字乱码 | 宽字符串没有正确以UTF-16编码编译 | 查看内存中的字节,确认是Unicode |
| 内存持续增长 | 画笔画刷或Graphics对象未释放 | 在循环中严格配对创建与释放 |
| 程序退出时崩溃 | 未调用GdiplusShutdown,或token被改动 | 保存token并在程序退出前调用 |
| 图片加载后拉伸变形 | 直接硬编码目标宽高,未按原始尺寸等比缩放 | 用GdipGetImageWidth/Height取宽高计算比例 |
| 动画闪烁 | 没有双缓冲,或GDI+对象未释放就BitBlt | 使用内存DC双缓冲流程 |
7.2 性能建议和最后的调试技巧
GDI+不是以性能见长的绘图库,尤其是在纯汇编程序里,函数调用栈比C++更薄,但并不意味着可以滥用。减少绘制开销的第一条原则就是:不要在WM_PAINT里创建和销毁大量对象。把画笔、画刷、字体、StringFormat在初始化时就创建好,需要改变颜色时才创建新的,不用立刻释放,等下次再复用。
第二条原则是合理使用抗锯齿。GDI+默认很多绘制不上抗锯齿,如果你想漂亮,可以用GdipSetSmoothingMode开启抗锯齿,但代价是速度降低。对于静态界面无所谓,动画里能用关闭就关闭,或者只在文字上开启抗锯齿,图形保持快速绘制。
第三条是善用GdipSaveGraphics和GdipRestoreGraphics。这两个函数能把当前Graphics的变换、抗锯齿、裁剪区域作为一个容器保存,之后统一恢复,比手动GdipResetTransform加重新设置状态更安全。我特别喜欢在绘制复杂场景前先Save,绘制完自定义动画再Restore,这样写出来的代码不容易出现“这个对象状态被上一帧污染”的诡异问题。
最后分享一个调试技巧:GDI+函数失败只是返回非0状态码,不会输出到调试器。你可以给每个关键函数返回值写一段OutputDebugString日志,把失败的函数名和返回值一起打出来,然后用DebugView观察。这比断点单步跟踪高效得多,尤其是处理图像和字体这种对象类型复杂的逻辑时,日志能直接告诉你哪一步开始异常。
我在实际写这一类程序的体会是,GDI+在汇编里的使用难点从来不是某个函数不会调用,而是对象生命周期和状态管理。一旦你养成了“创建后必有释放、变换后必恢复”的习惯,就会发现其他一切不过是查参数表的问题。这一篇的实战内容比较多,建议你把图片加载、像素反色和双缓冲动画三个模块分别单独写成小Demo,跑通后再合并,不要一上来就指望一次写出复杂的综合程序。