☰
VB6内存中生成Bitmap并保存为Byte数组的GDI实战
2026/10/12 18:36:07 网站建设 项目流程

1. 为什么要在内存里折腾Bitmap

VB6这个老家伙,做桌面工具确实顺手,但一碰到图像处理就容易让人抓狂。尤其是当你需要把一张图传给别的模块、塞进数据库、走网络接口,或者干脆只是不想在硬盘上留一堆临时文件的时候,SavePicture这种直接写盘的方法就显得特别碍事。标题里说的“在内存中生成bitmap图像,并将其保存为byte()数组”,本质上就是解决这个痛点的:图像数据全程不落地,从创建到序列化都在内存里完成。

这个需求在实际项目里出现的频率其实很高。比如你写一个报表工具,需要动态生成一张带水印的缩略图,然后直接通过HTTP POST发给服务端;或者你做一个数据采集程序,要把摄像头抓到的帧转成字节流存进数据库的BLOB字段;再或者你写一个ActiveX DLL,对外只暴露一个返回Byte()的函数,调用方拿到字节后自己决定怎么处理。这些场景的共同点就是:中间不能有文件系统参与。

适合看这篇内容的人,大概分三类。第一类是还在维护VB6老系统的开发者,手头有一堆遗留代码,突然接到需求要加图像导出功能。第二类是做工业控制或仪器对接的,VB6写的上位机需要把波形图、截图直接传给后台。第三类纯粹是对Windows GDI和VB6底层机制感兴趣,想搞清楚StdPicture、IPicture、SavePicture这些玩意儿到底在背后干了什么。不管你是哪一类,下面这些内容都是从实际项目里一点点试出来的,不是抄MSDN就能搞定的。

2. 核心思路与方案选型

2.1 为什么不用SavePicture直接写文件

VB6自带的SavePicture函数用起来确实简单,一行代码就能把PictureBox里的内容存成BMP。但它的硬伤也很明显:必须指定一个文件路径。这意味着你得先写一个临时文件,再用Open语句读成字节数组,最后还得记得删掉临时文件。这一来一回,磁盘I/O不说,临时文件的管理就是个坑。程序崩溃了临时文件残留,多线程环境下文件名冲突,权限不足导致写入失败,这些问题在真实项目里都遇到过。

更关键的是,SavePicture保存出来的BMP格式是未压缩的,一张800x600的24位图就要占1.4MB左右。如果你只是想把图像传给另一个进程,这个体积在内存里复制来复制去,效率很低。所以我们需要一个更直接的办法:用GDI在内存DC里画好图,然后直接把像素数据读出来。

2.2 内存DC加DIB的经典组合

Windows GDI提供了一套完整的机制来做这件事。核心思路是:创建一个内存设备上下文(Memory DC),选入一个设备无关位图(DIB),然后在上面随便画。画完之后,DIB的像素数据就在一块连续的内存里,你只需要把这块内存拷贝到Byte()数组就行。

这里涉及几个关键API:

  • CreateCompatibleDC:创建一个与目标DC兼容的内存DC。
  • CreateDIBSection:创建一个DIB,并且返回一个指向像素数据的指针。这个函数比CreateCompatibleBitmap好用,因为它直接给你内存地址。
  • SelectObject:把DIB选进内存DC。
  • BitBlt或StretchBlt:把源图像画到内存DC上。
  • CopyMemory:把像素数据从指针拷贝到Byte()数组。

这套流程在C++里很常见,但在VB6里需要自己声明API,而且要注意指针和数组的转换。下面会一步步拆开讲。

2.3 为什么选择DIB而不是DDB

DDB是设备相关位图,它的像素格式依赖于当前显示设备,你没法直接访问像素内存。DIB是设备无关位图,它的格式由BITMAPINFO结构体定义,你可以指定24位、32位、8位等。更重要的是,CreateDIBSection会返回一个ppvBits指针,直接指向像素数据。这意味着你不需要用GetDIBits再拷贝一次,效率更高。

还有一个好处:DIB的内存布局是自下而上的,第一行像素在内存的最后。这个特性在拷贝数据时要注意,但同时也方便你做翻转操作。如果你需要自上而下的布局,可以把BITMAPINFOHEADER的biHeight设为负数,这样第一行就在内存开头了。

3. 核心API声明与结构体定义

3.1 必须声明的GDI函数

在VB6里用API,第一步就是声明。下面这些声明是经过实际项目验证的,可以直接抄。

