当前位置: 首页 > news >正文

VC++底层实现BMP图像读取与GDI显示:从文件结构到双缓冲渲染

1. 项目概述与核心价值

在桌面应用开发,尤其是涉及图像处理、工业视觉或游戏开发的领域,直接操作和显示BMP图像是一项基础且核心的技能。很多朋友可能觉得,现在有OpenCV、Qt、GDI+这些成熟的库,为什么还要从底层用VC++去折腾BMP?我个人的体会是,这就像学开车,自动挡固然方便,但理解了手动挡的离合、换挡原理,你才能真正掌控车辆,应对更复杂的路况。使用VC++(这里特指Visual C++配合Windows API)从零实现BMP图像的读取、解析与显示,正是这样一个“理解手动挡”的过程。它能让你透彻理解图像数据在内存中的排布、设备上下文(DC)的运作机制,以及Windows图形子系统最底层的交互逻辑。当你遇到图像显示异常、性能瓶颈,或者需要实现一些库函数不支持的定制化像素操作时,这份底层功底的价值就凸显出来了。

这个教程的目标,就是带你走通这个完整的流程。我们不会仅仅停留在调用一个现成的CImage类或者StretchDIBits函数。我会从BMP文件的结构讲起,带你手动解析文件头和信息头,将原始的像素数据读入内存,然后利用Windows GDI(图形设备接口)的API,一步步将这些数据“画”到窗口上。过程中,你会遇到字节对齐、颜色通道顺序、内存DC等关键概念。掌握这些,不仅能让你轻松应对“C语言读取bmp”这类基础面试题,更能为后续深入学习图像处理算法、优化渲染性能打下坚实基础。无论你是刚接触Windows编程的新手,还是想巩固图形学基础的老手,这篇内容都能提供直接的、可运行的代码参考和深入的原理解析。

2. BMP文件格式深度解析与手动读取

2.1 BMP文件结构:不只是“头”和“数据”

很多人知道BMP文件有“文件头”和“图像数据”,但其中的细节才是正确读取的关键。一个标准的Windows BMP文件(DIB,设备无关位图)主要包含四个部分:

  1. 位图文件头 (BITMAPFILEHEADER):14字节,包含文件类型标识(‘BM’)、文件大小、以及图像数据在文件中的起始偏移量。这个偏移量至关重要,它告诉我们从哪里开始读像素数据,因为文件头和信息头之后、像素数据之前,可能还存在颜色表。
  2. 位图信息头 (BITMAPINFOHEADER):40字节,这是核心。它包含了图像的宽度、高度、每个像素的位数(如24位真彩色、8位索引色)、压缩类型(通常为BI_RGB,即不压缩)、图像数据的大小等。这里的高度值有讲究:正数表示图像数据是从下到上存储的(即第一行数据对应图像最底下一行),负数则表示从上到下。我们常见的BMP通常是正数。
  3. 颜色表 (Color Table):对于使用调色板的图像(如1位、4位、8位),这部分定义了索引对应的实际RGB颜色。24位或32位真彩色图像没有颜色表。
  4. 位图数据 (Pixel Data):即图像的像素阵列。这里有一个极易出错的细节:行字节对齐。为了处理器访问效率,图像每一行像素数据占用的字节数必须是4的倍数。对于24位图像(每个像素3字节),如果图像宽度为W,则理论行字节数为W * 3,但实际存储的行字节数需要向上取整到4的倍数。计算公式为:((W * bitsPerPixel + 31) / 32) * 4。读取时如果忽略这个对齐,图像就会错位、扭曲。

2.2 手动读取BMP的C++实现

理解了结构,我们就可以用C++标准库来读取了。这里的关键是使用二进制模式打开文件,并按照结构体定义精确读取。

