首页 / 资讯中心 / 文章详情

Qt Model/View实战:排序筛选、自定义委托与大数据量渲染优化

Qt Model/View实战:排序筛选、自定义委托与大数据量渲染优化 ★ FEATURED ARTICLE
这一篇是 Model/View 系列最后一篇。上一篇把项目的基础架构搭完了一个基于 SQLite 的文档资料管理工具用 QSqlQueryModel 的子类做数据源四个视图列表、树、表格、卡片同屏展示。当时写到“能显示、能点选”就收住了因为再往下写排序、筛选、编辑、性能这些坑会越踩越深不适合和基础代码塞在一起。这篇就把项目下半场一次性补齐排序筛选、自定义委托、大数据量渲染、多视图联动外加最后的坑位清单。如果你正在把一个普通管理系统改造为 Model/View 架构这篇应该能直接对号入座。1. 实战项目的下半场四个“能看不能用”的问题先花几十秒回顾一下项目现状。数据源是一张文档表字段包括标题、分类、标签、评分、阅读进度、最后访问时间等。数据通过 QSqlQueryModel 子类加载在四个视图里展示。上一篇做到了点击行能在详情面板显示整条记录双击文本字段能直接编辑。功能看起来完整了但内部试用一周后反馈集中在这几件事上列表想看“评分最高的文档”只能自己拿眼睛找表格不能点表头排序想要“只看待办分类”代码里写死了数据源每次都要改查询语句表格里“阅读进度”和“评分”是纯数字不直观数据量从几千条涨到几万条时滚动开始明显掉帧四个视图各管各的选中列表里的行表格不会跟着跳右键删除一行后偶尔崩溃后来定位到是 QModelIndex 失效问题。这几个问题正好对应 Model/View 架构里最核心的四个扩展点QSortFilterProxyModel、QStyledItemDelegate、渲染优化和 QItemSelectionModel。项目下半场的任务就是把它们一个个填上。我当时的做法是先把问题拆开不要一上来就改代码。排序和筛选本质上是“数据视图”层面的需求不应该改动底层 SQL委托是“呈现和编辑”的需求和数据存储无关性能问题要分“模型层慢”和“视图层慢”两类原因完全不同联动则是信号槽的组织问题。理清这个边界之后每个扩展点都变得很清晰。下面的内容会按这个顺序推进每部分都会给出可以直接抄的关键代码和踩过的坑。代码示例基于 Qt6 环境如果你还在用 Qt5把 QRegularExpression 相关部分微调一下即可。2. QSortFilterProxyModel排序和筛选怎么做才不会带歪2.1 为什么排序筛选一定要放在代理层而不是改源模型这是 Model/View 架构里一个很容易走偏的地方。很多人一开始会觉得既然排序就是一条 SQL 的 ORDER BY那我在 QSqlQueryModel 的 setQuery 里把排序写进 SQL 不就完了筛选也是改 WHERE 条件就行。表面上看确实更简单实际用起来会很痛。原因有三点源模型的数据顺序一旦改变所有连着它的视图都会受影响。如果同一份数据同时挂在表格、树形和列表三个视图上表格按标题排序后列表视图也变成了新的顺序用户会非常困惑。每次改 SQL 都要重新 setQuery相当于把整个 model 重置一次所有视图的 selection、滚动位置全部丢失。用户排完序之后原本选中的行就莫名丢失选中状态了。排序、筛选和用户手动操作比如拖拽调整顺序会互相干扰如果哪天要支持“手动排序 自动筛选”的组合改 SQL 的方式直接卡死。QSortFilterProxyModel 就是为解决这个问题而存在的。它夹在源模型和视图之间视图请求数据时先经过它的排序和筛选逻辑但源模型本身的数据一排都不会动。简单说视图看到的顺序是代理算出来的源数据的物理顺序始终不变。在项目里我这样接sourceModel new DocumentQueryModel(this); sourceModel-setQuery(query, db); proxyModel new QSortFilterProxyModel(this); proxyModel-setSourceModel(sourceModel); proxyModel-setSortRole(Qt::EditRole); proxyModel-setDynamicSortFilter(true); tableView-setModel(proxyModel); tableView-setSortingEnabled(true); tableView-sortByColumn(1, Qt::DescendingOrder);这里有个细节setSortingEnabled(true) 是为了让点击表头时自动触发排序sortByColumn(1, Qt::DescendingOrder) 是程序主动执行一次默认排序。setDynamicSortFilter(true) 表示源数据有变化时重新排序筛选代价是频繁操作时有额外计算但日常管理工具规模的数据量影响不大。2.2 实战中的两个坑排序后行号错位、正则筛选漏数据先说行号错位。排序之后视图上第 3 行在源模型里可能根本不是第 3 行。项目里右键菜单要删除“当前行”如果用 tableView-currentIndex().row() 去删除删除的就会是代理排序前的源模型行完全错位。正确的做法是通过 mapToSource / mapFromSource 做映射。删除时这样拿源模型的行号QModelIndex proxyIndex tableView-currentIndex(); QModelIndex sourceIndex proxyModel-mapToSource(proxyIndex); int sourceRow sourceIndex.row();反向操作也一样。比如某个操作需要在源模型里定位某条记录然后让视图滚动到它QModelIndex sourceIndex sourceModel-index(row, 0); QModelIndex proxyIndex proxyModel-mapFromSource(sourceIndex); tableView-scrollTo(proxyIndex, QAbstractItemView::PositionAtCenter);这个映射关系是整个 Model/View 编程里最容易出错的地方。只要排序或筛选是启用状态凡是涉及行号、index 传递的代码都必须经过 mapToSource / mapFromSource这是个可以当作铁律的规则。第二个坑是筛选时的正则表达式写法。项目里筛选框是按关键词搜全部列早期代码写的是proxyModel-setFilterKeyColumn(-1); // 搜所有列 proxyModel-setFilterFixedString(keyword); // 按固定字符串匹配一开始没问题后来发现只要输入“待办”两个字包含“待办事项”“待办清单”的行能出来但“办理中”匹配不到因为 setFilterFixedString 是全字匹配。后来换成 QRegularExpression 做模糊过滤QRegularExpression regex( QRegularExpression::escape(keyword), QRegularExpression::CaseInsensitiveOption ); proxyModel-setFilterRegularExpression(regex);还有一个容易忽略的点是 setSortCaseSensitivity。默认排序区分大小写对文献标题这种混合大小写的内容很不友好。我习惯在模块初始化时统一设一次proxyModel-setSortCaseSensitivity(Qt::CaseInsensitive);这样“ABC”和“abc”在排序时被视为同一个级别体验更自然。3. 自定义委托表格里的进度条、星级和下拉框3.1 QStyledItemDelegate 负责什么在 Model/View 架构里数据存的是 QVariant视图默认把它渲染成文本。可现实场景里“阅读进度”存的是 0.7用户就想看到 70% 的进度条“评分”存的是 4用户希望看到四颗星而不是数字“4”“分类”存的是“技术/数据库”用户希望直接用下拉框选而不是手敲文本。这些需求落在一个类上委托也就是 QStyledItemDelegate 的子类。委托的核心职责有两个paint()控制单元格怎么画createEditor() / setEditorData() / setModelData()控制单元格进入编辑状态时用什么编辑器、编辑前怎么把当前值填进去、编辑完成后怎么把新值写回模型。在实际项目里我做了两个委托一个是 RatingDelegate负责画星级并支持用鼠标点选评分的编辑器一个是 ComboBoxDelegate负责分类列的下拉选择。3.2 星级绘制的关键代码星级绘制的核心是 paint()。思路很简单根据 option.rect 算出一排星星的位置然后用 QPainter 画实心星和空心星。高分的时候可以整星也可以允许半星但内部工具用整星就够了。void RatingDelegate::paint(QPainter *painter, const QStyleOptionViewItem option, const QModelIndex index) const { int rating index.data(Qt::EditRole).toInt(); const QRect rect option.rect; const int starCount 5; const int starWidth 20; const int spacing 2; const int totalWidth starCount * (starWidth spacing); QStyleOptionViewItem opt option; initStyleOption(opt, index); if (opt.state QStyle::State_Selected) { painter-fillRect(opt.rect, opt.palette.highlight()); } else { painter-fillRect(opt.rect, opt.backgroundBrush); } // 这里用两套 QIcon 缓存避免每帧都去重新加载星星图片 static QIcon starOn(:/icons/star_on.svg); static QIcon starOff(:/icons/star_off.svg); int startX rect.left() (rect.width() - totalWidth) / 2; int startY rect.top() (rect.height() - starWidth) / 2; for (int i 0; i starCount; i) { QRect target(startX i * (starWidth spacing), startY, starWidth, starWidth); QIcon icon (i rating) ? starOn : starOff; icon.paint(painter, target); } }绘制时有个经验不要每次 paint 都重新加载图片QIcon 加载一次后在类里做 static 或成员缓存。数据量大时滚动重绘是非常频繁的操作图片加载这种 IO 开销会直接拖垮帧率我在这上面吃过亏。3.3 下拉框委托的三步走ComboBoxDelegate 的实现有一个固定套路三个虚函数各管一段QWidget *ComboBoxDelegate::createEditor(QWidget *parent, const QStyleOptionViewItem option, const QModelIndex index) const { QComboBox *editor new QComboBox(parent); editor-addItems(itemList); // 可选分类清单 return editor; } void ComboBoxDelegate::setEditorData(QWidget *editor, const QModelIndex index) const { QComboBox *combo static_castQComboBox*(editor); QString current index.data(Qt::EditRole).toString(); int idx combo-findText(current); if (idx 0) combo-setCurrentIndex(idx); } void ComboBoxDelegate::setModelData(QWidget *editor, QAbstractItemModel *model, const QModelIndex index) const { QComboBox *combo static_castQComboBox*(editor); QString value combo-currentText(); model-setData(index, value, Qt::EditRole); }setEditorData 是把当前单元格的值“预填”进编辑器setModelData 是把编辑后的值“提交”回模型。初学者最容易漏掉 setEditorData结果一打开下拉框永远是第一项根本看不到当前值。还有一个细节如果源模型本身是只读的委托的编辑功能不会生效因为最终写入数据要经过 model-setData()。我们的 QSqlQueryModel 子类需要重写 setData() 并返回 true再调用 submitAll() 写库这样才能让编辑链路完整跑通。这部分在上一篇的基础代码里已经有了这里就不再展开了。4. 大数据量下的渲染优化从卡顿到顺滑的实测记录4.1 先定位瓶颈在模型还是视图项目数据量从几千条涨到五万条之后表格滚动开始“一顿一顿”。当时我做的第一件事不是急着优化而是判断瓶颈到底在哪一层。我做了个简单实验把 QSqlQueryModel 换成同样数据的 QStandardItemModel滚动依旧卡顿说明问题大概率在视图层。然后我把表格从 QTableView 换成 QListView只显示一列滚动流畅很多说明问题的一部分和多列布局计算有关。最后定位下来瓶颈是两个QSqlQueryModel 一次性把五万行全部加载进内部缓存加载过程卡内存占用高视图默认每行都独立计算尺寸和绘制细节滚动时开销非常大。对应两个方案模型层做分页加载视图层开启统一行高和滚动模式优化。4.2 模型层用 fetchMore 而不是一次 setQueryQSqlQueryModel 本身支持增量加载核心方法是 fetchMore()。默认情况下 QSqlQueryModel 一次 setQuery 会立刻拉取全部数据这在大表下就是灾难。正确思路是在程序启动时只取第一页等用户滚动到接近底部时再拉下一批。代码思路是这样class PagedQueryModel : public QSqlQueryModel { public: void setPageSize(int size) { m_pageSize size; } void fetchPage() { if (m_isFetching || m_allLoaded) return; m_isFetching true; int current rowCount(); QSqlQuery q query(); q.seek(current); // 从当前行继续取 // 手动控制 SQL 的 LIMIT/OFFSET取出 m_pageSize 条 // 插入到 model 中 fetchMore(); m_isFetching false; } private: int m_pageSize 1000; bool m_isFetching false; bool m_allLoaded false; };这里要说清楚QSqlQueryModel 的 fetchMore 机制依赖底层数据库驱动的支持程度不同驱动表现不完全一样。我在 SQLite 和 PostgreSQL 上都验证过最稳妥的还是用 SQL 的 LIMIT :offset, :pageSize 手动分页配合 QSqlQuery 拿到结果后再 setQuery 一个假查询或者直接继承 QAbstractTableModel 自己缓存页数据。内部工具不需要太复杂手动分页足够。4.3 视图层三个开关一键点亮视图层的优化几乎不需要改逻辑关键是这几个设置tableView-setUniformRowHeights(true); tableView-setVerticalScrollMode(QAbstractItemView::ScrollPerPixel); tableView-setSortingEnabled(false); // 大数据量下默认关闭排序setUniformRowHeights(true) 会让视图假设所有行高度一致从而大幅简化布局计算。如果你的单元格里没有多行文本、没有自适应行高一定要开启这是五万行表格滚动流畅的最关键开关。setVerticalScrollMode(ScrollPerPixel) 让滚动更平滑默认的 ScrollPerItem 在数据多时会有“跳格感”。setSortingEnabled 在性能敏感场景下要慎重。大数据量下点击表头触发一次全量排序会卡几百毫秒。我的处理是保留表头但关闭自动排序用户点表头时手动调用 sortByColumn并在排序期间显示一个等待光标。这样既保留了功能又不会让 UI 假死。实测效果列个表方便对照数据量优化前优化后5000 行加载约 300ms滚动轻微卡顿加载约 50ms滚动流畅50000 行加载约 2s滚动明显掉帧分页加载首屏快滚动基本流畅100000 行接近不可用内存占用高分页加载下仍可操作但排序仍是瓶颈这个数据和机器配置有关仅供参考。核心要表达的是同样的视图代码静默三个开关之后体验差异巨大。5. 多视图联动与右键删除别把信号写得到处都是5.1 用统一的控制器管理四个视图选中行为项目里四个视图展示的是同一份数据但用户期望的是选中任一个视图里的行其它视图能跳到对应位置。最容易出现的反面做法是每个视图各写一套逻辑信号槽满天飞改一个地方牵一发动全身。我最终采用了控制器模式一个 CentralController 对象持有四个 view 和四个 selectionModel所有联动逻辑集中在同一个槽函数里。核心思路是监听 currentChanged 信号但要注意防止循环触发。比如列表视图触发了联动更新表格视图时又导致表格视图发出 currentChanged反过来再触发列表视图更新形成无限循环。解决办法是加一个简单的递归锁标志void CentralController::onCurrentChanged( const QModelIndex current, const QModelIndex previous) { if (m_updating) return; m_updating true; QObject *senderView sender(); QAbstractItemView *srcView qobject_castQAbstractItemView*(senderView); if (srcView listView) { QModelIndex proxyIdx listView-currentIndex(); QModelIndex sourceIdx proxyModel-mapToSource(proxyIdx); QModelIndex tableProxyIdx tableProxyModel-mapFromSource(sourceIdx); tableView-setCurrentIndex(tableProxyIdx); tableView-scrollTo(tableProxyIdx); QModelIndex treeProxyIdx treeProxyModel-mapFromSource(sourceIdx); treeView-setCurrentIndex(treeProxyIdx); treeView-scrollTo(treeProxyIdx); } // 其它视图的分支写法相同 m_updating false; }这段代码的关键还是 mapToSource / mapFromSource。因为四个视图可能各自挂在不同的代理后面它们之间的索引不能直接互用必须先换算成源模型的索引再从源模型换成目标视图代理的索引。5.2 右键删除行的正确姿势右键菜单删除本身不难难点在删除后 model 和 view 的同步。如果用 QModelIndex 去删除删除前获取的 index 在 beginRemoveRows 之后立刻失效。稳妥做法是删除前先用 QPersistentModelIndex 保存位置QModelIndex proxyIdx view-currentIndex(); QPersistentModelIndex sourcePersistentIdx( proxyModel-mapToSource(proxyIdx)); // 弹确认框、执行删除 if (sourceModel-removeRow(sourcePersistentIdx.row())) { // delete 成功后不需要手动刷新view 会收到 rowsRemoved 信号 }QPersistentModelIndex 的生命周期比普通 QModelIndex 长。普通 QModelIndex 在模型结构变化后可能变成无效索引而 QPersistentModelIndex 会自动跟随数据变化更新自己的行号。右键删除场景下从弹出菜单到真正执行删除往往隔了几帧先用 persistent index 存下来能避免一大类崩溃。如果是一次性删多行直接用 beginRemoveRows 逐行删也可以但性能较差。批量删除建议用 beginResetModel endResetModel 一次性通知视图全部清空重建。代价是所有视图的选中状态和滚动位置会丢失所以只有在“删除所有筛选结果”这种整批操作时才用。项目里还有一个容易忽略的点删除是数据库操作必须在模型层完成事务处理。不建议在视图的右键菜单槽函数里直接执行 SQL因为这会绕过 Model/View 的信号机制视图不会收到任何变化通知。正确做法是在源模型的 removeRow 里完成数据库删除和 beginRemoveRows/endRemoveRows 的包裹视图只需要调用 removeRow剩下的都由模型层通知扩散。6. 最后的坑位清单与调试工具6.1 我踩过的坑整理成一张清单做这个项目期间大部分时间不是花在写功能上而是花在查“为什么视图不更新”“为什么排序后删除错位”这类问题上。整理一个踩坑清单方便遇到问题时直接对照。问题现象根因对策排序后删除错行直接用 proxy 的 row() 去操作源模型统一走 mapToSource筛选框输入后列表不变化忘了 setDynamicSortFilter(true)构造代理时开启动态排序筛选委托编辑器打开后空白setEditorData 没把当前值填进去实现 setEditorData 并设置初始值委托编辑完数据没写回源模型 setData 返回 false在源模型里重写 setData几万行数据滚动卡死开启自动排序 每行独立计算高度关闭表头自动排序 setUniformRowHeights删除后程序崩溃QModelIndex 失效未感知改用 QPersistentModelIndex四个视图联动循环跳转currentChanged 互相触发加 m_updating 递归锁表格数据量太大启动慢setQuery 一次全拉用 LIMIT/OFFSET 分页加载这个清单不是从文档里抄的每一条都在调试器里真实出现过。其中“排序后删除错行”和“QModelIndex 失效崩溃”是最隐蔽的因为它们不是必现往往是在特定操作顺序下才暴露。6.2 调试技巧让模型自己报告问题最后分享一个能省大量时间的手段在模型开发阶段给 QAbstractItemModel 挂上自检工具使用 Qt 自带的 QAbstractItemModelTester。这个类会遍历模型的所有索引验证行数、列数、父子关系、数据角色的返回是否一致还能检查 begin/end 包裹是否成对出现。用法很简单在你的源模型构造时加一句sourceModel new DocumentQueryModel(this); new QAbstractItemModelTester(sourceModel, QAbstractItemModelTester::FailureReportingMode::Warning, this);挂上之后很多问题会以 Warning 的形式主动暴露比如行列数变化没有正确通知、dataChanged 信号范围不对、beginRemoveRows 和 endRemoveRows 不配对等。我在测试阶段就靠着它揪出了好几处“视图不刷新”的隐患。实际使用中要注意QAbstractItemModelTester 应该只在调试构建里启用。正式发布时用宏包住或者放在 #ifdef QT_DEBUG 里避免自检逻辑影响线上性能。顺带说一句Model/View 架构的调试思路和普通 GUI 程序不太一样。普通程序出问题先看事件循环Model/View 程序出问题先看信号。视图不更新十有八九不是绘制代码的问题而是模型压根没有发出正确的 rowsInserted 或 dataChanged。调试时先在模型层的每个信号发射处加 qDebug 输出基本能定位八成问题。这个项目做到这里四个视图、一套数据、排序筛选、自定义编辑、大数据加载、多视图联动就全齐了。从“能显示”到“好用”中间隔的不只是代码量更是对 Model/View 信号流转和索引映射的理解。如果你也在做类似的内部管理工具建议先把 QSortFilterProxyModel 的映射关系吃透那是后续所有联动功能的地基。
阅读完成 · 觉得有帮助?
咨询建站