Private Declare Function CreateCompatibleDC Lib "gdi32" (ByVal hdc As Long) As Long Private Declare Function CreateDIBSection Lib "gdi32" (ByVal hdc As Long, pBitmapInfo As BITMAPINFO, ByVal un As Long, ByRef lplpVoid As Long, ByVal handle As Long, ByVal dw As Long) As Long Private Declare Function SelectObject Lib "gdi32" (ByVal hdc As Long, ByVal hObject As Long) As Long Private Declare Function DeleteObject Lib "gdi32" (ByVal hObject As Long) As Long Private Declare Function DeleteDC Lib "gdi32" (ByVal hdc As Long) As Long Private Declare Function BitBlt Lib "gdi32" (ByVal hDestDC As Long, ByVal x As Long, ByVal y As Long, ByVal nWidth As Long, ByVal nHeight As Long, ByVal hSrcDC As Long, ByVal xSrc As Long, ByVal ySrc As Long, ByVal dwRop As Long) As Long Private Declare Sub CopyMemory Lib "kernel32" Alias "RtlMoveMemory" (Destination As Any, Source As Any, ByVal Length As Long)

注意CreateDIBSection的第三个参数un,它指定颜色使用方式,通常传DIB_RGB_COLORS,值为0。第五个参数handle传0,第六个参数dw也传0。

3.2 BITMAPINFO结构体的坑

BITMAPINFO结构体在VB6里需要自己定义。它包含一个BITMAPINFOHEADER和一个颜色表。对于24位或32位图,颜色表可以为空,但结构体大小要算对。

Private Type BITMAPINFOHEADER biSize As Long biWidth As Long biHeight As Long biPlanes As Integer biBitCount As Integer biCompression As Long biSizeImage As Long biXPelsPerMeter As Long biYPelsPerMeter As Long biClrUsed As Long biClrImportant As Long End Type Private Type BITMAPINFO bmiHeader As BITMAPINFOHEADER bmiColors As Long End Type

这里有个细节:bmiColors我定义成Long,只占4个字节。对于24位和32位图,不需要颜色表,所以这样定义没问题。但如果你要处理8位或4位图,就需要定义一个真正的颜色数组,结构体大小也要相应调整。实际项目里,我一般只处理24位和32位,所以这个简化版够用了。

注意:BITMAPINFOHEADER的biSize必须设为40,这是结构体本身的大小,不是整个BITMAPINFO的大小。这个值设错了,CreateDIBSection会直接失败。

3.3 像素格式与行对齐

24位图的像素格式是BGR,每个像素3个字节,顺序是蓝、绿、红。32位图是BGRA,多一个Alpha通道。这里有个很容易踩的坑:每行像素的字节数必须是4的倍数。比如宽度是3的24位图,每行理论上是9字节,但实际会补齐到12字节。这个补齐的规则叫“行对齐”或“步幅对齐”。

计算每行实际字节数的公式是:

Stride = ((Width * BitCount + 31) \ 32) * 4

对于24位图,BitCount是24,公式简化为((Width * 24 + 31) \ 32) * 4。举个例子,宽度100,100 * 24 = 2400,2400 + 31 = 2431,2431 \ 32 = 75,75 * 4 = 300。而100 * 3 = 300,刚好是4的倍数,所以没有补齐。但如果宽度是101,101 * 3 = 303,补齐后是304。这个差异在拷贝数据时必须考虑,否则图像会错位。

4. 完整实现步骤与代码

4.1 从PictureBox到Byte数组

假设你有一个PictureBox叫picSource,里面已经加载了一张图。现在要把它转成Byte()数组。步骤如下:

  1. 获取源图的宽度和高度。
  2. 计算步幅和总字节数。
  3. 创建内存DC。
  4. 填充BITMAPINFO结构体。
  5. 调用CreateDIBSection创建DIB,拿到像素指针。
  6. 把DIB选进内存DC。
  7. 用BitBlt把picSource的内容画到内存DC。
  8. 用CopyMemory把像素数据拷到Byte()数组。
  9. 清理GDI对象。

下面是完整的函数代码:

