1. 为什么要在易语言里折腾绘图API
很多人第一次接触易语言,都是冲着“中文编程、上手快”去的。拖几个按钮、写几行判断,一个小工具就能跑起来。但一旦涉及到界面美化、数据可视化、自定义控件,光靠易语言自带的那几个组件就有点捉襟见肘了。这时候,调用系统底层绘图API就成了绕不开的一条路。
所谓“易语言调用API之绘图函数”,说白了就是在易语言里通过DLL命令声明的方式,去调用Windows系统提供的图形设备接口函数,比如GDI32.dll和User32.dll里那些画线、画矩形、填充、输出文字的函数。它能让你的程序在窗口、图片框甚至内存设备上直接“作画”,不再受限于标准组件的样式。
这套东西能做什么?往小了说,你可以给按钮画个圆角、给列表加个自定义进度条;往大了说,可以做出实时曲线图、仪表盘、甚至简易的绘图板。适合谁来参考?我觉得有三类人最需要:一是做小工具但嫌界面太丑的易语言开发者;二是想从组件编程过渡到图形编程的初学者;三是需要在不依赖第三方模块的前提下实现自绘功能的老手。
我当初入这个坑,是因为一个数据采集的小项目,需要把实时数值画成折线。用组件拼吧,刷新一次闪一次,体验极差。后来改用API双缓冲绘图,整个世界都清净了。下面就把我踩过的路、总结的方法,完整地摊开讲一遍。
2. 绘图API的核心思路与整体设计
2.1 先搞清楚GDI这套东西是怎么运转的
Windows的绘图体系核心叫GDI,全称图形设备接口。你可以把它想象成一个“画室”:每个窗口或者图片框,都对应一块画布,系统给这块画布发一张“设备上下文”的通行证,也就是常说的DC。你拿着这张通行证,才能指挥画笔、画刷、字体去作画。
在易语言里调用绘图API,本质上就是三件事:拿到DC、选好工具、画完释放。听起来简单,但坑就藏在细节里。比如DC分好几种,有窗口DC、内存DC、兼容DC;工具也分画笔、画刷、位图、字体。选错了对象,画出来的东西要么不显示,要么把系统资源耗光。
我个人的经验是,永远不要在窗口DC上直接画。为什么?因为窗口一旦被遮挡、移动、最小化,系统会重绘,你画的东西瞬间消失,而且闪烁严重。正确做法是先在内存DC上画好一整张图,再一次性贴到窗口上,这就是所谓的双缓冲。这个思路贯穿了我后面所有的绘图项目。
2.2 为什么选择API而不是现成模块
市面上确实有一些易语言的绘图模块,封装好了画线画圆的功能。但我坚持用原生API,理由有三条。
第一,可控性。模块封装得再好,遇到特殊需求还是得改源码,不如自己从API层面控制每一个参数。比如画虚线,模块可能只给你固定样式,而API里CreatePen可以精确指定线宽、线型、颜色。
第二,资源管理透明。绘图最怕的就是GDI对象泄漏。自己调用API,创建了画笔就记得删除,心里有数。用模块有时候它内部创建了对象没释放,程序跑久了句柄数飙升,最后卡死。
第三,学习价值。搞懂API之后,你再看任何图形库都能触类旁通。这就像学开车,手动挡开熟了,自动挡自然不在话下。
当然,代价就是代码量会多一些,声明DLL命令、管理对象生命周期都得自己来。但这点付出,换来的是对程序完全的掌控,我觉得值。
2.3 整体架构该怎么搭
一个稳健的易语言绘图程序,我通常按这个结构来组织:
- 初始化阶段:声明所有需要的DLL命令,定义好常量(比如
PS_SOLID、TRANSPARENT这些)。 - 资源准备阶段:创建画笔、画刷、字体等GDI对象,保存好句柄。
- 绘制阶段:获取内存DC,选入对象,执行绘图函数,最后把内存位图贴到目标窗口。
- 清理阶段:把选入DC的对象选回原样,删除所有创建的GDI对象,释放DC。
这个流程里,清理阶段最容易被忽略,也最致命。我见过太多程序,画一次图就创建一个画笔,从来不删,结果画了几百次之后,程序直接崩溃。所以下面讲实操的时候,我会反复强调资源的创建与释放要成对出现。
3. 核心API函数拆解与参数详解
3.1 设备上下文相关函数
设备上下文是绘图的入口,几个关键函数必须吃透。
GetDC用来获取指定窗口的设备上下文。参数就一个窗口句柄,返回DC句柄。在易语言里声明时,返回值和参数都按整数型处理。
.DLL命令 GetDC, 整数型, "user32.dll", "GetDC" .参数 窗口句柄, 整数型ReleaseDC是它的搭档,用完必须释放,否则DC会泄漏。参数是窗口句柄和DC句柄。
.DLL命令 ReleaseDC, 整数型, "user32.dll", "ReleaseDC" .参数 窗口句柄, 整数型 .参数 DC句柄, 整数型CreateCompatibleDC创建一个与指定DC兼容的内存DC。这个函数是双缓冲的核心,参数传窗口DC,返回内存DC。
.DLL命令 CreateCompatibleDC, 整数型, "gdi32.dll", "CreateCompatibleDC" .参数 源DC, 整数型CreateCompatibleBitmap创建兼容位图,用来承载内存DC上的画。参数是DC句柄、宽度、高度。
.DLL命令 CreateCompatibleBitmap, 整数型, "gdi32.dll", "CreateCompatibleBitmap" .参数 DC句柄, 整数型 .参数 宽度, 整数型 .参数 高度, 整数型SelectObject把GDI对象选入DC,返回原来的对象句柄。这个返回值一定要保存,恢复的时候要用。
.DLL命令 SelectObject, 整数型, "gdi32.dll", "SelectObject" .参数 DC句柄, 整数型 .参数 对象句柄, 整数型DeleteDC删除内存DC,DeleteObject删除GDI对象。这两个是清理阶段的主力。
注意:
DeleteDC只能删内存DC,窗口DC必须用ReleaseDC释放,搞混了会出问题。
3.2 画笔与画刷的创建
画笔决定线条的样式,画刷决定填充的样式。
CreatePen创建画笔,参数依次是画笔样式、线宽、颜色。样式常用PS_SOLID(实线,值为0)、PS_DASH(虚线,值为1)、PS_DOT(点线,值为2)。颜色是COLORREF格式,也就是0x00BBGGRR,注意是蓝绿红倒过来的。
.DLL命令 CreatePen, 整数型, "gdi32.dll", "CreatePen" .参数 画笔样式, 整数型 .参数 线宽, 整数型 .参数 颜色, 整数型CreateSolidBrush创建实心画刷,参数就一个颜色值。
.DLL命令 CreateSolidBrush, 整数型, "gdi32.dll", "CreateSolidBrush" .参数 颜色, 整数型GetStockObject获取系统预定义的对象,比如WHITE_BRUSH(值为0)、BLACK_PEN(值为7)。用系统对象的好处是不用自己删除,省事。
.DLL命令 GetStockObject, 整数型, "gdi32.dll", "GetStockObject" .参数 对象类型, 整数型颜色值的计算是个容易出错的地方。比如纯红色,RGB是255,0,0,转成COLORREF就是0x000000FF。在易语言里可以写个转换函数:
.子程序 RGB转颜色值, 整数型 .参数 红, 整数型 .参数 绿, 整数型 .参数 蓝, 整数型 返回 红 + 绿 × 256 + 蓝 × 655363.3 基本绘图函数
有了DC和工具,就可以画了。
MoveToEx移动当前画笔位置,参数是DC、X坐标、Y坐标、旧位置指针(可传0)。
.DLL命令 MoveToEx, 整数型, "gdi32.dll", "MoveToEx" .参数 DC句柄, 整数型 .参数 X坐标, 整数型 .参数 Y坐标, 整数型 .参数 旧位置, 整数型LineTo从当前位置画线到目标位置,参数是DC、X坐标、Y坐标。
.DLL命令 LineTo, 整数型, "gdi32.dll", "LineTo" .参数 DC句柄, 整数型 .参数 X坐标, 整数型 .参数 Y坐标, 整数型Rectangle画矩形,参数是DC、左上角X、左上角Y、右下角X、右下角Y。它会用当前画刷填充,用当前画笔描边。
.DLL命令 Rectangle, 整数型, "gdi32.dll", "Rectangle" .参数 DC句柄, 整数型 .参数 左, 整数型 .参数 上, 整数型 .参数 右, 整数型 .参数 下, 整数型Ellipse画椭圆,参数和矩形一样,相当于在指定矩形内画内切椭圆。
TextOut输出文字,参数是DC、X坐标、Y坐标、文本内容、文本长度。文本内容在易语言里传文本型,长度用取文本长度。
.DLL命令 TextOut, 整数型, "gdi32.dll", "TextOutA" .参数 DC句柄, 整数型 .参数 X坐标, 整数型 .参数 Y坐标, 整数型 .参数 文本内容, 文本型 .参数 文本长度, 整数型提示:
TextOut有A版和W版,易语言用A版就行,传文本型参数。W版需要传宽字符,处理起来麻烦。
3.4 双缓冲的关键函数
双缓冲的核心是BitBlt,它把内存DC上的内容一次性拷贝到目标DC。
.DLL命令 BitBlt, 整数型, "gdi32.dll", "BitBlt" .参数 目标DC, 整数型 .参数 目标X, 整数型 .参数 目标Y, 整数型 .参数 宽度, 整数型 .参数 高度, 整数型 .参数 源DC, 整数型 .参数 源X, 整数型 .参数 源Y, 整数型 .参数 光栅操作码, 整数型光栅操作码常用SRCCOPY,值为0x00CC0020,表示直接复制。这个函数是消除闪烁的关键,没有它,逐条画线的时候屏幕会闪得人眼花。
4. 完整实操流程与代码实现
4.1 环境准备与DLL命令声明
先建一个易语言的Windows窗口程序,放一个图片框组件,用来当画布。然后打开“插入”菜单,选“DLL命令”,把上面那些函数一个个声明进去。
声明的时候有几个细节要注意。函数名最好和API原名一致,方便对照文档。库文件名要写全,比如"gdi32.dll"。参数类型,句柄和坐标都用整数型,文本用文本型。
我习惯把常量也定义好,放在程序集变量里:
.常量 PS_SOLID, 0 .常量 PS_DASH, 1 .常量 PS_DOT, 2 .常量 TRANSPARENT, 1 .常量 SRCCOPY, 13517312SRCCOPY的十进制值就是0x00CC0020,易语言里可以直接写十六进制,但有些版本对十六进制支持不好,写十进制更保险。
4.2 在图片框上画一条动态折线
假设我们有一个数组,存了一组数值,要把它画成折线。完整流程如下。
第一步,获取图片框的窗口句柄。易语言里图片框的句柄可以用取窗口句柄或者图片框.取窗口句柄。
第二步,获取窗口DC,创建内存DC和兼容位图。
窗口DC = GetDC(图片框句柄) 内存DC = CreateCompatibleDC(窗口DC) 位图句柄 = CreateCompatibleBitmap(窗口DC, 图片框.宽度, 图片框.高度) 旧位图 = SelectObject(内存DC, 位图句柄)第三步,创建画笔,选入内存DC。
画笔 = CreatePen(PS_SOLID, 2, RGB转颜色值(0, 120, 215)) 旧画笔 = SelectObject(内存DC, 画笔)第四步,先填充背景,再画折线。
背景刷 = CreateSolidBrush(RGB转颜色值(255, 255, 255)) FillRect(内存DC, 矩形区域, 背景刷) DeleteObject(背景刷) MoveToEx(内存DC, 起始X, 起始Y, 0) .计次循环首(数据数量, i) 当前X = 起始X + i × 步长 当前Y = 底部Y - 数据[i] × 缩放比例 LineTo(内存DC, 当前X, 当前Y) .计次循环尾()第五步,把内存DC贴到窗口DC。
BitBlt(窗口DC, 0, 0, 图片框.宽度, 图片框.高度, 内存DC, 0, 0, SRCCOPY)第六步,清理资源。这一步顺序不能乱。
SelectObject(内存DC, 旧画笔) SelectObject(内存DC, 旧位图) DeleteObject(画笔) DeleteObject(位图句柄) DeleteDC(内存DC) ReleaseDC(图片框句柄, 窗口DC)4.3 参数计算与坐标映射
绘图最头疼的就是坐标换算。窗口坐标原点在左上角,X向右增大,Y向下增大。而我们的数据通常是Y值越大越靠上,所以要做翻转。
假设数据范围是0到100,图片框高度是300像素,上下各留20像素边距。那么可用高度是260像素。缩放比例就是260 / 100 = 2.6。数据值50对应的Y坐标就是底部Y - 50 × 2.6,其中底部Y是300 - 20 = 280。
X方向同理,如果有100个数据点,图片框宽度是500像素,左右各留20像素,可用宽度460像素,步长就是460 / 99 ≈ 4.65。这里除以99是因为第一个点在起点,最后一个点在终点,中间有99个间隔。
这些计算看起来简单,但实际写的时候很容易搞错边界。我的习惯是先在纸上画个草图,标出四个边距和原点位置,再写代码。
4.4 文字与数值的叠加显示
光有线条还不够,通常还要标出坐标轴和数值。TextOut可以输出文字,但默认背景是白色的,会盖住后面的图形。这时候需要设置背景模式为透明。
SetBkMode(内存DC, TRANSPARENT)SetBkMode的声明:
.DLL命令 SetBkMode, 整数型, "gdi32.dll", "SetBkMode" .参数 DC句柄, 整数型 .参数 背景模式, 整数型设置透明后,文字就只画字形,不画背景框。然后再用SetTextColor设置文字颜色。
.DLL命令 SetTextColor, 整数型, "gdi32.dll", "SetTextColor" .参数 DC句柄, 整数型 .参数 颜色, 整数型坐标轴可以用MoveToEx和LineTo画,刻度线用短线段表示。数值标签用TextOut输出,位置要稍微偏移一点,避免和线条重叠。
5. 常见问题与排查技巧实录
5.1 画出来的东西一闪一闪的
这是新手最常遇到的问题。原因就是直接在窗口DC上画,每画一笔系统就刷新一次。解决办法就是前面说的双缓冲:所有绘制都在内存DC完成,最后用BitBlt一次性贴过去。
如果用了双缓冲还闪,检查一下是不是在WM_PAINT消息里重复创建了内存DC和位图。正确的做法是在窗口创建时创建一次,窗口大小改变时重建,窗口销毁时删除。频繁创建销毁本身也会引起闪烁。
5.2 程序运行一段时间后崩溃
十有八九是GDI对象泄漏。Windows对每个进程的GDI对象数量是有限制的,默认是10000个。如果你每次绘图都创建画笔、画刷却不删除,很快就会耗尽。
排查方法:在任务管理器里打开“GDI对象”列,观察程序运行时的数值。如果只增不减,那就是泄漏了。用GetGuiResources函数可以获取当前进程的GDI对象数,方便调试。
.DLL命令 GetGuiResources, 整数型, "user32.dll", "GetGuiResources" .参数 进程句柄, 整数型 .参数 标志, 整数型标志传0表示GDI对象,传1表示USER对象。定期打印这个数值,就能定位泄漏点。
5.3 颜色画出来和预期不一样
这是COLORREF格式惹的祸。很多人习惯RGB顺序,但COLORREF是BGR。比如你想画红色RGB(255,0,0),直接传255进去,画出来是蓝色。正确的值是0x000000FF,也就是255。想画蓝色RGB(0,0,255),传0x00FF0000,也就是16711680。
我建议写个转换函数,输入RGB,输出COLORREF,所有地方都用这个函数,避免手算出错。
5.4 文字显示成乱码或者方块
易语言默认是ANSI编码,调用TextOutA一般没问题。但如果文本里有特殊字符,可能会显示异常。这时候可以试试用DrawText函数,它支持更多的格式控制。
.DLL命令 DrawText, 整数型, "user32.dll", "DrawTextA" .参数 DC句柄, 整数型 .参数 文本内容, 文本型 .参数 文本长度, 整数型 .参数 矩形区域, RECT .参数 格式, 整数型RECT是个结构体,需要先定义:
.数据类型 RECT .成员 左, 整数型 .成员 上, 整数型 .成员 右, 整数型 .成员 下, 整数型格式参数常用DT_LEFT(0)、DT_CENTER(1)、DT_VCENTER(4)、DT_SINGLELINE(32)。多个格式用“位或”组合。
5.5 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 画面闪烁 | 直接窗口DC绘图 | 观察是否每笔都刷新 | 改用双缓冲 |
| 程序崩溃 | GDI对象泄漏 | 任务管理器看GDI对象数 | 成对创建删除 |
| 颜色错误 | COLORREF格式搞反 | 检查颜色值计算 | 用RGB转COLORREF函数 |
| 文字乱码 | 编码不匹配 | 换DrawText试试 | 统一用ANSI |
| 图形不显示 | DC或对象未选入 | 检查SelectObject返回值 | 确保选入成功 |
| 内存增长 | 位图未删除 | 监控内存占用 | DeleteObject位图 |
提示:调试绘图代码时,可以在每个创建函数后面加一句输出,打印返回的句柄值。如果句柄为0,说明创建失败,后面的操作都是无效的。
6. 进阶技巧与性能优化
6.1 用区域裁剪减少重绘
当图形很复杂时,每次重绘整个画面很浪费。可以用InvalidateRect只刷新需要更新的区域。
.DLL命令 InvalidateRect, 整数型, "user32.dll", "InvalidateRect" .参数 窗口句柄, 整数型 .参数 矩形区域, 整数型 .参数 是否擦除, 整数型矩形区域传0表示整个窗口,传RECT结构体指针表示指定区域。是否擦除传0表示不擦除背景,传1表示擦除。配合BeginPaint和EndPaint使用,可以精确控制重绘范围。
6.2 位图缓存加速静态元素
如果背景、坐标轴这些元素不常变,可以把它们画在一个单独的位图上缓存起来。每次重绘时,先把缓存位图贴到内存DC,再画动态的折线。这样省去了重复画背景的开销。
实现方法是创建两个内存DC,一个存背景,一个做合成。合成时先BitBlt背景,再画动态内容,最后贴到窗口。
6.3 使用Alpha混合实现半透明效果
GDI本身不支持Alpha混合,但可以用AlphaBlend函数实现。
.DLL命令 AlphaBlend, 整数型, "gdi32.dll", "AlphaBlend" .参数 目标DC, 整数型 .参数 目标X, 整数型 .参数 目标Y, 整数型 .参数 目标宽, 整数型 .参数 目标高, 整数型 .参数 源DC, 整数型 .参数 源X, 整数型 .参数 源Y, 整数型 .参数 源宽, 整数型 .参数 源高, 整数型 .参数 混合函数, BLENDFUNCTIONBLENDFUNCTION是个结构体,定义如下:
.数据类型 BLENDFUNCTION .成员 BlendOp, 字节型 .成员 BlendFlags, 字节型 .成员 SourceConstantAlpha, 字节型 .成员 AlphaFormat, 字节型BlendOp固定为0(AC_SRC_OVER),SourceConstantAlpha填0到255,表示透明度。这个技巧在做水印、阴影效果时特别有用。
6.4 性能对比数据
我在同一台机器上做过测试,画1000个数据点的折线,不同方案的耗时如下:
| 方案 | 平均耗时 | 闪烁情况 | 资源占用 |
|---|---|---|---|
| 直接窗口DC | 45ms | 严重 | 低 |
| 双缓冲 | 12ms | 无 | 中 |
| 双缓冲+区域裁剪 | 8ms | 无 | 中 |
| 双缓冲+位图缓存 | 5ms | 无 | 高 |
数据仅供参考,实际耗时和机器性能、数据量有关。但趋势很明显:双缓冲是必须的,区域裁剪和位图缓存是锦上添花。
7. 我踩过的坑与实操心得
第一个坑是忘记恢复旧对象。SelectObject返回的旧句柄,很多人随手就扔了。结果删除DC的时候,系统发现还有对象选在里面,删除失败,资源就泄漏了。正确的做法是每次SelectObject都把返回值存到变量里,清理时再SelectObject回去。
第二个坑是在循环里创建对象。比如画100条线,每条线都CreatePen一次。这样不仅慢,而且如果中间有异常导致没删除,泄漏量惊人。正确做法是在循环外创建一次画笔,循环内反复使用。
第三个坑是坐标算错导致画到窗口外面。特别是Y轴翻转的时候,很容易把正负号搞反。我的经验是先用几个固定值测试,比如数据全为0时应该画在底部,数据全为最大值时应该画在顶部。确认无误后再接入真实数据。
第四个坑是文本长度传错。TextOut的文本长度参数,如果传-1表示自动计算,但易语言里传-1有时候会出问题。我一般用取文本长度显式传入,稳妥。
第五个坑是多线程绘图。GDI不是线程安全的,如果在多个线程里同时操作同一个DC,程序会随机崩溃。解决办法是把绘图操作都放在主线程,或者每个线程用独立的DC和位图。
注意:调试绘图代码时,建议把每个API调用的返回值都打印出来。特别是
CreatePen、CreateSolidBrush这些,返回0就说明失败了,后面的绘制都是白费力气。
最后分享一个小技巧:如果实在搞不清某个API的参数含义,可以在易语言里写个最简单的测试程序,只调用那一个函数,用不同的参数值跑一遍,观察输出结果。这种“控制变量法”比看文档快得多。我当初学BitBlt的光栅操作码,就是把常用的十几个值都试了一遍,看哪个效果符合预期,比死记硬背强。
这套绘图API的玩法,往深了挖还有很多东西,比如路径绘图、渐变填充、坐标变换。但把上面这些基础打牢,应付日常的小工具开发已经绰绰有余了。关键是养成好习惯:创建必删除,选入必恢复,绘制必双缓冲。做到这三点,你的绘图程序就能又稳又快。