简介:一套基于Qt5的Excel读取与表格展示封装示例,面向需要在桌面应用中导入Excel数据并显示到QTableWidget的Qt开发者而设计。压缩包体积仅8KB,合计8个文件,涵盖3个cpp、2个h、1个ui、1个pro与1个user配置:cpp和h组成excelengine读取与数据封装层,ui定义了主窗口及表格布局,pro工程文件可直接使用Qt Creator加载编译,user中保存了本地构建配置。该demo在Qt5环境下已验证可运行,结构精简,将xls/xlsx解析、数据提取与表格填充逻辑分离,示例自带可运行的测试入口,启动后能直观看到Excel数据逐行填充到表格控件中的效果,方便对照源码理解封装边界。调用方只需把engine与widget集成到自身项目即可快速复用,降低了处理办公文档格式的入门成本。已有2997人学习/下载,适合Qt初、中级开发者学习封装思路,也可作为自研Excel工具模块的起步框架。
1. Excel 读取到 tablewidget:这个高频需求为什么值得封装
做桌面工具的人迟早会遇到这样一个需求:用户丢来一个 Excel,要求程序把里面的内容读出来、显示在表格里,再允许编辑或者导出。Qt 里最直接的承接控件就是 tablewidget,但“读 Excel”这一步远没有想象中那么简单——格式、编码、数据类型、大文件性能、样式还原,每一层都有坑。如果代码直接写在业务窗口里,第一版功能是通了,第二版换个文件格式或者换套界面,就要从头推翻。封装的价值在于把“读文件”和“填表格”拆成两个稳定的部分,业务层永远只面对一个统一的接口。这篇文章讲的就是这套封装的选型、核心代码、性能处理和落地排错,适合正在做数据导入、报表查看、批量录入类 Qt 桌面项目的开发者。
2. 读 Excel 的三种选型:QAxObject、QXlsx 还是 ODBC 最稳
2.1 QAxObject:Windows 下最省事,但线程和部署都是约束
QAxObject 是 Qt ActiveQt 模块里用来操作 COM 组件的类,读写 Excel 靠的是本机安装的 Office Excel 程序。写法和操作 Excel 的 VBA 几乎一一对应:启动 Excel.Application、打开 Workbooks、拿到 Worksheet、读 Cells。优点是功能最全,Excel 里能看到的合并单元格、背景色、字体、列宽、公式结果都能拿到;缺点是它依赖真实的 Office 环境,服务器上没装 Excel 就没法跑,而且跨不了平台。
常见做法是.pro里加一行QT += axcontainer,然后代码这样起步:
#include <QAxObject> QAxObject* excel = new QAxObject("Excel.Application", this); excel->setProperty("Visible", false); // 不弹出 Excel 窗口 QAxObject* workbooks = excel->querySubObject("Workbooks"); QAxObject* workbook = workbooks->querySubObject("Open(const QString&)", filePath); QAxObject* sheets = workbook->querySubObject("Worksheets"); int sheetCount = sheets->dynamicCall("Count()").toInt();这里querySubObject就是“从当前的 COM 对象再往下取一层子对象”。每次调用都会创建一个新的 QAxObject,用完要deleteLater(),不然对象会堆在内存里。Visible设成 false 是必须的,否则每次读取屏幕上都会闪一个 Excel 窗口,用户体验很差。
QAxObject 最大的暗坑是线程模型。COM 组件中的 Excel.Application 需要在 STA(单线程套间)里运行,而 Qt 默认界面线程恰好是 STA,所以你只能在主线程里用它。许多人第一次尝试把读取扔到 QThread 里,结果程序在启动 Excel 时直接崩溃或者一直卡死,这就是线程问题,不是代码写错了。另外,它本质是进程外 COM 调用,逐格读取速度很慢,后面第四章会讲怎么绕过。
2.2 QXlsx:跨平台纯 C++ 方案,样式覆盖有限
如果不想依赖 Office,或者程序要同时跑 Windows、Linux、macOS,常见替代方案是 QXlsx——一个基于 Qt 的 C++ 开源 Excel 读写库。它直接解析 xlsx 文件(本质是 zip 包里的 XML),不需要系统装任何办公软件,部署干净,也适合放到服务器环境里做批量导入。
用起来也很直白:
#include "xlsxdocument.h" QXlsx::Document doc(filePath); if (!doc.load()) { qWarning() << "文件加载失败:" << filePath; return; } int rowCount = doc.dimension().rowCount(); int colCount = doc.dimension().columnCount(); for (int row = 1; row <= rowCount; ++row) { for (int col = 1; col <= colCount; ++col) { QXlsx::Cell* cell = doc.cellAt(row, col); if (!cell) continue; // 稀疏存储,空格子直接跳过 QVariant value = cell->value(); // 这里把 value 放入你的数据结构 } }注意这里行和列的索引都是从 1 开始的,不是从 0 开始,很多人第一次写循环时容易越界或者漏掉第一行。dimension().rowCount()返回的是当前工作表的使用区域边界,理论上包含了所有编辑过的格子,但格式残留会让这个边界虚高,后面避坑章节会细说。
QXlsx 的劣势在于样式支持有限。它能读出加粗、底色这类基础的格式,但 Excel 里的图表、图片、数据透视表基本拿不到;遇到加密文件、旧版 .xls 也读不了。对新项目来说,如果只做数据导入导出,它是性价比最高的方案。如果必须兼容老旧的 .xls 和样式还原,就得回退到 QAxObject。
2.3 封装接口设计:读取与显示解耦,引擎可替换
不管底层用哪套方案,封装的目标是让业务层只看到一个稳定的数据结构和几个明确的接口。我一般会定义一个统一的内存表格结构,用来承载“某一行某一列是什么值、带什么格式”,然后让 loader 类负责从各种来源填满这个结构,再让一个独立的工具函数把结构渲染到 QTableWidget 上。
// ExcelLoader.h —— 封装思路的核心骨架 class ExcelLoader : public QObject { Q_OBJECT public: enum Engine { Auto, OLE, Xlsx }; // OLE 走 QAxObject,Xlsx 走 QXlsx struct CellData { QVariant value; // 原始值,保留类型信息 QString displayText; // 格式化后的展示文本 QColor backgroundColor; QColor foregroundColor; bool bold = false; Qt::Alignment alignment = Qt::AlignLeft | Qt::AlignVCenter; int rowSpan = 1; // 合并行数 int colSpan = 1; // 合并列数 }; struct SheetData { int rowCount = 0; int colCount = 0; QVector<QVector<CellData>> cells; // 行列索引从 0 开始,统一内部约定 }; public: bool load(const QString& filePath, Engine engine = Auto); SheetData data() const; bool fillTable(QTableWidget* table, const SheetData& data) const; };这里的关键决策是:读取和显示完全分离。load只负责把 Excel 解析成SheetData,fillTable只负责把SheetData渲染到控件上。这样以后要支持 CSV、支持网络下载的表格内容,只需要再实现一个load,完全不动渲染逻辑。内部引擎的差别也被挡在load一个函数里,业务侧根本感知不到。
一个常见的反面写法是在窗口构造函数里直接堆 QAxObject 操作,窗口类里全是 COM 调用。表面看代码量更少,但一旦 Excel 读取逻辑要复用、要加 loading 提示、要切换引擎,就只能复制粘贴然后改得七零八落。封装的收益不在第一版,而在第二次、第三次需求变化的时候开始体现。
3. 核心实现:解析 Excel 到内存数据,再填进 tablewidget
3.1 解析工作表:按行读取并转换为单元格数据
以 QXlsx 为例,我要把 sheet 里的内容读进SheetData。第一步是拿到行数列数,第二步逐格读取,第三步根据cell->value()的类型决定如何生成展示文本。代码里最容易忽略的是空格判断——xlsx 是稀疏存储,没写内容的格子返回空指针。
bool ExcelLoader::loadFromXlsx(const QString& path) { QXlsx::Document doc(path); if (!doc.load()) return false; int rows = doc.dimension().rowCount(); int cols = doc.dimension().columnCount(); m_data.cells.clear(); m_data.rowCount = rows; m_data.colCount = cols; m_data.cells.resize(rows); for (int row = 0; row < rows; ++row) { m_data.cells[row].resize(cols); for (int col = 0; col < cols; ++col) { QXlsx::Cell* cell = doc.cellAt(row + 1, col + 1); if (!cell) continue; CellData cellData; cellData.value = cell->value(); cellData.displayText = formatCellValue(cell->value(), cell->format()); // QXlsx 的 format() 能拿到加粗、底色等基础格式 QXlsx::Format fmt = cell->format(); if (fmt.fontBold()) cellData.bold = true; if (fmt.patternBackgroundColor().isValid()) cellData.backgroundColor = fmt.patternBackgroundColor(); m_data.cells[row][col] = cellData; } } return true; }这里resize(rows)是一次性把二维结构分配到位的做法。如果改用逐行append再加resize(cols),在行数很大时会反复触发内存分配,读取 5 万行的文件会有肉眼可见的停顿。内部统一从 0 开始索引,和 QTableWidget 的行列规则一致,可以省掉后续填表时反复做row - 1这种偏移换算的麻烦。
formatCellValue是一个辅助函数,核心逻辑是按 QVariant 的类型分流:
QString ExcelLoader::formatCellValue(const QVariant& v, const QXlsx::Format& fmt) { if (v.type() == QVariant::String) return v.toString(); if (v.type() == QVariant::Double) { // 整数样式的浮点值不该显示成 "1.0" double d = v.toDouble(); if (qAbs(d - qRound(d)) < 1e-9) return QString::number(qRound(d)); return QString::number(d, 'g', 10); } if (v.type() == QVariant::DateTime) return v.toDateTime().toString("yyyy-MM-dd hh:mm:ss"); return v.toString(); }整数判断是很多人会漏掉的细节。Excel 里输入 1,QXlsx 读到的是 double 类型的 1.0,直接toString()会变成"1"还算好,但遇到 1.25 和 10.0 混在一起时,统一用'g'格式化能避免出现 “10” 显示成 “10.0” 这种低级问题。
3.2 填充表格:从 SheetData 到 QTableWidget 的骨架代码
数据进 QTableWidget 这一步的逻辑很机械:先设置行列数量,再逐个创建 QTableWidgetItem。但这里有几个会影响体验的细节:填充前暂停界面刷新、一次性设置行数和列数、item 里需要正确设置对齐和换行,特别是包含长文本或换行符的单元格。
bool ExcelLoader::fillTable(QTableWidget* table, const SheetData& data) const { if (!table) return false; table->clear(); // 清掉旧的表头和内容 table->setUpdatesEnabled(false); // 关键:暂停重绘,避免卡顿 table->setSortingEnabled(false); // 填充期间必须关掉排序 table->setColumnCount(data.colCount); table->setRowCount(data.rowCount); QStringList headers; headers.reserve(data.colCount); for (int c = 0; c < data.colCount; ++c) headers << QString::fromLatin1("列 %1").arg(c + 1); table->setHorizontalHeaderLabels(headers); for (int r = 0; r < data.rowCount; ++r) { for (int c = 0; c < data.colCount; ++c) { const CellData& cell = data.cells[r][c]; QTableWidgetItem* item = new QTableWidgetItem(cell.displayText); item->setFlags(item->flags() | Qt::ItemIsEditable); // 允许编辑 item->setTextAlignment(cell.alignment); if (cell.bold) { QFont f = item->font(); f.setBold(true); item->setFont(f); } if (cell.backgroundColor.isValid()) item->setBackground(cell.backgroundColor); table->setItem(r, c, item); } } table->setUpdatesEnabled(true); // 恢复重绘,一次性刷新出来 return true; }setUpdatesEnabled(false)是这段代码的核心。如果不关掉它,每setItem一次表格就重绘一次,几千行写下来窗口会明显卡顿,用户看到的是数据一行一行“长”出来,而不是一次性完成。setSortingEnabled(false)同样重要,开启排序状态时插入数据,QTableWidget 会在每次 item 插入时去比较排序键,时间复杂度接近 O(n² log n),5 万行直接卡死。
默认表头“列 1、列 2”只是占位,实际项目里通常把 Excel 的第一行当作表头,那就在读取时把第一行单独拿出来填到setHorizontalHeaderLabels,然后从第二行开始填充正文。这种“首行表头”模式是导入需求里最常用的,封装里可以加一个bool firstRowAsHeader的开关。
3.3 类型判断:日期、数字和文本不能统一走 toString
如果只调用displayText就把所有值塞进表格,日期列几乎必然显示成一串数字。QAxObject 场景下尤为明显:Excel 里的 2024-01-15,通过Value2()读出来是 double,值是 45206,这是 Excel 内部存储日期的序列号。不转换就直接显示,用户看到的就是一个没有意义的四位数。
针对 QAxObject,处理方式是这样:
QVariant v = cell->dynamicCall("Value2()"); QString numberFormat = cell->property("NumberFormat").toString(); if (v.type() == QVariant::Double && (numberFormat.contains("yyyy") || numberFormat.contains("yy"))) { // Excel 日期序列从 1900-01-01 开始,但存在 1900 年闰年 bug,偏移用 1899-12-30 QDateTime base(QDate(1899, 12, 30), QTime(0, 0)); QDateTime dateTime = base.addDays(v.toInt()); cellData.displayText = dateTime.toString("yyyy-MM-dd"); }判断依据是 NumberFormat 里是否带年或月格式。更稳妥的做法是把dateTime作为原始值存进CellData.value,显示文本单独格式化,这样后续排序会按真实日期排,而不是按字符串排。很多导入工具显示对但排序错,就是因为在底层把日期提前转成了字符串。
反过来说,纯文本列里如果出现数字,也不要提前toInt。Excel 里手机号、工号、邮编这类长数字经常超过 15 位有效精度,double 会丢位,读出来已经变了。正确策略是:先看单元格的 NumberFormat,如果是“文本”格式就走字符串读取路径;如果没有格式,再按 double 处理。这个判断要在解析层做,不要推到显示层。
4. 大数据量显示与样式保真:tablewidget 优化的关键参数
4.1 万行数据填表不卡的三板斧:暂停刷新、批量 setItem、关闭排序
tablewidget 本身是 QTableWidget 家族的控件,每个单元格都是独立的 QTableWidgetItem 对象。填入 1 万行 × 20 列,就要创建 20 万个对象,这个开销逃不掉。我们能做的是把开销集中处理掉,而不是让它均匀地卡在用户眼前。前面第 3 章的暂停刷新是第一步,这里补充另外两个实用技巧。
第一个技巧是复用 item 对象。如果某些列是空单元格或者重复值,不要每个格子都 new,可以在循环里缓存上一格的值,相同就复用同一个 item 指针。注意 QTableWidget 只接受每个 item 挂在唯一的单元格上,复用的前提是内容完全一致且不需要独立编辑,否则必须 new。这个优化在列多、空值多的时候效果尤其明显。
QTableWidgetItem* lastItem = nullptr; for (int r = 0; r < rows; ++r) { for (int c = 0; c < cols; ++c) { QString text = data.cells[r][c].displayText; if (text.isEmpty()) { table->setItem(r, c, new QTableWidgetItem("")); continue; } // 内容相同时复用指针,前提是样式也相同 if (lastItem && lastItem->text() == text) { table->setItem(r, c, lastItem); } else { lastItem = new QTableWidgetItem(text); table->setItem(r, c, lastItem); } } }第二个技巧是合理设置列宽模式。resizeColumnsToContents()会在所有数据填充完之后遍历计算最优宽度,调用一次是可以的,但如果你在填充过程中每加一行就调一次,那就是自找苦吃。常见做法是:填充结束后只对表头做一次内容适配,或者直接用QHeaderView::ResizeToContents交给控件自己处理。数据量超过 1 万行时,后者在滚动时也会有一定开销,可以在 1 万行以下时用它,以上改回固定列宽加横向滚动。
第三个技巧是控制行高。默认的verticalHeader会给每行留很大的高度,同样行数下显示密度低,滚动范围大。一般把行高压到 24~28 像素,观感接近 Excel 默认行高,滚动也不会一顿一顿。
table->verticalHeader()->setDefaultSectionSize(26); table->horizontalHeader()->setDefaultSectionSize(100); table->horizontalHeader()->setStretchLastSection(true); // 最后一列自动拉宽,避免右侧空白setStretchLastSection(true)在小数据量时很实用,但大数据量下会让最后一列被拉得非常宽。实际项目里我通常只在列数少于 6 列时打开它。
4.2 合并单元格与背景色映射:能保真的核心样式只有三类
用 QAxObject 读合并单元格,判断逻辑在 COM 侧:一个单元格如果属于合并区域,它的 MergeCells 属性为 true,MergeArea 就是整个合并区域。拿到区域的行数和列数,再映射到 tablewidget 的setSpan上。
QAxObject* cellObj = sheet->querySubObject("Cells(int,int)", row, col); if (cellObj->property("MergeCells").toBool()) { QAxObject* area = cellObj->querySubObject("MergeArea"); int spanRows = area->querySubObject("Rows")->property("Count").toInt(); int spanCols = area->querySubObject("Columns")->property("Count").toInt(); // QTableWidget 行列从 0 开始,data 内部索引已经对齐过 table->setSpan(row - 1, col - 1, spanRows, spanCols); }这里有个天然限制:QTableWidget 的 setSpan 只能在同一个单元格上设置一次,所以填充循环里要跳过同属一个合并区域的其他格子。判断方式是记录已经处理过的合并区域左上角坐标集合,碰到后续格子直接不填内容,否则同一个位置可能被设置两次 span,表现就是行列错乱。
样式保真这件事,需要尽早和业务对齐预期。QAxObject 能读底色、字体、边框、对齐,QXlsx 能读一部分;但 QTableWidget 和 Excel 的差异是本质性的——Excel 的“边框线”是多层次的线型组合,tablewidget 只有 grid line 一种表达。我的经验是只映射三类核心样式:背景色、加粗、合并单元格。这三类信息用户在导入时真正关心,其他细节做了也不像,反而花时间。
背景色的映射要注意优先级:Excel 单元格有默认的“无填充”,只有在颜色有效时才调用 item 的setBackground。如果无脑设置,所有单元格都会变成默认白色,视觉对比反而不如表格自带的隔行变色。一个可选的提升方案是,映射完成后对整表开启setAlternatingRowColors(true),用控件的隔行色弥补 Excel 基础底色信息的缺失。
5. 封装落地排查:5 个踩过的坑和解决方案
5.1 QAxObject 报“未找到文件”或打开失败
现象:在开发机上读取 xlsx 一切正常,部署到另一台 Windows 机器上后,程序报错,提示类似“未找到文件”或 Excel 打开失败。代码里文件路径明明是对的。
原因:目标机器没有安装 Office Excel,或者安装的是不带 COM 注册的精简版。QAxObject 是通过"Excel.Application"这个 ProgID 找 COM 组件的,本机没有注册这个服务就找不到。另外,路径中如果带中文或空格,Open调用需要把路径转换成绝对路径再传入。
解决:部署前先检查目标机器是否装了 Excel,或者改用 QXlsx 作为部署环境的读取引擎。代码里做兼容:先用 QAxObject 尝试加载,失败后自动切到 QXlsx。这就是封装里设计Engine Auto的原因——先按环境自动选引擎,不需要业务层干预。路径问题则在入口处统一调用QFileInfo(filePath).absoluteFilePath()后再传给 COM 接口。
5.2 日期显示成数字
现象:读取完表格后,所有日期列显示为“45206”之类的数字,用户根本看不懂。把鼠标点进单元格里,编辑框里显示的又是1/15/2024。
原因:Excel 的日期本质是带格式的 double 序列号,Value2()返回的是原始数值,格式信息在 NumberFormat 属性里。解析层只读了值,没读格式,所以显示层拿到的就是数字。
解决:按 3.3 节的做法,解析时根据 NumberFormat 是否为日期格式做转换。需要注意 Excel 1900 日期系统的闰年 bug:它虚构了 1900-02-29,所以从 1900-01-01 到某日期的天数偏移要从 1899-12-30 的基准点开始算,多写一天就全表日期错一天。这类偏差在人工核对样本数据时极难发现,最好在封装里多做一步自动校验,比如拿一个已知日期和原始值对比。
5.3 表格有内容没有样式,底色和加粗全丢
现象:数据读出来了,但整个表格是默认白底黑字,原本标黄的告警行、加粗的合计行全部消失。业务方认为功能不完整。
原因:读取引擎在解析时只调用了value()获取内容,没有把对应格式对象带出来。QXlsx 需要在拿到 Cell 时同时调cell->format(),QAxObject 需要逐个查Font.Bold和Interior.Color,这些容易遗漏。
解决:在load阶段就把样式信息封装进CellData,显示时按需应用。同时要控制保真范围——我一般会在交付 demo 时直接演示三类核心样式(底色、加粗、合并),明确告诉使用者这是能稳定还原的边界。重新发明一种通用 Excel 样式转换器不在这个封装的目标范围内,投入产出比太低。
5.4 UsedRange 边界远超实际数据
现象:一个只有 30 行数据的表格,读出来 rowCount 却是 2500 行,表格末尾全是空白行。找到原因后发现是 Excel 文件长期维护,很多行被编辑过内容又删掉,留下了格式残留,UsedRange 把格式区域也算进去了。
原因:Excel 的 UsedRange 以“曾经使用过的区域”为准,不只是“有内容的区域”。一个设置过底色又清空内容的行,也会被计入边界。QXlsx 的 dimension() 同样存在这个问题。
解决:解析完成后做一层数据裁剪。逐行统计该行是否所有列都为空,从尾部开始删掉连续的全空行;同理裁剪全空列。裁剪逻辑放在load的最后一步,统一形成干净的SheetData。如果文件里存在“整行只有格式没有值”的需求,可以在裁剪时加一个开关,默认启用裁剪即可。
5.5 库编译环境不一致导致运行闪崩
现象:QXlsx 静态编译进程序后没问题,一旦改成动态库方式分发,运行到读取 Excel 的部分直接崩溃,而且崩溃点不在自己代码里,看起来毫无规律。
原因:QXlsx 内部使用大量 Qt 宏和 qobject_cast,它和主程序必须由同一套 Qt 版本、同一个编译器、同一个编译参数生成。主程序用 MSVC 编译,QXlsx 用 MinGW 编的,或者 Qt 版本一个 5.15 一个 6.2,运行期对象系统匹配不上,qobject_cast 返回空指针,后续访问必然崩溃。
解决:QXlsx 不单独分发动态库,直接和主程序一起编译,确保构建环境一致。这类问题最容易在“某个同事本地编了库,其他人各自链了一份”的协作模式里出现。我现在的习惯是,项目里带一个脚本从源码构建 QXlsx,构建参数和主工程统一,避免任何人手动去下载一份现成的二进制。
6. 进阶:异步加载与耗时验证的封装套路
6.1 异步加载:QXlsx 扔线程,QAxObject 必须留主线程
如果用户打开的是一个几万行的 Excel,同步加载在界面上表现为窗口“死了”几秒到十几秒。体验好的做法是加载期间显示 loading,数据就绪后再填充表格。封装里我会提供两个加载入口:同步接口load供需要阻塞的场景用;异步接口loadAsync供界面交互用。
QXlsx 纯解析不碰 COM,可以放心扔进线程池。一个最小实现是 QtConcurrent 加跨线程回传:
void ExcelLoader::loadAsync(const QString& path) { QtConcurrent::run([this, path]() { bool ok = load(path); QElapsedTimer timer; timer.start(); emit loadFinished(ok, m_lastError); emit loadSpentMs(timer.elapsed()); }); }但 QAxObject 不行,它必须在 STA 线程运行,直接放到 QtConcurrent 线程里会出问题。对于 QAxObject 引擎,异步方案是保留在主线程,用 QTimer 分片处理:每批读 1000 行,用 while 循环加一定等待时间,避免一次调用阻塞太久。实现会复杂一些,如果应用不强制要求 OLE 引擎,我通常建议直接走 QXlsx 异步路径。
6.2 用耗时与内存做验证,别靠感觉
封装修完不是能跑就行,需要一组量化指标确认它能扛住真实数据。我建议准备三个测试文件:5 万行 × 10 列的大文件、含日期和合并单元格的复杂文件、含空行和格式残留的脏文件。然后分别测解析耗时、填充耗时、峰值内存。
QElapsedTimer timer; timer.start(); bool ok = loader.load("benchmark.xlsx"); qDebug() << "解析耗时:" << timer.elapsed() << "ms"; timer.restart(); loader.fillTable(table, loader.data()); qDebug() << "填充耗时:" << timer.elapsed() << "ms";填表耗时是一个关键指标。如果填充 5 万行花了 3 秒以上,优先检查是否忘记关闭排序、是否每行都调用了resizeColumnsToContents、是否每次setItem都触发了重绘。这三点是 tablewidget 性能的三座大山,按顺序排查基本能解决 90% 的卡顿。用户感知度上,解析 1 秒 + 填充 1 秒是可接受的下限,超过 3 秒就需要考虑异步加载加分页或者改用 QTableView + 自定义 model。
我现在的习惯是:每次改完封装的解析或填充逻辑,都先跑一遍上面的耗时验证再提交,不凭感觉说“好像快了”。这套封装最初的动机,是某个导入模块在数据量突然翻倍后把界面卡死,被迫重构的教训。把读 Excel 和显示表格彻底分开之后,这类性能问题就再也没蔓延到业务层过。希望你按这套思路搭出来的封装,也能少踩几个重复的坑。
本文还有配套的精品资源,点击获取