Public Function PictureToByteArray(ByRef pic As PictureBox) As Byte() Dim hMemDC As Long Dim hDIB As Long Dim pBits As Long Dim bmi As BITMAPINFO Dim stride As Long Dim totalBytes As Long Dim result() As Byte Dim oldObj As Long Dim w As Long Dim h As Long w = pic.ScaleWidth h = pic.ScaleHeight ' 计算步幅,24位图 stride = ((w * 24 + 31) \ 32) * 4 totalBytes = stride * h ' 填充BITMAPINFO With bmi.bmiHeader .biSize = 40 .biWidth = w .biHeight = h ' 正数表示自下而上 .biPlanes = 1 .biBitCount = 24 .biCompression = 0 ' BI_RGB .biSizeImage = totalBytes .biXPelsPerMeter = 0 .biYPelsPerMeter = 0 .biClrUsed = 0 .biClrImportant = 0 End With ' 创建内存DC hMemDC = CreateCompatibleDC(pic.hdc) ' 创建DIB hDIB = CreateDIBSection(hMemDC, bmi, 0, pBits, 0, 0) ' 选入DIB oldObj = SelectObject(hMemDC, hDIB) ' 拷贝图像 BitBlt hMemDC, 0, 0, w, h, pic.hdc, 0, 0, vbSrcCopy ' 分配数组 ReDim result(0 To totalBytes - 1) ' 拷贝像素数据 CopyMemory result(0), ByVal pBits, totalBytes ' 清理 SelectObject hMemDC, oldObj DeleteObject hDIB DeleteDC hMemDC PictureToByteArray = result End Function

这段代码里有个关键点:CopyMemory的第二个参数用了ByVal pBits。pBits是一个Long类型的指针,ByVal告诉VB6传递指针本身的值,而不是指针变量的地址。如果写成ByRef或者不加ByVal,拷出来的就是垃圾数据。

4.2 从零生成一张Bitmap

有时候你不需要从PictureBox拷贝,而是想直接生成一张图,比如画个矩形、写点文字。这时候流程稍微不同:你需要在内存DC上直接用GDI绘图函数画,画完再拷贝像素。

Public Function GenerateBitmapToByteArray(ByVal w As Long, ByVal h As Long) As Byte() Dim hMemDC As Long Dim hDIB As Long Dim pBits As Long Dim bmi As BITMAPINFO Dim stride As Long Dim totalBytes As Long Dim result() As Byte Dim oldObj As Long stride = ((w * 24 + 31) \ 32) * 4 totalBytes = stride * h With bmi.bmiHeader .biSize = 40 .biWidth = w .biHeight = h .biPlanes = 1 .biBitCount = 24 .biCompression = 0 .biSizeImage = totalBytes End With hMemDC = CreateCompatibleDC(0) ' 传0也可以,但最好传屏幕DC hDIB = CreateDIBSection(hMemDC, bmi, 0, pBits, 0, 0) oldObj = SelectObject(hMemDC, hDIB) ' 在这里画图 ' 比如填充白色背景 Dim hBrush As Long Dim rect As RECT rect.Left = 0 rect.Top = 0 rect.Right = w rect.Bottom = h hBrush = CreateSolidBrush(RGB(255, 255, 255)) FillRect hMemDC, rect, hBrush DeleteObject hBrush ' 画个红色矩形 hBrush = CreateSolidBrush(RGB(255, 0, 0)) rect.Left = 10 rect.Top = 10 rect.Right = w - 10 rect.Bottom = h - 10 FillRect hMemDC, rect, hBrush DeleteObject hBrush ReDim result(0 To totalBytes - 1) CopyMemory result(0), ByVal pBits, totalBytes SelectObject hMemDC, oldObj DeleteObject hDIB DeleteDC hMemDC GenerateBitmapToByteArray = result End Function

这里用到了FillRect和CreateSolidBrush,需要额外声明。RECT结构体也要定义。这些在VB6的API浏览器里都能找到,但要注意FillRect的第二个参数是RECT结构体,必须用ByRef传递。

4.3 处理32位带Alpha通道的情况

如果你需要透明度,就得用32位图。32位图的像素格式是BGRA,每个像素4个字节。步幅计算更简单,因为每个像素刚好4字节,天然对齐。

stride = w * 4 totalBytes = stride * h

BITMAPINFOHEADER的biBitCount设为32。拷贝出来的数据里,每个像素的第四个字节就是Alpha值。但要注意,GDI的绘图函数通常不处理Alpha通道,画上去的Alpha值可能是0。如果你需要正确的Alpha,得手动填充或者用GDI+。

实操心得:32位图在内存里占的空间比24位大三分之一,如果不需要透明度,尽量用24位。另外,有些打印机驱动或图像处理库对32位BMP的支持不好,传过去可能显示异常。

5. 常见问题与排查技巧

5.1 图像倒置怎么办

前面说过,DIB默认是自下而上的,第一行像素在内存最后。如果你把Byte()数组直接传给一个期望自上而下布局的库,图像就会上下颠倒。解决办法有两个:一是把biHeight设为负数,这样DIB就是自上而下的;二是在拷贝完数据后手动翻转行。

