1. 为什么我在Qt项目里最终选了QXlsx来处理Excel
上个月在维护一套库存导出工具时,同事跑过来说:“这个导出功能能不能不要依赖本机Office?咱们的部署环境全是精简版系统,客户机器上装没装Excel都不一定。”我当时的方案是VAxObject调用COM接口,听着没问题,但细想就头疼——每次部署都要先摸一遍目标机器,Win7、Win10、Win11行为还不太一样,稍微缺个运行库就直接罢工。后来我花了一个周末把导出模块全部切到QXlsx,效果立竿见影,客户机器上再也不需要装任何Office组件了。
QXlsx是一个基于Qt的开源C++库,核心职责就是读写Microsoft Excel的xlsx文件。它不是用COM调用Excel进程,而是直接解析、生成xlsx文件本身。xlsx说到底是个zip压缩包,里面装着一堆描述工作簿、工作表、单元格、样式、公式的xml文件。QXlsx做的事情就是把“往A1单元格写入一段文字”翻译成对应的xml节点,再按规范打包成zip。整个过程完全不经过Excel,所以跨平台、不依赖Office环境、也不受服务端有没有图形界面的影响,这点在任何需要自动化出报表的桌面应用里都属于硬性需求。
这篇内容我打算把真正用得上的东西都摊开讲:从工程接入、写入读取、样式和公式,到实际踩过的坑和排查链路,再到最后我给自己的项目封装的一套小工具类。适合正在用Qt写桌面端、Web服务端的开发者,也适合刚接手项目、需要在短时间内搞定Excel导入导出的人。
顺便说一句,现在很多团队一提Excel处理,第一反应是python的pandas,但放在纯C++/Qt项目里,引入python这层就不太值了,部署成本、进程通信、类型转换全是负担。QXlsx直接把自己编进Qt项目里,你只需要Document xlsx; xlsx.write(...); xlsx.saveAs(...),十行代码就完成一次导出,这才是做桌面端该有的姿势。
2. 编译接入:qmake和CMake两种工程配置,我都试了一遍
2.1 把QXlsx源码直接“揉”进项目的做法
QXlsx的源码仓库不大,核心代码在src目录下。最简单的方式不是去编译一个静态库再链接,而是直接把src目录放进你的工程源码树里,通过它的pri文件引入。以qmake工程为例,你在项目根目录建一个3rdparty/qxlsx目录,把源码拷贝进去,然后在.pro文件里写一句话:
include(3rdparty/qxlsx/qxlsx.pri) QT += core gui CONFIG += c++17这里必须把C++17打开,QXlsx的新版本内部用了不少C++17语法,不开的话编译一堆报错,我第一次踩到的时候就卡在这里。include之后,qxlsx的源码会被当成你工程的一部分参与编译,链接时也不需要额外加dll或lib,发布时只要带上你自己编出来的可执行文件就行,这点对部署非常友好。
有的老教程会让你在.pro里手动加一堆HEADERS和SOURCES列表,现在不推荐这么干了,pri文件已经把包含路径、源文件、头文件依赖全部整理好了,直接include是最高效的。如果你同时使用Qt5和Qt6,需要注意qxlsx对两个大版本是分别维护的,拉源码时要选择对应分支。我这边主力环境是Qt 6.5,用的就是Qt6兼容分支。
2.2 用CMake构建项目时的配置方式
新项目里越来越多团队开始转向CMake。QXlsx官方也提供了CMake支持,接法很直接。假设你把QXlsx源码文件夹放在3rdparty/qxlsx,在CMakeLists.txt里这样写:
cmake_minimum_required(VERSION 3.16) project(QtXlsxDemo) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) find_package(Qt6 REQUIRED COMPONENTS Core Gui Widgets) add_subdirectory(3rdparty/qxlsx) add_executable(QtXlsxDemo main.cpp) target_link_libraries(QtXlsxDemo PRIVATE Qt6::Widgets qxlsx)add_subdirectory之后,QXlsx源码会作为子项目编进构建系统,target_link_libraries里写qxlsx即可。这里有个容易漏掉的点:你必须在find_package里把Qt6组件引全了,尤其是Qt6::Gui,QXlsx在内部生成图片、处理画刷、字体样式时会用到Gui模块。仅引Core的话连接阶段会出现一堆诡异的undefined reference,第一次遇到时很容易让人往qxlsx身上怀疑。
2.3 编译阶段最容易翻车的几个细节
第一,尽量不要图省事去网上找别人编译好的现成dll或lib。QXlsx版本很多,每个版本对Qt主版本的依赖不一样,你用的是Qt 6.5,别人那个库可能是按Qt 5.15编的,运行时符号匹配不上,查问题的时间比你直接编译源码久得多。第二,不要开启预编译头后把这几个头文件塞进PCH里,QXlsx的命名空间宏QXLSX_USE_NAMESPACE在很多头文件里用到了,在预编译头里的展开顺序一旦不对,会出大量“宏未定义”的报错。第三,Windows上如果你选择动态构建QXlsx,会需要定义QXLSX_LIBRARY_BUILD之类的导出宏,不同版本宏名不太一样,但直接把源码编译成静态库的方式一般不需要管这个,最省心。
我在给某个项目做技术调研时做了个小对比,把主流的几种方案拉到表格里看差异:
| 方案 | 依赖外部Office | 跨平台 | 读取能力 | 写入能力 | 上手成本 |
|---|---|---|---|---|---|
| QAxObject/COM | 需要 | 仅Windows | 强 | 强 | 中 |
| 直接拼CSV | 不需要 | 全部 | 无 | 基础文本 | 低 |
| libxlsxwriter | 不需要 | 全部 | 弱 | 较强 | 中 |
| QXlsx | 不需要 | 全部 | 强 | 强 | 低 |
对比下来,QXlsx在Qt生态内的综合性价比确实突出,尤其是读取这块,和写入几乎是对称的,不需要为“只读”单独再引一个库,维护成本一下就降下来了。
3. 写入和读取的核心API,用起来其实没那么玄乎
3.1 从创建文档到保存,一条最基础的链路
先看一段最直接、跑得通的代码。这段代码做的事情很简单:新建一个Excel文件,往第一个工作表写入几行数据,然后保存到磁盘:
#include "xlsxdocument.h" #include "xlsxworksheet.h" #include "xlsxcellrange.h" #include "xlsxformat.h" QXLSX_USE_NAMESPACE void createSimpleExcel(const QString& filePath) { Document xlsx; Worksheet* ws = xlsx.currentWorksheet(); ws->write("A1", "商品名称"); ws->write("B1", "库存数量"); ws->write("C1", "单价"); ws->write("A2", "机械键盘"); ws->write("B2", 125); ws->write("C2", 399.0); ws->write("A3", "无线鼠标"); ws->write("B3", 86); ws->write("C3", 129.0); xlsx.saveAs(filePath); }这里write函数的第一个参数是单元格坐标,可以传"A1"这种字符串,也可以传行列号write(2, 1, ...)。两种都支持,具体用哪个取决于你的代码上下文。我的习惯是:固定表头用字符串坐标,读起来直观;循环里填数据用行列号,因为行列是递增变量。
saveAs是直接把整个Document写到磁盘。QXlsx内部用的是zip打包逻辑,不是简单的xml文本写入,所以最终文件结构和Excel完全兼容。你在记事本里打开xlsx看到的是乱码,这是正常的,因为它本质是压缩包。
3.2 写入不同类型的数据,远不止字符串和数字
Excel单元格能承载的数据类型比很多人想象的丰富。QXlsx基本都覆盖到了:
// 写入日期时间 ws->write(4, 1, QDateTime::currentDateTime()); // 写入超链接 ws->write(5, 1, "https://example.com"); // 写入布尔值 ws->write(6, 1, true); // 写入公式 ws->write(7, 1, "=SUM(B2:B3)");日期时间写入后,在Excel里会识别为日期类型,右侧显示时可以指定格式。布尔值不仅写true/false,Excel文件内部其实会把它存成"1"或"0",但你在QXlsx里重新读出来时依然是QVariant::Bool类型,这一点库帮你处理好了。
这里有个值得注意的细节:write本质上是重载了非常多的函数签名,从字符串、整数、双精度、日期,再到QVariant,都做了适配。所以你在代码里传任何基本类型都可以直接工作,不需要像某些库那样先手动构造Cell对象、设置类型再塞进工作表。这份“无感适配”算是QXlsx很贴心的一面,写代码时体感很顺畅。
3.3 读取Excel时,最容易忽略的类型判断和空值
读取一个已有文件同样很简单:
Document xlsx("D:/data/import.xlsx"); if (!xlsx.load()) { qWarning() << "文件加载失败"; return; } Worksheet* ws = xlsx.currentWorksheet(); QString name = ws->read("A1").toString(); double count = ws->read("B2").toDouble();看起来确实和其它教程里写的一样。但真的用到生产环境时,有几类问题必须处理:
第一个是空单元格。如果用户表格里有没填的格子,read返回的是一个默认构造的QVariant,调用toString()得到空字符串,这还好。但如果你直接toDouble(),会得到0.0,这在统计报表里就是错误。正确做法是先判断isValid():
QVariant v = ws->read("C3"); if (v.isValid()) { double price = v.toDouble(); } else { // 处理空值,比如记录缺数据日志 }第二个是类型不确定。Excel里用户输入的数字可能是文本格式,也可能是数字格式。当你用read(...).toDouble()时,文本类型也能转成功,这是QVariant的韧性,但如果你需要严格校验,最好先看cell->dataType(),QXlsx里定义了Cell::Number、Cell::String、Cell::Date等枚举。当然这是进阶需求,普通填报表场景不必太较真。
第三个是日期读取。Excel里日期存储的底层是数字,QXlsx会自动把它识别成QDateTime类型,但你必须在读出来之后确认它的类型确实是日期,再转成字符串,否则你拿到的是那串浮点数字,看起来特别莫名其妙。经验是用read(...).toDateTime(),如果是日期类型会直接拿到格式化时间,如果确实只是个数字会返回无效的QDateTime,你再降级处理。
3.4 写入几千行数据时,性能上的两条经验
第一次用QXlsx写两万行数据时,我天真地写了个循环,每行都new一个Format对象,结果程序内存飙升。后来看QXlsx源码才知道,Format对象里维护了大量样式属性,每个实例在Document内部都有开销。正确做法是能复用的Format就定义在循环外:
Format headerFormat; headerFormat.setFontName("微软雅黑"); headerFormat.setFontSize(11); headerFormat.setFontBold(true); headerFormat.setPatternBackgroundColor(QColor(240, 240, 240)); for (int row = 0; row < data.size(); ++row) { ws->write(row + 2, 1, data.at(row).name); ws->write(row + 2, 2, data.at(row).count); }另一个性能点:如果只是导出大量数据而完全不需要样式,可以不用创建任何Format对象,让QXlsx以默认格式写入,这样文件和内存都会小不少。记住一个原则——样式是开销,用多少就创建多少。
4. 玩转样式、合并单元格、公式和图片,让导出报表真正“像个人做的”
导出数据只是第一步。客户真正抱怨的往往是“这个Excel打开格式全乱了,没法直接拿去汇报”。所以样式这块值得认真做。
4.1 Format对象的几种常见用法,一定要封装好
Format是QXlsx整个样式体系的核心。它背后对应的是Excel里那套富文本样式模型:字体、颜色、边框、填充、对齐、数字格式等。直接用起来其实很简单:
Format titleFormat; titleFormat.setFontName("微软雅黑"); titleFormat.setFontSize(16); titleFormat.setFontColor(QColor(0x33, 0x33, 0x33)); titleFormat.setHorizontalAlignment(Format::AlignHCenter); titleFormat.setVerticalAlignment(Format::AlignVCenter); Format headerFormat; headerFormat.setFontName("微软雅黑"); headerFormat.setFontSize(11); headerFormat.setFontBold(true); headerFormat.setPatternBackgroundColor(QColor(0xF2, 0xF2, 0xF2)); headerFormat.setBorderStyle(Format::BorderThin); Format dataFormat; dataFormat.setFontName("微软雅黑"); dataFormat.setFontSize(11); dataFormat.setBorderStyle(Format::BorderThin);这里最容易被忽略的是setBorderStyle需要配套四个方向的设置,不过QXlsx里的setBorderStyle方法默认一次性设置四边,你不用分别去指定“上边框”和“下边框”,这对开发者算是很友好。
但直接在一个函数里塞五六个Format变量,代码会迅速变成一坨。我的建议是写一个简单的报表样式构造器,比如createHeaderFormat()、createDataFormat()、createTitleFormat()。这样在调用处看到的是表意清晰的函数,而不是一堆Format参数的堆叠。
4.2 合并单元格和列宽,别把顺序搞错了
合并单元格是我用得最多的功能之一,一般用来做表头标题跨列居中。用法如下:
ws->mergeCells("A1:F1"); ws->write("A1", "2024年度库存汇总表");先merge再write,还是先write再merge?这是个细节顺序问题。实践下来两个都行,但我习惯先合并再写入,这样合并后的单元格在写入时逻辑更统一,不容易出现定位偏差。
合并单元格后的行高列宽也是个坑。很多项目生成的Excel,表头文字明明设置了居中,但列宽不够,字被截断了。你需要在合并后主动设置列宽:
ws->setColumnWidth(1, 20); ws->setColumnWidth(2, 15); ws->setColumnWidth(3, 12);列宽的单位是字符数,不是像素。20大概能容纳20个中文字符。你可以按内容长度估算,比如表头“商品名称”四个字加边距,给到15基本够。
还有一个隐蔽的细节:默认行高是自动的,但如果你把某几行标题字的字号调大,行高可能撑不起来。手动设置行高可以解决:
ws->setRowHeight(1, 30);这里序号从1开始,第一行就是表头行。如果是大标题行,一般给它30到40的高度才不显得挤。
4.3 公式写入后的缓存问题,必须提前知道
Excel公式写入很简单:
ws->write(8, 1, "=SUM(B2:B3)");但这里存在一个在工程上容易翻车的坑:当Excel打开这个文件时会自动重新计算公式,得到正确结果;但如果用某些轻量级的Excel表格预览组件(比如网页端在线预览)打开,它可能读取的是缓存值而不是重新计算。QXlsx在写入公式时默认是不会帮你预计算结果的。也就是说,如果你生成的xlsx文件里包含=SUM(B2:B3),你自己用Excel软件打开没问题,但客户如果在某个在线预览系统里看,很可能看到空白。为了解决这个问题,你需要养成一个习惯:在业务允许的情况下,先把公式结果算出来,写进单元格,同时再写公式。QXlsx支持用write的方式在同一个单元格里写入值和公式吗?API上没这么对称,但我通常这样处理——把公式写在旁边的“计算列”,把纯数值结果写进主列,必要时再加个下拉刷新按钮,让客户自己看得到两种形态。
4.4 图片插入和图表,这块做得比想象中稳
QXlsx支持插入图片,API非常简洁:
ws->insertImage(2, 2, "D:/logo.png");行列坐标是图片左上角位置。图片默认按原始大小插入,如果需要缩放,可以在插入前用QImage读取图片并处理尺寸:
QImage img("D:/logo.png"); img = img.scaled(QSize(120, 40), Qt::KeepAspectRatio); ws->insertImage(2, 2, img);insertImage的重载里直接支持QImage参数,所以你可以灵活处理图片尺寸。这个功能适合在报表页眉加公司Logo,比Excel的页眉配置还省事。
至于图表,QXlsx在较新版本里加入了少量图表能力,但远不如把数据交给Excel后手动做图表方便。我个人的看法是:程序生成报表时先把图表需要的原始数据列好,真正的图表让客户自己在Excel里根据透视表生成,这份自由度更高。除非你做的是完全自动化无人干预的报表系统,否则不要尝试用QXlsx硬画图表。
5. 我在真实项目中踩过的坑,以及完整的排查链路
5.1 中文乱码问题的排查全过程
项目里第一次遇到中文乱码时,我在浏览器预览生成的Excel文件,里面所有中文全变成了???。当时的排查步骤我按这三步走:
第一步,先确认Qt源码文件本身是否是UTF-8编码。Qt 5以上版本默认源码编码已经是UTF-8,但如果项目里其他老文件是GBK,文本编辑器又没正确识别,字符串里的中文在编译器眼里就是错乱的字节序列。解决办法是用Qt Creator重新保存为UTF-8,或在代码里明确#pragma execution_character_set("utf-8")(仅MSVC有效)。
第二步,确认读取的源数据是不是UTF-8。如果数据是从数据库拿出来的,要看数据库连接是否设置了UTF-8。曾有同事在MySQL连接串里漏掉了charset=utf8,导致读出来的是GBK字节。
第三步,检查QXlsx写文件时内部编码。QXlsx内部始终按UTF-8写xml,这是规范要求,不会改。如果你前面的两步没问题,那生成的Excel在Excel里打开应该不会乱码。乱码的另外一个大头其实是用Windows记事本打开xlsx文件去看xml内容,这当然乱码,但这不是程序bug,别被误导。
最终这个项目的乱码根因是数据库连接编码没设置,属于老生常谈,但当时让我困惑了两小时,因为现象完全出现在Excel输出端,差点去QXlsx的issue列表里发帖。
5.2 打开第一次正常、第二次就损坏文件的怪问题
这是个特别容易出现的病。第一次生成文件,Excel正常打开,第二次生成时覆盖同名文件,再打开就提示文件损坏。我排查了很久,最后发现是saveAs时文件正在被Excel进程占用,Windows上文件被占用后写入失败,但QXlsx没有抛出明显错误,表面上看起来一切正常,实际是在磁盘上留下了一个不完整的zip包。
排查思路是:第一次打开前旧文件不存在,写入正常;第二次Excel还停在打开状态,保存到同一路径时就写入失败。解决办法很简单:
QString tmpPath = filePath + ".tmp"; xlsx.saveAs(tmpPath); QFile::remove(filePath); QFile::rename(tmpPath, filePath);先存临时文件再改名,既避免了被占用问题,也防止了保存中途崩溃导致原文件损坏。这是处理文件导出的标准姿势,强烈建议用上。
5.3 行列号“差一行”的经典误会
QXlsx里你用write("A1", ...)是第一个单元格,用write(1, 1, ...)还是第一个单元格,这点是统一的。但有些老项目代码里会见到write(row - 1, col - 1)的写法,因为QXlsx早期某个分支的行列索引是从0开始的。如果你在网上下载的教程里混着这种写法,在新版上就会出现“表头整体上移一格、首行数据丢失”的问题。
我的排查经验是:如果导出的表格出现首行数据意外消失,或者所有数据都往左上偏移了一格,优先怀疑行列索引基准。统一改成从1开始即可。还要小心CellReference("A1")这种对象,它内部的行列号也从1开始,但会有人误以为它是零起始,写循环时多减了1。
5.4 saveAs和Document生命周期导致的进程卡死
还有一个每个项目几乎都会碰到的问题:Document对象在函数结束后才释放,但如果你在同一个函数里连续创建多个Document并不断saveAs,记得让对象尽早离开作用域,或者主动调用xlsx.clear()。在循环里反复创建Document,如果保存失败又没及时deleteLater,内存会越涨越多,程序在长时间运行后开始卡顿甚至崩溃。
诊断方法也简单,Windows上打开任务管理器看进程内存是否持续上升,Linux上用top或者ps跟踪。如果确认和Document创建相关,就在循环里用局部作用域包住每一次导出:
{ Document xlsx; // 写入数据 xlsx.saveAs(filePath); }作用域结束,Document析构,内存随之释放。这个习惯帮我规避掉了不少内存泄漏问题。
6. 我给项目封装的一个Excel导出小工具类
接触QXlsx一段时间后,我发现项目里到处是Document和Worksheet的裸调用。代码越写越散,于是做了一个极简的导出封装。设计目标只有一个:让上层业务方不用知道QXlsx的存在,传一个二维表格进去,拿到一个做好的Excel文件路径。
6.1 核心接口只保留三个
首先是写入表头类型的数据结构。我为业务方设计了一个最简单的SheetData结构:
struct SheetData { QString sheetName; QStringList headers; QList<QStringList> rows; };如果业务方手里是QAbstractTableModel之类更复杂的结构,我可以再写一个toSheetData的转换函数。导出接口只需要一个静态函数:
class ExcelExporter { public: static bool exportToFile(const QString& filePath, const QList<SheetData>& sheets); };支持一个工作簿里多个工作表。内部统一处理样式、列宽、表头冻结等细节。这样上层代码只需要构造数据,不需要管QXlsx的任何对象,依赖方向非常清晰。
6.2 封装内部的核心实现
bool ExcelExporter::exportToFile(const QString& filePath, const QList<SheetData>& sheets) { QXlsx::Document xlsx; Format headerFormat; headerFormat.setFontName("微软雅黑"); headerFormat.setFontSize(11); headerFormat.setFontBold(true); headerFormat.setPatternBackgroundColor(QColor(0xE0, 0xE0, 0xE0)); headerFormat.setBorderStyle(Format::BorderThin); Format dataFormat; dataFormat.setFontName("微软雅黑"); dataFormat.setFontSize(11); dataFormat.setBorderStyle(Format::BorderThin); for (const SheetData& sheet : sheets) { Worksheet* ws = xlsx.addWorksheet(sheet.sheetName); // 写表头 for (int col = 0; col < sheet.headers.size(); ++col) { ws->write(1, col + 1, sheet.headers.at(col), headerFormat); ws->setColumnWidth(col + 1, 18); } // 写数据 for (int row = 0; row < sheet.rows.size(); ++row) { const QStringList& rowData = sheet.rows.at(row); for (int col = 0; col < rowData.size(); ++col) { ws->write(row + 2, col + 1, rowData.at(col), dataFormat); } } // 冻结首行 ws->freezePanes(2, 1); } return xlsx.saveAs(filePath); }这个封装大概一百行,但把项目里的导出需求完全吸收掉了。样式统一调、列宽统一调、冻结窗格自动开,不会出现A模块导出的Excel长得和B模块导出的完全两张脸的情况。
6.3 什么时候不建议再继续封装下去
封装到这一步之后,我建议你就停下来。不要把QXlsx彻底包死,因为总会遇到特殊需求:某个客户要公式、另一个客户要插入Logo、第三个要自定义数字格式。如果把所有能力都封进你自己的工具类,那这个工具类会越做越臃肿,最终变成一个“夹生饭”——既不比直接用QXlsx简单,又比直接用QXlsx多了一层理解成本。
比较好的边界是:通用的、高频的做封装,特殊的、低频的直接在业务代码里用QXlsx原生API。保持这层弹性,比追求一个包罗万象的工具类实用得多。
最后再分享一个我个人的经验:如果你做的项目是给外部客户交付,最好在生成的Excel底部加上一个固定样式的工作表,叫“使用说明”,里面简单写清楚这个表格的数据来源、更新周期以及哪些单元格是公式计算列。这个做法看起来不起眼,但能有效减少客户“你生成的Excel是不是有问题”的反馈,省下大量的沟通成本。QXlsx让你有能力做这件事,那就顺手做上。