#include <fstream> #include <windows.h> // 包含BITMAPFILEHEADER等结构定义 #pragma pack(push, 1) // 确保结构体紧凑对齐,无编译器填充字节 struct MyBitmapFileHeader { WORD bfType; // 文件类型,必须是"BM" DWORD bfSize; // 文件大小 WORD bfReserved1; WORD bfReserved2; DWORD bfOffBits; // 像素数据偏移量 }; #pragma pack(pop) bool LoadBMPFile(const char* filename, int& width, int& height, int& bitsPerPixel, std::vector<BYTE>& pixelData) { std::ifstream file(filename, std::ios::binary); if (!file.is_open()) { return false; } // 1. 读取文件头 MyBitmapFileHeader fileHeader; file.read(reinterpret_cast<char*>(&fileHeader), sizeof(fileHeader)); if (fileHeader.bfType != 0x4D42) { // 'B'=0x42, 'M'=0x4D, 小端序为0x4D42 return false; // 不是有效的BMP文件 } // 2. 读取信息头 BITMAPINFOHEADER infoHeader; file.read(reinterpret_cast<char*>(&infoHeader), sizeof(infoHeader)); // 简化处理,这里假设是40字节的BITMAPINFOHEADER,且未压缩 if (infoHeader.biSize != sizeof(BITMAPINFOHEADER) || infoHeader.biCompression != BI_RGB) { return false; // 不支持的类型或压缩格式 } width = infoHeader.biWidth; height = infoHeader.biHeight; bitsPerPixel = infoHeader.biBitCount; // 3. 计算实际行字节数(考虑4字节对齐) int rowSize = ((width * bitsPerPixel + 31) / 32) * 4; int imageDataSize = rowSize * abs(height); // 使用绝对值,处理高度正负 // 4. 定位并读取像素数据 pixelData.resize(imageDataSize); file.seekg(fileHeader.bfOffBits, std::ios::beg); file.read(reinterpret_cast<char*>(pixelData.data()), imageDataSize); // 5. 处理高度为负(从上到下存储)的情况 // 标准BMP数据是从下到上存储的,如果我们需要一个从上到下的缓冲区用于显示,可能需要翻转。 // 许多显示API(如StretchDIBits)可以接受从下到上的数据,但为了通用性,这里可以选择翻转。 if (height > 0) { // 原始数据是从下到上的,我们需要翻转行序 std::vector<BYTE> flippedData(imageDataSize); for (int y = 0; y < height; ++y) { const BYTE* srcRow = pixelData.data() + (height - 1 - y) * rowSize; BYTE* dstRow = flippedData.data() + y * rowSize; memcpy(dstRow, srcRow, rowSize); } pixelData.swap(flippedData); } else { height = -height; // 将高度转为正数 // 数据已经是从上到下,无需翻转 } return true; }

注意:上面的代码使用了#pragma pack(push, 1)来确保结构体按1字节对齐,这是为了与BMP文件严格的字节布局匹配。否则,编译器可能会在结构体成员间插入填充字节,导致读取错位。同时,我们手动处理了行对齐和图像翻转,这是很多现成库帮你做了但底层必须清楚的事情。

3. Windows GDI显示机制与核心API详解

3.1 设备上下文(DC):绘图的画布与桥梁

在Windows中,任何绘图操作都不是直接针对屏幕或内存进行的,而是通过一个叫**设备上下文(Device Context, DC)**的抽象层。你可以把DC想象成一张画布和一套画笔、调色板的组合,它关联着具体的输出设备(如显示器、打印机)或内存位图。我们要在窗口上显示图像,本质上就是先获取窗口的DC,然后将我们的图像数据“贴”到这个DC上。

关键API是GetDC(HWND hWnd),它获取指定窗口客户区的DC。绘图完成后,必须用ReleaseDC(HWND hWnd, HDC hDC)释放,否则会造成资源泄漏。

3.2 内存DC与位图选入:解决闪烁的关键技术

如果直接将图像数据绘制到窗口DC上,在复杂或频繁的绘制中很容易出现闪烁。因为绘制过程是“直接上墙”,用户可能看到中间过程。标准的解决方案是使用双缓冲技术:

  1. 创建一个与窗口DC兼容的内存DCCreateCompatibleDC(hdcWindow)
  2. 创建一个与窗口DC兼容的、指定大小的位图CreateCompatibleBitmap(hdcWindow, width, height)
  3. 将这个位图选入内存DC:SelectObject(hdcMem, hBitmap)。现在,所有针对hdcMem的绘图操作,实际上都发生在这个内存位图上,屏幕上看不见。
  4. 在内存DC上完成所有绘制(包括我们的BMP图像)。
  5. 最后,一次性将内存位图的内容传输到窗口DC上:通常使用BitBltStretchBlt。这个过程很快,用户感知不到中间状态,从而消除了闪烁。
