
简介这份参考工程围绕 Qt 的 Model/View 架构演示如何从 QAbstractTableModel 抽象基类派生自定义表格模型解决数据展示与界面交互的分离问题。工程体积仅约 7KB共 7 个文件主要类型包括 cpp 源文件、hpp 头文件、pro 工程配置以及 ui 界面文件自定义模型头文件是核心完整实现了行数查询、列数查询、数据读取、表头显示、行列的插入与删除、以及项目编辑标志等关键虚函数主窗口类负责将模型通过 setModel 关联到表格视图完成界面搭建工程配置则让项目可以直接编译运行。对于希望掌握 Model/View 机制、了解如何让表格支持动态增删行列和单元格编辑的开发者这套参考工程提供了清晰且精简的示例有助于理解数据与界面分离的设计思路并为扩展列表模型或树形模型打下基础。目前已有 1137 人学习下载适合 Qt 初学者和希望深入定制数据模型的开发者。 做Qt开发只要界面上要摆列表、表格、树迟早都要碰Model/View这套东西。我之前写工具图省事一个QTableWidget往里塞几千行数据结果滚动、排序、刷新全卡后来才下决心把手头几个工程里的数据展示层统一改成自定义Model类代码量不但没涨很多历史问题反而消失了。这篇记录的就是一套可以直接抄的自定义Model类参考工程以“任务列表”为例完整实现一个基于QAbstractTableModel的只读可编辑Model再把排序、过滤、删除这些实际工程里一定会用到的功能串起来。适合刚开始接触Model/View的新手也适合已经被QTableWidget卡顿折磨过、想重构数据展示层的Qt开发者。下面这堆代码和思路拿过去按自己的业务字段改就能用。1. 项目概述与整体设计思路1.1 这个参考工程要解决什么问题自定义Model的核心价值是把数据和界面彻底拆开。业务层拿到串口报文、数据库结果、算法计算值之后统一塞进一个数据结构里Model负责把这个结构翻译成QTableView能理解的行、列、单元格View不关心业务对象长什么样它只调Model的接口。这样带来的直接好处有三个切界面不切数据。同一个Model今天用QTableView展示明天想换成QTreeView或者QListView业务代码一行都不用动。排序、过滤、拖拽这些能力不用自己重复造。Qt自带的QSortFilterProxyModel就是为这种架构设计的。大数据量下性能有保障。局部刷新、增量插入都可以精准通知View而不是每次全表重绘。很多时候我们觉得Model/View复杂是因为一上来就去啃QAbstractItemModel的十几个纯虚函数。实际上做平面表格Qt早就给你准备好了QAbstractTableModel要重写的函数少得可怜。1.2 为什么不用现成的Model类每次讲到自定义Model总有人问Qt不是有QStandardItemModel和QSqlTableModel吗为什么还要自己写对比一下就很清楚。QStandardItemModel的通用性很强适合快速搭原型但它的数据载体是QStandardItem对象如果你的业务数据本身放在结构体、QList、QVector里就得在Item和数据实体之间来回同步两套数据一旦不同步定位问题非常痛苦。QSqlTableModel则绑定SQL表适合后台就是数据库的项目但很多桌面工具的数据来源是串口、文件、算法计算为这些场景强行套SQL模型反而别扭。自定义Model的本质不是“创造一套新数据”而是把现有数据结构包装成View能用的行列接口。我只维护一套业务数据View需要什么我在data()里现查现给永远不会有双份数据不一致的烦恼。1.3 选择哪个基类Table还是Item先根据数据形态选基类这是新手最容易走偏的地方数据形态推荐基类需要重写的核心函数一维列表QAbstractListModelrowCount、data二维表格QAbstractTableModelrowCount、columnCount、data、headerData树形结构QAbstractItemModel上述全部外加index、parent我身边有人学Model/View时一上来就继承QAbstractItemModel为了一个平面表格硬写index()和parent()写出一堆索引越界的bug。记住一条只有数据本身是树才需要QAbstractItemModel平面表用QAbstractTableModel基类已经帮你把索引相关的脏活干完了。2. 核心细节解析与实操要点2.1 数据结构与role的约定以“任务管理”为例任务实体长这样struct TaskItem { int id 0; QString name; QString category; bool done false; QString deadline; };Model内部维护一个QListTaskItem m_tasks每一行对应一个TaskItem每一列对应不同的字段。Model的作用就是把m_tasks[i]的某个字段按照View要的“用途”返回出去。这里必须理解role这个概念。role是View向Model索要数据时附加的“用途说明”Qt::DisplayRole单元格显示的文本Qt::EditRole进入编辑状态时编辑控件里显示的原始值Qt::CheckStateRole复选框的勾选状态Qt::BackgroundRole、Qt::ForegroundRole背景色、前景色Qt::UserRole自定义业务数据通道。data()函数本质就是一个大一点的分发器先根据role确定返回什么类型的数据再根据index.column()确定返回哪一列。2.2 data()的边界检查与角色分支View有时候会拿一些“试探性”的索引来问数据比如拖拽过程中、隐藏列的时候都可能出现越界索引。所以data()里第一件事就是做边界检查index.isValid()为假直接返回空QVariantindex.row()不在[0, m_tasks.size())范围内直接返回空取实体时尽量用m_tasks.at(index.row())而不是m_tasks[index.row()]前者带越界保护更稳。角色分支里我见过最典型的坑是只写了if (role Qt::DisplayRole)其他角色返回的默认值忘写了结果某些列在某些操作下突然空白。正确做法是用switch(role)每个分支都有明确返回default返回QVariant()兜底。2.3 编辑能力flags()和setData()的配合只读模型只需要data()但参考工程通常都要能编辑于是必须补三件套flags()里给需要编辑的列加上Qt::ItemIsEditable或Qt::ItemIsUserCheckable告诉View“这列能交互”setData()里校验传入的value并写回m_tasks写完数据后手动emit dataChanged(...)通知View刷新对应区域。dataChanged这个信号太容易被漏掉了。我见过一个同事改了setData()但没发信号双击编辑、回车、单元格纹丝不动查了半天最后就是少了一行emit。View不会主动感知数据变化它的所有活泼全靠Model发信号驱动。3. 实操过程与核心环节实现3.1 TaskModel头文件先建一个Qt Widgets Application界面放一个QTableView、一个“添加任务”按钮、一个“删除任务”按钮再加一个QLineEdit做搜索过滤。模型头文件如下#ifndef TASKMODEL_H #define TASKMODEL_H #include QAbstractTableModel #include QList struct TaskItem { int id 0; QString name; QString category; bool done false; QString deadline; }; class TaskModel : public QAbstractTableModel { Q_OBJECT public: enum Column { ColId 0, ColName, ColCategory, ColStatus, ColDeadline, ColCount }; explicit TaskModel(QObject *parent nullptr); int rowCount(const QModelIndex parent QModelIndex()) const override; int columnCount(const QModelIndex parent QModelIndex()) const override; QVariant data(const QModelIndex index, int role) const override; QVariant headerData(int section, Qt::Orientation orientation, int role) const override; Qt::ItemFlags flags(const QModelIndex index) const override; bool setData(const QModelIndex index, const QVariant value, int role Qt::EditRole) override; void addTask(const TaskItem item); void removeTask(int row); void loadDemoData(); private: QListTaskItem m_tasks; }; #endif // TASKMODEL_HQAbstractTableModel要求的最小实现就是rowCount、columnCount、data这三个。headerData不写也行但不写就看不到列标题所以参考工程里保留。flags和setData是给编辑功能用的。3.2 TaskModel源文件下面是核心实现这一段是整个参考工程的骨架可以直接复制改成自己的字段#include taskmodel.h #include QBrush #include QColor TaskModel::TaskModel(QObject *parent) : QAbstractTableModel(parent) { } int TaskModel::rowCount(const QModelIndex parent) const { if (parent.isValid()) return 0; return m_tasks.size(); } int TaskModel::columnCount(const QModelIndex parent) const { if (parent.isValid()) return 0; return ColCount; } QVariant TaskModel::data(const QModelIndex index, int role) const { if (!index.isValid()) return QVariant(); if (index.row() 0 || index.row() m_tasks.size()) return QVariant(); const TaskItem item m_tasks.at(index.row()); switch (role) { case Qt::DisplayRole: switch (index.column()) { case ColId: return item.id; case ColName: return item.name; case ColCategory: return item.category; case ColStatus: return item.done ? tr(已完成) : tr(未完成); case ColDeadline: return item.deadline; default: return QVariant(); } case Qt::EditRole: switch (index.column()) { case ColName: return item.name; case ColCategory: return item.category; default: return QVariant(); } case Qt::CheckStateRole: if (index.column() ColStatus) return item.done ? Qt::Checked : Qt::Unchecked; return QVariant(); case Qt::TextAlignmentRole: if (index.column() ColId || index.column() ColStatus) return QVariant(Qt::AlignCenter); return QVariant(); case Qt::BackgroundRole: if (item.done) return QBrush(QColor(0xe8f5e9)); return QVariant(); default: return QVariant(); } } QVariant TaskModel::headerData(int section, Qt::Orientation orientation, int role) const { if (role ! Qt::DisplayRole) return QVariant(); if (orientation Qt::Horizontal) { switch (section) { case ColId: return tr(ID); case ColName: return tr(任务名称); case ColCategory: return tr(分类); case ColStatus: return tr(状态); case ColDeadline: return tr(截止日期); default: return QVariant(); } } return section 1; } Qt::ItemFlags TaskModel::flags(const QModelIndex index) const { if (!index.isValid()) return Qt::NoItemFlags; Qt::ItemFlags f Qt::ItemIsEnabled | Qt::ItemIsSelectable; switch (index.column()) { case ColName: case ColCategory: f | Qt::ItemIsEditable; break; case ColStatus: f | Qt::ItemIsUserCheckable; break; default: break; } return f; } bool TaskModel::setData(const QModelIndex index, const QVariant value, int role) { if (!index.isValid()) return false; if (index.row() 0 || index.row() m_tasks.size()) return false; TaskItem item m_tasks[index.row()]; if (role Qt::EditRole) { switch (index.column()) { case ColName: item.name value.toString(); break; case ColCategory: item.category value.toString(); break; default: return false; } } else if (role Qt::CheckStateRole index.column() ColStatus) { item.done (value.toInt() Qt::Checked); } else { return false; } emit dataChanged(index, index, QVectorint{Qt::DisplayRole, Qt::EditRole, Qt::CheckStateRole}); return true; } void TaskModel::addTask(const TaskItem item) { int row m_tasks.size(); beginInsertRows(QModelIndex(), row, row); m_tasks.append(item); endInsertRows(); } void TaskModel::removeTask(int row) { if (row 0 || row m_tasks.size()) return; beginRemoveRows(QModelIndex(), row, row); m_tasks.removeAt(row); endRemoveRows(); } void TaskModel::loadDemoData() { // 实际使用中这里可以替换成SQL查询、文件解析或者网络数据源 addTask({1, tr(整理串口日志模块), tr(开发), false, tr(2024-12-01)}); addTask({2, tr(补充QCustomPlot波形Demo), tr(文档), false, tr(2024-12-10)}); addTask({3, tr(梳理Model/View面试题), tr(学习), true, tr(2024-11-20)}); }讲几个这里面的设计要点。rowCount和columnCount里对parent.isValid()的情况返回0是QAbstractTableModel的标准写法。平面表格没有子节点任何带parent的索引都不应该有行和列。flags()里不同列返回不同的交互能力。任务名称和分类支持文本编辑状态列支持复选框切换ID列和日期列只读。这样可以让View自动选择交互控件不需要自己写Delegate就能实现“只能改该改的”。setData()里的dataChanged第三参数写的是受影响角色集合。这个参数不是必须的但写上之后View可以更精确地重绘不传的话默认所有角色都刷新逻辑上也没问题。3.3 在MainWindow中对接QTableView模型写完了接下来在窗口里对接。这里我用了一个排序过滤代理QSortFilterProxyModel这样既能点表头排序又能让过滤搜索不破坏原始数据的顺序// MainWindow构造函数局部代码 m_model new TaskModel(this); m_model-loadDemoData(); m_proxy new QSortFilterProxyModel(this); m_proxy-setSourceModel(m_model); m_proxy-setFilterCaseSensitivity(Qt::CaseInsensitive); ui-tableView-setModel(m_proxy); ui-tableView-setSelectionBehavior(QAbstractItemView::SelectRows); ui-tableView-setSelectionMode(QAbstractItemView::SingleSelection); ui-tableView-setSortingEnabled(true); ui-tableView-horizontalHeader()-setSectionResizeMode(QHeaderView::Interactive); ui-tableView-verticalHeader()-setVisible(false);这里有个新手必踩的坑一旦用了QSortFilterProxyModelView里所有的QModelIndex都是代理索引不是源模型的索引。删除任务时必须把当前选中索引映射回源模型void MainWindow::onDeleteClicked() { QModelIndex proxyIndex ui-tableView-currentIndex(); if (!proxyIndex.isValid()) return; QModelIndex sourceIndex m_proxy-mapToSource(proxyIndex); m_model-removeTask(sourceIndex.row()); }如果直接用proxyIndex.row()去调用removeTask在排序或过滤后一定会删错行这是高频事故。3.4 添加任务与插入通知添加任务时先在按钮的槽函数里构造一个TaskItem再调用addTaskvoid MainWindow::onAddClicked() { TaskItem item; item.id m_model-rowCount() 1; item.name tr(新任务); item.category tr(未分类); item.done false; item.deadline QDate::currentDate().toString(Qt::ISODate); m_model-addTask(item); }注意addTask内部是先调beginInsertRows再改容器数据最后endInsertRows。三者的顺序是有讲究的beginInsertRows会通知View“索引集合即将变化”此时View会去做一些校验和缓存操作数据插入动作必须夹在begin和end之间完成这样endInsertRows通知View后View拿到的数据已经是插入完成的完整状态。如果顺序反了虽然大多数情况下也能跑但遇到复杂的View交互场景可能出缓存错乱。4. 常见问题与排查技巧实录4.1 表格空白只有表头没有行先别急着怀疑Qt坏了按这个顺序排查setModel之后确认Model不是局部临时变量rowCount和columnCount返回值是否正确data()里是否每个角色分支都有返回尤其查Qt::DisplayRole分支有没有覆盖所有列如果用的是QAbstractItemModel还要检查index()和parent()实现是否正确。最直接的定位方法是在data()里加一行qDebug()看View到底有没有来取数据、取的role是什么。很多“表格空白”问题其实是某个case分支没写返回默认走到了空QVariant。4.2 双击编辑不生效或者编辑后界面不刷新先查三件事flags()里对应列是否加了Qt::ItemIsEditablesetData()里role判断是否正确普通文本编辑时role是Qt::EditRolesetData()末尾是否调用了emit dataChanged(...)。如果使用了QSortFilterProxyModel还要确认编辑走的是不是代理索引。建议直接在setData里打点确认是否被调用、传进来的value是什么。4.3 排序/过滤后行号错乱前面提到过这是QSortFilterProxyModel最经典的坑。只要界面排序是开启的拿到当前索引后一律用mapToSource()转回源模型索引再用转换后的row()去操作源Model。往深了说这也是不直接在业务层依赖View索引的原因业务层只认数据实体ID不认行号会省很多麻烦。4.4 大数据量下卡顿如果数据量从几千行涨到几万行注意以下几点不要在data()里做耗时计算。data()会被View高频调用绘制、尺寸计算都会来问任何复杂格式化都提前在业务层算好缓存。不要用beginResetModel/endResetModel作为万能刷新。局部结构变化用beginInsertRows、beginRemoveRows单个单元格变化用dataChanged。频繁批量插入时可以考虑把多次插入包在同一个beginInsertRows/endInsertRows区间里减少View的重复布局。4.5 子线程数据源怎么跟Model配合串口、采集卡、网络数据往往在子线程到达。千万不要在子线程里直接操作Model或发射dataChanged。正确姿势是子线程计算出结果后用信号把结构化数据发回GUI线程GUI线程的槽函数里统一更新Model再通过信号驱动View刷新。这样既安全又不会把界面卡住。4.6 打包运行报“no qt platform plugin could be initialized”这个是参考工程做完后经常遇到的发布问题。第一次用windeployqt部署双击exe就弹这个错原因基本就两个exe同级的platforms目录缺失或里面没有qwindows.dll或者platforms下的dll和主程序使用的Qt版本/编译套件不一致。部署后从build目录运行看是否正常别手动单独拷dll。这条虽然不是Model/View本身的问题但比Model/View更折磨人放在一起提醒一下。5. 后续扩展方向5.1 从表格模型升级到树形模型如果哪天数据从“任务列表”变成“设备树”基类要换成QAbstractItemModel并且要自己实现index()和parent()。核心思路其实不复杂在index()里通过createIndex(row, column, nodePointer)把节点指针挂到索引上在parent()里从child.internalPointer()取出当前节点再找父节点。父节点是根就返回QModelIndex()否则返回一个由createIndex生成的父索引。树形模型难在边界但平面模型练熟之后理解树只是时间问题。5.2 和QCustomPlot图表组件的配合思路最近总有朋友问“Qt时域图怎么转频域图”“QCustomPlot显示波形卡不卡”。这类需求也可以沿用“单一数据源多个View”的思想采集到的原始时域序列放在一个数据结构里QCustomPlot负责把它画成曲线做完FFT之后得到频域序列再画第二条曲线如果还需要表格同时展示频点、幅值、相位就再包一个自定义Model给QTableView用。三个视图共享同一份计算结果Model只做“把数据变成行列”绘图组件只做“把数据变成图形”分工清楚以后后面加任何新视图都只是加一个展示层而已。5.3 我的几点经验最后说几句实在话。第一刚开始学Model/View不要一上来就研究自定义Delegate先把Model做对Delegate是后面的进阶题。第二用QTableView的setSortingEnabled(true)时自定义Model如果没实现sort()排序行为会和预期不一致建议直接用QSortFilterProxyModel统一管理排序和过滤。第三任何“数据改了但界面不动”的问题先想是不是信号通知的问题再怀疑其它。View不会自己感知数据变化它所有的活泼全靠Model信号驱动。本文还有配套的精品资源点击获取