本帖最后由 莫小乐 于 2026-7-18 15:18 编辑
内存画板类 - 实现思路与教学解析
一、核心概念
什么是内存画板?
内存画板就是在内存中虚拟一块画布,所有绘制操作都在内存里完成,不会直接显示在屏幕上。画完后可以一次性导出为图片保存或显示。
打个比方:就像你在草稿纸上画画,画完后再拍照给别人看,而不是直接在展览墙上画。
为什么要用内存画板?
| 场景 |
说明 |
| 后台绘图 |
程序静默生成验证码、图表,无需界面 |
| 高性能渲染 |
在内存中拼合复杂图形,一次性输出,避免闪烁 |
| 图像处理 |
对已有图像做叠加文字、画框等二次加工 |
| 批量导出 |
批量生成证书、名片等带文字和图形的图片 |
二、整体架构设计思路
2.1 最小化设计原则
这个类定位为"最小版",核心思想是:只保留最必要的功能,让使用者一眼看懂本质流程。
选定的四个核心能力:
- 创建画板 —— 开辟内存空间
- 设置工具 —— 换画笔、换字体
- 执行绘制 —— 画线、写文字
- 导出结果 —— 取出来用
不做的:画圆、画矩形、贴图、裁切等。这些不是不会,是故意留给读者扩展。
2.2 状态管理模式
类内部维护了几个关键的状态变量:
| 变量 |
作用 |
为什么需要 |
内存场景 |
指向内存 DC 的句柄 |
所有绘制操作的"目标" |
位图句柄 |
指向内存中的位图 |
DC 必须在上面"画",相当于画纸 |
背景画笔 |
创建画板时的背景色画笔 |
清除画板时复用,不用每次重建 |
填充矩形 |
画板的坐标范围 |
清除和调整大小时使用 |
设计要点:背景画笔和填充矩形在创建时初始化,之后复用。这种"创建一次,多次使用"的思路,是性能优化的基础。
三、GDI 绘图模型理解
3.1 Windows 绘图的"选工具"模型
GDI 的核心设计哲学是:先把工具选入 DC,再执行绘制命令。
① 创建工具(字体、画笔、画刷)
② 选入 DC(SelectObject)
③ 执行绘制(LineTo、TextOut 等)—— 自动使用当前选入的工具
所以类里的 设置字体、设置画笔,本质就是:
- 创建新工具
- 选入 DC
- 删除旧工具(避免内存泄漏)
3.2 坐标系统
内存画板的坐标系:
- 原点:左上角 (0, 0)
- X 轴:向右递增
- Y 轴:向下递增
所有绘制函数传入的坐标都是相对于这个左上角原点的逻辑坐标。
四、各功能模块的设计思路
4.1 创建画板
流程:
获取屏幕 DC → 创建兼容位图 → 创建内存 DC → 位图选入 DC → 填充背景色 → 设置默认工具
关键决策点:
- 为什么用屏幕 DC 创建位图? 为了保证位图与当前显示设备兼容,避免颜色失真
- 为什么立即填充背景? 新位图内容是未定义的,不填充可能出现随机像素
- 为什么要设默认画笔和画刷? 防止用户在不设置的情况下调用绘制导致失败
4.2 设置字体
字体大小计算:
实际像素 = (屏幕DPI × 字体磅值 ÷ 72) × (-1)
- 除以 72:1 英寸 = 72 磅
- 乘以 DPI:根据屏幕分辨率换算为像素
- 乘以 -1:负值表示以字符高度为单位(正值表示以单元格高度为单位)
设计选择:
- 只暴露名称、大小、粗细三个参数,其余参数内部固定
- 字体映射到实际物理字体的策略选
2(匹配最接近的字体)
4.3 设置画笔
相对简单,就是指定线宽和颜色,样式固定为实线。这是最常用的画笔类型。
4.4 画直线
两步走:
MoveToEx 把画笔移到起点
LineTo 从当前位置画到终点
画完后,画笔停留在终点位置,下次画线会从这里继续。如果不想连续,每次画之前都要 MoveToEx。
4.5 定位写出
直接调用 TextOutA,在指定坐标输出文本。简单直接,适合已知确切位置的场景。
4.6 矩形内写文本(重点设计)
这个功能的设计思路体现了API 封装的艺术:
原始 API 的痛点:
DrawTextA 需要传入一个复杂的格式标志组合(位或运算),对初学者不友好。
封装策略:
- 用三个易懂的参数(水平位置、垂直位置、是否多行)替代位标志
- 内部做映射转换:
1 → DT_LEFT,2 → DT_RIGHT,其他 → DT_CENTER
- 自动判断单行/多行模式,避免使用者接触
DT_SINGLELINE、DT_WORDBREAK 等概念
单行 vs 多行的处理差异:
- 单行模式:文本超出矩形范围会被截断,需指定
DT_SINGLELINE
- 多行模式:文本自动换行,不能同时使用
DT_VCENTER(API 限制)
4.7 清除画板
直接用背景色画笔填充整个矩形。简单但有效,背景色在创建时就已保存。
4.8 调整画板尺寸
核心难点:调整大小后要保留原有内容。
实现思路:
创建新位图(目标尺寸)→ 填充背景 → 把旧内容拷贝过去 → 替换旧位图
用 BitBlt 做位图块传输,SRCCOPY 模式直接拷贝像素。这也是 GDI 中最常见的位图操作方式。
4.9 导出图片
BMP 文件结构:文件头(14 字节)+ 信息头(40 字节)+ 像素数据
实现流程:
GetObjectA 获取位图的宽、高、色深
- 根据位图信息计算像素数据大小
- 手动拼接 BMP 文件头和信息头
GetDIBits 提取像素数据
- 三部分拼接成完整 BMP
为什么用 GetDIBits 而不用 GetBitmapBits?
GetDIBits 能保证像素数据是 DIB 格式(设备无关位图),便于跨设备使用和保存。
五、资源管理哲学
5.1 谁创建谁释放
- DC 创建 →
DeleteDC
- GDI 对象创建 →
DeleteObject
- 屏幕 DC(
GetDC 获取)→ ReleaseDC
这三类资源有各自的释放函数,不能混用。
5.2 SelectObject 的返回值利用
DeleteObject(SelectObject(DC, 新对象))
这句经典写法做了三件事:
- 把新对象选入 DC
- 返回被替换出来的旧对象
- 立刻删除旧对象
一行代码完成"替换 + 释放",简洁高效。
5.3 析构时机
_销毁 方法里只释放了 内存场景。这是因为位图选入 DC 后,删除 DC 会自动清理位图。但严格来说,背景画笔没有被释放,这是最小版故意留下的资源泄漏,留给读者发现和完善。
六、局限性与改进方向
| 局限 |
原因 |
改进思路 |
| 无文本颜色设置 |
最小版未加入 |
增加 SetTextColor 调用 |
| 只支持 BMP 导出 |
BMP 结构最简单 |
引入 GDI+ 或编码库支持 PNG |
| 无抗锯齿 |
GDI 原生限制 |
升级到 GDI+ 绘图 |
| 无图形绘制 |
最小版裁剪 |
增加 Rectangle、Ellipse 等 |
| 无错误处理 |
保持代码简洁 |
每步判断返回值并抛出异常 |
七、总结
这个内存画板类的设计体现了几个重要的编程思想:
- 最小可用原则 —— 先让核心流程跑通,再逐步丰富
- 封装复杂度 —— 把难用的原始 API 包装成易用的接口
- 资源生命周期管理 —— 创建、使用、释放的完整闭环
- 权衡取舍 —— 在简洁性和完整性之间找到平衡点
掌握了这些思路,就可以很容易地扩展出画矩形、画圆形、图片贴图、裁剪区域等更高级的功能。
常量
API
类
| 窗口程序集名 | 保 留 | 保 留 | 备 注 | | 类_内存画板 | | | | 变量名 | 类 型 | 数组 | 备 注 | | 内存场景 | 整数型 | | | 位图句柄 | 整数型 | | | 背景画笔 | 整数型 | | | 填充矩形 | 矩形数据 | | DeleteDC (内存场景 ) 如果真 (内存场景 ≠ 0 ) DeleteDC (内存场景 )内存场景 = CreateCompatibleDC (0)桌面场景 = GetDC (0 )位图句柄 = CreateCompatibleBitmap (桌面场景, 宽度, 高度 )ReleaseDC (0, 桌面场景 )SelectObject (内存场景, 位图句柄 ) 如果真 (是否为空 (背景颜色 ))  背景颜色 = #白色 填充矩形.左边 = 0填充矩形.顶边 = 0 填充矩形.右边 = 宽度 填充矩形.底边 = 高度 背景画笔 = CreateSolidBrush (背景颜色 )FillRect (内存场景, 填充矩形, 背景画笔 )SelectObject (内存场景, CreateSolidBrush ( #白色 )) SelectObject (内存场景, CreatePen ( #PS_SOLID, 2, #黑色 )) 返回 (位图句柄 ≠ 0 )返回 (FillRect (内存场景, 填充矩形, 背景画笔 ) ≠ 0 )| 变量名 | 类 型 | 静态 | 数组 | 备 注 | | 缓存场景 | 整数型 | | | | 桌面场景 | 整数型 | | | | 缓存位图 | 整数型 | | |
缓存场景 = CreateCompatibleDC (0 )桌面场景 = GetDC (0 )缓存位图 = CreateCompatibleBitmap (桌面场景, 新宽度, 新高度 )ReleaseDC (0, 桌面场景 )SelectObject (缓存场景, 缓存位图 )填充矩形.左边 = 0 填充矩形.顶边 = 0 填充矩形.右边 = 新宽度 填充矩形.底边 = 新高度 FillRect (缓存场景, 填充矩形, 背景画笔 )BitBlt (缓存场景, 0, 0, 新宽度, 新高度, 内存场景, 0, 0, 13369376 )DeleteObject (SelectObject (内存场景, 缓存位图 )) DeleteDC (缓存场景 )位图句柄 = 缓存位图 返回 (缓存位图 )|
| 设置字体 | 整数型 | | |
| 字体名称 | 文本型 | | | | 字体大小 | 整数型 | | | | 字体粗细 | 整数型 | | | |
字体对象 = CreateFontA (GetDeviceCaps (内存场景, #LOGPIXELSX ) × 字体大小 ÷ 72 × -1, 0, 0, 0, 字体粗细, 0, 0, 0, 1, 0, 0, 2, 0, 字体名称 )DeleteObject (SelectObject (内存场景, 字体对象 )) 返回 (字体对象 )画笔 = CreatePen ( #PS_SOLID, 线宽, 颜色 )DeleteObject (SelectObject (内存场景, 画笔 )) 返回 (画笔 )|
| 画直线 | 整数型 | | |
| 起点X | 整数型 | | | | 起点Y | 整数型 | | | | 终点X | 整数型 | | | | 终点Y | 整数型 | | | | MoveToEx (内存场景, 起点X, 起点Y, 0 )返回 (LineTo (内存场景, 终点X, 终点Y )) |
| 定位写出 | 逻辑型 | | |
| X坐标 | 整数型 | | | | Y坐标 | 整数型 | | | | 文本内容 | 文本型 | | | | 返回 (TextOutA (内存场景, X坐标, Y坐标, 文本内容, 取文本长度 (文本内容 )) )|
| 写文本_矩形内 | 整数型 | | |
| 欲写出文本 | 文本型 | | | | 起始左边 | 整数型 | | | | 起始顶边 | 整数型 | | | | 画出宽度 | 整数型 | | | | 画出高度 | 整数型 | | | | 水平位置 | 整数型 | | | | 垂直位置 | 整数型 | | | | 多行显示 | 逻辑型 | | | |
矩形范围.左边 = 起始左边 矩形范围.顶边 = 起始顶边 矩形范围.右边 = 起始左边 + 画出宽度 矩形范围.底边 = 起始顶边 + 画出高度 判断 (水平位置 = 1 ) 水平位置 = 0 判断 (水平位置 = 2 ) 水平位置 = 2  水平位置 = 1 判断 (垂直位置 = 1 )垂直位置 = 0 判断 (垂直位置 = 2 )垂直位置 = 8  垂直位置 = 4 返回 (DrawTextA (内存场景, 欲写出文本, -1, 矩形范围, 水平位置 + 垂直位置 + 选择 (多行显示, 0, 32))) | 变量名 | 类 型 | 静态 | 数组 | 备 注 | | 位图头部 | 字节集 | | | | 位图信息 | 字节集 | | | | 位图数据 | 字节集 | | | | 返回数据 | 字节集 | | | | 位图宽度 | 整数型 | | | | 位图高度 | 整数型 | | | | 位图深度 | 短整数型 | | | | 位图大小 | 整数型 | | |
返回数据 = 取空白字节集 (24 )GetObjectA (位图句柄, 24, 取指针_字节集 (返回数据 )) 位图宽度 = 取字节集数据 (返回数据, #整数型, 5 )位图高度 = 取字节集数据 (返回数据, #整数型, 9 )位图深度 = 取字节集数据 (返回数据, #短整数型, 19 )位图大小 = 位图宽度 × 位图高度 × 位图深度 ÷ 8 位图数据 = 取空白字节集 (位图大小 )位图头部 = { 66, 77 } + 到字节集 (到整数 (位图大小 + 54 )) + { 0, 0, 0, 0, 54, 0, 0, 0 }位图信息 = { 40, 0, 0, 0 } + 到字节集 (位图宽度 ) + 到字节集 (位图高度 ) + { 1, 0 } + 到字节集 (位图深度 ) + { 0, 0, 0, 0 } + 到字节集 (位图大小 ) + { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }GetDIBits (内存场景, 位图句柄, 0, 位图高度, 取指针_字节集 (位图数据 ), 取指针_字节集 (位图信息 ), 0 )返回 (位图头部 + 位图信息 + 位图数据 )
调用
| 窗口程序集名 | 保 留 | 保 留 | 备 注 | | 窗口程序集_启动窗口 | | | | | 变量名 | 类 型 | 数组 | 备 注 | | 画板 | 类_内存画板 | | 跑测试 ()| 变量名 | 类 型 | 静态 | 数组 | 备 注 | | 成功 | 整数型 | | | | 失败 | 整数型 | | | | 图片数据 | 字节集 | | | | 句柄 | 整数型 | | | | 结果 | 整数型 | | | | 标志 | 逻辑型 | | |
成功 = 0 失败 = 0 调试输出 (“========================================”)调试输出 (“ 内存画板类 - 功能测试”)调试输出 (“========================================”) 调试输出 (“”)调试输出 (“[1/10] 创建画板 (640×480, 红色背景)”)标志 = 画板. 创建 (640, 480, #天蓝 ) 如果 (标志 ) 成功 = 成功 + 1 调试输出 (“通过”) 失败 = 失败 + 1 调试输出 (“失败”) 调试输出 (“”)调试输出 (“[2/10] 设置字体 (微软雅黑, 32, 粗体)”)句柄 = 画板. 设置字体 (“微软雅黑”, 32, 700 ) 如果 (句柄 ≠ 0 ) 成功 = 成功 + 1 调试输出 (“通过 (句柄=” + 到文本 (句柄 ) + “)”) 失败 = 失败 + 1 调试输出 (“失败”) 调试输出 (“”)调试输出 (“[3/10] 设置画笔 (线宽3, 蓝色)”)句柄 = 画板. 设置画笔 (2, #红色 ) 如果 (句柄 ≠ 0 ) 成功 = 成功 + 1 调试输出 (“通过 (句柄=” + 到文本 (句柄 ) + “)”) 失败 = 失败 + 1 调试输出 (“失败”) 调试输出 (“”)调试输出 (“[4/10] 画直线 (3条水平线)”)结果 = 画板. 画直线 (50, 50, 300, 50 )画板. 画直线 (50, 100, 300, 100 )画板. 画直线 (50, 150, 300, 150 ) 如果 (结果 ≠ 0 ) 成功 = 成功 + 1 调试输出 (“通过”) 失败 = 失败 + 1 调试输出 (“失败”) 调试输出 (“”)调试输出 (“[5/10] 定位写出 (坐标100, 200)”)标志 = 画板. 定位写出 (100, 200, “定位写出 (坐标100, 200)”) 如果 (标志 ) 成功 = 成功 + 1 调试输出 (“通过”) 失败 = 失败 + 1 调试输出 (“失败”) 调试输出 (“”)调试输出 (“[6/10] 矩形内写文本 (单行居中)”)结果 = 画板. 写文本_矩形内 (“矩形内写文本 (单行居中)”, 50, 250, 540, 50, 0, 0, 假) 如果 (结果 ≠ 0 ) 成功 = 成功 + 1 调试输出 (“通过”) 失败 = 失败 + 1 调试输出 (“失败”) 调试输出 (“”)调试输出 (“[7/10] 矩形内写文本 (多行左上)”)结果 = 画板. 写文本_矩形内 (“第一行” + #换行符 + “第二行” + #换行符 + “第三行”, 50, 320, 540, 100, 1, 1, 真) 如果 (结果 ≠ 0 ) 成功 = 成功 + 1 调试输出 (“通过”) 失败 = 失败 + 1 调试输出 (“失败”) 调试输出 (“”)调试输出 (“[8/10] 清除画板”)标志 = 画板. 清除 () 如果 (标志 ) 成功 = 成功 + 1 调试输出 (“通过”) 失败 = 失败 + 1 调试输出 (“失败”) 画板. 设置画笔 (2, #黑色 )画板. 画直线 (0, 0, 640, 480 )画板. 画直线 (640, 0, 0, 480 )画板. 设置字体 (“宋体”, 24, 400 )画板. 定位写出 (200, 220, “画板测试_入门级思路”) 调试输出 (“”)调试输出 (“[9/10] 获取位图数据”)图片数据 = 画板. 取图片 () 如果 (取字节集长度 (图片数据 ) > 0 ) 写到文件 (取运行目录 () + “\test_output.bmp”, 图片数据 ) 成功 = 成功 + 1 调试输出 (“通过 (” + 到文本 (取字节集长度 (图片数据 )) + “ 字节, 已保存)”) 失败 = 失败 + 1 调试输出 (“失败”) 调试输出 (“”)调试输出 (“[10/10] 调整尺寸 (800×600)”)结果 = 画板. 置画板尺寸 (800, 600 ) 如果 (结果 ≠ 0 ) 画板. 设置画笔 (3, #绿色 ) 画板. 画直线 (0, 0, 800, 600 ) 画板. 画直线 (800, 0, 0, 600 ) 画板. 设置字体 (“楷体”, 36, 700 ) 画板. 定位写出 (250, 280, “尺寸已调整”) 图片数据 = 画板. 取图片 () 图片框1.图片 = 图片数据 写到文件 (取运行目录 () + “\test_resized.bmp”, 图片数据 ) 成功 = 成功 + 1 调试输出 (“通过 (已保存 test_resized.bmp)”) 失败 = 失败 + 1 调试输出 (“失败”)调试输出 (取运行目录 () + “\test_resized.bmp”) 调试输出 (“”)调试输出 (“========================================”)调试输出 (“ 通过: ” + 到文本 (成功 ) + “ | 失败: ” + 到文本 (失败 ) + “ | 总计: ” + 到文本 (成功 + 失败 )) 调试输出 (“========================================”)置入代码 ({ 139, 69, 8, 139, 0, 131, 192, 8, 201, 194, 4, 0 })返回 (0 )
|