1. 为什么要在内存里折腾Bitmap而不是直接存文件
做过VB6老项目的人多半遇到过这种场景:上位机采集了一批图像数据,需要先做一轮预处理,再通过串口、Socket或者数据库传给别的模块。如果每一步都往硬盘上写临时文件,再读回来,整个流程会变得又慢又脏——磁盘IO拖后腿不说,临时文件的管理、清理、并发冲突都是麻烦事。这时候把Bitmap直接生成在内存里,再转成byte()数组往下游传,就是最干净的做法。
这个思路的核心价值在于把图像当成一段二进制数据流来处理,而不是当成一个"文件"。一旦Bitmap变成了byte()数组,它就可以像普通字节流一样被压缩、加密、切片、塞进消息队列,甚至直接写进数据库的BLOB字段。对于VB6这种没有原生图像内存流支持的语言来说,掌握这套手法基本等于打通了图像处理的任督二脉。
这篇文章适合三类人看:一是维护VB6遗留系统、需要做图像数据管道的老手;二是从VB6往现代语言迁移、想理解底层图像内存布局的开发者;三是单纯好奇"一张图在内存里到底长什么样"的技术爱好者。我会从GDI+和GDI两条路线分别拆解,把每一步的坑都摊开讲,代码可以直接抄。
需要先说明一点:VB6本身没有内置的Bitmap内存操作API,我们得借助系统提供的图形接口。主流有两条路——GDI+(通过gdiplus.dll)和传统GDI(通过gdi32.dll)。前者功能现代、支持多种格式,后者轻量、兼容性极好。选哪条取决于你的运行环境和格式需求,后面会详细对比。
2. 两条技术路线的选型逻辑与底层差异
2.1 GDI+路线:功能全但依赖组件
GDI+是随系统提供的一套图形接口,VB6可以通过声明GdiplusStartup、GdipCreateBitmapFromScan0、GdipSaveImageToStream等函数来调用。它的最大优势是原生支持PNG、JPEG、GIF等多种编码格式,而且能直接往内存流里写,不需要经过临时文件。
走GDI+路线的典型流程是这样的:先调用GdiplusStartup初始化,拿到一个token;然后用GdipCreateBitmapFromScan0创建一张指定宽高和像素格式的位图;接着用GdipGetImageGraphicsContext拿到绘图上下文,往上面画东西;最后用GdipSaveImageToStream把位图编码进一个内存流,再从这个流里把字节读出来。
听起来顺理成章,但实际操作中有几个硬骨头。第一,GDI+的API全是C风格的,参数多、指针多,VB6里声明起来容易出错。第二,IStream接口在VB6里没有原生实现,你得自己写一个实现IStream的类,或者借助CreateStreamOnHGlobal这个系统函数来造一个内存流。第三,GDI+的初始化必须在所有GDI+调用之前完成,而且程序退出时要记得GdiplusShutdown,否则会有资源泄漏。
2.2 传统GDI路线:轻量但格式受限
传统GDI走的是另一条路。核心思路是用CreateCompatibleDC创建一个内存设备上下文,用CreateDIBSection创建一张DIB(设备无关位图),拿到指向像素数据的指针,直接往那块内存里写像素,最后用GetDIBits或者直接读指针把数据拷出来。
这条路线的好处是不依赖GDI+组件,兼容性极好,从很老的系统到最新的系统都能跑。而且因为直接操作像素内存,速度非常快,适合做大批量的图像处理。缺点也很明显:它只能生成原始的BMP格式数据,没有压缩,文件体积大;如果要转成PNG或JPEG,还得自己实现编码器或者再走一趟GDI+。
2.3 选型对照表
| 对比维度 | GDI+路线 | 传统GDI路线 |
|---|---|---|
| 支持格式 | PNG/JPEG/GIF/BMP等 | 仅BMP原始数据 |
| 依赖组件 | gdiplus.dll | gdi32.dll |
| 内存流支持 | 需自实现IStream | 直接操作像素指针 |
| 执行速度 | 中等(有编码开销) | 极快(无编码) |
| 代码复杂度 | 高(指针和接口多) | 中(结构体较多) |
| 适用场景 | 需要压缩格式传输 | 内部原始数据处理 |
我的建议是:如果下游需要的是标准图片格式(比如要存成PNG发给前端),走GDI+;如果只是内部模块之间传原始像素做算法处理,传统GDI更省事。下面两章分别把两条路线的完整实现拆开讲。
3. GDI+路线:从初始化到字节数组的完整链路
3.1 初始化与内存流的搭建
GDI+的所有调用都必须建立在初始化成功的基础上。VB6里声明GdiplusStartup需要传入一个GdiplusStartupInput结构体,这个结构体的版本字段必须填对,否则会返回错误。实测下来,GdiplusStartupInput的GdiplusVersion填1是稳妥的,DebugEventCallback和SuppressBackgroundThread都填0即可。
Private Type GdiplusStartupInput GdiplusVersion As Long DebugEventCallback As Long SuppressBackgroundThread As Long SuppressExternalCodecs As Long End Type Private Declare Function GdiplusStartup Lib "gdiplus" ( _ ByRef token As Long, _ ByRef inputBuf As GdiplusStartupInput, _ ByVal outputBuf As Long) As Long初始化之后,下一步是造一个内存流。VB6没有现成的IStream实现,但系统提供了CreateStreamOnHGlobal,可以基于一块全局内存创建一个流对象。这个函数的妙处在于,你传hGlobal为0,它会自动分配一块内存;等流写完之后,用GetHGlobalFromStream把内存句柄取回来,再用GlobalLock拿到指针,就能把字节读出来了。
Private Declare Function CreateStreamOnHGlobal Lib "ole32" ( _ ByVal hGlobal As Long, _ ByVal fDeleteOnRelease As Long, _ ByRef ppstm As Long) As Long Private Declare Function GetHGlobalFromStream Lib "ole32" ( _ ByVal pstm As Long, _ ByRef phglobal As Long) As Long这里有个关键细节:fDeleteOnRelease参数要传True(在VB6里是-1),这样流对象释放时会自动清理内存,避免泄漏。如果你传了False,就得自己管内存,很容易忘。
3.2 创建Bitmap并绘制内容
有了流之后,用GdipCreateBitmapFromScan0创建位图。这个函数的参数依次是宽度、高度、跨距(stride)、像素格式、扫描线指针、位图句柄输出。跨距传0表示让GDI+自动计算,像素格式常用PixelFormat32bppARGB(值为&H26200A)。
Private Declare Function GdipCreateBitmapFromScan0 Lib "gdiplus" ( _ ByVal width As Long, _ ByVal height As Long, _ ByVal stride As Long, _ ByVal pixelFormat As Long, _ ByVal scan0 As Long, _ ByRef bitmap As Long) As Long创建完位图后,用GdipGetImageGraphicsContext拿到绘图上下文,就可以像在窗体上画图一样往上面画线、画矩形、写文字了。这里要注意,GDI+的坐标系统原点在左上角,和VB6的默认坐标系一致,不需要做翻转。
绘制完成后,用GdipSaveImageToStream把位图编码进之前创建的内存流。这个函数需要传入编码器的CLSID,获取CLSID要用GdipGetImageEncoders遍历所有编码器,找到对应MIME类型的那个。PNG的MIME是image/png,JPEG是image/jpeg。
3.3 从内存流提取字节数组
编码完成后,流的位置在末尾,需要先Seek回开头。然后调用GetHGlobalFromStream拿到内存句柄,用GlobalLock锁定,再用CopyMemory把数据拷进byte()数组。数组的大小可以通过GlobalSize获取。
Dim hGlobal As Long Dim pData As Long Dim nSize As Long Dim bytes() As Byte GetHGlobalFromStream pStream, hGlobal nSize = GlobalSize(hGlobal) pData = GlobalLock(hGlobal) ReDim bytes(0 To nSize - 1) CopyMemory bytes(0), ByVal pData, nSize GlobalUnlock hGlobal这一步最容易踩的坑是忘记Seek。如果流指针停在末尾,GetHGlobalFromStream拿到的内存句柄虽然是对的,但后续读取会从错误位置开始,导致字节数组内容错乱。我当初调试这个问题花了整整一个下午,最后发现就是少了一行Seek。
注意:GDI+的编码器CLSID获取是一次性的,建议在程序启动时缓存起来,不要每次保存都遍历一遍,那样性能损耗很大。
4. 传统GDI路线:直接操作像素内存的硬核玩法
4.1 DIB Section的创建与像素指针获取
传统GDI路线的核心是CreateDIBSection。这个函数会创建一张DIB,并返回一个指向像素数据的指针。你可以直接往这块内存里写RGB值,写完再用GetDIBits或者直接读指针把数据拷出来。
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 Private Declare Function CreateDIBSection Lib "gdi32" ( _ ByVal hdc As Long, _ ByRef pbmi As BITMAPINFO, _ ByVal iUsage As Long, _ ByRef ppvBits As Long, _ ByVal hSection As Long, _ ByVal dwOffset As Long) As Long创建时,biBitCount填32表示32位色,每个像素4字节(BGRA顺序)。biHeight填正数表示自下而上的位图,填负数表示自上而下。如果你希望像素数组的顺序和屏幕显示一致,建议填负数,这样第一行就是图像顶部。
4.2 像素数据的写入与格式转换
拿到ppvBits指针后,就可以用CopyMemory往里面写像素了。假设你要生成一张纯色图,可以构造一个byte()数组,按BGRA顺序填充,然后整块拷进去。
Dim pixels() As Byte Dim i As Long ReDim pixels(0 To width * height * 4 - 1) For i = 0 To width * height - 1 pixels(i * 4) = 0 ' B pixels(i * 4 + 1) = 128 ' G pixels(i * 4 + 2) = 255 ' R pixels(i * 4 + 3) = 255 ' A Next i CopyMemory ByVal ppvBits, pixels(0), width * height * 4这里有个性能技巧:不要一个像素一个像素地写指针,那样在VB6里开销极大。正确做法是先在自己的byte()数组里把整张图拼好,然后一次性CopyMemory过去。实测下来,对于一张1024x768的图,逐像素写要几百毫秒,整块拷贝只要几毫秒,差距是数量级的。
4.3 把DIB数据转成byte()数组
DIB的像素数据本身就是一段连续的字节,所以转byte()数组非常直接。如果你要的是完整的BMP文件格式(带文件头和信息头),需要自己拼一个BITMAPFILEHEADER在前面。如果只是要原始像素数据,直接从ppvBits拷出来就行。
Dim rawBytes() As Byte ReDim rawBytes(0 To width * height * 4 - 1) CopyMemory rawBytes(0), ByVal ppvBits, width * height * 4需要注意的是,DIB Section创建后要记得用DeleteObject释放,DC也要用DeleteDC释放。VB6没有RAII机制,这些资源全靠手动管理,漏一个就会泄漏。我习惯在函数开头就把所有需要释放的句柄声明好,在Exit Function之前统一释放,避免中途Exit导致遗漏。
提示:如果你的图像要跨模块传递,建议在
byte()数组前面加一个自定义的小头部,记录宽度、高度、像素格式等信息,这样接收方才知道怎么解析。这个头部可以是固定16字节,前4字节宽度,接着4字节高度,再4字节格式码,最后4字节保留。
5. 踩过的坑与排查链路实录
5.1 字节数组总是比预期大一圈
第一次做这个功能时,我发现生成的byte()数组比理论值大了几十个字节。排查过程是这样的:先算理论值,宽度乘以高度乘以4,得到像素数据大小;然后打印实际数组大小,发现多出来的部分正好是BMP文件头(14字节)加信息头(40字节)再加可能的调色板。原来是我在读取时把整个BMP文件都读进来了,而不是只读像素数据。
这个问题的根因在于没有区分"BMP文件"和"DIB像素数据"。BMP文件是带文件头的,DIB是纯像素。如果你用GdipSaveImageToStream保存成BMP,得到的是完整文件;如果你用CreateDIBSection,得到的是纯像素。两者差了一个文件头,解析时要注意区分。
5.2 GDI+初始化失败返回错误码
有次在某个精简版系统上跑,GdiplusStartup一直返回错误码。查了半天发现是GdiplusStartupInput结构体的GdiplusVersion字段没填对。VB6里结构体默认是0,而GDI+要求必须是1。这个坑很隐蔽,因为错误码不会告诉你具体是哪个字段的问题。
解决办法是在声明结构体后,显式给GdiplusVersion赋值1。另外,GdiplusStartup的第三个参数outputBuf传0即可,不需要分配内存。
5.3 内存流读取位置错误导致数据错乱
前面提过,GdipSaveImageToStream写完后流指针在末尾,必须Seek回0再读。我当时的排查方法是:先把流里的数据写到一个临时文件,用十六进制编辑器打开看,发现文件头是对的,但内容全是乱的。后来意识到是读取位置问题,加了Seek之后一切正常。
这个坑的教训是:凡是涉及流操作,写完一定要复位指针。不管是文件流、内存流还是网络流,这个规则都适用。
5.4 像素格式不匹配导致颜色错位
用传统GDI路线时,我一开始把biBitCount设成24,结果读出来的数据每3字节一个像素,但我的数组是按4字节分配的,导致颜色全部错位。后来改成32位,问题消失。
24位和32位的区别在于:24位没有Alpha通道,每像素3字节;32位有Alpha通道,每像素4字节。如果你不需要透明度,24位更省内存;如果需要,必须用32位。但VB6里处理24位数据比较麻烦,因为跨距可能不是3的整数倍,需要做对齐处理。所以除非有特殊需求,我建议统一用32位。
6. 性能优化与跨模块传递的实战建议
6.1 批量处理时的内存复用策略
如果你要连续生成多张图,不要每次都重新创建DC和DIB Section。正确的做法是复用同一个DC和DIB,只清空像素数据重新写。这样可以省掉大量的创建和销毁开销。
具体做法是:在模块级声明DC和DIB句柄,在第一次调用时创建,后续调用直接复用。程序退出时再统一释放。实测下来,复用方案比每次重建快3到5倍,尤其是在小图高频生成的场景下。
6.2 字节数组的压缩与传输
原始像素数据体积很大,一张1024x768的32位图要3MB。如果要在网络上传输,建议先压缩。VB6里可以用Compress相关的API,或者走GDI+编码成PNG再传。PNG是无损压缩,对于图形类图像压缩率很高;JPEG是有损压缩,适合照片类图像。
如果下游是C#或Python写的,它们都能直接解析PNG字节流,所以走GDI+编码成PNG再传是最省事的方案。如果下游也是VB6,那可能得约定一个自定义的简单压缩格式,比如RLE。
6.3 跨模块传递时的头部设计
前面提过,纯像素数据没有自描述信息,接收方不知道宽度高度。所以传递时最好加一个头部。我常用的头部格式是:
| 偏移 | 长度 | 含义 |
|---|---|---|
| 0 | 4 | 魔数,固定值 |
| 4 | 4 | 宽度(像素) |
| 8 | 4 | 高度(像素) |
| 12 | 4 | 像素格式码 |
| 16 | 4 | 数据长度 |
| 20 | N | 像素数据 |
这样接收方先读20字节头部,解析出宽度高度和格式,再按对应规则解析后面的像素数据。这个设计简单可靠,跨语言也容易实现。
6.4 资源释放的检查清单
VB6里资源泄漏是常见问题,我整理了一个检查清单,每次写完相关代码都过一遍:
GdiplusStartup是否配对GdiplusShutdownCreateStreamOnHGlobal创建的流是否释放GdipCreateBitmapFromScan0创建的位图是否GdipDisposeImageCreateCompatibleDC创建的DC是否DeleteDCCreateDIBSection创建的位图是否DeleteObjectGlobalLock是否配对GlobalUnlock
这份清单帮我避免了很多隐蔽的泄漏问题。尤其是GDI+的对象,如果不释放,程序跑久了会越来越慢,最后崩溃。
7. 一个完整的可运行示例框架
把上面的碎片拼起来,一个完整的函数框架大概长这样。这个框架走的是GDI+路线,生成一张指定颜色的PNG图片并返回字节数组:
Public Function GeneratePngBytes(ByVal width As Long, ByVal height As Long, _ ByVal r As Byte, ByVal g As Byte, ByVal b As Byte) As Byte() Dim token As Long Dim inputBuf As GdiplusStartupInput Dim pStream As Long Dim pBitmap As Long Dim pGraphics As Long Dim hGlobal As Long Dim pData As Long Dim nSize As Long Dim result() As Byte inputBuf.GdiplusVersion = 1 If GdiplusStartup(token, inputBuf, 0) <> 0 Then Exit Function CreateStreamOnHGlobal 0, 1, pStream GdipCreateBitmapFromScan0 width, height, 0, &H26200A, 0, pBitmap GdipGetImageGraphicsContext pBitmap, pGraphics ' 这里用GDI+的绘图函数填充颜色 Dim brush As Long GdipCreateSolidFill ARGB(255, r, g, b), brush GdipFillRectangle pGraphics, brush, 0, 0, width, height GdipDeleteBrush brush GdipSaveImageToStream pBitmap, pStream, GetEncoderClsid("image/png"), 0 ' 复位流指针并读取数据 SeekStream pStream, 0 GetHGlobalFromStream pStream, hGlobal nSize = GlobalSize(hGlobal) pData = GlobalLock(hGlobal) ReDim result(0 To nSize - 1) CopyMemory result(0), ByVal pData, nSize GlobalUnlock hGlobal ' 释放资源 GdipDeleteGraphics pGraphics GdipDisposeImage pBitmap ReleaseStream pStream GdiplusShutdown token GeneratePngBytes = result End Function这个框架里省略了GetEncoderClsid、SeekStream、ReleaseStream等辅助函数的实现,它们分别负责获取编码器CLSID、复位流指针、释放流对象。这些辅助函数写一次就能复用,建议放在一个独立的模块里。
实际使用时,调用这个函数就能拿到PNG格式的字节数组,可以直接写文件、发网络、存数据库。如果你需要JPEG,把image/png改成image/jpeg即可,但JPEG编码器可能需要额外的质量参数,这个要通过EncoderParameters结构体传入,稍微复杂一点。
我在实际项目里用这套方案跑了两年多,处理了几十万张图像,稳定性没问题。唯一需要注意的是GDI+的线程模型——它默认不是线程安全的,如果你在多线程环境里调用,需要自己做同步,或者每个线程独立初始化一个GDI+实例。VB6本身对多线程支持有限,所以这个问题在纯VB6项目里不太会遇到,但如果你用ActiveX DLL被多线程调用,就要留心了。