设负数的方法:

.biHeight = -h

但要注意,有些GDI函数对负高度的DIB支持不好,比如BitBlt可能画不出来。我一般用第二种方法,手动翻转:

Dim rowBytes As Long Dim i As Long Dim temp() As Byte rowBytes = stride ReDim temp(0 To rowBytes - 1) For i = 0 To h \ 2 - 1 CopyMemory temp(0), result(i * rowBytes), rowBytes CopyMemory result(i * rowBytes), result((h - 1 - i) * rowBytes), rowBytes CopyMemory result((h - 1 - i) * rowBytes), temp(0), rowBytes Next i

这段代码把第i行和第h-1-i行交换,循环到一半就行。

5.2 内存泄漏怎么查

GDI对象泄漏是VB6图像处理里最常见的问题。每创建一个DC、一个DIB、一个Brush,都要记得删除。我见过一个项目,每次生成缩略图都泄漏一个DC,跑了几百次之后程序直接卡死。

排查方法很简单:在任务管理器里打开“GDI对象”列,观察程序运行过程中这个数字是否持续增长。如果是,那就是有泄漏。常见的泄漏点包括:

  • CreateCompatibleDC之后忘记DeleteDC。
  • CreateDIBSection之后忘记DeleteObject。
  • SelectObject选入新对象后,忘记把旧对象选回来再删除。
  • CreateSolidBrush之后忘记DeleteObject。

注意:DeleteObject不能删除正在被DC选中的对象。必须先SelectObject把旧对象选回来,再删除新对象。

5.3 大图导致内存不足

VB6是32位程序,进程地址空间有限。如果你要处理一张4000x3000的24位图,光像素数据就36MB,再加上VB6数组本身的开销和GDI的内部结构,很容易触发“内存不足”错误。解决办法有几个:

  • 分块处理,每次只处理图像的一部分。
  • 用StretchBlt先缩小再处理。
  • 如果只是传输,考虑用JPEG压缩后再转字节数组。

我试过用StretchBlt把大图缩到800x600再转字节,内存占用直接降到十分之一,传输速度也快了很多。具体做法是在BitBlt的位置换成:

StretchBlt hMemDC, 0, 0, dstW, dstH, pic.hdc, 0, 0, srcW, srcH, vbSrcCopy

StretchBlt需要额外声明,参数比BitBlt多两个目标宽高。

5.4 颜色偏差问题

有时候拷出来的字节数组,用其他工具打开发现颜色不对,比如红色和蓝色互换了。这是因为BMP格式的像素顺序是BGR,而有些图像库期望RGB。解决办法是在拷贝完数据后,把每个像素的第0字节和第2字节交换。

Dim i As Long Dim temp As Byte For i = 0 To totalBytes - 1 Step 3 temp = result(i) result(i) = result(i + 2) result(i + 2) = temp Next i

这个操作会遍历所有像素,大图会比较慢。如果性能要求高,可以在创建DIB时用biCompression设为BI_BITFIELDS,然后指定红绿蓝的掩码,但那样更复杂。实际项目里,如果下游能接受BGR,就尽量不交换。

6. 性能优化与扩展思路

6.1 避免频繁创建DC

如果你的程序需要连续生成多张图,比如视频帧序列,每次都CreateCompatibleDC和CreateDIBSection开销很大。更好的做法是创建一个内存DC和DIB,反复使用。每次只需要BitBlt新内容,然后拷贝数据。

' 初始化时 hMemDC = CreateCompatibleDC(0) hDIB = CreateDIBSection(...) SelectObject hMemDC, hDIB ' 每帧 BitBlt hMemDC, 0, 0, w, h, srcDC, 0, 0, vbSrcCopy CopyMemory result(0), ByVal pBits, totalBytes ' 结束时 DeleteObject hDIB DeleteDC hMemDC

这样能把每帧的处理时间从几毫秒降到几十微秒。我在一个每秒30帧的采集程序里用过这个办法,CPU占用从40%降到了5%以下。

6.2 直接操作像素内存

拿到pBits指针后,你其实可以直接读写像素,而不需要先拷贝到Byte()数组。比如你要把图像二值化,可以遍历pBits指向的内存,根据亮度把像素设成黑或白。这样省去了一次拷贝,速度更快。

