简介:面向Qt开发者的Excel读取与表格展示方案,适用于需要在Qt界面中导入Excel数据并呈现到QTableWidget的场景,尤其适合具备基础Qt知识、想快速实现表格联动功能的开发者。压缩包基于Qt5编写,共8个文件,3个cpp与2个h构成核心封装与界面逻辑,同时附有ui布局文件、pro工程配置以及user辅助文件;整体大小仅8KB,结构紧凑,下载后即可对照工程结构理解调用关系。其中C++源文件分工明确,ui文件用于设计表格显示区域,pro文件声明了所需依赖模块。作者实测可运行,已吸引2997人学习下载。通过demo可直观看到excelengine封装类的使用方法,掌握如何解析指定Excel内容并填充到表格控件中;轻量级代码便于修改移植,对处理Office表格交互的Qt入门者是一个省时省力的参考。
1. 拿到一个 Qt 项目先解决 Excel 读取:封装和 Demo 到底教会了你什么
做桌面数据工具的人,多半碰过这句话:“把这张 Excel 导进界面里。”QTableWidget 只管显示格子,它不认得 .xlsx;Qt 自带模块里也没有 Excel 解析接口。这个标题指向的资源包,是一套把读取逻辑抽出来的封装类,加一个能直接落到工程里的 Demo:给文件路径,返回一张填满数据的表格。适合正被导入需求卡住的 Qt 新人,也适合想把数据源换成 CSV、数据库、Excel 的中间层做替换的老手。这份资源的价值不在“能跑”,而在“读完能自己扩”。
2. 读 Excel 之前先选路:QXlsx、QAxObject 与 ODBC 到底差在哪
2.1 三种主流方案的依赖与适用边界
Qt 里读 Excel 不是没有方案,而是方案太多,选错后面全是坑。我最早做这个需求时,先试的是 QAxObject——因为网上教程最多。后来换了 QXlsx,再后来某公司内部工具里又见过用 ODBC 驱动读 Excel 的写法。三条路我都走过,先说清楚它们各自的边界。
QXlsx 是开源库里最常见的选项,基于 Qt 自身的 ZIP 和 XML 解析,不需要安装 Office,跨平台,支持 .xlsx(不支持老 .xls)。它读的是单元格值和部分格式,对纯数据导入场景最合适。QAxObject 走 COM,本质上是把 Excel 进程拽到后台干活,能够拿到公式计算结果、样式、合并单元格这些“所见即所得”的细节,但依赖 Windows 和 Office/WPS,发布到没装 Office 的机器上直接失效。ODBC 方案是拿 SQL 查 Excel,把 sheet 当表,适合几十万行的大文件过滤查询,但列类型推断、公式、合并单元格这些基本别指望。
| 方案 | 运行依赖 | 格式支持 | 跨平台 | 典型性能(万行10列) | 适用场景 |
|---|---|---|---|---|---|
| QXlsx | 无 | xlsx | 是 | 300-600ms | 日常读取、写入、分发 |
| QAxObject | Windows + Office | xls / xlsx | 否 | 2-4 秒 | 需要公式值、样式还原 |
| ODBC 驱动 | Windows + 驱动配置 | xls / xlsx | 否 | 1 秒左右 | 大表 SQL 过滤、聚合 |
表里这个“万行读取时间”是我在普通办公机上反复跑出来的参考区间,不是官方标称。QXlsx 慢在解析 XML,QAxObject 慢在 COM 启动 Excel 进程,ODBC 快在把 Excel 当数据库查询。
2.2 QAxObject 的代码滋味:COM 方式读取一段
QAxObject 用起来最直观的是“模拟人操作 Excel”,代码长,但概念好懂:
QAxObject* excel = new QAxObject("Excel.Application", this); excel->setProperty("Visible", false); // 后台运行,不弹窗口 QAxObject* workbooks = excel->querySubObject("Workbooks"); QAxObject* workbook = workbooks->querySubObject("Open(const QString&)", filePath); QAxObject* worksheet = workbook->querySubObject("Worksheets(int)", 1); QAxObject* usedRange = worksheet->querySubObject("UsedRange"); int rows = usedRange->property("Rows").toInt(); int cols = usedRange->property("Columns").toInt();逻辑说明:这串代码每次都把 Excel COM 对象拉起来,读完后必须释放。Release 顺序反过来:先 workbook->dynamicCall("Close()"),再 excel->dynamicCall("Quit()"),最后 delete 对象。少一步,任务管理器里就挂一个 Excel 僵尸进程。参数说明:Worksheets(int)起始下标是 1 不是 0,和 Qt 里 QTableWidget 的行习惯正好相反,写的时候最容易错位。
2.3 我为什么最终把 QXlsx 定为默认选型
某公司内部有个工具,最初用 QAxObject 读员工填表的 xls,结果现场有些电脑装了精简版 Office,COM 组件注册不完整,程序读出来全空。后来切 QXlsx,只允许上传 xlsx,问题消失。分发成本也低:QXlsx 是源码编译,直接进你的工程,没有额外运行库。
但 QXlsx 有个公认的边界:不执行公式计算。它读到的公式结果是 Excel 文件里缓存的值,如果一个 xlsx 是程序生成的、没有计算缓存,那公式列读出来就是空。这个特点放在这篇文章后面避坑部分细说。对纯导入展示场景,你的数据表基本都是手工填的,没有公式;有公式的,要么让用户另存为值,要么你接受展示原始公式字符串。选型结论:默认 QXlsx,只有必须读 xls 老文件或必须还原公式值时再考虑 QAxObject。
3. 动手封装 ExcelReader 类:接口设计、核心实现与类型处理
3.1 类接口设计的思路:调用方不应该看到 QXlsx 的任何类型
把读取逻辑封装成独立类,最直接的收益是业务代码里不再出现#include "xlsxdocument.h"。如果你后续想把数据源换成 CSV 或数据库,只需要换掉这个类的实现,窗口代码一行不用动。先看头文件:
#ifndef EXCELREADER_H #define EXCELREADER_H #include <QObject> #include <QVariant> #include <QStringList> class ExcelReader : public QObject { Q_OBJECT public: explicit ExcelReader(QObject* parent = nullptr); ~ExcelReader(); bool open(const QString& filePath); // 打开 xlsx 文件 bool readSheet(int sheetIndex); // 读取指定 sheet 到内存 int rowCount() const; // 当前 sheet 的总行数 int colCount() const; // 当前 sheet 的总列数 QVariant data(int row, int col) const; // 按 0 基坐标取单元格 QStringList sheetNames() const; // 所有工作表名称 bool setCurrentSheet(const QString& sheetName); // 按名称切换工作区 void close(); // 释放文件句柄与内存 private: struct PrivateData; PrivateData* d; }; #endif逻辑说明:这里用了 pimpl 惯用法,QXlsx 的 Document 指针被藏进 PrivateData 结构体。好处有二:一是把 QXlsx 依赖隔离在 ExcelReader.cpp 内部,窗口里包含 ExcelReader.h 不会被 xlsxdocument.h 污染;二是以后换库只改 cpp,头文件接口稳定。参数说明:rowCount 和 colCount 返回的是“当前读入的 sheet”的维度,不是整个工作簿的维度,所以 readSheet 之后这两个值才有效。
3.2 open 与 readSheet 的实现:校验、加载与维度获取
QXlsx 的 Document 构造函数里可以直接传路径,构造时不暴露失败标志,所以 open 里必须有明确的失败检查。我的做法是先查文件存在性和后缀,再用 sheet 列表非空判断加载成功:
#include "ExcelReader.h" #include "xlsxdocument.h" #include "xlsxcellrange.h" #include "xlsxcell.h" #include "xlsxformat.h" #include <QFileInfo> struct ExcelReader::PrivateData { QXlsx::Document* doc = nullptr; QXlsx::Worksheet* workSheet = nullptr; int rows = 0; int cols = 0; }; ExcelReader::ExcelReader(QObject* parent) : QObject(parent), d(new PrivateData) { } ExcelReader::~ExcelReader() { close(); delete d; } bool ExcelReader::open(const QString& filePath) { close(); QFileInfo fi(filePath); if (!fi.exists()) { return false; } if (fi.suffix().toLower() != QStringLiteral("xlsx")) { return false; } d->doc = new QXlsx::Document(filePath); if (!d->doc) { return false; } // 构造后立即检查 sheet 总数,加载失败时 doc 内部无任何工作表 if (d->doc->sheetNames().isEmpty()) { delete d->doc; d->doc = nullptr; return false; } return true; } bool ExcelReader::readSheet(int sheetIndex) { if (!d->doc) return false; QStringList names = d->doc->sheetNames(); if (sheetIndex < 0 || sheetIndex >= names.size()) return false; QXlsx::Worksheet* ws = d->doc->workbook()->worksheet(names.at(sheetIndex)); if (!ws) return false; d->workSheet = ws; QXlsx::CellRange range = d->workSheet->dimension(); d->rows = range.rowCount(); d->cols = range.columnCount(); return (d->rows > 0 && d->cols > 0); }逻辑说明:open 里 delete 旧 doc 是为了同一个 reader 对象能反复 open 不同文件,不会内存泄漏。后缀只认 xlsx 是个强约束,如果遇到 .xls,Demo 的打开对话框会直接过滤掉,避免 QXlsx 解析到一半返回空表,用户还不知道哪里错了。参数说明:readSheet 的 sheetIndex 从 0 开始,与 QList 的索引习惯一致,但 QXlsx 内部 worksheet 的 cellAt 是从 (1,1) 开始的,这个偏移在 data() 里处理,调用方不需要关心。
3.3 data() 的类型处理:日期、空串与边界检查
单元格值的类型决定了你展示给用户的内容对不对,这是封装里最细的一层:
QVariant ExcelReader::data(int row, int col) const { if (!d->workSheet) return QVariant(); if (row < 0 || row >= d->rows || col < 0 || col >= d->cols) { return QVariant(); } QXlsx::Cell* cell = d->workSheet->cellAt(row + 1, col + 1); if (!cell) return QVariant(); QVariant v = cell->value(); // 1) 日期序列号:在 xlsx 里日期存成 double,QXlsx 提供了 dateTime() 接口 if (v.type() == QVariant::Double || v.type() == QVariant::Int) { QDateTime dt = cell->dateTime(); if (dt.isValid()) { return dt.toString("yyyy-MM-dd HH:mm:ss"); } } // 2) 空字符串统一转成 invalid,方便上层区分“没数据”和“空字符串” if (v.type() == QVariant::String && v.toString().isEmpty()) { return QVariant(); } // 3) 公式单元格:QXlsx 不计算,只取缓存值 QString formula = cell->formula(); if (!formula.isEmpty()) { QVariant cache = cell->value(); if (cache.isValid()) return cache; return QVariant(QStringLiteral("=") + formula); } return v; }逻辑说明:日期转换是这里最容易出错的地方。Excel 把日期存成浮点数序列,1900 日期系统下 2024-06-01 对应一个很大的 double,直接 toString 会显示成一串小数。cell->dateTime()是 QXlsx 内部根据单元格格式判断后生成的 QDateTime,比手写序列号转换可靠得多,还自动处理了 1904 日期系统。公式缓存值判断放在最后,避免把普通字符串当成公式处理。参数说明:data(0,0)返回第一行第一列,和 QTableWidget 的setItem(row, col)参数方向一致。实际使用中,日期列在 Demo 表格里直接显示成 "yyyy-MM-dd HH:mm:ss" 字符串,排序不会太准确,但作为展示足够。
3.4 释放资源的 close():不把文件句柄留到析构
QXlsx 的 Document 在析构时会自动释放文件资源,但你的业务对象可能长时间存活,手动 close 更稳妥:
void ExcelReader::close() { delete d->doc; d->doc = nullptr; d->workSheet = nullptr; d->rows = 0; d->cols = 0; }逻辑说明:close 之后 reader 回到“未打开”状态,可以重新 open 另一个文件。这里把三个状态值全部归零,避免重复使用时残留上一个文件的坐标。如果你在业务代码里反复调用 open 而不 close,QXlsx 内部缓存会导致内存缓慢上涨,跑一个长时间驻留的工具时尤其明显。参数说明:close 是幂等操作,多次调用也不会出错,因为 delete nullptr 是安全的。一般我在窗口关闭事件里调用一次,或者干脆在 reader 的析构函数里兜底。
4. 把数据装进 QTableWidget:Demo 的主流程、表头与多 sheet 切换
4.1 打开文件的信号连接与骨架
Demo 的界面极简:一个“打开”按钮、一个 QTableWidget、一个状态标签。按钮点击后弹 QFileDialog,选文件后调用封装好的 loader:
void MainWindow::onBtnOpenClicked() { QString filePath = QFileDialog::getOpenFileName( this, tr("选择Excel文件"), QString(), tr("Excel 2007+ (*.xlsx);;All Files (*)") ); if (filePath.isEmpty()) return; bool ok = loadSheetToTable(filePath, ui->tableWidget, 0, true); if (ok) { ui->labelStatus->setText( tr("读取成功:%1 行 %2 列") .arg(ui->tableWidget->rowCount()) .arg(ui->tableWidget->columnCount())); } else { ui->labelStatus->setText(tr("读取失败,请检查文件是否为 xlsx")); } }逻辑说明:QFileDialog 的过滤器把 .xls 挡在门外,这不是刻意为难用户,而是 QXlsx 确实读不了老格式,与其让用户选完报错,不如从一开始就不让他选。状态标签直接从 QTableWidget 的 rowCount/columnCount 统计,而不是从 reader 拿,这样可以验证 loader 是否正确设置了表格维度。参数说明:All Files (*)加在后面,是为了让用户能选无后缀文件,但 loader 内部还会再校验一次后缀,双重保险。
4.2 loader 主函数:首行当表头还是当数据
这块是 Demo 里最值得抄的一个函数。核心问题是:Excel 第一行往往是标题,直接塞进表格数据区显得很愣。所以 loader 加一个 bool 参数统一处理:
bool loadSheetToTable(const QString& filePath, QTableWidget* tw, int sheetIndex, bool firstRowAsHeader) { ExcelReader reader; if (!reader.open(filePath)) return false; if (!reader.readSheet(sheetIndex)) return false; int rows = reader.rowCount(); int cols = reader.colCount(); int dataStart = firstRowAsHeader ? 1 : 0; tw->clearContents(); tw->setRowCount(0); tw->setColumnCount(cols); if (firstRowAsHeader) { QStringList headers; for (int c = 0; c < cols; ++c) { QString cellText = reader.data(0, c).toString(); headers << (cellText.isEmpty() ? QStringLiteral("列%1").arg(c + 1) : cellText); } tw->setHorizontalHeaderLabels(headers); } int dataRows = firstRowAsHeader ? (rows - 1) : rows; tw->setRowCount(dataRows); for (int r = dataStart; r < rows; ++r) { int tableRow = r - dataStart; for (int c = 0; c < cols; ++c) { QVariant v = reader.data(r, c); QTableWidgetItem* item = new QTableWidgetItem(v.toString()); tw->setItem(tableRow, c, item); } } return true; }逻辑说明:设置表头时如果 Excel 第一行有空单元格,setHorizontalHeaderLabels 会生成空白标题,用户以为表头丢了。这里做补位,用“列1”“列2”兜底。tw->setRowCount(0)写在 setColumnCount 之前,是为了清掉上一次加载残留下的旧行,避免新数据行号错位。参数说明:firstRowAsHeader为 true 时表格行数要减 1,否则数据区底部会多一行空行。如果你的 Excel 文件前面有“说明”区域(前两行是标题,第三行才是表头),这个参数不够用,需要改成skipRows数字。
4.3 多 sheet 切换:下拉框联动表格刷新
单 sheet 的 Demo 好写,实际业务里一个 Excel 可能带好几个 sheet。我一般会在界面上放一个 QComboBox,打开文件后把 sheetNames 填进去,切换时重新 load:
void MainWindow::onSheetComboChanged(int index) { if (m_currentFilePath.isEmpty()) return; bool ok = loadSheetToTable(m_currentFilePath, ui->tableWidget, index, ui->checkBoxFirstRowAsHeader->isChecked()); if (!ok) { ui->labelStatus->setText(tr("Sheet %1 读取失败").arg(index + 1)); } }逻辑说明:这里不重新 open 文件,只传 sheetIndex 进去,ExcelReader 内部会重新读入指定工作表。因为文件路径没变,复用同一个文件句柄比重新 open 快。参数说明:QComboBox 的 index 从 0 开始,和 readSheet 的参数正好对上。如果你用了addItem(sheetName),index 不是 sheet 编号,需要记录映射关系,复杂度就上来了。一般我选择只存 sheetName,在 loader 里改成按名称读取,避免 index 错位。
4.4 Demo 的边界:它不负责写回 Excel
这个资源包里的 Demo 解决的是“读值显示”,不包含以下能力:合并单元格自动合并、公式重新计算、列宽按原始 Excel 自适应、行高样式还原。如果你的需求包含“导入后还能导出同格式 Excel”,那这套封装只能做数据层,样式层需要另配一套写样式。实际项目中,导出通常选 QXlsx 的写接口,重新生成一份新文件,而不是在内存里维护原始格式。Demo 里没做这部分是好事——让你看清楚边界,不会被一个“全能工具”打包成黑匣子。
5. 避坑指南:Qt 读 Excel 时最容易踩的四个坑
5.1 单元格明明有内容,读出来却是空白
现象:同一个文件用 Excel 打开,单元格里文字清清楚楚,QXlsx 读出来 toString() 却是空字符串。
原因:最常见的是公式单元格。QXlsx 默认cell->value()返回公式字符串“=SUM(B1:B5)”,如果这个公式在文件里没有缓存计算结果,或者缓存值本身就是空,读到就显示成空白。另一种是共享字符串解析问题,某些版本的 QXlsx 对 xlsx 里的 inline string 支持不全,内容被丢掉。
解决:先判断cell->formula()是否为空,非空时优先取缓存值,缓存无效就返回带“=”前缀的公式字符串,至少让用户看到这里是公式而不是空白。代码已在 3.3 节给出。当时我在某项目的报表导入功能里碰到整列空白,排查半天发现不是读取问题,是上游程序生成的文件没触发 Excel 重算公式。最终方案是要求上游生成时把公式列另外输出为值,或者导入前在 Excel 里按 Ctrl+A、Ctrl+C、Ctrl+V(粘贴为值)再保存。
5.2 日期显示成数字小数
现象:Excel 里是 2024-06-01,导入后 QTableWidget 显示 45414.0 或者 45414。
原因:xlsx 存储日期用的是序列号,单元格格式决定它显示成日期还是数字。QXlsx 的value()拿的是底层序列值,不碰格式,直接 toString 自然是一串小数。如果你不处理,所有日期列全部翻车。
解决:走cell->dateTime(),这个方法根据单元格的 format 判断日期系统,返回 QDateTime。3.3 节 data() 里已经包含了这层判断,但如果你自己写循环没用封装类,千万别手写“序列号转日期”,1900 和 1904 两套日期系统会让你的转换结果偏移几十天。某数据系统项目里就有人手写过,上线后在数据看板上差了一个月,最后改回 dateTime() 一次通过。参数说明:如果你只想显示日期不带时间,用toString("yyyy-MM-dd");要带时分秒就用完整格式。
5.3 大表格加载卡死界面,用户以为程序死了
现象:双击打开一个带 2 万行数据的表格,窗口立刻“未响应”,点哪都没反应。
原因:QXlsx 解析本身几百毫秒,慢的是 QTableWidget 逐格创建 item。2 万行乘 10 列就是 20 万个 QTableWidgetItem,每个都要走 setItem、触发视图刷新,主线程卡死是必然的。
解决:加载期间关掉刷新:
tw->setUpdatesEnabled(false); for (int r = 0; r < rows; ++r) { for (int c = 0; c < cols; ++c) { QTableWidgetItem* item = new QTableWidgetItem(reader.data(r, c).toString()); tw->setItem(r, c, item); } } tw->setUpdatesEnabled(true); tw->viewport()->repaint();逻辑说明:setUpdatesEnabled(false) 只停掉视觉刷新,不阻塞写入数据。填充完再恢复重绘,整个过程从“肉眼可见的卡死”变成“点击后短暂等待,然后一次性刷出来”。注意恢复刷新后要调 viewport()->repaint(),否则某些平台上表格区域会留白。参数说明:这招对 2 万行有效,5 万行以上该上异步还是得上异步。分布式渲染见第 6 章,不要只靠这一处优化硬撑。
5.4 中文路径和文件名导致读不出来
现象:代码里写死英文路径能读,用 QFileDialog 选中文路径就读不出来;偶尔带空格的文件读出来是全空表。
原因:QXlsx 内部走 ZIP 解析,在 Windows 中文环境下,路径的编码转换在某些版本里会翻车。项目目录带空格、网络盘符映射、中文文件名,都会触发类似问题。
解决:在 open 里对路径做一次标准化和本地编码往返:
QString nativePath = QDir::toNativeSeparators(filePath); QByteArray localPath = nativePath.toLocal8Bit(); QString safePath = QString::fromLocal8Bit(localPath);逻辑说明:toNativeSeparators 先把分隔符统一成 Windows 风格,toLocal8Bit / fromLocal8Bit 这对转换在 Windows 上能修正大部分中文路径乱码问题。如果还不行,兜底方案是把文件复制到临时目录(纯 ASCII 路径)再解析,虽然丑但稳定。我在分发现场遇到过客户把文件放在“桌面/新建文件夹(2)/”这种路径里的情况,这套兜底逻辑后来又救了几次。参数说明:这个修复不会影响 Linux 和 macOS,toLocal8Bit 在 UTF-8 系统上基本原样返回,可以放心加进去。
6. 进阶技巧:用 QTimer 分片插入,让大表加载不再“未响应”
QTableWidget 插入 item 的耗时瓶颈,不在创建对象本身,而在每次 setItem 都触发视图的局部刷新信号。一次插 20 万个 item,信号风暴就把主线程打满。第 5.3 节用 setUpdatesEnabled(false) 把刷新压抑到了最后,但如果文件大到 5 万行,单次循环还是会卡住事件循环,用户照样看到“未响应”。
我的做法是把插入拆成“小批量 + 定时器”。核心思路:一次只插 300 行,插完把控制权还给事件循环,30ms 后再插下一批。这样窗口在加载期间仍然能拖动,用户会产生一种“在滚动加载”的错觉,体验比卡死好很多:
void DemoWidget::startBatchLoad(ExcelReader* reader, QTableWidget* tw) { tw->setUpdatesEnabled(false); tw->clearContents(); tw->setRowCount(0); tw->setColumnCount(reader->colCount()); int batchRows = 300; int totalRows = reader->rowCount(); int currentRow = 0; QTimer* timer = new QTimer(tw); timer->setInterval(30); connect(timer, &QTimer::timeout, tw, [=]() mutable { int end = qMin(currentRow + batchRows, totalRows); int tableRow = tw->rowCount(); tw->setRowCount(tableRow + (end - currentRow)); for (int r = currentRow; r < end; ++r) { for (int c = 0; c < reader->colCount(); ++c) { QTableWidgetItem* item = new QTableWidgetItem(reader->data(r, c).toString()); tw->setItem(tableRow + (r - currentRow), c, item); } } currentRow = end; if (currentRow >= totalRows) { timer->stop(); timer->deleteLater(); tw->setUpdatesEnabled(true); tw->viewport()->repaint(); } }); timer->start(); }逻辑说明:这个方案没有动用线程,Excel 解析仍在主线程,但解析相对快,真正耗时的是 item 创建和表格重排。分片把一次次 item 创建摊到多个事件循环周期,窗口不会被一个超长 for 循环锁死。参数说明:batchRows 设 200-500 都行,太小则定时器频繁触发、CPU 占用高;太大又回到卡顿本位。2 万行、批 300 行、30ms 间隔,实测约 6 秒全部插入完成,期间按钮、滚动条全部可操作。
如果连解析也要异步,那就把 ExcelReader 丢进 QtConcurrent::run,只把二维 QVariant 矩阵返回到主线程,再走同样的分片插入。要注意的是 QTableWidgetItem 只能在主线程创建,工作线程里只能生成数据,不要试图在子线程 new item,否则 Qt 会直接崩溃。因为我和项目团队的旧代码在这上面翻过车,从那以后我每次写表格导入,都强制走一遍“选方案、封装 Reader、分片刷新、记录坏文件样本”的固定流程,坏数据复盘放到下一版里提前处理。希望帮到你。
本文还有配套的精品资源,点击获取