// 在窗口的WM_PAINT消息处理中 PAINTSTRUCT ps; HDC hdc = BeginPaint(hWnd, &ps); // 1. 创建兼容的内存DC HDC hdcMem = CreateCompatibleDC(hdc); // 2. 创建兼容位图(大小与需要显示的图像或客户区一致) HBITMAP hBitmap = CreateCompatibleBitmap(hdc, imageWidth, imageHeight); // 3. 将位图选入内存DC,并保存旧的(用于恢复) HBITMAP hOldBmp = (HBITMAP)SelectObject(hdcMem, hBitmap); // 4. 先在内存DC上绘制(例如,先填充白色背景,再画图像) RECT rect = {0, 0, imageWidth, imageHeight}; FillRect(hdcMem, &rect, (HBRUSH)GetStockObject(WHITE_BRUSH)); // ... 这里调用我们显示BMP的函数,将图像画到hdcMem上 ... // 5. 将内存DC的内容一次性传输到窗口DC BitBlt(hdc, // 目标DC (窗口) 0, 0, // 目标起点 imageWidth, imageHeight, // 传输区域大小 hdcMem, // 源DC (内存) 0, 0, // 源起点 SRCCOPY); // 复制操作 // 6. 清理:恢复旧位图,删除资源 SelectObject(hdcMem, hOldBmp); DeleteObject(hBitmap); DeleteDC(hdcMem); EndPaint(hWnd, &ps);

3.3 显示BMP数据的两种核心API:SetPixel与StretchDIBits

有了像素数据和DC,如何把数据“画”上去?有两种主要方式,适用于不同场景。

方法一:使用SetPixel逐点绘制这是最直观但效率最低的方法。SetPixel(HDC hdc, int X, int Y, COLORREF crColor)函数可以在DC的指定坐标设置一个像素的颜色。我们需要遍历每个像素,从我们的pixelData数组中取出BGR值,转换成COLORREF,然后调用SetPixel

// 假设pixelData是24位BGR格式,已按行对齐,且已翻转为上到下存储 for (int y = 0; y < height; ++y) { const BYTE* rowStart = pixelData.data() + y * rowSize; for (int x = 0; x < width; ++x) { int pixelIndex = x * 3; // 每像素3字节 BYTE blue = rowStart[pixelIndex]; BYTE green = rowStart[pixelIndex + 1]; BYTE red = rowStart[pixelIndex + 2]; COLORREF color = RGB(red, green, blue); // RGB宏将分量组合成COLORREF SetPixel(hdcMem, x, y, color); } }

实操心得SetPixel在调试、学习原理或处理极小图像时有用,但绝对不要用于显示稍大尺寸的图像。它的调用开销极大,绘制一张800x600的图片就需要调用48万次API,会导致界面严重卡顿。这是性能的“反面教材”。

方法二:使用StretchDIBits一次性绘制这是显示DIB(设备无关位图)的标准且高效的方法。它直接将一个内存中的DIB数据块传输到DC,可以同时完成缩放(Stretch)和传输。

// 首先,我们需要构建一个BITMAPINFO结构,它包含了信息头和颜色表(真彩色通常为空) BITMAPINFO bmi = {0}; bmi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); bmi.bmiHeader.biWidth = width; bmi.bmiHeader.biHeight = -height; // 负高度!表示数据是“从上到下”的,避免再次翻转。 bmi.bmiHeader.biPlanes = 1; bmi.bmiHeader.biBitCount = bitsPerPixel; // 24 bmi.bmiHeader.biCompression = BI_RGB; bmi.bmiHeader.biSizeImage = 0; // 对于BI_RGB可以设为0 bmi.bmiHeader.biXPelsPerMeter = 0; bmi.bmiHeader.biYPelsPerMeter = 0; bmi.bmiHeader.biClrUsed = 0; bmi.bmiHeader.biClrImportant = 0; // 调用StretchDIBits StretchDIBits(hdcMem, // 目标DC 0, 0, width, height, // 目标矩形 0, 0, width, height, // 源矩形 pixelData.data(), // 像素数据指针 &bmi, // BITMAPINFO指针 DIB_RGB_COLORS, // 颜色表使用RGB SRCCOPY); // 光栅操作码

关键点在于biHeight设置为负数。这告诉API,我们提供的数据是“从上到下”存储的。这样,我们之前读取并可能翻转好的pixelData就可以直接使用了,API内部会正确处理。StretchDIBits是硬件加速的,效率极高,是显示BMP的首选方法。

4. 完整项目集成与窗口程序搭建