Dim p As Long Dim x As Long, y As Long Dim offset As Long Dim b As Byte, g As Byte, r As Byte Dim gray As Byte For y = 0 To h - 1 For x = 0 To w - 1 offset = y * stride + x * 3 CopyMemory b, ByVal pBits + offset, 1 CopyMemory g, ByVal pBits + offset + 1, 1 CopyMemory r, ByVal pBits + offset + 2, 1 gray = (b + g + r) \ 3 If gray > 128 Then b = 255: g = 255: r = 255 Else b = 0: g = 0: r = 0 End If CopyMemory ByVal pBits + offset, b, 1 CopyMemory ByVal pBits + offset + 1, g, 1 CopyMemory ByVal pBits + offset + 2, r, 1 Next x Next y

这段代码直接修改DIB内存,改完之后再拷贝到数组。注意CopyMemory的ByVal pBits + offset写法,这是VB6里指针运算的常用技巧。

6.3 与数据库BLOB字段对接

把Byte()数组存进数据库的BLOB字段,不同数据库驱动写法不同。以ADO为例,可以用AppendChunk方法:

Dim rs As ADODB.Recordset Dim stream As ADODB.Stream Set stream = New ADODB.Stream stream.Type = adTypeBinary stream.Open stream.Write byteArray stream.Position = 0 rs.AddNew rs("ImageData").Value = stream.Read rs.Update

从数据库读回来的时候,用Stream对象读成字节数组,再还原成图像。还原的方法和生成相反:创建一个DIB,把字节数组拷到pBits,然后用BitBlt画到PictureBox。

6.4 跨进程传输的注意事项

如果你要把字节数组通过COM或网络传给另一个进程,要注意字节数组的封送方式。VB6的Byte()数组在COM里是SAFEARRAY,可以直接传。但如果对方是C++程序,可能需要指定数组的维度。另外,大数组传输时最好分块,避免一次性占用太多内存。

实操心得:我在一个项目里把图像字节数组通过Socket发给服务端,一开始每次发几MB,经常超时。后来改成先压缩成JPEG再发,体积降到几百KB,稳定多了。压缩可以用GDI+的EncoderParameters,但VB6调用GDI+比较麻烦,需要自己声明一堆API。如果不想折腾,可以用第三方控件,或者干脆用SavePicture存成JPEG文件再读成字节,虽然落了地,但胜在简单可靠。

7. 几个容易忽略的细节

7.1 ScaleWidth和Width的区别

PictureBox的Width属性是外部尺寸,包含边框。ScaleWidth是内部可用宽度,不包含边框。如果你用Width去创建DIB,画出来的图右边会多出一条黑边。正确的做法是用ScaleWidth和ScaleHeight。

7.2 AutoRedraw的影响

如果PictureBox的AutoRedraw设为False,hdc属性可能无效,BitBlt会失败。解决办法是在拷贝之前先把AutoRedraw设为True,或者用Picture属性拿到StdPicture对象,再通过IPicture接口获取DC。我一般直接设AutoRedraw = True,省事。

7.3 颜色深度转换

如果你的源图是16位或8位,直接BitBlt到24位DIB上,GDI会自动做颜色转换。但转换质量取决于调色板,可能和原图有偏差。如果对颜色要求高,最好先把源图转成24位再处理。

7.4 资源释放的顺序

清理GDI对象时,顺序很重要。正确的顺序是:

  1. 把旧对象选回DC:SelectObject hMemDC, oldObj
  2. 删除DIB:DeleteObject hDIB
  3. 删除DC:DeleteDC hMemDC

如果先删DC再删DIB,DIB可能删不掉,造成泄漏。这个顺序在MSDN里没写清楚,但实际测试下来必须这样。

8. 实际项目中的取舍

这套方案我在几个项目里都用过,有成功的经验,也有踩过的坑。最深的体会是:不要为了追求“纯内存”而把代码搞得过于复杂。如果项目对性能要求不高,用SavePicture写临时文件再读回来,代码量少一半,维护起来也简单。只有在确实不能落地、或者性能瓶颈明显的时候,才值得上这套GDI内存操作。

另外,VB6的API声明很容易出错,尤其是CreateDIBSection的参数。建议先用一个简单的测试程序验证,确认能拿到正确的像素数据,再集成到主项目里。测试的时候可以用一张纯色图,比如全红,然后检查字节数组里是不是00 00 FF重复,这样能快速定位问题。

最后,如果你发现代码在XP上跑得好好的,到Win10上就出问题,大概率是DPI缩放导致的。Win10默认会对非DPI感知的程序做缩放,ScaleWidth返回的可能是缩放后的值。解决办法是在程序清单里声明DPI感知,或者用GetDeviceCaps获取真实的屏幕DPI再做换算。这个坑我踩过两次,每次都是图像模糊或者尺寸不对,查了半天才发现是DPI的问题。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询