资讯动态

Qt复杂表头与固定行冻结行实现:基于QTableView双视图架构完整方案

发布时间:2026/10/20 21:57:07 来源:尧图企业网站定制
简介这是一份面向 Qt 开发者的表格控件示例工程围绕 QTableView 与 QTableWidget 中复杂表头多行表头、冻结行以及固定特定行这一常见难点提供可直接对照的完整实现。资源包为 zip 格式共 9 个文件约 10KB包含 C 源文件与头文件、UI 界面文件以及 qrc、pro、png 等工程配置与界面素材文件数量少、依赖简单适合快速阅读和迁移。示例工程由两个界面窗体和一个自定义表格组件构成清楚演示了多行表头的结构设计与指定行冻结/固定的处理方式关键代码集中便于按需抽取到自己的项目中。目前已有 7985 人学习/下载对于需要实现复杂表格交互的 Qt 中级开发者是一份可落地的参考样例。1. 复杂表头与固定行一个 Qt 表格工程能解决什么某公司内部报表工具上个月要改版表格列数从 6 列涨到 18 列而且表头变成“项目 / 指标 / 年度 / 季度”两级分类。第一版用 QTableWidget 加上 setSpan表面看好像能出效果可一滚动数据行表头跟着数据一起滚没领导当场否了。后来我换了个思路把表头前几行“伪装”成数据行放进独立的冻结视图再通过双视图共享同一个数据模型让表头和数据行各管各的滚动。这个 Demo 项目就是把这条完整链路做了出来包括多行表头构造、固定行冻结行显示、列宽同步、滚动条同步和常见坑位规避。如果你正在做财务台账、指标看板这类带复杂表头且需要固定行的界面可以直接把这个工程拿过去改。2. 工程结构与技术选型QTableView 双视图架构的设计动机2.1 目录结构每个文件的职责边界这个 Demo 源码不大但文件划分是有意为之的fixed_row_table_demo/ ├── CMakeLists.txt ├── main.cpp ├── MainWindow.h / MainWindow.cpp ├── FixedRowTableWidget.h / FixedRowTableWidget.cpp └── TableModel.h / TableModel.cppmain.cpp 是程序入口只负责创建 QApplication 并显示 MainWindow。MainWindow 是壳放一个 FixedRowTableWidget 加两个测试按钮一个按钮用来追加一行数据另一个按钮用来切换“是否显示固定数据行”。FixedRowTableWidget 是最核心的容器内部持有两个 QTableViewm_freezeView 和 m_mainView把所有冻结逻辑都封装在它里面。TableModel 继承 QAbstractTableModel负责把两行表头 业务数据拼成一个平面模型。这样拆分的好处是MainWindow 完全不知道冻结是怎么实现的它只调用 addRowData() 和 setFixedDataRows()数据和视图被解耦。如果将来你想换一个模型比如从数据库读只需要动 TableModelFixedRowTableWidget 的代码一行不用改。2.2 为什么不用 QTableWidget 而要自写模型这是我最想强调的选型点。QTableWidget 用起来确实快new 出来 setItem 就能填数据但它的能力边界在“非规则表格”场景下非常明显QTableWidget 的单元格数据由 QTableWidgetItem 自己管理很难让“两个控件共享同一份数据”因为两份 QTableWidget 各自持有独立的数据副本同步要么靠人工遍历要么写一堆信号槽。QTableWidget 不能派生出独立的滚动视图组合。它本身是一个封装好的视图你拿不到它的 viewport 来做二次布局。多行表头如果用 QTableWidget 硬做通常要开一个单独的 widget 放在 table 上面伪装表头滚动时还要手动跟着 x 轴跑这是典型的打补丁写法。而 QTableView QAbstractTableModel 是 model/view 架构的标配。同一个 model 可以同时挂到两个 QTableView 上隐藏行是视图级别的状态互不干扰数据刷新通过 dataChanged 信号同时通知所有视图。这个特性正是冻结行实现的基础。2.3 最小可运行工程CMake 配置与启动流程CMakeLists.txt 的内容不长贴出来可以直接跑cmake_minimum_required(VERSION 3.16) project(FixedRowTableDemo) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) find_package(Qt6 COMPONENTS Widgets REQUIRED) add_executable(FixedRowTableDemo main.cpp MainWindow.cpp FixedRowTableWidget.cpp TableModel.cpp ) target_link_libraries(FixedRowTableDemo PRIVATE Qt6::Widgets)这里有两个参数值得说明。CMAKE_AUTOMOC 是必须开的因为 TableModel、FixedRowTableWidget 里都有 Q_OBJECT 宏Qt 的元对象编译器要靠它自动生成 moc 文件。find_package 如果找不到 Qt6可以换成 Qt5这个工程的代码本身没有用到 Qt6 专属内容Qt 5.12 以上都能编过。main.cpp 就几行不做多余的事#include QApplication #include MainWindow.h int main(int argc, char *argv[]) { QApplication app(argc, argv); MainWindow w; w.show(); return app.exec(); }构建命令在 Linux 下是cmake -B build cmake --build build ./build/FixedRowTableDemoWindows 上用 Qt Creator 打开 CMakeLists.txt 直接就能加载工程。跑起来后会看到一个两行多级表头的大表格顶部两行固定不动下面数据可以自由滚动。2.4 TableModel表头与数据如何共存TableModel 的构造函数接收两个参数表头行数和数据行初始值。内部用一个嵌套 vector 记录两行表头再加一个嵌套 vector 存业务数据。rowCount 返回表头行数加业务数据行数columnCount 则取决于列配置。这就是多行表头实现的关键——把表头“降维”成普通行数据int TableModel::rowCount(const QModelIndex parent) const { if (parent.isValid()) return 0; return m_headerRows.size() m_dataRows.size(); } int TableModel::columnCount(const QModelIndex parent) const { if (parent.isValid()) return 0; return m_totalColumns; }headerData 里只返回普通的横向列名真正的“大分组”不在这里展示而是靠前两行数据行在固定视图里用 setSpan 视觉合成。这是这套方案与传统 setHorizontalHeaderLabel 的区别传统方案最多做到一行表头而数据行的方案可以做到任意层级。3. 核心实现之一用模型行 setSpan 构造多行表头3.1 两级表头的模型布局构造 TableModel 时我把前两行预留出来第 0 行是“大组名”第 1 行是“具体列名”。这两行在业务上不是数据但在模型里就是普通行这样在视图层看过去表格最上面天然就有两行实体可以随便合并、设置背景色、调行高。TableModel::TableModel(int headerRows, QObject *parent) : QAbstractTableModel(parent) { m_totalColumns 6; m_headerRows.resize(headerRows); // 第 0 行一级分组每个分组占用 3 列 m_headerRows[0] {基础信息, , , 扩展信息, , }; // 第 1 行二级列名 m_headerRows[1] {工号, 姓名, 部门, 职位, 入职日期, 状态}; m_dataRows { {A001, 张三, 研发部, 工程师, 2023-02-01, 在职}, {A002, 李四, 研发部, 高级工程师, 2022-05-11, 在职}, // ... 更多示例数据 }; }注意这里 m_headerRows[0] 里“基础信息”后面跟了两个空字符串这是配合 setSpan 合并单元格用的。视图上第 0 行的 0-2 列合并成一个大单元格3-5 列合并成另一个大单元格那空字符串天然就看不见了。这个布局有个约定合并范围必须在 setSpan 时显式指定不能靠模型自动推导。data() 里针对前两行做了特殊处理把文字居中和背景色一起返回QVariant TableModel::data(const QModelIndex index, int role) const { if (!index.isValid()) return {}; int row index.row(); if (row m_headerRows.size()) { if (role Qt::DisplayRole) return m_headerRows[row][index.column()]; if (role Qt::TextAlignmentRole) return Qt::AlignCenter; if (role Qt::BackgroundRole) return QColor(238, 242, 248); } // 业务数据部分正常返回 if (role Qt::DisplayRole) return m_dataRows.at(row - m_headerRows.size()).at(index.column()); return {}; }text alignment 返回的是全局对齐比在构造字符串时手动加空格更可靠。背景色用浅蓝灰让表头区在视觉上和滚动区拉开层次。业务数据部分row 减掉表头行数就可以映射回真实数据数组。3.2 在固定视图里用 setSpan 渲染合并表头固定视图 m_freezeView 拿到同一个 model之后做两件事隐藏非固定行设置 span。void FixedRowTableWidget::setupHeader() { m_freezeView-setModel(m_model); m_mainView-setModel(m_model); // 隐藏纵向行号让表头区域更干净 m_freezeView-verticalHeader()-setVisible(false); m_mainView-verticalHeader()-setVisible(false); // 只保留前 fixedHeaderRows 行作为表头其余行在固定视图里隐藏 int headerCount m_fixedHeaderRows; for (int row 0; row m_model-rowCount(); row) { m_freezeView-setRowHidden(row, row headerCount); } // 第一行表头合并成两个大分组 m_freezeView-setSpan(0, 0, 1, 3); m_freezeView-setSpan(0, 3, 1, 3); // 第二行表头不合并直接显示列名 }setSpan 的参数是 (row, column, rowSpan, columnSpan)。这里把第 0 行的 0 列到 2 列合并再把 3 列到 5 列合并视觉上就是“基础信息”和“扩展信息”两大组。如果想做三级表头就再加一行第一行合并 6 列第二行按 222 合并第三行列名逻辑完全一样。合并完成后把固定视图的行高和表头样式调一下m_freezeView-verticalHeader()-setSectionResizeMode(QHeaderView::Fixed); m_freezeView-verticalHeader()-setDefaultSectionSize(38); m_freezeView-setRowHeight(0, 42); m_freezeView-setRowHeight(1, 34);setDefaultSectionSize 是全局默认行高setRowHeight 可以单独覆盖前两行让分组行比列名行略高这是报表里常见的层级节奏。如果觉得两行高度都固定更省事直接把 setSectionResizeMode 设成 Fixed 就行。提示关闭 verticalHeader 后setRowHeight 和 setSectionResizeMode 仍然有效因为 verticalHeader 对象还在只是不显示而已不需要担心调用失效。3.3 两视图列宽同步最容易被忽略的联动完成上述设置后拖动主视图的列宽时固定视图并不会自动跟着变因为两个视图是两个独立控件没有天然的列宽共享。我一般这样连接信号connect(m_mainView-horizontalHeader(), QHeaderView::sectionResized, this, [this](int logicalIndex, int /*oldSize*/, int newSize) { if (m_syncingColumns) return; m_syncingColumns true; m_freezeView-setColumnWidth(logicalIndex, newSize); m_syncingColumns false; });这里加了个 m_syncingColumns 布尔量做重入保护因为固定视图的 horizontalHeader 也会发出 sectionResized双向连接时两边会来回触发。如果只让主视图改变列宽也可以只连单向把固定视图的列宽调整关掉但实际项目里用户往往希望两边的列都能拉所以我还是保留双向同步用锁变量防递归。3.4 自定义 QHeaderView 的备选方案如果你不想动模型只想要一个“多行横表头”还有一个备选实现继承 QHeaderView重写 paintSection 画两行文字并重写 sizeHint 返回两倍行高。这种方案适合表头只有两行且合并边界不复杂的情况。class MultiRowHeader : public QHeaderView { Q_OBJECT public: explicit MultiRowHeader(Qt::Orientation orientation, QWidget *parent nullptr) : QHeaderView(orientation, parent) {} protected: void paintSection(QPainter *painter, const QRect rect, int logicalIndex) const override { painter-save(); // 第一行文字 painter-drawText(rect.adjusted(0, 0, 0, -rect.height() / 2), Qt::AlignCenter, m_topLabels[logicalIndex]); // 第二行文字 painter-drawText(rect.adjusted(0, rect.height() / 2, 0, 0), Qt::AlignCenter, m_bottomLabels[logicalIndex]); painter-restore(); } QSize sizeHint() const override { return QSize(100, 64); } };这个方案的优点是表头真正是 QHeaderView横表头排序箭头也能正常显示缺点是合并单元格要自己画背景和分隔线逻辑复杂后代码会很啰嗦。Demo 里选的是“模型行 固定视图”方案因为我后面还要做固定行两个需求共用一套双视图架构改动量最小。4. 核心实现之二固定行/冻结行的滚动同步与数据联动4.1 双视图共享模型的冻结布局固定行的思路和表头其实是同一套把需要固定显示的行全放在 m_freezeView 里剩下行放在 m_mainView 里两个视图共享同一个 model。FixedRowTableWidget 的构造函数把整体布局搭成上下结构FixedRowTableWidget::FixedRowTableWidget(int fixedHeaderRows, int fixedDataRows, QWidget *parent) : QWidget(parent) , m_fixedHeaderRows(fixedHeaderRows) , m_fixedDataRows(fixedDataRows) { m_freezeView new QTableView(this); m_mainView new QTableView(this); m_model new TableModel(fixedHeaderRows, this); m_freezeView-setModel(m_model); m_mainView-setModel(m_model); // 冻结区只做展示不允许编辑避免两边同时弹编辑器 m_freezeView-setEditTriggers(QAbstractItemView::NoEditTriggers); // 组装成上下布局冻结区在上滚动区在下 auto *layout new QVBoxLayout(this); layout-setContentsMargins(0, 0, 0, 0); layout-setSpacing(0); layout-addWidget(m_freezeView); layout-addWidget(m_mainView, 1); }布局参数里 addWidget(m_mainView, 1) 的第二个参数是 stretch 因子意思是在垂直方向上滚动区占满剩余高度冻结区只占它自己内容的高度。这样窗口拉大缩小时冻结区纹丝不动滚动区自适应。然后执行 updateFixedRows()void FixedRowTableWidget::updateFixedRows() { int fixedCount m_fixedHeaderRows m_fixedDataRows; for (int row 0; row m_model-rowCount(); row) { m_freezeView-setRowHidden(row, row fixedCount); m_mainView-setRowHidden(row, row fixedCount); } m_freezeView-setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); m_freezeView-setVerticalScrollMode(QAbstractItemView::ScrollPerPixel); }这段循环就是冻结的核心。m_freezeView 里只露前 fixedCount 行其余全部隐藏m_mainView 正好相反把前 fixedCount 行隐藏只露后面的滚动区。同一行在两个视图里的隐藏状态是各自维护的所以不会互相覆盖。三个固定参数的含义要分开记fixedHeaderRows 是表头行数fixedDataRows 是“需要固定在表头下面”的数据行数比如合计行、汇总行fixedCount 是两者的总和。想让固定行不可编辑在 m_freezeView 上设置 setEditTriggers(QAbstractItemView::NoEditTriggers) 即可。4.2 滚动条同步与重入保护垂直滚动不需要手动做m_freezeView 没有垂直滚动条内容就永远停在顶部。需要处理的是水平滚动条两个视图横向能滚的范围一样用户拖其中一个另一个必须跟着移动。connect(m_mainView-horizontalScrollBar(), QScrollBar::valueChanged, this, [this](int value) { m_freezeView-horizontalScrollBar()-setValue(value); });这个连接在大多数场景够用但如果你把 m_freezeView 的水平滚动条也调成可用的那还得把反方向的连接也加上。反方向连接最关键的是防递归我习惯这么写void FixedRowTableWidget::syncHorizontalScroll(int value) { if (m_syncLock) return; m_syncLock true; m_freezeView-horizontalScrollBar()-setValue(value); m_mainView-horizontalScrollBar()-setValue(value); m_syncLock false; }然后把两个滚动条的 valueChanged 都连到 syncHorizontalScroll 上m_syncLock 置位期间第二次触发直接 return。这个做法比 QSignalBlocker 更直观特别是你后面要加第三个滚动条比如固定列视图时一个锁变量能挡住所有回环。4.3 选中联动与编辑同步双视图共享同一个 model 后数据编辑天然同步因为 edit 会调用 model 的 setData然后发 dataChanged所有视图统一刷新。但选中状态默认是各看各的因为 QTableView 创建时会自动生成一个自己的 selectionModel。要联动选中在创建完两个视图后执行QItemSelectionModel *selectionModel m_model-selectionModel(); m_mainView-setSelectionModel(selectionModel); m_freezeView-setSelectionModel(selectionModel);现在点击主视图任意一行冻结区里对应的行也会高亮。这种“两区同亮”的效果对用户判断当前位置非常有用。要注意的是如果模型重新 setModelselectionModel 会被重建所以这段代码必须放在 setModel 之后调用。编辑方面冻结区已经设成只读避免两边同时弹编辑器。主视图保持默认双击单元格就能改改完冻结区里的同一行马上跟着变因为底层模型数据已经变了。4.4 固定列的对称实现固定行做完固定列只是转置。我在工程里预留了一个 freezeColumnCount 属性核心逻辑如下void FixedRowTableWidget::setupFixedColumns() { int count m_fixedColumnCount; int total m_model-columnCount(); for (int col 0; col total; col) { m_freezeView-setColumnHidden(col, col count ? false : true); m_mainView-setColumnHidden(col, col count ? true : false); } }这次把隐藏逻辑从行换成列冻结区只显示前几列。滚动条同步从水平换成垂直列宽同步换成行高同步。如果行和列同时要固定那把冻结区拆成四块左上角固定标题区、右上角固定表头区、左下角固定行区、右下角滚动区每个区域的 hidden 规则按行列取交集信号连接会比单方向复杂一截建议把连接逻辑拆成三个独立函数不要堆在构造函数里。5. 避坑指南表头渲染、滚动同步和刷新的五个常见问题5.1 setSpan 后排序把表头排飞了现象表头用前两行加 setSpan 合并好后一旦点击列头排序合并单元格还在原位置但数据内容整体重排表头文字和数据对应关系全乱套。原因排序发生在整个 model 层面表头行也参与了排序隐藏行被 sort 引擎当成普通行处理排序后表头行跑到了数据中间。解决不要直接调用 QTableView::sortByColumn而是在 TableModel 外层加一个 QSortFilterProxyModel重写 lessThan 时把前 fixedHeaderRows 行强制排在最前。或者在业务层就约定“表头行不参与排序”点击排序时先冻结表头行再排数据区。两种方案里 proxy 方案更优雅因为它不改变 model 内部的数据顺序。5.2 双向联动滚动条死循环现象程序运行后横向滚动条没被用户操作却自动小幅度来回抖动鼠标滚轮也时灵时不灵。原因两个 QScrollBar 的 valueChanged 相互触发A setValue 引发 B 的 valueChangedB 又 setValue 引发 A形成循环更新。setValue 即使设置相同值也会触发 valueChanged所以这种循环非常容易发生。解决统一走一个同步函数加锁变量如 4.2 节所示。锁变量的作用域要覆盖两个 setValue而不是只锁一个否则另一个方向仍然会咬住更保险的做法是在锁变量加计数避免异常退出后锁没释放。提示不要在一个槽函数里连续对两个滚动条 setValue 却不加锁这是最常见的死循环来源。用单一入口函数 m_syncLock 是最稳妥的写法。5.3 固定区域的单元格编辑后不刷新现象在主视图修改某个单元格的值冻结区对应的行还是旧值甚至有时候主视图改完自己也变灰需要重新选中才刷新。原因最大概率是模型引用不一致。检查一下是否在 FixedRowTableWidget 里 new 了多个 TableModel 实例或者 setModel 时传了两个不同的指针。两个视图必须指向同一个 model 对象dataChanged 才会广播给双方。解决把 setModel 的入参统一用一个变量不要写 m_freezeView-setModel(new TableModel(...)) 这种临时对象。验证手段是在 model 的 dataChanged 信号里加一行 qDebug看修改一个单元格时两个视图的 index 是否都能收到更新。5.4 固定视图列宽不听指挥现象主视图拖动列宽正常冻结视图同步失效或者程序启动时固定视图列宽跟主视图不一样像被截断了一样。原因一是列宽同步只连了 sectionResized没连 sectionHandleDoubleClicked 双击自动调整二是 fixed 视图的 horizontalHeader 模式是 FixedsetColumnWidth 被内部策略忽略三是初始化时两个视图列宽本来就是默认值没做一次全量拷贝。解决初始化时遍历所有列显式 m_freezeView-setColumnWidth(i, m_mainView-columnWidth(i)) 一把再把 horizontalHeader 的 sectionResizeMode 设为 Interactive给 setColumnWidth 放行。倾听信号时 mainView 的 sectionResized 和固定视图的 sectionResized 都连到同一个同步函数锁变量保证不死循环。5.5 固定行数变更后界面没有反应现象调用 setFixedDataRows(5) 后界面依旧显示原来的固定行数像接口失效。原因setFixedDataRows 只改了成员变量但 updateFixedRows 依赖的这个循环里用的还是旧的计算值或者 updateFixedRows 被调用了但固定视图的高度没有重新计算底部多余行被窗口裁掉了。解决在 setFixedDataRows 里同步更新 m_fixedDataRows 再调用 updateFixedRows并且在 updateFixedRows 最后重算冻结视图高度int height m_freezeView-verticalHeader()-length(); m_freezeView-setFixedHeight(height 2);verticalHeader()-length() 返回所有可见行高总和加 2 是为了留边框像素。如果算出来为 0说明模型还没有加载完用 QTimer::singleShot(0, ...) 延迟一拍再计算。6. 二次开发技巧把固定表头做成可配置、可验证的模块6.1 表头分组配置化项目里表头目前是写死在 TableModel 的构造函数里真要拿到业务上最好把表头结构做成外部配置。我一般让表头支持 JSON 文件一个典型配置片段是这样{ headerRows: [ { row: 0, cells: [ {col: 0, span: 3, text: 基础信息}, {col: 3, span: 3, text: 扩展信息} ] }, { row: 1, cells: [ {col: 0, span: 1, text: 工号}, {col: 1, span: 1, text: 姓名} ] } ] }解析后动态 setSpan固定行数也能从配置里读。这样换一套报表配置只改 JSON 文件不需要重新编译发布对运维和产品人员都比较友好。6.2 大数据量的性能处理当业务行数超过几万时循环 setRowHidden 会明显卡顿。两个优化方向第一用 QSortFilterProxyModel 按行号过滤固定区显示 row fixedCount 的行滚动区显示 row fixedCount 的行替代隐藏式循环第二开启 setUniformRowHeights(true)让所有行高统一滚动条位置计算从线性查找变成 O(1)滚动流畅度提升很明显。数据量特别大时还可以把模型改成懒加载只返回可视区域的 data() 请求。6.3 改造后的验证清单每次改完和“固定”相关的代码我强制走一遍验证流程追加一行数据看冻结区和滚动区是否同步刷新拖动任意列宽看两个视图是否一致垂直滚动十万行看冻结区域是否稳定不动水平滚动到最右侧看列宽同步是否正常双击滚动区单元格后确认冻结区没有出现重影或残留高亮。这套流程帮我在一次改版里抓住了列宽同步的隐性问题我以为固定视图已经跟了主视图结果双击列头自适应宽度时固定视图纹丝不动就是因为漏了 sectionHandleDoubleClicked 信号。从那以后我每次改表格工程都强制走一遍上面的验证清单普通滚动、列宽、编辑、刷新四个维度全过一遍再交付代码。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