这个系列写到第4篇,后台问我“数据怎么显示”的人越来越多了。前几篇把按钮、输入框、单选复选这类交互控件梳理了一遍,这回轮到列表(QListWidget)、表格(QTableView/QTableWidget)、树形(QTreeWidget/QTreeView)这几个“数据展示型”控件了。说实话,这几个控件才是绝大多数桌面工具里最不好讲清楚的部分,因为它们表面上是控件使用问题,实际是数据组织问题。
这期内容适合两类人看:一类是刚把基础控件跑通、准备做正经界面的初学者;另一类是已经写了小工具、想在数据处理上少踩几个坑的进阶选手。如果你想看的是“某个控件怎么用”的速查,可以直接跳到对应章节;如果你愿意花十几分钟从源头理解,后面所有操作都会顺很多。
1. 为什么“控件指南(4)”要聊数据展示控件
1.1 从单控件思维到数据驱动的界面
前几篇里那些控件,逻辑链条非常短:按钮点了就发信号,行编辑器读完就返回字符串,数值框直接取数字。写起来痛快,但真实项目不会一直这么简单。做设备台账、订单管理、文件浏览器、配置面板的时候,你手里拿到的是一批结构化的数据,不是单个字段,这时候只懂“控件怎么用”远远不够,你还得回答三个问题:数据放在哪、怎么装进界面、用户选中某一条之后怎么回传。
很多初学者在这里栽跟头,是因为习惯把控件当成数据库,把数据一项一项填进去,结果界面和业务逻辑搅成一锅粥。QListWidget、QTableWidget、QTreeWidget 这类封装好的控件,确实允许你“往里面塞数据”,它们用起来顺手,但背后藏着一套数据与界面分离的规则。你可以先不用完全理解,但至少要意识到:列表、表格、树不是简简单单的容器,你选择哪一种,实际上是在选择一种数据关系的表达方式。
打个比方,按钮像开关,按下去灯就亮;而数据展示控件像书架。书架上放什么书、按什么顺序放、用户抽出哪一本、抽出来之后你想不想记录,这些都需要你自己定。控件本身只保证“书能放上去、能看见”,不保证“放得聪明”。
1.2 本篇的控件范围与选型逻辑
这一篇我挑了四个主题:QListWidget 列表、QTableView 表格(带 QStandardItemModel)、QTreeWidget 树形控件,以及它们组合布局时几乎必然要用的 QSplitter。选这几样的理由很简单:它们覆盖了桌面软件里最常见的三类数据展示场景——“一维明细”“二维表格”“树形分层”。
QListWidget 适合展示单列或少列、总量在千行以内的数据,比如日志列表、搜索结果、事件记录。QTableView 适合展示列固定、行可变、需要排序和编辑的二维数据,比如资产清单、库存表、报价单。QTreeWidget 则适合天然带有父子层级的数据,比如设备分组、项目目录、地区分类。至于 QSplitter,它本身不做数据展示,但它能把多个数据控件拼成一个可调宽窄的工作台界面,组合实战那一节会看到它有多好用。
选择哪一个控件,核心看数据结构和用户操作方式。数据本身层次不深,用列表;数据是行列立体结构,用表格;数据有父子包含关系,用树。不要一上来就追求复杂,能用列表解决的事情,不要硬套树形。
2. QListWidget 列表控件:从添加数据到信号处理
2.1 核心接口与添加数据
QListWidget 是我在实际项目里用得最多的轻量数据控件,没有之一。它继承自 QListView,封装好了默认模型和列表项 QListWidgetItem,所以日常开发不需要关心 Model/View 的细节,addItem 就行。
// 创建一个列表 QListWidget *list = new QListWidget(this); list->addItem("项目一"); list->addItem("项目二"); // 也可以用 QListWidgetItem 添加 QListWidgetItem *item = new QListWidgetItem("项目三"); list->addItem(item); // 批量添加,适合静态数据 QStringList items; items << "A类" << "B类" << "C类"; list->addItems(items);注意一个容易踩坑的点:addItem 之后,列表项的所有权就归 QListWidget 了,你不需要也不应该去 delete 这个 item。反过来,如果你调用了takeItem(row)把项从列表里摘出来,那这个摘出来的项就需要你自己负责释放,否则会泄漏。
2.2 在列表项上挂业务数据
列表界面显示的是给人看的文本,但业务逻辑里你通常需要的是另一个数据,比如数据库主键、文件路径、设备编号。这种“显示文本”和“真实标识”分离的做法,QListWidget 天然支持,用 Qt::UserRole 即可。
QListWidgetItem *deviceItem = new QListWidgetItem("设备A"); deviceItem->setData(Qt::UserRole, "DEV-001"); deviceItem->setData(Qt::UserRole + 1, "192.168.1.10"); list->addItem(deviceItem); // 读取 QListWidgetItem *cur = list->currentItem(); if (cur) { qDebug() << cur->text(); qDebug() << cur->data(Qt::UserRole).toString(); }这个做法非常实用。你可以在界面上任意修改显示文案,只要不破坏 UserRole,后面的业务逻辑照常运行。像我早期写代码,经常犯的错是拿 text 去反查数据表,一旦界面文案被用户改了,整个查询就崩了。用 UserRole 挂真实标识,就彻底绕开了这个问题。
2.3 批量插入、删除和自定义 Item 的注意事项
批量插入几千行的时候,如果直接循环 addItem,界面每插一行都要做一次布局计算,肉眼可见地卡。正确姿势是先关闭更新,插完再打开:
list->setUpdatesEnabled(false); for (int i = 0; i < 5000; ++i) { list->addItem(QString("条目 %1").arg(i)); } list->setUpdatesEnabled(true);删除项的时候要注意,clear()会清空所有项并释放内部 item,但如果你之前用item(row)拿了指针存起来,clear 之后那个指针就是悬空指针,再用就会出问题。取出来的项仅用于当前逻辑,不要长期保存。
如果列表项里面不只是一行字,还需要放按钮、进度条、缩略图之类的东西,可以用setItemWidget把一个普通 QWidget 塞进某一行。但这个方法有代价:widget 开销比纯 item 大,行数一多就吃内存。行数少没问题,行数上了千,我建议考虑 QListView + 自定义 Delegate,等后面有机会单独写一篇。
信号处理这里有个很容易混淆的细节:currentRowChanged 只在当前行变化时触发,而 itemClicked 在用户点击任意一行时都会触发,哪怕点击的还是当前行。你如果连接的是 itemClicked,又在槽里读 currentItem,会出现“点同一行也反复触发”的情况。做选中详情联动时,用 currentRowChanged 更精准。
3. QTableView 表格:核心玩法是 QStandardItemModel
3.1 先搞懂 Model/View 框架,再谈表格
很多教程一上来就贴 QTableWidget 的代码,告诉你“表格就这么用”,结果读者写完之后一旦想实现从文件加载数据、按列排序、动态更新,就开始抓耳挠腮。QTableWidget 确实简单,它相当于帮你预置了一个小模型,但这个简单是有边界的。真正要驾驭表格,必须理解 Qt 的 Model/View 架构。
Model/View 架构拆开说就是三个角色:Model 负责存数据、提供访问接口,View 负责把数据显示在屏幕上,Delegate 负责具体绘制和编辑。View 不知道数据是什么,Model 不知道界面长什么样,二者靠信号和索引通信。你操作表格里的格子,其实是操作 Model 里的一个 QModelIndex。
这个设计的好处是:同一份数据可以用表格显示、用列表显示、用图表显示,互不干扰;数据可以从数据库、文件、网络等途径来,View 不关心来源。代价是上手时比 QTableWidget 多一层概念。但这一层概念值得学,因为它决定了你写出来的代码是“能跑的 demo”还是“能改的项目”。
3.2 用 QStandardItemModel 快速搭出表格
日常中小型项目里,我不想直接写一个自定义 QAbstractTableModel 时,就会用 QStandardItemModel。它把二维表数据放进内存,自己能管理行、列、数据项,配合 QTableView 非常顺手。
// 创建表格控件 QTableView *table = new QTableView(this); // 创建模型,指定行数列数 QStandardItemModel *model = new QStandardItemModel(this); model->setColumnCount(4); model->setHorizontalHeaderLabels({"序号", "名称", "数量", "状态"}); // 填充数据 for (int i = 0; i < 20; ++i) { QList<QStandardItem *> row; row << new QStandardItem(QString::number(i + 1)); row << new QStandardItem(QString("零件%1").arg(i + 1)); row << new QStandardItem(QString::number((i + 1) * 2)); row << new QStandardItem(i % 2 ? "待检" : "已入库"); model->appendRow(row); } table->setModel(model); // 表头拉伸,列宽自适应 table->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch);这里有个默认行为要注意:新建的 QStandardItem 默认是可编辑的,用户双击单元格就能改。如果只想展示、不让编辑,要手动关掉标志位:
QStandardItem *nameItem = new QStandardItem("只读内容"); nameItem->setEditable(false);如果整列都只读,在填数据时统一处理即可。别等到用户误改了数据再来补救,防呆设计要前置。
3.3 排序、居中、列宽调整与数据修正
表格类需求里,排序基本是标配。QTableView 自带表头排序开关:
table->setSortingEnabled(true);开完之后,用户点表头就能排序。但这里藏着一个很典型的坑:如果模型里所有单元格存的都是文本,数字列的排序会按字典顺序来。比如 2、10、3 排序之后可能变成 10、2、3。解决办法是给数字列存 QVariant 原生数值,不要存字符串:
QStandardItem *countItem = new QStandardItem(); countItem->setData(12, Qt::DisplayRole); // 以 int 存入这样排序时 Qt 才能用 int 比较,而不是把字符串“12”和“2”当文本比。这个细节我在实际项目里被坑过一次,排查了很久才发现是数据在填充时被QString::number()转成了字符串。
列宽控制也建议单独设置,不要所有列都用 Stretch。展示型表格适合:
table->horizontalHeader()->setSectionResizeMode(0, QHeaderView::ResizeToContents); table->horizontalHeader()->setSectionResizeMode(1, QHeaderView::Stretch);序号列按内容自适应,名称列拉伸填充剩余空间,视觉效果比平均分列好很多。
3.4 获取选中行数据的正确姿势
表格点击联动是高频操作。很多人第一反应是用table->selectedItems(),但这个方法拿到的是一堆单元格,而且如果用户按列选中,拿到的列表很乱。我更推荐用 selectionModel 的 currentRowChanged 信号,拿到当前行的索引后再去模型里取数据:
connect(table->selectionModel(), &QItemSelectionModel::currentRowChanged, this, [=](const QModelIndex ¤t, const QModelIndex &previous) { if (!current.isValid()) return; QStandardItemModel *mdl = qobject_cast<QStandardItemModel *>(table->model()); if (!mdl) return; int row = current.row(); QStandardItem *nameItem = mdl->item(row, 1); QStandardItem *countItem = mdl->item(row, 2); if (nameItem && countItem) { qDebug() << nameItem->text() << countItem->text(); } });注意两点:current 索引可能无效,别上来就取 row;item 可能为空指针,尤其是空行或者某些列没有数据的时候。做空指针判断,能省掉后面大量的崩溃现场。
如果你想拿到多行选中数据,比如用户勾选了好几行然后点击批量处理,可以遍历 selectionModel 的 selectedRows:
QModelIndexList rows = table->selectionModel()->selectedRows(); for (const QModelIndex &idx : rows) { QStandardItem *idItem = mdl->item(idx.row(), 0); if (idItem) listOfIds << idItem->text(); }selectedRows 默认只返回有数据的列对应行索引,数量可控,遍历起来很干净。
4. QTreeWidget 树形控件:层级数据的组织与联动
4.1 树形控件到底解决什么问题
列表和表格适合表达“平铺”的数据,但真实世界里很多数据是有层次的:设备列表下面有子设备,部门下面有小组,目录下面有子目录。这种父子关系如果用表格硬撸,要么堆一堆前缀空格,要么加一列“父级编号”,可读性都很差。树形控件就是专门解决“层级展示与折叠”这件事的。
QTreeWidget 是封装好的 item-based 树控件,创建节点、加子节点非常直观。它尤其适合节点数量几千以内的静态层级数据,比如配置面板里的分类导航、文件浏览器的目录树。节点数到了几万,或者数据本身来自动态查询,那就要考虑 QTreeView + 自定义 Model,后面我会讲判断依据。
4.2 创建节点、遍历节点与删除节点
先看最基本的构建:
QTreeWidget *tree = new QTreeWidget(this); tree->setColumnCount(2); tree->setHeaderLabels({"名称", "类型"}); // 顶层节点 QTreeWidgetItem *root = new QTreeWidgetItem; root->setText(0, "设备分组"); root->setText(1, "分组"); // 子节点 QTreeWidgetItem *child1 = new QTreeWidgetItem(root); child1->setText(0, "数控机床"); child1->setText(1, "CNC"); QTreeWidgetItem *child2 = new QTreeWidgetItem(root); child2->setText(0, "工业机器人"); child2->setText(1, "ROBOT"); tree->addTopLevelItem(root); tree->expandAll();这里可以偷个懒:QTreeWidgetItem 的构造函数接受父节点指针,创建的同时就把父子关系挂好了,不需要先 new 再 addChild。但注意,addTopLevelItem和addChild都会接管 item 所有权,父节点销毁时会一并清理子节点。
遍历树是另一个高频操作。我想统计整棵树有多少有效节点,或者找出某一类型的所有节点,最好的办法是写一个递归遍历函数:
void foreachTreeNode(QTreeWidgetItem *parent, const QString &targetType, QList<QTreeWidgetItem *> &result) { if (!parent) return; for (int i = 0; i < parent->childCount(); ++i) { QTreeWidgetItem *child = parent->child(i); if (child->text(1) == targetType) { result.append(child); } foreachTreeNode(child, targetType, result); } } // 使用:从根节点开始 QList<QTreeWidgetItem *> matches; foreachTreeNode(tree->invisibleRootItem(), "CNC", matches);invisibleRootItem()是 QTreeWidget 的隐藏根节点,用它做遍历起点最合适,不会漏掉任何一个顶层节点。
删除节点时有个容易出 bug 的点:直接delete item之后,如果这个 item 之前被局部变量保存着,局部变量的指针就成了悬空指针。更稳妥的删除方式是先摘再删:
QTreeWidgetItem *cur = tree->currentItem(); if (!cur) return; int index = cur->parent() ? cur->parent()->removeChild(cur) // 返回索引,并解除父子关系 : tree->indexOfTopLevelItem(cur); if (index >= 0) delete cur;不过实际开发中,最省心的是直接使用delete currentItem,前提是确认不会再用那个指针。
4.3 节点勾选、itemChanged 信号与数据同步
树形控件经常用来做权限树、标签树,用户通过勾选节点选择归属。开启勾选很简单:
QTreeWidgetItem *item = new QTreeWidgetItem; item->setText(0, "可勾选的节点"); item->setFlags(item->flags() | Qt::ItemIsUserCheckable); item->setCheckState(0, Qt::Unchecked);开启后,监听 itemChanged 信号就能拿到勾选变化:
connect(tree, &QTreeWidget::itemChanged, this, [=](QTreeWidgetItem *item, int column) { if (column == 0 && item->checkState(0) == Qt::Checked) { qDebug() << item->text(0) << "被勾选"; } });重点来了:itemChanged 不只是用户点击触发,代码里调用setText、setCheckState、setData也会触发。如果你在槽函数里又去设置勾选状态,就会形成递归触发。最直接的规避方法是在程序性修改数据时 block 信号,修改完再恢复:
tree->blockSignals(true); item->setCheckState(0, Qt::Checked); tree->blockSignals(false);我遇到过一种更隐蔽的情况:勾选父节点之后,希望自动勾选所有子节点。这时候如果在 itemChanged 里遍历子节点 setCheckState,每个子节点又触发 itemChanged,槽函数再次进入,处理不当会越勾越乱。建议在遍历子节点之前先 blockSignals,做完之后统一恢复,或者用一个成员布尔变量做重入保护,保证同一时间只有一次联动逻辑在执行。
4.4 什么时候换成 QTreeView + 自定义 Model
QTreeWidget 方便,但它把所有节点都加载到内存里。数据量一大,创建几万个 QTreeWidgetItem 的开销立刻就能感觉到。另一个问题是节点数据结构不好复用,如果数据在数据库里,每次都要从表记录转换成 item,逻辑重复。
这时候就该上 QTreeView + 自定义 Model。QTreeView 只负责显示,数据由 QAbstractItemModel 提供。你可以实现只加载当前展开节点需要的子节点,真正做到“按需取数”。代价是至少要理解几个纯虚函数:index、parent、rowCount、columnCount、data。这个组合我会在后面的系列里单独展开,这里先给一个判断标准:
- 节点数几千以内,树结构固定:用 QTreeWidget。
- 节点数过万、数据来自外部存储、需要动态增删模型:用 QTreeView + 自定义 Model。
- 需要多列展示、排序、拖拽、拖放:QTreeWidget 能实现但别扭,建议 QTreeView。
5. 组合实战:树+表格+列表用 QSplitter 联动
5.1 一个设备管理窗口的三区联动需求
单独讲每个控件的用法,哪怕讲得再细,也解决不了“这些控件怎么拼在一起”的问题。所以这一节,我带你做一个很常见的窗口:左侧树形区域显示设备分组,中间表格区域显示当前分组下的设备明细,右侧列表区域显示选中设备的最近事件。
这个小窗口几乎把所有数据展示控件的用法串起来了,而且你会看到信号联动、批量刷新、布局控制这些细节是怎么在真实场景里落地的。不要因为看着稍微复杂就跳过,这一节的组合方式,可以直接套用到很多“分类+明细+详情”的工具里。
5.2 用 QSplitter 搭建可拖拽布局
如果只是把三个控件用 QWidget 手动排布,窗口大小一变,比例就乱了,用户也没法自己调整宽窄。QSplitter 就是来解决这个问题的。它是 Qt 里的分割器,能让子控件之间出现可拖拽的分隔条,用户喜欢看宽一点的明细,就往右拖一拖。
// 假设三个成员控件已经创建 QSplitter *splitter = new QSplitter(Qt::Horizontal, this); splitter->addWidget(m_tree); splitter->addWidget(m_table); splitter->addWidget(m_list); // 设置初始宽度,单位是像素 splitter->setSizes({220, 520, 300}); // 设置伸缩因子,窗口变宽时中间的表格优先拉伸 splitter->setStretchFactor(0, 1); splitter->setStretchFactor(1, 3); splitter->setStretchFactor(2, 2); setCentralWidget(splitter);setStretchFactor 的第二个参数不是固定宽度,而是相对伸缩比例。中间表格设为 3,两边设为 1 和 2,窗口拉伸时表格获得更多增量空间。这个细节决定了缩放体验,别忽略。
5.3 树选中联动表格与列表的完整逻辑
联动思路很简单:树当前节点变化时,取到节点文本作为分组标识,刷新表格模型;表格当前行变化时,拿这一行的设备 ID,刷新右侧列表。
树的信号连接:
connect(m_tree, &QTreeWidget::currentItemChanged, this, [=](QTreeWidgetItem *current, QTreeWidgetItem *) { if (!current) return; const QString group = current->text(0); refreshTableByGroup(group); });刷新表格的核心步骤是:先断开表格选中信号,避免刷新过程触发无意义操作;清空旧数据;填入新数据;恢复选中信号。
void MainWindow::refreshTableByGroup(const QString &group) { // 断开表格选中信号 disconnect(m_table->selectionModel(), nullptr, this, nullptr); QStandardItemModel *model = qobject_cast<QStandardItemModel *>(m_table->model()); if (!model) return; model->removeRows(0, model->rowCount()); // 这里从业务数据容器中取当前分组的数据 const QList<DeviceRecord> records = m_dataStore.recordsByGroup(group); for (const DeviceRecord &rec : records) { QList<QStandardItem *> row; row << new QStandardItem(rec.id); row << new QStandardItem(rec.name); row << new QStandardItem(QString::number(rec.count)); model->appendRow(row); } // 恢复表格选中信号 connect(m_table->selectionModel(), &QItemSelectionModel::currentRowChanged, this, [=](const QModelIndex &cur, const QModelIndex &) { if (cur.isValid()) refreshEventListByDeviceId(cur); }); statusBar()->showMessage(QString("当前分组:%1,设备数量:%2") .arg(group).arg(records.size())); }表格选中行变化后再刷新右侧列表:
void MainWindow::refreshEventListByDeviceId(const QModelIndex &index) { QStandardItemModel *model = qobject_cast<QStandardItemModel *>(m_table->model()); if (!model) return; QStandardItem *idItem = model->item(index.row(), 0); if (!idItem) return; const QString deviceId = idItem->text(); const QStringList events = m_dataStore.eventsByDeviceId(deviceId); m_list->clear(); m_list->addItems(events); }这里用 QString 传递 deviceId 只是演示,真实项目里建议在 model 格子的 UserRole 里存整型 ID,取的时候再取出来,避免字符串转换的损耗和潜在错误。
5.4 刷新数据时的性能细节与信号防抖
刷新表格几十行数据不算什么,但如果你的数据是几百上千行,几个细节就变得很重要。
第一,批量更新时用 setUpdatesEnabled(false) 包住整个刷新逻辑,刷完再恢复 true,界面不会闪。
第二,removeRows 之后再 appendRow,会频繁触发行插入信号,如果表格数据量上千,建议把removeRows改成整体 reset:
model->setRowCount(0);这样一次清空,免去逐行通知。
第三,信号防抖。我在刷新表格前 disconnect 了 selectionModel,是为了避免 removeRows 和 appendRow 过程中触发 currentRowChanged,导致右侧列表跟着反复刷新。恢复连接之后,用户重新选中一行才刷新。这里其实还有一种更干净的做法:用QSignalBlocker作用在 selectionModel 和 table 上,刷完就自动恢复:
QSignalBlocker blocker0(m_table->selectionModel()); QSignalBlocker blocker1(m_table);QSignalBlocker 的析构会自动恢复信号,不用手动 reconnect,比 disconnect 更不容易出错。我后面很多代码都换成了这个写法。
6. 常见问题与排查技巧实录
6.1 高频问题排查速查表
下面这些坑,都是我在写代码和帮人调试时反复遇到的。做成表格方便你直接查:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 列表插入几千行卡顿 | 每 addItem 一次触发一次重绘 | 批量添加前后加 setUpdatesEnabled(false/true) |
| currentRowChanged 在刷新时频繁触发 | 模型重建过程改变了选中状态 | 刷新前用 QSignalBlocker 临时屏蔽 |
| 表头排序后数字顺序不对 | 数字列存成了字符串 | 使用 setData 存入 int,而不是 QString::number |
| 树节点勾选后 itemChanged 反复触发 | 代码里 setCheckState 导致信号递归 | 程序性修改前调用 blockSignals |
| 删除树节点后程序崩溃 | 指针引用悬空 item | 删除后不再使用旧指针,注意父节点所有权 |
| 表格选中拿不到数据 | 用了 selectedItems 而选中状态是选区模式 | 改用 currentIndex 或 selectedRows 取行索引 |
| 自定义角色数据取不出来 | UserRole 的 QVariant 类型传错 | 存的时候和取的时候保证类型一致 |
| QListWidget setItemWidget 后样式不生效 | 原生 widget 不响应 QSS | 给内嵌 widget 单独设样式,或改用 Delegate |
| 表格排序后行号错乱 | 排序后的视线行号不等于模型行号 | 操作数据时始终基于 index.sibling 或唯一 ID |
6.2 关于信号触发,几个容易搞混的点
QListWidget 有两个信号最容易让新手懵,一个是 currentRowChanged(int),一个是 itemClicked(QListWidgetItem *)。前者只在当前行变化时触发,后者点击任意一行都触发,哪怕点的是同一行。如果你在 itemClicked 里通过 currentItem 去拿数据,用户重复点击同一行,数据是拿到了,但代码逻辑却执行了多遍,后面再做耗时操作就会导致界面卡。做“选中详情联动”时,优先用 currentRowChanged。
QTreeWidget 的 itemChanged 则是最容易制造恶性循环的信号。它包含文本变化、勾选变化、图标变化、数据角色变化,几乎什么都能触发。只要你在槽函数里写了 setText 或者 setCheckState,就可能再次触发。处理这类联动的铁律是:先判断当前变化是自己关心的类型再执行,并且所有程序性修改都用 blockSignals 包住,避免信号递归。
QTableView 这边的信号嵌套也不省心。selectionModel 的 currentRowChanged 和表格的 clicked 信号看着像,实际触发时机完全不同。currentRowChanged 是选中状态变化时触发,通过上下键移动时也会触发;clicked 是鼠标点击完成后触发,不含键盘操作。如果你希望键盘上下键也能联动详情,就应该连 currentRowChanged。
6.3 数据量上来之后,界面卡顿怎么破
说句实话,QListWidget、QTableWidget、QTreeWidget 这三个封装控件,都不适合海量数据展示。它们的 item 对象是独立分配的,几万个节点对象光是创建和释放就要消耗不少时间。
如果你的数据量到了“万”这个级别,先别急着优化控件属性,要检查几个方向:
第一,是否在批量刷新时把信号全关了。有没有用 QSignalBlocker,有没有关 update。很多卡顿其实只是界面在重复刷新,和控件本身没关系。
第二,是否完全没必要用重量级控件。比如几千行的只读列表,QListWidget 每一行一个 item,改成 QListView + 数据模型之后,显示性能能明显改善。QTableWidget 同理,换成 QTableView + 自定义 Model,让 View 只绘制可见区域,性能会好很多。
第三,是否可以考虑开启某些性能选项。表格设置setUniformRowHeights(true)能省下计算行高的时间;树控件如果不需要展开动画,关掉动画也能减少绘制压力。
我个人的经验是:一万行以内的数据用封装控件完全没问题,只要注意上面说的刷新细节;如果数据超过几万行,就别硬撑了,花点时间写一个精简的自定义模型,收益远比想象的大。
6.4 自定义角色数据丢失的排查思路
很多人喜欢在 item 上挂业务数据,比如setData(Qt::UserRole, record.id),这没问题。问题常常出在两个地方:一是类型不匹配,存入时 QVariant 包装的是 int,取的时候用 toString,得到一个空字符串;二是排序之后,原来行号对应的数据变了,拿 text 却没意识到这一行已经被排到别的位置了。
排查这类问题,我一般会先打印 QVariant 的类型名:
qDebug() << cur->data(Qt::UserRole).typeName(); qDebug() << cur->data(Qt::UserRole).isValid() << cur->data(Qt::UserRole).toString();typeName 能直接告诉你里面装的是 int 还是 QString,问题基本一眼就能看出来。
如果是排序之后数据对不上,解决思路是不要依赖视觉行号。取数时通过索引去模型里找,而不是记一个“第几行”然后在另一个信号里反推。必要的时候可以在数据里单独加一列不可见的 ID,保证唯一标识永远跟着数据走。
写完这一篇,我最大的体会就是:数据展示类控件的难点,根本不在“控件”本身
前面几篇讲按钮、输入框,我可以说“这些控件的坑很清晰”;到了列表、表格、树,情况就变了。它们真正考验的不是 API 记没记住,而是你对数据关系的理解。控件只是壳子,数据才是里子;壳子选错了还可以换,数据组织若是乱的,后面所有功能都会跟着遭罪。
我现在的习惯是,动手写界面之前先画一张简单的数据关系图。哪一层是树,哪一层是表格,哪一层是列表,它们之间的选中关系怎么联动,先想清楚,再回来写代码。这个习惯帮我少踩了一半的坑,也让我后面改需求时不用推翻重来。
回到这期内容,QListWidget 解决一维列表,QTableView 解决二维表格,QTreeWidget 解决层级数据,QSplitter 解决布局组合。把这四个组合拳打熟,日常百分之八十的数据展示窗口都能顺顺利利搭起来。等再过一阵,我再把自定义 Model、Delegate 绘制和真正的海量数据优化单独写一篇,把数据展示这条线彻底讲透。