VB6内存中生成Bitmap并保存为Byte数组的GDI实战 1. 为什么要在内存里折腾BitmapVB6这个老家伙做桌面工具确实顺手但一碰到图像处理就容易让人抓狂。尤其是当你需要把一张图传给别的模块、塞进数据库、走网络接口或者干脆只是不想在硬盘上留一堆临时文件的时候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()数组就行。这里涉及几个关键APICreateCompatibleDC创建一个与目标DC兼容的内存DC。CreateDIBSection创建一个DIB并且返回一个指向像素数据的指针。这个函数比CreateCompatibleBitmap好用因为它直接给你内存地址。SelectObject把DIB选进内存DC。BitBlt或StretchBlt把源图像画到内存DC上。CopyMemory把像素数据从指针拷贝到Byte()数组。这套流程在C里很常见但在VB6里需要自己声明API而且要注意指针和数组的转换。下面会一步步拆开讲。2.3 为什么选择DIB而不是DDBDDB是设备相关位图它的像素格式依赖于当前显示设备你没法直接访问像素内存。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。举个例子宽度100100 * 24 24002400 31 24312431 \ 32 7575 * 4 300。而100 * 3 300刚好是4的倍数所以没有补齐。但如果宽度是101101 * 3 303补齐后是304。这个差异在拷贝数据时必须考虑否则图像会错位。4. 完整实现步骤与代码4.1 从PictureBox到Byte数组假设你有一个PictureBox叫picSource里面已经加载了一张图。现在要把它转成Byte()数组。步骤如下获取源图的宽度和高度。计算步幅和总字节数。创建内存DC。填充BITMAPINFO结构体。调用CreateDIBSection创建DIB拿到像素指针。把DIB选进内存DC。用BitBlt把picSource的内容画到内存DC。用CopyMemory把像素数据拷到Byte()数组。清理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 * hBITMAPINFOHEADER的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, vbSrcCopyStretchBlt需要额外声明参数比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设为Falsehdc属性可能无效BitBlt会失败。解决办法是在拷贝之前先把AutoRedraw设为True或者用Picture属性拿到StdPicture对象再通过IPicture接口获取DC。我一般直接设AutoRedraw True省事。7.3 颜色深度转换如果你的源图是16位或8位直接BitBlt到24位DIB上GDI会自动做颜色转换。但转换质量取决于调色板可能和原图有偏差。如果对颜色要求高最好先把源图转成24位再处理。7.4 资源释放的顺序清理GDI对象时顺序很重要。正确的顺序是把旧对象选回DCSelectObject hMemDC, oldObj删除DIBDeleteObject hDIB删除DCDeleteDC hMemDC如果先删DC再删DIBDIB可能删不掉造成泄漏。这个顺序在MSDN里没写清楚但实际测试下来必须这样。8. 实际项目中的取舍这套方案我在几个项目里都用过有成功的经验也有踩过的坑。最深的体会是不要为了追求“纯内存”而把代码搞得过于复杂。如果项目对性能要求不高用SavePicture写临时文件再读回来代码量少一半维护起来也简单。只有在确实不能落地、或者性能瓶颈明显的时候才值得上这套GDI内存操作。另外VB6的API声明很容易出错尤其是CreateDIBSection的参数。建议先用一个简单的测试程序验证确认能拿到正确的像素数据再集成到主项目里。测试的时候可以用一张纯色图比如全红然后检查字节数组里是不是00 00 FF重复这样能快速定位问题。最后如果你发现代码在XP上跑得好好的到Win10上就出问题大概率是DPI缩放导致的。Win10默认会对非DPI感知的程序做缩放ScaleWidth返回的可能是缩放后的值。解决办法是在程序清单里声明DPI感知或者用GetDeviceCaps获取真实的屏幕DPI再做换算。这个坑我踩过两次每次都是图像模糊或者尺寸不对查了半天才发现是DPI的问题。