1. 为什么要在易语言里折腾绘图API
很多人对易语言的印象还停留在“拖控件、写窗口、做小工具”的阶段,觉得它就是个快速拼界面的工具,跟图形绘制这种偏底层的事情沾不上边。我刚开始也是这么想的,直到有一次需要在一个数据采集工具里实时画出采集到的信号波形,用易语言自带的画板控件刷新起来闪得眼睛疼,数据量一上来直接卡成幻灯片。那时候我才认真去研究易语言调用系统绘图API这条路子,实测下来性能提升非常明显,而且可控性完全不是一个级别。
所谓“易语言调用API之绘图函数”,说白了就是在易语言里通过DLL命令声明的方式,去调用操作系统底层提供的图形设备接口函数,把绘制工作直接交给系统去处理,而不是依赖易语言自带的那套封装控件。这套东西能做什么?小到画一条线、一个圆、一段文字,大到做实时波形图、自定义进度条、异形窗口、图像滤镜处理,都能覆盖。它解决的核心问题是性能和自由度——自带控件刷新慢、样式死板、双缓冲支持差,而API绘制可以自己控制刷新时机、自己管理内存位图、自己决定每一个像素怎么画。
这篇文章适合谁看?如果你已经会用易语言写基本的窗口程序,知道什么是子程序、什么是变量类型,但一看到DLL命令、句柄、设备上下文这些词就头大,那这篇就是写给你的。我会从最基础的概念讲起,把每个参数为什么这么传、每个步骤为什么这么设计都掰开说清楚,最后给出一套可以直接抄作业的完整绘制框架。如果你已经是老手,也可以看看后面关于双缓冲和资源释放的避坑部分,那些都是我实际踩过的坑。
2. 绘图API的核心概念拆解
2.1 设备上下文到底是个什么东西
要理解绘图API,第一个绕不开的概念就是设备上下文,英文叫Device Context,简称DC。你可以把它想象成一张“画布的通行证”。在系统里,任何绘制操作都不是凭空画在屏幕上的,而是先画到某个“目标”上,这个目标可能是一个窗口、一张内存里的位图、一台打印机,甚至是一个虚拟的图形表面。设备上下文就是描述这个目标当前状态的一堆信息的集合——画笔颜色、画刷样式、字体、坐标系原点、裁剪区域等等。
为什么要有这么个东西?因为系统需要知道你到底想往哪儿画、用什么工具画。你调用绘图函数的时候,几乎每个函数的第一个参数都是这个DC句柄。句柄你可以理解成一个编号,系统内部维护了一张表,你拿着这个编号去操作,系统就知道对应的是哪块画布。在易语言里,句柄就是一个整数型变量,但它背后代表的东西远比一个数字复杂。
获取DC有几种常见方式。最直接的是调用GetDC,传入窗口句柄,系统返回这个窗口客户区的DC。还有GetWindowDC,返回的是包含标题栏和边框的整个窗口DC。另外在响应WM_PAINT消息的时候,可以用BeginPaint获取一个专用于重绘的DC,配合EndPaint使用。这几种方式拿到的DC,用途和释放方式都不一样,后面实操部分我会详细说。
2.2 画笔、画刷与GDI对象的基本关系
有了DC这张通行证,接下来要准备“画笔”和“画刷”。这两个概念很容易混,我用一个生活化的类比:画笔决定线条长什么样,画刷决定填充区域长什么样。你画一个矩形,边框用画笔,内部填充用画刷。画一条直线,只用画笔。画一段文字,用的是字体对象。
在系统底层,这些统称为GDI对象。常见的GDI对象包括:HPEN画笔、HBRUSH画刷、HFONT字体、HBITMAP位图、HRGN区域。它们都有一个共同特点——需要你先创建,使用完之后必须销毁,否则就会造成资源泄漏。这一点非常关键,我见过太多人写绘图代码,画是画出来了,程序跑一会儿内存和GDI句柄数就爆了,最后整个系统都变卡。
创建画笔用CreatePen,需要指定线型、线宽和颜色。线型有实线、虚线、点线等几种,线宽是像素值,颜色用RGB值表示。创建画刷用CreateSolidBrush可以创建纯色画刷,用CreateHatchBrush可以创建带纹理的画刷。创建好之后,用SelectObject把它们选进DC,这时候DC才知道接下来绘制要用什么工具。这里有个细节:SelectObject会返回之前被选入的那个同类型对象的句柄,你一定要把这个旧句柄保存下来,等绘制完成后把它选回去,然后再销毁你自己创建的对象。这个“选入-保存旧句柄-选回旧句柄-销毁新对象”的流程,是GDI编程的铁律。
2.3 坐标系与映射模式
绘图离不开坐标。默认情况下,DC的坐标原点在客户区左上角,X轴向右递增,Y轴向下递增,单位是像素。这个叫MM_TEXT映射模式,也是最常用的模式。但有些场景下你可能需要不同的坐标系,比如画数学函数图像的时候,希望原点在中间、Y轴向上递增,这时候就需要切换映射模式。
系统提供了多种映射模式,比如MM_LOENGLISH(单位是0.01英寸,Y轴向上)、MM_ANISOTROPIC(可以自定义X和Y的缩放比例)。切换映射模式用SetMapMode函数。不过说实话,在实际项目里我很少去改映射模式,因为改完之后所有坐标计算都要跟着变,容易出错。更常见的做法是保持默认的MM_TEXT模式,自己在代码里做坐标变换——比如画波形图的时候,自己写一个把数据值映射到像素坐标的函数,这样逻辑更清晰,调试也方便。
坐标变换里还有一个常用的函数是SetViewportOrgEx,可以设置坐标原点偏移。比如你想让原点移到窗口中心,就调用它把原点设成宽度的一半和高度的一半。这个比切换映射模式要轻量得多,推荐优先考虑。
3. 易语言中声明与调用API的完整流程
3.1 DLL命令的声明格式与参数类型对应
易语言调用API的第一步是在“DLL命令”表里声明函数。声明的时候需要填写几个关键信息:命令名称(你自己起的名字)、返回值类型、库文件名、在库中的对应名称、参数列表。库文件名对于系统绘图API来说就是gdi32.dll或者user32.dll,具体看函数属于哪个库。
参数类型的对应是个容易出错的地方。系统API的参数类型和易语言的数据类型不是一一对应的,需要你手动转换。我整理了一张常用对照表:
| API参数类型 | 易语言声明类型 | 说明 |
|---|---|---|
| HWND | 整数型 | 窗口句柄 |
| HDC | 整数型 | 设备上下文句柄 |
| HPEN/HBRUSH/HFONT | 整数型 | GDI对象句柄 |
| COLORREF | 整数型 | 颜色值,0x00BBGGRR格式 |
| int | 整数型 | 32位整数 |
| BOOL | 整数型 | 返回1成功,0失败 |
| LPRECT | 整数型 | 传结构体地址时用整数型 |
| LPCTSTR | 文本型 | 字符串参数 |
这里要特别说一下COLORREF。系统API里的颜色值不是我们常见的RGB顺序,而是BGR顺序,也就是低字节是红色,中间字节是绿色,高字节是蓝色。易语言里可以用RGB函数来生成,但要注意易语言自带的RGB函数返回的格式和API要求的可能不一致,最稳妥的做法是自己用位运算拼:颜色值 = 红色 + 绿色 × 256 + 蓝色 × 65536。这个坑我踩过,当时画出来的颜色红蓝颠倒,排查了半天才发现是字节序的问题。
3.2 结构体参数的传递技巧
有些API需要传结构体指针,比如RECT结构体用于指定矩形区域,PAINTSTRUCT用于绘制消息。易语言里没有直接的指针类型,通常的做法是定义一个自定义数据类型,然后取它的地址传进去。取地址用取变量地址命令,返回一个整数,把这个整数传给API的指针参数即可。
以RECT为例,定义一个自定义数据类型,包含left、top、right、bottom四个整数型成员。使用的时候先给成员赋值,然后调用取变量地址(矩形变量)传给API。需要注意的是,结构体的内存对齐方式要和系统API一致。系统API默认使用4字节对齐,易语言的自定义数据类型默认也是4字节对齐,所以一般情况下没问题。但如果结构体里有字节型或短整数型成员,就要小心对齐填充的问题,必要时手动加填充字段。
还有一个细节:结构体作为参数传递时,生命周期要保证覆盖整个API调用过程。如果你在一个子程序里定义局部结构体变量,取地址传给API,API执行完之前这个变量不能被销毁。局部变量的生命周期是子程序执行期间,所以只要API调用在同一个子程序内完成就没问题。但如果你把地址存起来异步使用,那就必须用全局变量或者手动分配内存。
3.3 返回值与错误码的处理
系统API的返回值含义各不相同。有的返回句柄,失败时返回0;有的返回布尔值,1表示成功,0表示失败;有的返回具体数值。判断调用是否成功,不能一概而论,要看具体函数的文档说明。
当API调用失败时,可以调用GetLastError获取错误码。这个函数在kernel32.dll里,返回一个整数型错误码。常见的GDI错误码比如6表示句柄无效,87表示参数错误。不过说实话,在实际开发中,GDI函数失败的原因往往比较隐蔽,错误码不一定能直接定位问题。我的经验是,在关键调用之后加日志输出,把返回值、参数值都记录下来,出问题的时候对照排查,比单纯看错误码有效得多。
另外要提醒一点:GetLastError返回的是“最近一次失败”的错误码,如果你在API调用失败之后又调用了其他API,这个错误码可能被覆盖。所以正确的做法是API调用失败后立即调用GetLastError,不要中间插入其他操作。
4. 从零搭建一个双缓冲绘图框架
4.1 为什么必须用双缓冲
先说说不用双缓冲会怎样。如果你直接在窗口DC上绘制,每次重绘的时候系统会先发WM_ERASEBKGND消息擦除背景,然后再发WM_PAINT消息让你绘制。擦除和绘制之间有个时间差,用户看到的就是闪烁。数据量越大、绘制越复杂,闪烁越明显。我最早做的那个波形工具就是这样,每刷新一次闪一次,眼睛都快看瞎了。
双缓冲的思路很简单:先在内存里画好一整张图,然后一次性把这张图贴到窗口上。因为贴图是一个原子操作,中间没有擦除步骤,所以不会闪烁。具体做法是创建一个内存DC,创建一个和窗口客户区同样大小的内存位图,把位图选进内存DC,所有绘制操作都往内存DC上画,画完之后用BitBlt函数把内存DC的内容一次性复制到窗口DC。
这个方案的好处不仅仅是消除闪烁。因为所有绘制都在内存里完成,你可以在贴图之前对整张图做后处理,比如整体调色、加滤镜、叠加水印。而且内存DC的绘制不依赖窗口是否可见,即使窗口被遮挡或者最小化,绘制逻辑照样能跑,数据不会丢。
4.2 内存DC与兼容位图的创建
创建内存DC用CreateCompatibleDC函数,传入窗口DC作为参数,系统会返回一个和窗口DC兼容的内存DC。所谓“兼容”,主要是指颜色格式和位深度一致,这样后面贴图的时候不需要做颜色转换,速度快。
有了内存DC,接下来创建兼容位图,用CreateCompatibleBitmap,传入窗口DC和位图的宽高。位图的宽高应该和窗口客户区一致,这样贴图的时候才能完整覆盖。创建好位图之后,用SelectObject把它选进内存DC。注意,刚创建的内存DC默认只有一张1×1像素的单色位图,必须选入自己的位图之后才能正常绘制。
这里有个资源管理的要点:内存DC和位图都是GDI对象,用完之后必须销毁。销毁的顺序是:先把原来的位图选回内存DC(SelectObject返回的那个旧位图句柄),然后DeleteObject删除自己创建的位图,最后DeleteDC删除内存DC。顺序反了会导致资源泄漏或者程序崩溃。
窗口大小变化的时候,内存DC和位图需要重建。我的做法是在窗口尺寸改变的消息处理里,先销毁旧的内存DC和位图,然后按新尺寸重新创建。重建的时机很重要,太频繁会影响性能,太晚又会导致绘制区域不对。一般是在WM_SIZE消息里做,但要注意WM_SIZE可能被连续触发多次,可以加一个判断,只有尺寸真正变化了才重建。
4.3 绘制流程的完整代码结构
下面给出一个完整的绘制框架结构。我用的是伪代码加关键API调用的形式,你可以直接对应到易语言的DLL命令和子程序里。
首先是初始化部分,在窗口创建完成后执行:
' 获取窗口DC 窗口DC = GetDC(窗口句柄) ' 创建内存DC 内存DC = CreateCompatibleDC(窗口DC) ' 获取客户区尺寸 GetClientRect(窗口句柄, 取变量地址(客户区矩形)) 位图宽 = 客户区矩形.right - 客户区矩形.left 位图高 = 客户区矩形.bottom - 客户区矩形.top ' 创建兼容位图 内存位图 = CreateCompatibleBitmap(窗口DC, 位图宽, 位图高) ' 选入内存DC,保存旧位图句柄 旧位图 = SelectObject(内存DC, 内存位图) ' 释放窗口DC(注意:GetDC获取的DC必须ReleaseDC释放) ReleaseDC(窗口句柄, 窗口DC)然后是绘制部分,在需要刷新的时候调用:
' 设置背景模式为透明,避免文字有底色 SetBkMode(内存DC, 1) ' 创建画笔 新画笔 = CreatePen(0, 1, 颜色值) 旧画笔 = SelectObject(内存DC, 新画笔) ' 执行绘制操作,比如画线 MoveToEx(内存DC, 起点X, 起点Y, 0) LineTo(内存DC, 终点X, 终点Y) ' 恢复旧画笔 SelectObject(内存DC, 旧画笔) DeleteObject(新画笔) ' 贴图到窗口 窗口DC = GetDC(窗口句柄) BitBlt(窗口DC, 0, 0, 位图宽, 位图高, 内存DC, 0, 0, 13369376) ReleaseDC(窗口句柄, 窗口DC)最后是清理部分,在窗口销毁时执行:
SelectObject(内存DC, 旧位图) DeleteObject(内存位图) DeleteDC(内存DC)这个结构看起来简单,但每个环节都有细节要注意。比如BitBlt的最后一个参数是光栅操作码,13369376对应的是SRCCOPY,表示直接复制源像素。这个值你可以用计算器算,也可以用易语言的位运算拼出来。我习惯直接写常量,省得每次算。
5. 常见绘图函数的实战用法
5.1 线条与形状绘制
画线用MoveToEx加LineTo的组合。MoveToEx设置当前画笔位置,LineTo从当前位置画一条线到指定位置,同时把当前位置更新到终点。如果要画多条连续的线,只需要多次调用LineTo即可。注意MoveToEx的第三个参数可以传0,表示不需要获取之前的位置。
画矩形用Rectangle,传入左上角和右下角坐标。边框用当前选中的画笔,内部用当前选中的画刷填充。如果不想填充,可以把画刷选成NULL_BRUSH(系统预定义的空画刷句柄,值为5)。画椭圆用Ellipse,参数和矩形一样,椭圆内切于指定的矩形。画圆角矩形用RoundRect,多两个参数指定圆角的宽高。
画多边形用Polygon,需要传一个点数组的地址和点的数量。在易语言里,点数组可以用自定义数据类型POINTAPI的数组,然后取数组地址传进去。注意数组在内存里是连续存放的,每个点占8字节(两个整数型),所以点的数量要准确,否则会读到越界内存。
这里分享一个实用技巧:批量绘制时尽量减少画笔切换。比如你要画100条不同颜色的线,不要每画一条就创建和销毁一次画笔。更好的做法是按颜色分组,同一种颜色的线一起画,这样只需要切换一次画笔。如果颜色种类很多,可以预先创建一组画笔放在数组里,用的时候直接选,用完统一销毁。
5.2 文本输出与字体控制
输出文本用TextOut函数,传入DC、X坐标、Y坐标、文本内容、文本长度。文本内容在易语言里是文本型,但API需要的是字节集指针,所以要用取变量地址或者先转换成字节集再取地址。文本长度是字节数,不是字符数,中文一个字占两个字节,计算的时候要注意。
字体控制用CreateFont创建字体对象,参数非常多,包括字高、字宽、倾斜角、粗细、下划线、斜体、字符集、精度、字体系列名等。实际使用的时候大部分参数可以传0或默认值,重点设置字高、粗细和字体名。创建好之后用SelectObject选进DC,用完恢复旧字体并销毁。
文本颜色用SetTextColor设置,背景模式用SetBkMode设置。如果背景模式设为TRANSPARENT(值为1),文本背景透明,不会覆盖下面的图形。如果设为OPAQUE(值为2),文本背景会用当前背景色填充。我一般都用透明模式,这样文字叠加在图形上比较好看。
还有一个常用的函数是GetTextExtentPoint32,可以测量一段文本在指定字体下的宽度和高度。这个在做文本居中、自动换行、布局计算的时候非常有用。比如你要把一段文字水平居中,先用这个函数测出文本宽度,然后用(区域宽度减文本宽度)除以2得到起始X坐标。
5.3 位图操作与图像合成
除了画线和文字,绘图API还能直接操作位图。BitBlt是最常用的位图复制函数,可以把一个DC的内容复制到另一个DC。除了SRCCOPY,还有几种常用的光栅操作码:SRCAND(与运算)、SRCOR(或运算)、SRCINVERT(异或运算)、SRCPAINT(或运算,用于透明叠加)。利用这些操作码可以实现简单的图像合成效果。
比如要实现透明贴图,可以先用SRCAND把掩码位图和背景做与运算,再用SRCPAINT把彩色位图贴上去。这个原理和Windows早期图标透明处理是一样的。不过这种方法只支持单色透明,也就是要么完全透明要么完全不透明,做不了半透明效果。半透明需要用AlphaBlend函数,这个函数在msimg32.dll里,支持指定透明度。
StretchBlt是BitBlt的增强版,可以在复制的同时缩放图像。参数比BitBlt多两个,指定目标宽高。缩放模式用SetStretchBltMode设置,常用的有COLORONCOLOR(直接删像素,速度快但质量差)和HALFTONE(半色调,质量好但速度慢)。如果缩放比例不大,用COLORONCOLOR就够了;如果要大幅缩小图像,建议用HALFTONE,否则会有严重的锯齿。
6. 资源管理与性能优化避坑指南
6.1 GDI对象泄漏的排查方法
GDI对象泄漏是绘图程序最常见的问题,症状是程序运行一段时间后变卡,严重时整个系统都变慢,打开任务管理器看GDI对象数一直往上涨。排查方法很简单:在任务管理器的“详细信息”标签页里,右键列标题,勾选“GDI对象”,然后观察你的程序这一列的数字。正常情况下这个数字应该稳定在一个范围内波动,如果持续增长不回落,那就是泄漏了。
泄漏的原因通常是创建了GDI对象但没有销毁,或者选入了DC但没有选回旧对象。我总结了一个检查清单,每次写完绘图代码都对照过一遍:
- 每个
CreatePen、CreateSolidBrush、CreateFont、CreateCompatibleBitmap、CreateCompatibleDC都有对应的DeleteObject或DeleteDC - 每次
SelectObject都把返回的旧句柄保存了,并且在销毁新对象之前把旧句柄选回去了 GetDC和ReleaseDC成对出现,BeginPaint和EndPaint成对出现- 异常分支和提前返回的路径上也要保证资源被释放
最后一条特别容易被忽略。比如你在绘制过程中判断某个条件不满足就提前返回了,但前面创建的资源还没释放。我的做法是把资源释放统一放在子程序的末尾,用跳转或者标志位控制,确保无论从哪条路径退出都会执行清理。
6.2 刷新频率与重绘策略
绘图程序的性能瓶颈往往不在绘制本身,而在刷新频率。如果你每收到一个数据点就刷新一次界面,数据量大的时候CPU全花在重绘上了。正确的做法是控制刷新频率,比如用定时器每50毫秒刷新一次,把这段时间内积累的数据一次性画出来。
另一个优化点是局部重绘。如果只有一小块区域需要更新,不要整个窗口重绘。可以用InvalidateRect函数指定需要重绘的矩形区域,系统只会对这块区域发WM_PAINT消息。在双缓冲框架下,局部重绘的实现稍微复杂一点,因为内存位图是整个窗口大小的,你需要只把变化区域贴到窗口上。BitBlt可以指定源坐标和目标坐标以及宽高,所以只贴变化区域是可行的。
还有一个技巧是脏矩形合并。如果短时间内有多个区域需要重绘,不要每个区域都触发一次重绘,而是把所有脏矩形合并成一个大矩形,一次性重绘。这样可以减少重绘次数,提高效率。
6.3 高DPI环境下的适配问题
现在高分辨率屏幕越来越普及,在高DPI环境下,系统默认会对程序窗口进行缩放,导致绘图坐标和实际像素不对应,画出来的线条模糊、文字发虚。解决办法是在程序启动时声明DPI感知,让程序自己处理缩放。
在易语言里,可以通过调用SetProcessDPIAware函数来声明DPI感知。这个函数在user32.dll里,没有参数,调用一次即可。声明之后,程序获取到的窗口尺寸就是实际像素尺寸,绘图坐标也对应实际像素,线条和文字都会清晰很多。
不过声明DPI感知之后,你需要自己处理缩放逻辑。比如在100%缩放下按钮是30像素高,在150%缩放下应该变成45像素高。我的做法是定义一个缩放比例变量,在程序启动时根据系统DPI计算出来,所有涉及尺寸的地方都乘以这个比例。这样一套代码可以适配不同DPI的屏幕。
7. 实操案例:实时波形绘制器
7.1 需求分析与方案设计
假设我们要做一个实时波形绘制器,需求是:从某个数据源持续接收数值,在窗口里画出最近N个点的波形图,要求刷新流畅不闪烁,支持窗口缩放,支持暂停和继续。
方案设计上,核心是双缓冲加定时刷新。数据接收和界面绘制分离,数据接收线程只管往一个环形缓冲区里写数据,绘制线程(其实就是主线程的定时器)每隔固定时间从缓冲区读取最新数据并重绘。这样数据接收不会阻塞界面,界面刷新也不会丢数据。
缓冲区的大小根据采样率和显示窗口决定。比如采样率是1000Hz,显示最近5秒的数据,那缓冲区至少需要5000个点。用环形缓冲区的好处是不需要频繁移动内存,写入和读取都是O(1)复杂度。
7.2 关键代码实现与参数计算
坐标映射是波形绘制的核心。假设窗口客户区宽度是W像素,要显示N个数据点,每个数据点之间的水平间距是W / N像素。数据值的范围假设是0到100,窗口高度是H像素,那数据值v对应的Y坐标是H - (v / 100) * H,也就是把0映射到窗口底部,100映射到窗口顶部。
' 坐标映射函数 子程序 数据转坐标, 整数型, 公开, 把数据值转换为屏幕Y坐标 参数 数据值, 双精度小数型 参数 窗口高度, 整数型 参数 数据最大值, 双精度小数型 返回 (窗口高度 - 数据值 / 数据最大值 * 窗口高度)绘制的时候,先清空内存DC(用PatBlt填充背景色),然后创建画笔,从第一个点开始,依次LineTo到下一个点。如果数据点很密集,比如一个像素对应多个数据点,可以做降采样,每个像素只取该列数据的最小值和最大值,画一条垂直线段。这样既保留了波形特征,又减少了绘制量。
刷新定时器的间隔设置也有讲究。太短了CPU占用高,太长了波形不流畅。一般30到60毫秒比较合适,对应大约16到33帧每秒。如果数据更新很快,可以在定时器里判断是否有新数据,没有新数据就跳过重绘,进一步节省资源。
7.3 效果验证与性能对比
我用同一台机器做了对比测试。用易语言自带画板控件绘制5000个点的波形,刷新一次大约需要80到120毫秒,CPU占用率在30%以上,而且有明显闪烁。改用API双缓冲方案后,刷新一次大约需要8到15毫秒,CPU占用率降到5%以下,完全没有闪烁。数据量越大,差距越明显。
这个差距主要来自两个方面。一是自带控件每次重绘都会触发完整的消息流程,包括背景擦除和控件内部的状态维护,开销大。二是自带控件的绘制没有硬件加速,而API的BitBlt在多数情况下会走显卡的快速路径。所以如果你的程序涉及大量图形绘制,强烈建议走API这条路。
8. 几个容易踩的坑和排查技巧
8.1 画不出来的几种常见原因
第一种情况是选错了DC。比如你在WM_PAINT消息里用GetDC获取DC,画完之后没有释放,下次重绘的时候又获取一个新的,旧的DC一直没释放,最后系统DC耗尽,什么都画不出来。正确的做法是在WM_PAINT里用BeginPaint和EndPaint,在其他地方用GetDC和ReleaseDC。
第二种情况是画笔颜色和背景色一样。这个听起来很蠢,但实际排查的时候经常遇到。特别是用黑色背景的时候,默认画笔也是黑色,画出来的线完全看不见。解决办法是显式创建画笔并设置一个对比明显的颜色。
第三种情况是坐标超出了可见区域。比如窗口大小是800×600,你画的线坐标是(1000, 700),那肯定看不见。排查的时候可以在绘制前输出一下坐标值,确认在有效范围内。
第四种情况是忘记恢复旧对象。如果你把画笔选入DC之后没有选回旧画笔,下次绘制的时候DC里还是你上次选的画笔,可能导致颜色或线型不对。更严重的是,如果你在选入新画笔之前把旧画笔删了,DC里就有一个悬空句柄,后续绘制行为不可预测。
8.2 颜色显示异常的排查
颜色不对通常有三个原因。一是字节序问题,前面说过,API用的是BGR顺序,如果你按RGB顺序传,红色和蓝色会颠倒。二是调色板问题,在某些老旧的显示模式下,颜色会被映射到最近的调色板颜色,导致偏色。现在基本都是真彩色显示,这个问题很少见了。三是Alpha通道问题,如果你从带透明通道的位图里取颜色,忽略Alpha值可能导致颜色变淡或变深。
排查颜色问题的方法很简单:画一个纯红色的矩形,然后用取色工具看看实际显示的颜色值。如果红色和蓝色互换了,那就是字节序问题。如果颜色整体偏暗,可能是Alpha通道没处理好。
8.3 窗口缩放时的重绘处理
窗口缩放时,客户区尺寸变了,内存位图也需要重建。如果重建不及时,贴图的时候就会出错,表现为画面拉伸、错位或者部分区域空白。正确的处理流程是在WM_SIZE消息里先销毁旧的内存DC和位图,然后按新尺寸重新创建,最后触发一次重绘。
但这里有个陷阱:WM_SIZE消息可能在窗口创建过程中就被触发,那时候你的内存DC还没初始化,直接去销毁会出错。所以要在销毁之前判断句柄是否有效,无效就跳过。另外,窗口最小化的时候客户区尺寸会变成0,这时候创建0×0的位图会失败,也要做特殊处理,尺寸为0时直接跳过重建。
还有一个细节是缩放过程中的刷新。用户拖动窗口边缘的时候,WM_SIZE会连续触发很多次,如果每次都重建内存位图,性能会很差。我的做法是加一个延时,收到WM_SIZE后不立即重建,而是启动一个短定时器(比如100毫秒),定时器到期后再重建。这样连续缩放过程中只会重建一次,流畅度好很多。
9. 进阶方向与扩展思路
掌握了基础的绘图API之后,可以往几个方向深入。一个是图像处理,利用BitBlt和像素操作实现滤镜效果,比如灰度化、反色、模糊、锐化。这些操作的原理都是直接读写位图的像素数据,用GetDIBits和SetDIBits可以在位图和字节数组之间转换,然后对字节数组做运算。
另一个方向是自定义控件。用绘图API完全从零绘制一个按钮、滑块、进度条,外观和交互都可以自己定义,不受系统控件样式的限制。做出来的界面可以非常个性化,适合有特定视觉要求的项目。
还有一个方向是离屏渲染与打印输出。绘图API不仅可以画到窗口上,还可以画到内存位图或者打印机DC上。这意味着你可以用同一套绘制代码,既在屏幕上显示,又输出到打印机或者保存为图片文件。做报表、图表导出的时候非常实用。
我个人在实际操作中的体会是,绘图API的学习曲线前期比较陡,主要是概念多、参数多、容易出错。但一旦把双缓冲框架搭起来,后面就是不断往里面加绘制逻辑,越用越顺手。最关键的是把资源管理的习惯养好,创建和销毁严格配对,这样代码跑多久都不会出问题。