简介:这是一份基于OpenXLSX库的C++/MFC Excel读写示例资源,面向使用VS2019的桌面应用开发者,解决在Windows程序中读取、写入和修改XLSX文件的需求,适用于数据报表生成、自动化办公等实际场景。压缩包共143个文件,核心是50个hpp头文件与25个cpp源文件,构成库源码和MFC调用逻辑;另含工程文件、可执行示例、库文件与调试信息,以及MFC界面资源,整体大小128.69MB,便于在开发环境中直接打开查看、编译运行或修改扩展。已有3681人学习/下载。示例程序完整演示了在MFC项目中接入该库的流程,包括新建工作簿、读取单元格数据、写入文本/数字/日期/公式、管理多个工作表、设置字体颜色/对齐/边框/填充等样式,并附带错误处理机制;通过源码模块还可以看出文档、工作表、单元格等对象的层次化封装,进而理解Excel文件处理的核心思路。库采用内存映射技术优化读写性能,适合中大型数据处理任务,这份代码对需要实现Excel导入导出、批量报表输出或MFC桌面工具开发的开发者而言,是兼具参考价值和复用性的完整示例,适合有一定C++和MFC基础、希望快速落地Excel读写功能的开发者。 我估计不少搞MFC的老哥都遇到过同一个尴尬:程序写好了,业务逻辑跑通了,结果客户一句"给我导个Excel报表",整个方案都得重新折腾。直接用COM调Excel?打包部署要装Office,客户端还得弹宏安全警告。写成CSV?客户又说格式不对、公式没了、长数字变科学计数法。后来我换成了OpenXLSX这个开源C++库,在VS2019里编译完接到MFC工程里,总算是把这条链路彻底打通了。这篇文章就把我踩过的坑、整理好的配置步骤、以及一套可以直接抄的MFC调用示例完整分享出来。
1. 从MFC里处理Excel的痛点说起:为什么我盯上了OpenXLSX
1.1 传统方案在我实际项目里的真实现状
很多MFC老项目里都有这么一套"祖传"导出逻辑:打开Excel.Application的COM接口,逐行写入单元格,最后再SaveAs。这套方案最让人头疼的不是代码量,而是环境依赖。现场用户的机器五花八门,有的装WPS,有的Office是精简版,有的压根没装。程序跑起来之后要么CreateInstance失败,要么操作被UAC拦了一道,要么弹一个"此工作簿包含宏"之类的提示,光是应付这些现场问题就够喝一壶。
CSV虽然轻量,但遇到真正的Excel需求就露怯了。比如客户要求单元格里带换行、带背景色、带下拉选项,CSV完全搞不定。还有个更实际的例子:我一个做检测数据采集的项目,导出的结果表里有一列是设备SN码,明文是"ABCD1234567890",CSV一开自动变成科学计数法,客户当场就说我们数据错了。
ODBC访问Excel其实就是拿它当作数据库引擎来查,限制更多,写回原格式基本没门。所以我的结论是:如果MFC程序需要稳定、独立地生成和读取真正的.xlsx文件,最好选一个不依赖Office环境的原生C++库。OpenXLSX就是在这个背景下进入我视野的。
1.2 OpenXLSX抓住了哪个核心诉求
OpenXLSX是一个基于C++17的开源库,它的定位很纯粹:直接解析和生成ZIP压缩包里的XML内容,也就是xlsx文件的最底层结构。这意味着它不调用任何Office COM组件,不需要系统安装Excel,也跟WPS是否注册了ProgID没有半毛钱关系。对MFC这种讲究部署可控的程序来说,这一点几乎是决定性的。
它的读取和写入API设计得也相对直观。你先打开一个Workbook,再取某个Worksheet,然后按行列号操作Cell。整个心智模型跟我们在Excel界面里的操作是能对上的——工作表、单元格、区域就这么几层。用起来不用去理解XLSX那套复杂的XML文件拆解逻辑,库已经把底层全部封装好了。下面的小节我就按"编译 -> 核心API -> MFC集成 -> 避坑"这条主线来讲,保证每一步都能复现。
2. VS2019下编译OpenXLSX:环境配置与踩坑记录
2.1 获取源码与前置依赖
OpenXLSX的源码在GitHub上直接拉即可,我用的版本是基于当前master分支获取的(建议用稳定Release tag,后续API变化更可控)。除了解压源码,你还需要给工程补一个zip解压缩库,OpenXLSX官方推荐的是ZipLib。不过为了省事,可以从项目仓库里看它自带的third-party目录,有的版本已经把依赖打包好了,没有的话就单独去拉ZipLib源码,放到一个同名目录下。
我当时的目录结构是这样的:
D:\Libs\ ├─ OpenXLSX │ ├─ OpenXLSX │ │ ├─ headers │ │ └─ sources │ └─ ZipLib │ ├─ zip │ └─ ... └─ MyMfcApp如果你已经有自己惯用的第三方库目录,只要把OpenXLSX和ZipLib的源码路径都包含进工程即可。OpenXLSX工程文件里默认引用了相对路径,所以目录层级尽量保持一致能少改很多配置。
2.2 C++语言标准与运行库的选择
这里必须强调一个最容易被忽略的点:OpenXLSX要求C++17支持。VS2019默认的C++14在老工程里很常见,直接编译源码会报一堆std::optional、std::string_view找不到的错。解决办法是在项目属性里把"语言标准"改成ISO C++17 Standard(/std:c++17),MFC工程不用改字符集,继续用Unicode字符集没问题。
运行库方面,OpenXLSX本身编成静态库或直接源码参与编译都可以。我在MFC项目里是直接把OpenXLSX和ZipLib的.cpp/.cxx文件全部拖进工程一起编译的,这样生成的程序部署时不用额外带上动态库。需要注意Debug和Release的“运行库”设置必须与你MFC工程一致,我统一用的"多线程调试(/MTd)"和"多线程(/MT)",省去运行时组件依赖。若工程比较大,建议编一个静态库lib供多个子项目复用。
2.3 配置C/C++附加包含目录
编译之前,需要把OpenXLSX的headers目录和ZipLib的根目录加进项目的“附加包含目录”。我的配置内容大致如下:
D:\Libs\OpenXLSX\OpenXLSX\headers D:\Libs\OpenXLSX\ZipLib有一个细节值得提醒:OpenXLSX的某些头文件里会包含zip.h,ZipLib的头文件组织方式并不统一,有时还用到了"zlib"相关头文件。如果你在编译过程中遇到“无法打开包含文件zlib.h”之类的报错,多半是还缺一个zlib依赖。官方仓库的third_party里一般会带上,没有的话就去下载一个预编译的zlib,把头文件路径和对应的.lib库都配好。在我的环境下,直接借用ZipLib源码里自带的ZLIB目录就能解决,不需要额外装完整的zlib。
3. OpenXLSX核心API实战:从创建、写入到读取
3.1 基础数据类型与打开方式
OpenXLSX最常用的几个类就是XLDocument、XLWorksheet、XLCellValue和XLCell。它们之间的层级关系可以类比成“一本Excel文件 -> 一个工作表 -> 一个单元格的值 -> 单元格对象”。
创建一个新文件时,代码比想象中简单:
#include <OpenXLSX/OpenXLSX.hpp> using namespace OpenXLSX; // 创建并保存一个新xlsx文件 void CreateSimpleExcel(const std::string& path) { XLDocument doc; doc.create(path); auto wks = doc.workbook().worksheet("Sheet1"); wks.cell("A1").value() = "Hello OpenXLSX"; wks.cell("B1").value() = 12345; doc.save(); doc.close(); }注意wks.cell("A1").value()返回的是XLCellValue,这个对象是OpenXLSX类型体系里非常核心的一个“万能值”,它能存字符串、浮点数、整数、布尔值。当你给它赋一个const char*字符串时,它会自动转换为对应类型,不需要我们手动判断。
读取现有文件则是反过来:
XLDocument doc; doc.open(path); auto wks = doc.workbook().worksheet("Sheet1"); auto val = wks.cell("A1").value(); if (val.type() == XLValueType::String) { std::string text = val.as<std::string>(); }需要说明的是,OpenXLSX的cell("A1")这种写法是要解析列字母的,可以接收类似“B2”、“AA10”这样的范围引用,但要用到大数据量还是会有一点点开销。如果需要对大量单元格做顺序访问,更推荐用cell(row, col)这样的行列号重载,避免反复解析列名。
3.2 写入报表:生成带标题和样式的表格
实际业务中,表头必须有点样式。OpenXLSX虽然不像xlnt那样把样式作为第一公民,但基本操作还是能做的。下面这段代码演示了写入表头文字、设置字体加粗、列宽和简单边框:
#include <OpenXLSX/OpenXLSX.hpp> #include <iostream> using namespace OpenXLSX; void ExportReport(const std::string& path) { XLDocument doc; doc.create(path); auto wks = doc.workbook().worksheet("Sheet1"); // 设置表头 wks.cell(1, 1).value() = "设备编号"; wks.cell(1, 2).value() = "采集时间"; wks.cell(1, 3).value() = "温度"; wks.cell(1, 4).value() = "湿度"; // 设置表头样式 auto headerFont = wks.cell(1, 1).font(); headerFont.setFontName("微软雅黑"); headerFont.setFontSize(11); headerFont.setFontBold(true); // 列宽 wks.columnWidth(1, 18); wks.columnWidth(2, 22); wks.columnWidth(3, 12); wks.columnWidth(4, 12); // 写入数据行 std::vector<std::vector<std::string>> rows = { { "SN001", "2025-01-10 09:30:00", "36.5", "45.2" }, { "SN002", "2025-01-10 09:35:00", "36.8", "44.9" }, { "SN003", "2025-01-10 09:40:00", "37.1", "45.5" } }; for (size_t i = 0; i < rows.size(); ++i) { for (size_t j = 0; j < rows[i].size(); ++j) { wks.cell(static_cast<uint32_t>(i + 2), static_cast<uint16_t>(j + 1)) .value() = rows[i][j]; } } doc.save(); doc.close(); }这段代码里最需要注意的是行列号的数据类型:第一个参数是uint32_t行号,第二个参数是uint16_t列号。直接用int字面量可能触发隐式转换警告,我习惯在调用处手动static_cast,看着啰嗦但不会莫名其妙丢精度。还有一点,OpenXLSX默认会有一个Sheet1,如果希望重命名就直接doc.workbook().worksheet("Sheet1").setName("报表"),这样Excel打开时显示的工作表名就更友好。
3.3 读取既有Excel:遍历所有有数据的行
读取的另一个高频场景是:程序里要解析一份用户提交的模板文件,把内容提取出来。我在项目里封装过一个简单的遍历函数,只读非空单元格:
using namespace OpenXLSX; void ReadExcel(const std::string& path) { XLDocument doc; doc.open(path); auto wks = doc.workbook().worksheet("Sheet1"); // 获取实际数据范围 auto range = wks.range().value(); uint32_t rowCount = range.rowCount(); uint16_t colCount = range.columnCount(); for (uint32_t row = 1; row <= rowCount; ++row) { for (uint16_t col = 1; col <= colCount; ++col) { auto cell = wks.cell(row, col); if (!cell.value().isEmpty()) { // 根据类型取值 if (cell.value().type() == XLValueType::Integer) { int v = cell.value().as<int>(); } else if (cell.value().type() == XLValueType::String) { std::string s = cell.value().as<std::string>(); } } } } doc.close(); }range().value()拿到的是一个XLCellRange对象,rowCount()和columnCount()给的是这个区域里实际有内容的行列数。不过要注意:OpenXLSX对range的判断是基于工作表维度还是已用区域维度,不同版本有些微差异。如果你遍历到没有实际数据的巨大范围,多半是工作表里被误设置过格式,导致dimension范围很大。这种情况可以先用wks.rowCount()和wks.columnCount()看一下实际维度,再决定按哪个范围遍历。我在一次导入CSV转Excel的文件时,就遇到过一个标题上“行数正确但实际数据在200行开外”的鬼情况,后来打印dimension才定位到是模板原文件残留了格式。
4. 在MFC中落地:CString与std::string转换、线程安全与界面刷新
4.1 示例程序结构和对话框布局
为了让这套代码真正跑到MFC里,我建了一个基于对话框的示例工程,对话框上有两个按钮:一个叫“导出报表”,一个叫“读取模板”。对应的处理函数分别是OnBnClickedExport和OnBnClickedImport。
MFC工程默认是Unicode字符集,而OpenXLSX的API用的是std::string和std::wstring混着来(文件路径支持宽字符串)。所以我第一步要做的是把MFC的CString转成std::wstring再传给OpenXLSX的文档路径,尽量避免通过UTF-8再转一层,减少中文路径乱码的风险。
我这里提供一个稳定的转换工具:
#include <atlstr.h> // CStringW -> std::wstring static std::wstring CStringToStdWString(const CString& str) { return std::wstring(str.GetString(), str.GetLength()); }如果库内部接受的是std::string(UTF-8编码),那么你可以用WideCharToMultiByte转成UTF-8。但在Windows上,xlsx内部虽然是以UTF-8存储XML,路径参数要用本机编码更可靠,所以能用宽字符串接口就直接用宽字符串。
4.2 核心界面代码:按钮响应里调用导出
下面是一个完整的导出按钮处理函数,它演示了如何把CString列表转成OpenXLSX能识别的数据:
void CExcelDlg::OnBnClickedExport() { CString strPath; CFileDialog dlg(FALSE, _T("xlsx"), _T("report.xlsx"), OFN_HIDEREADONLY | OFN_OVERWRITEPROMPT, _T("Excel 文件 (*.xlsx)|*.xlsx|所有文件 (*.*)|*.*||"), this); if (dlg.DoModal() != IDOK) return; strPath = dlg.GetPathName(); std::wstring wsPath = CStringToStdWString(strPath); try { XLDocument doc; doc.create(wsPath); auto wks = doc.workbook().worksheet("Sheet1"); wks.cell(1, 1).value() = "序列号"; wks.cell(1, 2).value() = "状态"; // 向表格写入从List Control读取到的数据 int nItemCount = m_listCtrl.GetItemCount(); for (int i = 0; i < nItemCount; ++i) { CString strSN = m_listCtrl.GetItemText(i, 0); CString strStatus = m_listCtrl.GetItemText(i, 1); // CString -> std::string,这里注意用CT2A走系统代码页转换 std::string strSNUtf8 = CW2A(strSN, CP_UTF8); std::string strStatusUtf8 = CW2A(strStatus, CP_UTF8); wks.cell(static_cast<uint32_t>(i + 2), 1).value() = strSNUtf8; wks.cell(static_cast<uint32_t>(i + 2), 2).value() = strStatusUtf8; } doc.save(); doc.close(); AfxMessageBox(_T("导出成功")); } catch (const std::exception& ex) { CString strMsg = CString(_T("导出失败: ")) + CString(ex.what()); AfxMessageBox(strMsg); } }这里需要注意CW2A(strSN, CP_UTF8)是ATL的转换宏,会把CStringW转成UTF-8编码的std::string。在MFC常规字符串资源是中文的环境下,这个转换比直接强转char*要靠谱得多。前提是OpenXLSX写入单元格时,能正确处理UTF-8字符串,实测下来是支持的。
4.3 防止界面卡死:后台线程执行与进度提示
如果只是几百行数据,直接在按钮事件里同步生成Excel没多大问题。但一旦数据源来自数据库或大量导入,同步处理会让对话框卡住好几秒甚至十几秒。Windows可不会因为你程序正忙就容忍等待,用户很容易直接点一下窗口然后系统弹“未响应”。
我的做法是把导出操作放到工作线程中,用PostMessage把结果回传UI线程,期间禁用导出按钮并显示一个简单的“正在导出”状态文字。伪代码框架如下:
UINT ThreadExport(LPVOID pParam) { CExcelDlg* pDlg = static_cast<CExcelDlg*>(pParam); pDlg->DoExportInThread(); ::PostMessage(pDlg->GetSafeHwnd(), WM_EXPORT_DONE, 0, 0); return 0; } void CExcelDlg::OnBnClickedExport() { m_btnExport.EnableWindow(FALSE); SetDlgItemText(IDC_STATUS, _T("正在导出...")); AfxBeginThread(ThreadExport, this); } LRESULT CExcelDlg::OnExportDone(WPARAM, LPARAM) { m_btnExport.EnableWindow(TRUE); SetDlgItemText(IDC_STATUS, _T("导出完成")); return 0; }一个我需要提醒的坑:OpenXLSX的XLDocument对象并不是线程安全类,所以每个线程里最好只操作自己创建的XLDocument,不要跨线程共享同一个文档对象。我会把doc.create、写入、save、close全部放在同一个工作线程函数里完成,UI线程只负责显示结果,这样就不会出现两个线程同时写入同一个内部缓冲区导致崩溃的情况。
5. 实测中的翻车现场:编码、日期与性能对比
5.1 中文乱码的根子到底在哪儿
我在第一次用OpenXLSX往单元格里写中文时报错或乱码,排查了很久,最后发现不是库的问题,而是VS2019默认把源码文件当GBK/ANSI来编译,而OpenXLSX期望的是UTF-8字符串。解决办法有两个:
- 给MFC工程加上
/utf-8编译选项,让编译器默认按UTF-8理解源码和字符串字面量。 - 所有外部传入的CString都用CW2A(..., CP_UTF8)先转成UTF-8,再交给OpenXLSX。
强烈建议两种都做。加编译选项是为了让代码里直接写的字符串字面量像"报表"这类不经过手写转换就能用。做CW2A转换是为了保证运行时从控件、文件、数据库拿到的文本不会被系统默认代码页污染。两个都做之后,中文乱码基本绝迹。
5.2 日期单元格总是变成一串数字
Excel里日期的本质是一个浮点数序列值,OpenXLSX默认不会自动帮你转成"2025-01-10"这种显示格式。如果你直接往单元格里塞一个字符串"2025-01-10",打开文件时Excel会把它当文本,不是真的日期。如果你写入的是一个数值,又没设置单元格格式,用户看到的就是一堆整数。
我在项目里的做法是:如果业务方明确需要日期列参与Excel筛选、排序或计算,就把日期作为真正的序列值写入,并设置单元格为日期格式。OpenXLSX目前的样式API对日期格式支持比较有限,我踩过坑后选择了更稳妥的方案——在数据表里提前做好一列格式模板,用OpenXLSX读取模板并填充数据,保留模板中的日期列格式。这样既避免了库样式API的不完善,也让生成的文件看起来更像专业报表。
5.3 一万行导出要多久
我在一个验收报告里做过一次简单对比:同样是一万行、四列数据,生成一个没有样式的xlsx,OpenXLSX在我的i5工控机上大概耗时0.8秒左右;如果给每一行都设置边框和字体,耗时会上到2秒多。这个成绩对于MFC桌面程序来说完全可以接受。相比传统COM方式,省去了启动Excel进程和逐格写入的通信开销,整体提速非常明显。
如果数据量更大,比如十万行以上,建议分批写入并定期调用doc.save()做临时保存,避免内存中缓存太多修改导致突然崩溃时全部丢失。OpenXLSX的save机制是整文件重写,频繁save反而慢,所以合理策略是每写入2万行save一次,兼顾安全和性能。
5.4 一个被忽视的部署细节
最后说一个部署时很容易踩的坑:如果OpenXLSX编译时使用的是动态链接的C++运行库,而目标机器上没有对应版本的VC++运行库,程序启动时会弹The procedure entry point...错误。我前面强调编译时期用/MT或/MTd,就是为了把运行库静态链接进exe。MFC工程里还需要在“代码生成”里把“运行库”设为多线程(/MT),这样才能让独立部署的exe不依赖额外的VC_redist。这一项在x64和x86配置下都要各自检查一次,别只改了一个平台。
如果你跟我一样有多个平台配置,建议把这部分编译选项整理成一个单独的PropertySheet,放到项目里,下次新建MFC工程直接导入,一句话就能配置好OpenXLSX的编译环境。
OpenXLSX本身还在持续更新,API版本之间也有一些细微调整,但核心的XLDocument、XLCellValue这套模型基本稳定。如果你只是想在MFC里摆脱Office依赖、快速生成/读取xlsx,这个库值得纳入备选清单。我现在的几个工具类里已经把它封装成了独立的"ExcelService",不管导出报表还是解析模板都能复用,后续如果再踩到新坑,再单独写一篇分享。
本文还有配套的精品资源,点击获取