4.1 创建Win32项目与消息循环骨架

我们使用Visual Studio创建一个“Windows桌面应用程序”项目。核心是WinMain入口函数和主窗口过程WndProc

#include <windows.h> #include <vector> // 全局变量存储图像数据 int g_nImageWidth = 0; int g_nImageHeight = 0; std::vector<BYTE> g_pixelData; LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam) { switch (message) { case WM_CREATE: { // 在窗口创建时加载BMP if (!LoadBMPFile("test.bmp", g_nImageWidth, g_nImageHeight, g_pixelData)) { MessageBox(hWnd, L"Failed to load BMP file!", L"Error", MB_OK); } break; } case WM_PAINT: { PAINTSTRUCT ps; HDC hdc = BeginPaint(hWnd, &ps); // 调用我们的显示函数 DisplayBMP(hdc, g_nImageWidth, g_nImageHeight, g_pixelData); EndPaint(hWnd, &ps); break; } case WM_DESTROY: PostQuitMessage(0); break; default: return DefWindowProc(hWnd, message, wParam, lParam); } return 0; } // DisplayBMP函数,使用双缓冲和StretchDIBits void DisplayBMP(HDC hdcTarget, int width, int height, const std::vector<BYTE>& pixelData) { if (pixelData.empty() || width <= 0 || height <= 0) return; RECT clientRect; GetClientRect(WindowFromDC(hdcTarget), &clientRect); int clientWidth = clientRect.right - clientRect.left; int clientHeight = clientRect.bottom - clientRect.top; HDC hdcMem = CreateCompatibleDC(hdcTarget); HBITMAP hBitmap = CreateCompatibleBitmap(hdcTarget, clientWidth, clientHeight); HBITMAP hOldBmp = (HBITMAP)SelectObject(hdcMem, hBitmap); // 用白色清空内存DC背景 HBRUSH hWhiteBrush = (HBRUSH)GetStockObject(WHITE_BRUSH); FillRect(hdcMem, &clientRect, hWhiteBrush); // 计算居中显示的坐标 int x = (clientWidth - width) / 2; int y = (clientHeight - height) / 2; if (x < 0) x = 0; if (y < 0) y = 0; // 使用StretchDIBits绘制 BITMAPINFO bmi = {0}; bmi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); bmi.bmiHeader.biWidth = width; bmi.bmiHeader.biHeight = -height; // 关键:负高度表示从上到下数据 bmi.bmiHeader.biPlanes = 1; bmi.bmiHeader.biBitCount = 24; // 假设是24位BMP bmi.bmiHeader.biCompression = BI_RGB; bmi.bmiHeader.biSizeImage = 0; bmi.bmiHeader.biXPelsPerMeter = 0; bmi.bmiHeader.biYPelsPerMeter = 0; bmi.bmiHeader.biClrUsed = 0; bmi.bmiHeader.biClrImportant = 0; StretchDIBits(hdcMem, x, y, width, height, // 目标位置和大小 0, 0, width, height, // 源位置和大小(不缩放) pixelData.data(), &bmi, DIB_RGB_COLORS, SRCCOPY); // 将内存DC内容复制到目标DC BitBlt(hdcTarget, 0, 0, clientWidth, clientHeight, hdcMem, 0, 0, SRCCOPY); // 清理 SelectObject(hdcMem, hOldBmp); DeleteObject(hBitmap); DeleteDC(hdcMem); }

4.2 处理窗口大小变化与图像缩放

上面的代码实现了居中显示,但窗口大小变化时(WM_SIZE),我们需要触发重绘(InvalidateRect),让WM_PAINT消息重新绘制。DisplayBMP函数中已经根据客户区大小创建了兼容位图,并计算了居中位置,因此能自适应窗口。

如果想实现图像随窗口拉伸,可以修改StretchDIBits调用中的目标矩形参数,将其设置为整个客户区或按比例计算后的区域。注意,拉伸可能导致图像失真。

