最近给一个Windows桌面工具加报表导出功能,数据量不大但格式要求不少:表头要加粗、要有合并单元格、多张工作表、还得支持读取别人填好的Excel再回写。开发环境是Qt 5.15.2加VS2019,网上翻了半天,可用方案无非是COM调用Office、转CSV、手写XML这几条路,挨个试下来都不太顺,最后定在用QtXlsx。这篇文章把从拉源码、编译库到在VS2019工程里读写xlsx的完整过程记录下来,重点说说配置环节容易踩的坑,给准备在这套环境下用QtXlsx的同学一个可直接对照的参考。
1. 方案选型:为什么最终选了QtXlsx
1.1 台面上能用的几个替代方案
很多人的第一反应是用QAxObject去调COM接口操作Excel。这个方案最大的优点是Office亲自下场,什么样式都能做,但代价也很明显:客户端必须装Office,每次操作都要拉起一个COM实例,几百个单元格写进去要来回调用多次,速度起不来。更麻烦的是部署,稍微要点安全权限的机器上,DCOM配置就能让你调试一整晚。我这边是要给同事的办公电脑分发工具的,不可能要求每个人都装Office再开宏权限,所以COM方案第一个被排除。
第二个常见方案是导出CSV。CSV确实简单,写个循环把数据拼成字符串存成txt就行。但问题在Excel打开CSV时对编码很敏感,UTF-8无BOM的中文内容经常乱码;而且CSV不支持多工作表、不支持合并单元格、不支持公式,也没有真正的列宽样式。如果只是导出最简单的一维表,CSV可以顶一下,一旦涉及"要好看一点的报表",它就无能为力了。
第三个路子是手写OOXML。xlsx文件本质是一个ZIP包,里面按固定目录结构放着一堆XML:[Content_Types].xml、_rels/.rels、xl/workbook.xml、xl/worksheets/sheet1.xml等等。理论上你可以用QZipWriter把这些XML拼出来。但OOXML的关系结构非常绕,少写一个relationship、命名空间拼错一个前缀,Excel打开就会弹"文件损坏,是否修复"。我自己试过做一个最简单的三行表,调试那个package关系就耗了一天,维护成本太高,不适合作为正式功能。
QtXlsx走的是另一条路:它把这些XML生成逻辑全部封装成QXlsx::Document这样的对象模型,你只管对着单元格写数据、设格式,剩下的文件打包细节库内部处理。它不依赖Office安装,不依赖Python运行时,是一个纯C++的静态/动态库,放进Qt工程就能用,正好契合"桌面端离线生成Excel"这个核心诉求。
1.2 QtXlsx的原理与能力边界
理解QtXlsx的工作方式,对后面排查问题很有帮助。它把xlsx文件当作一个普通的ZIP包处理,写入时在内存里构建xl/worksheets/sheet*.xml、xl/sharedStrings.xml等XML文档,再用Qt自带的zip能力压缩打包;读取时则做逆过程,解包、解析XML、把单元格内容还原成QVariant。所以它的运行不依赖Excel进程,这也是它能做轻量部署的底层原因。
能力边界要提前说清楚。QtXlsx支持的是OOXML规范里的常用部分:单元格读写、字符串和数字类型、日期时间、公式的写入和缓存值读取、字体加粗/颜色/对齐、背景填充、边框、合并单元格、行高列宽、冻结窗格、多工作表。它不支持图表、图片嵌入,也不支持读取条件格式和数据透视表这类高级对象。另外它只支持.xlsx,不支持老的.xls二进制格式。如果你需要的功能超出了这个范围,可以考虑社区维护度更高的QXlsx分支,但API可能和原版有些出入,具体以你拉到的源码头文件为准。
2. 编译前的环境准备:组件、版本与Perl依赖
2.1 组件清单与版本对应关系
我这边最终使用的环境组合如下,建议照着这个版本对应关系来,不要混搭:
| 组件 | 版本 | 备注 |
|---|---|---|
| Qt | 5.15.2 | 通过在线安装器安装,选择msvc2019_64组件 |
| 编译器 | VS2019 MSVC v142工具集 | 务必和Qt组件对应的编译器一致 |
| IDE | Visual Studio Community 2019 | 安装"使用C++的桌面开发"工作负载 |
| Qt VS Tools | 最新版 | 扩展里把Qt版本路径配到msvc2019_64目录 |
| Perl | 任意Windows发行版 | 构建Qt模块时需要,后面细说 |
| qmake/nmake | 随Qt安装自带 | 开始菜单有对应的命令行快捷方式 |
安装Qt时有一个常见误区:看到"MinGW 64-bit"就顺手勾了,结果用VS2019去建工程,编译QtXlsx时一堆链接错误。Qt 5.15的组件里,msvc2019_64是给MSVC编译器用的,MinGW是另一套工具链。如果你用VS2019写代码,组件一定要选msvc2019_64,否则后面静态库和动态库的符号约定对不上。
2.2 Perl这个不起眼的隐藏依赖
第一次在命令行跑qmake编译QtXlsx时报了个错,信息大致是找不到Perl,我当时第一反应是"一个C++项目要Perl干嘛"。后来才明白,Qt的模块化构建体系里有个叫syncqt的脚本,用于同步模块的头文件结构,它是用Perl写的。QtXlsx以Qt模块的标准形态组织源码,qmake解析工程时会触发这个脚本,所以构建环境里必须有一个可被命令行找到的Perl。
解决方法是装一个Windows版的Perl发行版,常见的是Strawberry Perl,装完后把它的安装目录加进系统PATH。注意改完PATH要重新打开命令行窗口,环境变量不会自动刷新到已开的窗口。另外,如果你之前装过其它Perl发行版,在命令行里执行perl -v确认一下版本,避免PATH里有多个Perl互相干扰。
2.3 打开一个干净的构建命令行
编译QtXlsx不需要打开VS2019图形界面,直接在命令行里做反而干净。关键是让命令行同时拿到两套环境:VS2019的编译环境变量和Qt的bin目录。
我习惯这样操作:打开"x64 Native Tools Command Prompt for VS2019",然后手动把Qt的bin目录追加到PATH里:
set PATH=C:\Qt\Qt5.15.2\5.15.2\msvc2019_64\bin;%PATH%也可以用Qt安装器生成的"Qt 5.15.2 (MSVC 2019 64-bit)"快捷方式,那个已经把QTDIR设置好了,但为了保险,我还是建议在VS2019的原生命令行里把Qt路径手动加一遍。检查环境是否就绪,执行:
qmake -v cl nmake /?三条命令都不报"不是内部或外部命令"就算OK。
3. 从源码构建QtXlsx的完整过程
3.1 获取源码与分支选择
在代码托管平台搜索QtXlsx,能看到原版仓库和一个长期维护的QXlsx分支。考虑到当前用的是Qt 5.15,原版就能正常编过,我直接拉的原版master分支。拉代码时注意不要用发布页的zip包和git clone混着来,选一种方式即可,clone我记得在Windows上更省事:
cd /d D:\Libs git clone https://host/QtXlsx.git cd QtXlsx如果网络不方便clone,下zip包解压到D:\Libs\QtXlsx也是一样的。源码目录结构里,核心代码在src/xlsx下,顶层有个QtXlsx.pro,这个pro就是整个工程的构建入口。README里如果写了构建说明,建议先瞄一眼,不同tag的构建方式略有差异。
3.2 qmake和nmake实践记录
构建分两步,先qmake生成Makefile,再nmake编译。在源码根目录执行:
qmake QtXlsx.pro nmake如果Qt安装目录的bin下有jom.exe,可以把nmake换成jom并行构建,速度会快不少:
jom -j8这里有几个细节经验。第一,建议在干净的源码目录里直接qmake,不要用shadow build,因为后面要用.pri文件时,源码路径和生成路径隔太远反而麻烦。第二,如果顶层pro把examples一起纳入构建导致报错,可以只进src目录构建核心库:
cd src qmake src.pro nmake第三,Debug和Release要分别编一次。先默认编Release,再把构建方式切到debug编一遍,后面集成时就不会出现Debug工程链Release库的问题。切换方式是在命令行里给qmake传配置参数:
qmake CONFIG+=debug .. nmake看到生成了Qt5Xlsx.dll、Qt5Xlsx.lib这类文件,编译就算通过了。
3.3 验证构建产物
构建完成后,去src/xlsx目录或者你在qmake时指定的输出目录看一眼,常常能看到这些文件:动态库文件、导入库文件、以及一堆头文件。以我的目录规划为例,我会把库文件和头文件单独整理出来,方便多个工程共用:
D:\Libs\QtXlsx\include\ (*.h,从src\xlsx拷过来) D:\Libs\QtXlsx\lib\ (Qt5Xlsx.dll、Qt5Xlsx.lib)注意Debug版库名可能带d后缀,比如Qt5Xlsxd.dll,这是Qt的命名习惯,后面配置工程时要把Debug和Release的库名区分开。整理完后可以用dumpbin /dependents看一下动态库依赖了哪些Qt库,如果提示找不到某个Qt DLL,先把Qt的bin目录加到PATH再执行。
4. 在VS2019工程里集成QtXlsx
4.1 方式一:qtxlsx.pri源码直引
集成方式有两条路,第一条是用QtXlsx自带的.pri文件把源码直接纳入你的工程。.pri文件是qmake的"子项目文件",里面已经写好了需要编译的源文件列表和头文件路径。在你的.pro工程里加一行include,编译时这些源码会跟着你的工程一起编进exe里。
假设我的工程pro文件在E:\Code\Reporter\Reporter.pro,QtXlsx源码在D:\Libs\QtXlsx,那么在pro里加:
include(D:/Libs/QtXlsx/src/xlsx/qtxlsx.pri)这里路径建议用绝对路径或者基于$$PWD的相对路径:
include($$PWD/../3rdparty/QtXlsx/src/xlsx/qtxlsx.pri)用$$PWD的好处是工程换目录后qmake自动重新解析,不用手改路径。注意路径里的斜杠统一用/,反斜杠在pro里有时会出问题。
这种方式优点是配置最简单,不需要关心库文件的Debug/Release配对问题,因为它根本没走链接库那条路,所有源码直接打在exe里。缺点是每次全量编译会多编一批xlsx相关的cpp文件,首次编译时间长一点。我个人做小工具时优先用这种方式。
4.2 方式二:链接编译好的Qt5Xlsx库
如果工程已经比较大,或者多个工程要共用同一个QtXlsx库,建议用第二种方式:链接我们之前编译好的库文件。在pro里配置:
INCLUDEPATH += D:/Libs/QtXlsx/include CONFIG(debug, debug|release) { LIBS += -LD:/Libs/QtXlsx/lib -lQt5Xlsxd } else { LIBS += -LD:/Libs/QtXlsx/lib -lQt5Xlsx }-L指定库搜索目录,-l指定库名。qmake在MSVC下会把-lQt5Xlsx自动解析成Qt5Xlsx.lib,Debug版的d后缀也是在CONFIG判断里手动加的,这一步一定要和实际构建出的库文件名对应上,否则链接时找不到库。
如果你用的是CMake而非qmake,那么配置思路一样,只是写法不同:include_directories指向头文件目录,target_link_libraries传入完整路径的.lib文件。VS2019配合Qt VS Tools的话,qmake工程和原生VS工程都能用Qt VS Tools建,但前提是在扩展设置里把Qt版本正确指到C:\Qt\Qt5.15.2\5.15.2\msvc2019_64,这一项配错会导致Qt头文件找不到。
4.3 DLL部署与MSVC编码开关
如果用动态库方式集成,运行时需要把Qt5Xlsx.dll放到exe同目录。方法是在VS工程属性里加一条后期生成事件:
copy /Y D:\Libs\QtXlsx\lib\Qt5Xlsx.dll "$(OutDir)"用.pri源码直引方式的话,这一步可以跳过。但不管哪种方式,最终发布时都建议用Qt自带的windeployqt工具,把依赖的Qt基础DLL一起收集齐全,不然到了没装Qt的机器上,运行时大概率提示缺少Qt5Cored.dll或Qt5Core.dll。
另外在pro里给MSVC加一个编译选项,强烈建议:
msvc: QMAKE_CXXFLAGS += /utf-8这个开关告诉编译器源码文件按UTF-8解释。QtXlsx的头文件和你的cpp里如果包含中文注释或中文字符串,不加这个选项,MSVC很容易报C4819警告,严重的会把中文字符串字面量解析成乱码写进Excel。加了之后中文存储问题基本一次解决。
5. 实战代码:读写Excel的核心实现
5.1 写入示例:生成带格式的报表
配置完成后,写一个最小demo验证环境。创建新工作簿,写几行数据,设置表头格式,合并标题行,最后保存文件:
#include <QGuiApplication> #include "xlsxdocument.h" #include "xlsxformat.h" int main(int argc, char *argv[]) { QGuiApplication app(argc, argv); QXlsx::Document doc; // 标题格式 QXlsx::Format titleFormat; titleFormat.setFontSize(14); titleFormat.setFontBold(true); titleFormat.setHorizontalAlignment(QXlsx::Format::AlignHCenter); // 表头格式 QXlsx::Format headerFormat; headerFormat.setFontBold(true); headerFormat.setPatternBackgroundColor(QColor(217, 225, 242)); headerFormat.setFillPattern(QXlsx::Format::PatternSolid); // 第一行合并作为标题 doc.mergeCells(QXlsx::CellRange("A1:D1")); doc.write(1, 1, "项目执行情况统计", titleFormat); // 第二行表头 doc.write(2, 1, "编号", headerFormat); doc.write(2, 2, "名称", headerFormat); doc.write(2, 3, "启动日期", headerFormat); doc.write(2, 4, "按期完成", headerFormat); // 数据 doc.write(3, 1, "PRJ-001"); doc.write(3, 2, "模拟项目X"); doc.write(3, 3, QDate(2025, 6, 1)); doc.write(3, 4, true); doc.write(4, 1, "PRJ-002"); doc.write(4, 3, QDate(2025, 7, 15)); doc.write(4, 4, false); // 列宽行高调整 doc.setColumnWidth(1, 16); doc.setColumnWidth(2, 30); doc.setRowHeight(1, 32); doc.setRowHeight(2, 24); if (doc.saveAs("report.xlsx")) { qDebug() << "保存成功"; } else { qDebug() << "保存失败"; } return 0; }这里要注意mergeCells的参数是QXlsx::CellRange对象,直接传"A1:D1"这个字符串在某些版本上也能隐式转换,但我建议显式构造,减少编译报错的概率。write函数是按行号列号从1开始定位的,不是0开始,这个和数组思维不一样,我第一次写成了write(0, 0, ...)结果内容跑到了奇怪的位置。
5.2 读取示例:遍历工作表数据
读取已有工作簿用的是Document的另一个构造方式,然后通过dimension()拿到有数据的范围,再逐格读取:
#include "xlsxdocument.h" #include <QDebug> void dumpExcel(const QString &filePath) { QXlsx::Document doc(filePath); if (!doc.isLoaded()) { qWarning() << "加载失败:" << filePath; return; } QStringList sheetNames = doc.sheetNames(); qDebug() << "工作表:" << sheetNames; QXlsx::CellRange range = doc.dimension(); int lastRow = range.lastRow(); int lastCol = range.lastColumn(); for (int row = 1; row <= lastRow; ++row) { QStringList line; for (int col = 1; col <= lastCol; ++col) { QXlsx::Cell *cell = doc.cellAt(row, col); if (!cell) { line << QString(); continue; } QVariant value = cell->readValue(); line << value.toString(); } qDebug() << "行" << row << line.join("|"); } }isLoaded()用来判断文件是否解析成功,如果传入的文件不是合法xlsx,这里会返回false,比直接崩溃友好得多。遍历时cellAt(row, col)返回null表示该单元格为空,这是很常见的判断。dimension()返回的是包含所有非空单元格的最小矩形,如果某个工作表只有右下角一个单元格有值,它的范围就是那个点,所以遍历前建议自己额外再确认一下范围是否符合预期。
5.3 格式化、公式与多表操作的细节
读写过程中,日期处理是大家问得最多的。写入时传QDateTime或QDate,QtXlsx内部会转成Excel的序列号存储,读取时用cellAt拿到的QVariant里保存的是QDateTime类型,可以直接toDateTime()转出来:
if (cell->cellType() == QXlsx::Cell::DateType) { QDateTime dt = value.toDateTime(); qDebug() << dt.toString("yyyy-MM-dd"); }公式写入也很简单,write函数的value如果是字符串,被识别成公式的条件是字符串以=开头:
doc.write(5, 5, "=SUM(B3:B4)");读取公式单元格时,readValue()拿到的是公式的缓存结果值,用cell->formula()才能拿到公式文本。要注意不是所有写入公式的场景都有缓存值,有些工具生成的xlsx里公式只有公式没有缓存,读出来的QVariant可能就是无效的。
多工作表操作,在同一个Document对象上加表、切换表、写数据:
doc.selectSheet("Sheet1"); doc.write(2, 2, "第一张表的内容"); doc.addSheet("汇总"); doc.selectSheet("汇总"); doc.write(1, 1, "合计"); doc.write(2, 1, "=Sheet1!B2");切换表以后再写数据,写的目标是当前选中的那张表,所以保存前最好确认当前工作表是不是你想写的那张。我吃过一次亏:数据写完了发现全跑到活跃表里去了,另一张表还是空的。
6. 常见问题与排查思路实录
6.1 高频报错对照表
把这一路踩过的坑整理成表格,基本覆盖了这套环境下的典型问题:
| 现象 | 直接原因 | 处理办法 |
|---|---|---|
| qmake报找不到Perl | 构建Qt模块需要syncqt脚本 | 安装Perl发行版并重新打开命令行确认PATH |
| 编译报LNK2019/LNK2001 | QtXlsx库编译用的编译器和当前工程不一致 | 确保都用MSVC2019,不要和MinGW产物混用 |
| 运行提示找不到Qt5Xlsx.dll | 动态库未拷贝到exe目录 | 添加后生成事件copy DLL到输出目录 |
| 中文字符串乱码或C4819 | MSVC按本地代码页解析源文件 | 加/utf-8编译选项,源码保存为UTF-8 with BOM |
| Excel打开提示文件损坏 | XML格式异常或非法控制字符 | 先用压缩工具解包检查sheet1.xml,定位特殊字符串 |
| 链接找不到Qt5Xlsxd.lib | Debug/Release库名不匹配 | 按CONFIG宏分别指定带d和不带d的库名 |
| 读出的日期全是无效值 | 单元格类型判断漏了DateType | 用cellType()分支处理后再toDateTime |
| 只有部分单元格被读到 | 行号列号从0开始写 | 记住QtXlsx是1-based定位,修正循环起点 |
第一个报错最迷惑,因为它看起来和你的代码毫无关系。第二个报错排在第二位,因为很多人会忘记在"链接器-输入"里添加库文件名。后面几个是运行时和编译期交叉出现的问题,按表格顺序排查基本能覆盖。
6.2 三个值得记住的排错习惯
第一个习惯是"把xlsx当压缩包看"。遇到Excel打不开或用QtXlsx读不出来时,别急着改代码,先复制一份文件,改后缀为zip,用解压工具打开。xlsx的目录结构是否完整、xl/worksheets/sheet1.xml里的XML是否解析正常,一眼就能看出问题。有次我发现生成的文件里有个单元格字符串带了控制字符,Excel打开就报修复,就是靠这个办法定位到的。
第二个习惯是"库文件编译环境只信命令行"。VS2019的IDE界面里要确认当前用的是哪个Qt版本、哪个编译器工具集,很容易看错。但命令行下qmake -v和cl输出的信息是最直接的。库文件和主工程务必用同一套编译环境,这一步确认过,后面链接错误至少少一半。
第三个习惯是"先最小复现再上量"。用QtXlsx写到几千行时性能还可以接受,但如果你的程序每写一个单元格都调一次write后再saveAs,大文件会非常慢。正确做法是把所有数据先写入内存里的Document,最后统一保存一次;如果数据量特别大,按批次分文件保存,不要一个sheet堆到几十万行,Excel本身对这种超宽表也不友好。
最后再分享一个自己用得顺手的扩展点:QtXlsx不直接支持图片,但我经常需要在报表里放一个二维码或条形码。我的做法是用QPainter把二维码画到QPixmap,再把图片转成Base64字符串,在别的列里用文本方式存储,或者单独生成一个图片目录和xlsx配套分发。这个方法不优雅,但在不引入更多依赖的前提下解决了需求。如果你也遇到类似的"库能力不够但不想换方案"的情况,这种把文件拆开处理的思路可以临时顶一阵子,等需求复杂到一定量级,再考虑升级到功能更全的QXlsx分支也不迟。