case WM_SIZE: { // 窗口大小改变,强制整个客户区重绘 InvalidateRect(hWnd, NULL, TRUE); break; }

5. 高级话题、调试与性能优化

5.1 支持多种位深度与颜色表

我们的示例主要针对24位真彩色BMP。要支持1位、4位、8位(索引色)或32位带Alpha通道的BMP,关键在于正确处理BITMAPINFOHEADER中的biBitCount,以及随之而来的颜色表。

  • 颜色表:位于信息头之后,像素数据之前。对于索引色图像,biClrUsed字段指示使用的颜色数量(如果为0,则对于8位以下图像,使用最大颜色数:如8位为256)。颜色表是一个RGBQUAD数组,每个条目定义一种颜色(B, G, R, Reserved)。
  • 32位BMP:通常每个像素4字节(B, G, R, A),其中A(Alpha)通道可能未被使用(为0)。显示时,需要创建32位位图或使用带Alpha混合的API(如AlphaBlend)。
  • 读取通用性:一个健壮的读取函数应该根据biBitCountbiCompression动态计算颜色表大小和像素数据偏移量,并分配正确的缓冲区。

5.2 资源管理与内存泄漏排查

GDI对象(HDC,HBITMAP,HBRUSH等)是系统资源,必须成对创建和删除。常见的错误是只创建不删除,或者删除后仍在使用。

  • 创建与删除必须匹配
    • CreateCompatibleDC->DeleteDC
    • CreateCompatibleBitmap/CreateBitmap->DeleteObject
    • GetDC->ReleaseDC
    • BeginPaint->EndPaintBeginPaint返回的DC由系统管理,无需ReleaseDC
  • 选出的对象要选回:在删除一个GDI对象(如位图)之前,必须确保它没有被任何DC选中。通常的做法是,调用SelectObject选择新对象时,保存返回的旧对象句柄,在清理时先选回旧对象。
  • 使用工具检测:Visual Studio的诊断工具(Debug -> Windows -> Diagnostic Tools)中的“内存使用量”和“GPU使用量”标签页可以帮助监控GDI对象和内存泄漏。更专业的工具如GDIView可以查看进程详细的GDI对象占用。

5.3 性能优化实践

  1. 避免在WM_PAINT中频繁加载和解析文件:图像数据应只加载一次(如在WM_CREATE中),保存在全局或静态变量中,绘制时直接使用。
  2. 使用双缓冲:如前所述,这是消除闪烁的标配,务必使用。
  3. 优先使用StretchDIBits:它比逐像素操作快几个数量级。
  4. 局部刷新:如果只有部分区域需要更新,可以使用InvalidateRect指定需要重绘的矩形区域,并在WM_PAINT中通过PAINTSTRUCT.rcPaint获取该区域,只重绘该部分,减少绘制量。
  5. 对于超大图像或频繁绘制:考虑使用Direct2D等现代图形API,它们硬件加速更彻底,性能远超GDI。

5.4 常见问题排查速查表

问题现象可能原因排查步骤与解决方案
图像显示为纯色或错乱条纹1. 行字节对齐计算错误。
2. 像素数据读取的起始位置(bfOffBits)不对。
3. 颜色通道顺序(BGR vs RGB)弄反。
1. 核对rowSize计算公式,打印并与小画图等工具保存的BMP文件二进制对比。
2. 确认bfOffBits值,确保文件指针跳转正确。
3. 检查StretchDIBitsBITMAPINFOHEADERbiBitCount设置是否正确,24位图像像素数据是BGR顺序。
图像上下颠倒BITMAPINFOHEADERbiHeight符号设置错误。如果像素数据是“从下到上”存储的,biHeight应为正数;如果是“从上到下”(如我们翻转后),biHeight应为负数。确保两者匹配。
显示区域有残留图像双缓冲中,内存位图在绘制新内容前未清除旧内容。在将图像绘制到内存DC (hdcMem) 之前,先用FillRect填充一个背景色(如白色)。
程序运行一段时间后变卡或崩溃GDI对象泄漏(未正确删除DC或Bitmap)。检查所有Create*函数是否有对应的Delete*SelectObject后是否在删除前选回旧对象。使用诊断工具监控GDI对象计数。
无法打开文件或读取失败1. 文件路径错误。
2. 文件被占用或无权限。
3. BMP格式不标准(如压缩格式)。
1. 使用绝对路径或确保相对路径正确。
2. 检查文件是否存在,属性是否只读。
3. 在代码中增加对bfTypebiCompression的检查,只支持BI_RGB等未压缩格式。
显示彩色图像出现异常色块对于索引色(8位及以下)图像,未加载或未正确应用颜色表。确认biBitCount,如果小于等于8,则需要在BITMAPINFO结构体中包含颜色表(bmiColors),并将像素数据作为索引值传入StretchDIBits

6. 从GDI到现代图形库的思考

掌握了VC++配合GDI显示BMP的整套流程,你就握住了Windows图形编程的一块基石。但这仅仅是开始。GDI是一个比较古老的软件渲染接口,在现代高分辨率、高刷新率、复杂动画的应用中,其性能已显不足。

  • GDI+:微软提供的下一代2D图形库,接口更面向对象(如Bitmap,Graphics类),支持更多图像格式(PNG, JPEG等)和图形特效(渐变、抗锯齿等),但本质上仍是CPU为主的渲染。
  • Direct2D:硬件加速的2D图形API,性能远超GDI/GDI+,是开发现代Windows桌面应用(如WPF底层、现代UI框架)的推荐选择。它同样支持从内存数据创建位图并渲染。
  • 其他框架:如你搜索热词中提到的Qt,它的QLabel可以轻松显示图像,其底层在不同平台上封装了原生API(Windows上可能是GDI+或Direct2D)。而Flutter桌面端打包时,确实可能需要包含VC++运行库,因为其引擎部分由C/C++编写,依赖这些运行时组件。

理解了这个底层过程,再学习这些高级库时,你会更容易理解它们的设计理念和性能瓶颈所在。例如,当你用Qt显示一张图片时,你就能想象到它大概经历了“加载文件 -> 解码为像素数据 -> 创建平台相关的位图对象 -> 通过平台API渲染”的类似流程,只是每个步骤都被框架优雅地封装和优化了。

最后,关于“BMP转SVG”,这是两个完全不同维度的格式。BMP是栅格图(像素点阵),SVG是矢量图(数学描述)。转换过程涉及复杂的图像追踪算法,并非简单的数据解析。常用的工具有Potrace(命令行)或Inkscape(图形界面),它们可以将BMP的轮廓提取为SVG路径。但这已经超出了本篇基础显示的范畴,属于计算机图形学中一个更专业的领域——矢量化和图像分析。

http://www.jsqmd.com/news/1185538/

相关文章:

  • 2026年九宫格拼图工具实测排行,手机电脑都能用 - 软件工具教程方法
  • 系统引导修复工具原理与实战:从MBR到UEFI故障解决指南
  • 【Linux专栏】ls 排除某个文件
  • OpenGL运行环境搭建学习01篇【Related to https://learnopengl-cn.github.io/】
  • 《超简单:用 Python 让 Excel 飞起来》读书笔记:第8章 扩展 使用子图展示多个视角
  • TLP241A光隔离继电器与PIC18F4620在工业控制中的应用
  • GitHub热门AI系统提示词泄露项目解析与实战应用指南
  • Steam成就管理终极指南:如何用开源工具完全掌控你的游戏成就
  • 我的世界Java版安装指南:从Java环境配置到游戏启动完整教程
  • TB67H480FNG与PIC18K40在电机控制中的黄金组合
  • 记忆的锁链:为什么线性时间流容不下爆发式的信息增长?
  • 九宫格拼图小程序免费攻略,不用花钱也能做 - 软件工具教程方法
  • 多模态情感识别技术:原理、实现与优化
  • 移位寄存器链:从 DPD 内存深度到 CNN 滑动窗口
  • YOLOv8蜜蜂检测实战:从环境配置到生产部署完整指南
  • 多维聚合实战:从SQL分组到可交互数据立方体
  • 锂离子电池过压保护方案设计与实现
  • UE5 C++定时器实战:从核心机制到避坑指南
  • AI工程师学习路径:从基础到工程实践全解析
  • 指针的底层原理与工程实践
  • 罗德与施瓦茨ZNB矢量网络分析仪实时滤波器调谐流程
  • 传统单体架构改造方案实战:从单体地狱到模块化架构的渐进式演进
  • 手把手教你用九宫格拼图软件,零基础入门教程 - 软件工具教程方法
  • Windows虚拟磁盘映射工具ExtraSubst Home详解与应用
  • Java XML转JSON实战:结构差异、转换库选择与生产环境最佳实践
  • Jenkins- centos7.X下安装Jenkins
  • 【2013-12-17】设计模式学习笔记:组合模式
  • MySQL数据库 (十四) MySQL事务(上),事务的四大特性,事务的操作方式,事务的隔离级别
  • C++17 主要新特性 汇总
  • Qt C++项目集成MeloTTS.cpp:纯C++离线TTS引擎的完整实践指南