资讯动态

Qt自定义Model类实战:从基类选型到增删改查与避坑指南

发布时间:2026/10/15 16:29:08 来源:尧图企业网站定制
简介面向Qt C开发者的Model/View自定义模型参考工程示范如何利用QAbstractTableModel派生数据模型并接入QTableView适合需要实现表格数据展示、编辑及动态增删行列的初学者和进阶者。压缩包共7个文件包括2个cpp源码、1个hpp模型声明、1个ui窗口界面及pro/user工程配置整体仅7KB结构精简便于快速定位核心逻辑。核心代码围绕MyTableModel展开重写了rowCount、columnCount、data、headerData、insertRows、removeRows、flags等关键虚函数并在mainwindow中通过setModel将模型与视图绑定完整呈现了Model/View的读写交互过程。由于工程极小读者可自行添加功能验证行列插入删除、表头样式及数据角色等细节也可将其作为扩展列表List或树形Tree模型的起点。该资源已有1137人学习适合在官方文档之外作为对照实验和入门参考。1. 接手一个界面和业务数据全堆在窗口类里的 Qt 项目通常是最痛苦的维护经历。十几个信号槽、三层嵌套的 QTreeWidgetItem、到处setText()数据一多界面就乱。后来把业务数据抽进一个自定义 Model 类里用 Qt Model/View 架构重写界面的增删改查逻辑才真正变清爽。所谓 Model 类就是离数据最近、离界面最远的那个“数据接口层”QTableView、QListView 都通过它读写数据。这篇参考工程以 DeviceTableModel 为例从基类选型讲到增删改查再到常见的崩溃、卡顿和编辑失效的排查覆盖日常写 Qt 业务界面最常用的部分。适合正在做桌面工具、设备管理、报表类界面不想再往 View 层堆业务代码的 Qt 开发者。2. Model 类选型为什么不用内置 StringListModel三个基类怎么挑2.1 内置 Model 只适合展示不适合业务QStandardItemModel 看起来很全能能加 item、能建树、能设图标但它解决的只是“展示数据”不是“承载业务”。实际项目中数据大多来自结构体、数据库结果集或网络报文如果用 QStandardItemModel你得把业务对象一个一个转换成 QStandardItem再塞到模型里界面要修改数据时又得从 item 里把值抠出来回写业务对象。这种双向搬运在数据量小的时候还能忍受一旦字段超过十个、表格需要频繁增删行、状态需要联动变化代码会快速失控。QStringListModel 就更局限了它只适合一维字符串列表连第二列都加不进去。自定义 Model 的意义在于数据本身放在一个你自己控制的容器里比如 QListDeviceItem界面向 Model 要数据、改数据Model 直接操作业务对象不需要 View 层感知内部结构。这样数据校验、默认值、编辑权限、格式化显示都能收口在 Model 类里而不是散落在每个控件的信号槽里。从维护角度看内置模型还有个隐藏问题刷新策略。QStandardItemModel 的数据变了你得手动触发各种信号界面上多个 View 共用同一个 Model 时通知逻辑很容易遗漏。自定义 Model 通过重写data()、setData()、insertRows()等接口把“数据何时变、通知谁”变成一套固定的调用习惯反而更可控。2.2 三个基类的继承关系与决策标准Qt 的模型基类关系很简单QAbstractItemModel 是所有 Model 的根它定义了索引、父子关系、数据读写、增删行等全套接口QAbstractListModel 和 QAbstractTableModel 是它的两个半成品子类分别针对一维列表和二维表格做了简化基类已经帮你实现了index()和parent()省去了最绕的部分。选哪个基类先看数据形态再做决定不需要一开始就冲 QAbstractItemModel 去。下面这个对照表可以直接当选择依据基类必须重写的最小接口适合的数据形态是否要自己实现 index/parentQAbstractListModelrowCount、data一维列表、下拉框数据源否QAbstractTableModelrowCount、columnCount、data二维表格、行列结构清晰否QAbstractItemModelrowCount、columnCount、index、parent、data树形结构、多级分组是我把这个表格贴在研发文档里后团队里的 A 同学就再没问过“为什么我的树形模型不能展开”这类问题。二维表格选 QAbstractTableModel树形结构才需要碰 QAbstractItemModel。如果你的树形结构里每个节点的列数不同那更得直接上 QAbstractItemModel因为 QAbstractTableModel 的结构假设是“每行列数一致”。2.3 QAbstractTableModel 最小骨架头文件与三件套选定基类后第一版先让表格能显示再逐步加编辑和增删行。头文件里最核心的是设备结构体和三个重写函数的声明// DeviceTableModel.h #pragma once #include QAbstractTableModel #include QList class DeviceTableModel : public QAbstractTableModel { Q_OBJECT public: explicit DeviceTableModel(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 Qt::DisplayRole) const override; private: struct DeviceItem { QString name; // 设备名称 int status; // 0-离线 1-在线 2-告警 int uptimeSec; // 已运行秒数 }; QListDeviceItem m_devices; };这里用QList\DeviceItem\而不是 QStandardItem 的列表目的就是让 Model 直接面对业务对象。Q_OBJECT宏保留下来的原因是后面要发dataChanged()这类信号moc 编译需要它。接下来是三个接口的最小实现把它们写进 DeviceTableModel.cppint DeviceTableModel::rowCount(const QModelIndex parent) const { // 表格模型没有子节点parent 有效时返回 0 return parent.isValid() ? 0 : m_devices.size(); } int DeviceTableModel::columnCount(const QModelIndex parent) const { // 三列设备名、状态、运行时长 return parent.isValid() ? 0 : 3; } QVariant DeviceTableModel::data(const QModelIndex index, int role) const { // 没判 isValid 就取 row是启动闪退的头号原因 if (!index.isValid()) return {}; if (index.row() 0 || index.row() m_devices.size()) return {}; if (index.column() 0 || index.column() 3) return {}; const DeviceItem item m_devices.at(index.row()); if (role ! Qt::DisplayRole) return {}; switch (index.column()) { case 0: return item.name; case 1: return item.status 0 ? QStringLiteral(离线) : item.status 1 ? QStringLiteral(在线) : QStringLiteral(告警); case 2: return QStringLiteral(%1 秒).arg(item.uptimeSec); default: return {}; } }第三列的格式化放在 Model 层而不是 View 层是刻意为之。View 只负责把返回的 QVariant 画出来单位换算、状态中文翻译都在data()里统一处理。后续如果要在界面上显示“已运行 2 小时 3 分”也只需要改这一处不必动 UI 代码。要看到这个最小骨架的效果还需要在 View 里把 Model 绑上去。常见做法是创建 QTableView 后调用setModel(model)表格就会自动按rowCount()和columnCount()布局然后逐格调用data()取 DisplayRole。setModel本质上让 View 成为 Model 的观察者后面数据发生变化时Model 发信号通知它刷新这就是第 4 章要展开的内容。3. 核心接口实现QModelIndex 协议与编辑、表头的落地3.1 索引协议为什么不能自己拼 QModelIndex刚接触 Model/View 的人常会踩同一个坑以为 QModelIndex 只是个“行号列号”于是自己构造一个拿去查数据。QModelIndex 确实是值类型内部也真的存着 row、column 和一个内部指针但它不是普通的结构体它的合法实例必须由 Model 通过index()或createIndex()产生。// 错误示范自己拼(row, col)这个索引没有合法的内部数据View 拿它取数据时行为未定义 QModelIndex fakeIndex(2, 1, nullptr); // 正确做法必须通过 Model 的 index() 拿到合法索引 QModelIndex realIndex model-index(2, 1, QModelIndex());在 QAbstractTableModel 里基类已经实现了index()默认会调用createIndex(row, column, nullptr)所以表格模型不写这个函数也能正常工作。但用 QAbstractItemModel 做树形模型时createIndex()的第三个参数经常传入一个节点指针View 后续通过index.internalPointer()把它取出来识别具体节点。这个指针的生命周期要由 Model 自己管理也就是节点删除时要保证没有残留索引去访问它。另外一个细节parent()函数决定索引的层级关系表格模型顶层就是全部数据所以默认返回 QModelIndex()。树形模型里parent()必须返回父节点的合法索引不能为了省事返回无效索引否则 View 展开子节点时会直接崩溃。索引协议是整个 Model/View 绕不开的基础后面的编辑能不能定位到格子、刷新能不能精确到范围全靠它。3.2 data() 与 headerData() 的完整写法第 2 章的data()只处理了 DisplayRole实际界面上还不够用。工具提示、文字对齐、前景色这些常见需求都可以通过 role 区分。下面的代码把前三列的角色处理补全QVariant DeviceTableModel::data(const QModelIndex index, int role) const { if (!index.isValid()) return {}; if (index.row() 0 || index.row() m_devices.size()) return {}; const DeviceItem item m_devices.at(index.row()); switch (index.column()) { case 0: if (role Qt::DisplayRole) return item.name; if (role Qt::ToolTipRole) return QStringLiteral(设备编号%1).arg(index.row() 1); break; case 1: if (role Qt::DisplayRole) { return item.status 0 ? QStringLiteral(离线) : item.status 1 ? QStringLiteral(在线) : QStringLiteral(告警); } // 告警状态整行红色字体方便值班界面快速识别 if (role Qt::ForegroundRole item.status 2) return QColor(Qt::red); break; case 2: if (role Qt::DisplayRole) return QStringLiteral(%1 秒).arg(item.uptimeSec); // 运行时长右对齐数字列可读性更好 if (role Qt::TextAlignmentRole) return QVariant(Qt::AlignRight | Qt::AlignVCenter); break; default: break; } return {}; }这段代码里ForegroundRole 和 TextAlignmentRole 都是“显示建议”View 会根据返回值调整绘制方式。注意一个原则data()不应该有任何副作用比如修改成员变量、打印日志、访问数据库因为 View 在绘制和布局时会高频调用它稍有不慎就会拖慢界面。表头文字是另一个高频需求QTableView 默认显示字母列名中文界面必须重写headerData()QVariant DeviceTableModel::headerData(int section, Qt::Orientation orientation, int role) const { if (role ! Qt::DisplayRole) return {}; if (orientation Qt::Horizontal) { switch (section) { case 0: return QStringLiteral(设备名); case 1: return QStringLiteral(状态); case 2: return QStringLiteral(已运行); default: return {}; } } return QStringLiteral(%1).arg(section 1); }section在这里就是列号或行号横向返回列名纵向返回行号。不用在 View 里单独设置表头文字的好处是同一份数据源绑定到 QTableView 和 QListView 时表头行为保持一致换个 View 不必重配一遍。3.3 setData() 与 flags()把表格从只读变成可编辑QTableView 默认展示出来的表格是只读的要让用户双击编辑某个格子需要同时满足两个条件flags()返回Qt::ItemIsEditable并且setData()真正把新值写入数据容器。漏掉任何一个界面都会表现为“双击没反应”。bool DeviceTableModel::setData(const QModelIndex index, const QVariant value, int role) { if (!index.isValid()) return false; if (role ! Qt::EditRole) return false; if (index.row() 0 || index.row() m_devices.size()) return false; bool ok false; switch (index.column()) { case 0: m_devices[index.row()].name value.toString(); ok true; break; case 1: m_devices[index.row()].status value.toInt(ok); break; default: // 第三列运行时长由后台统计不允许手动改 return false; } if (ok) emit dataChanged(index, index); return ok; } Qt::ItemFlags DeviceTableModel::flags(const QModelIndex index) const { if (!index.isValid()) return Qt::NoItemFlags; Qt::ItemFlags f Qt::ItemIsEnabled | Qt::ItemIsSelectable; if (index.column() 1) f | Qt::ItemIsEditable; // 前两列可编辑第三列保持只读 return f; }这里的关键是编辑完成后必须调用emit dataChanged(index, index)第一个参数是左上角索引第二个是右下角索引因为一次只改一个格子所以两个一样。如果忘了发这个信号View 不会重绘用户会看到“输入了字但界面没变”的诡异现象。flags()里按列控制编辑权限是常见做法。比如设备运行时长一般是系统自动统计的就不该让用户手动改名称和状态可以放开编辑。这种级别的权限控制在 Model 层做最方便View 层只需要遵循 flags 的约定双击前先判断是否可编辑。4. 数据变了界面跟着变增删改查的刷新机制4.1 插入行beginInsertRows 必须在修改数据之前表格单独能显示还不够运行时要动态新增设备记录。很多人直接在m_devices.append()之后调用dataChanged()想刷新界面结果表格行数没变新数据根本画不出来。原因是行数变了必须走插入行的专用流程。bool DeviceTableModel::insertRows(int row, int count, const QModelIndex parent) { if (parent.isValid() || count 0) return false; // 越界时兜底追加到末尾再往上层返回 true if (row 0 || row m_devices.size()) row m_devices.size(); beginInsertRows(QModelIndex(), row, row count - 1); for (int i 0; i count; i) m_devices.insert(row, DeviceItem{}); endInsertRows(); return true; }调用顺序是铁律先beginInsertRows()再改容器最后endInsertRows()。beginInsertRows会先通知 View 在内部更新行映射这时容器还没改View 的布局数据处于一个“即将变化”的中间态容器改完之后调用endInsertRows()View 重新查询rowCount()时就能拿到新行数然后为新行请求data()。如果颠倒顺序先改了数据再调用 beginView 拿到的行数变化就是突变的很容易触发断言或显示错乱。insertRow是插入一行的高层封装实际会调用insertRows(row, 1, parent)。外部代码不用直接碰 begin 和 end只要 Model 内部实现正确外部model-insertRow(m_devices.size())就能在末尾加一行空数据。插入操作通常会配合 Undo/Redo 框架做事务处理这一套 begin/end 流程天然支持嵌套但业务里尽量保持简单。4.2 删除行与局部刷新removeRows 和数据变更通知删除和插入是对称操作beginRemoveRows()会在 View 侧先行调整映射随后删容器数据、endRemoveRows()收尾。有一个常见坑是删除前先把QModelIndex存进了局部变量删除后这个索引已经失效再拿去访问数据就崩了。所以删除操作的代码里索引的生命周期必须严格限定在本函数内。bool DeviceTableModel::removeRows(int row, int count, const QModelIndex parent) { if (parent.isValid() || count 0) return false; if (row 0 || row count m_devices.size()) return false; beginRemoveRows(QModelIndex(), row, row count - 1); m_devices.remove(row, count); endRemoveRows(); return true; }删除完成后View 会自动重排行号不需要额外发dataChanged()。那什么时候才需要dataChanged()呢答案是“行数不变但内容变了”的时候。比如设备状态从在线变成告警void DeviceTableModel::updateDeviceStatus(int row, int newStatus) { if (row 0 || row m_devices.size()) return; m_devices[row].status newStatus; // 状态列的显示文字、颜色都可能变刷新这一行的所有列 QModelIndex topLeft index(row, 0); QModelIndex bottomRight index(row, columnCount() - 1); emit dataChanged(topLeft, bottomRight); }这里强调dataChanged的第一个参数和第二个参数是一个矩形区域的左上角与右下角。很多人只传单个格子索引导致同行其他列的显示残留旧值。我一般习惯把一整行都刷新即使只改了一个字段视觉上更可靠成本也可控。4.3 整表重置与批量更新beginResetModel 和 dataChanged 哪个更划算数据源整体变化时比如重新从数据库拉取了一张新表、配置档发生了切换逐个更新格子不现实。这时候用重置三连beginResetModel()、清空并重新填充容器、endResetModel()。void DeviceTableModel::reloadFromSource() { beginResetModel(); m_devices.clear(); for (int i 0; i 100; i) m_devices.append({QStringLiteral(设备%1).arg(i 1), i % 3, i * 60}); endResetModel(); }beginResetModel()会让 View 放弃当前所有索引缓存包括正在使用或选中的索引。所以重置之后选中状态会丢失滚动位置也会回到顶部。如果只是更新 20 行里的几个数值用dataChanged()精准刷新就比 reset 舒服得多——既能保留选中状态开销也更小。两种方式的使用边界可以看这张表通知方式开销特征典型适用场景beginResetModel/endResetModelView 全量重建成本高数据源整体切换、列结构变化、大面积缩容dataChanged(topLeft, bottomRight)只重绘范围内格子成本低修改某几行内容、状态联动变化beginInsertRows/endInsertRows局部重建行结构增加或删除行记录重置还有一个隐藏副作用所有关联的QPersistentModelIndex都会失效。如果你在某处长期保存了一个索引用于跟踪某条设备记录重置后它就不再指向原来那个设备了。想跨重置跟踪数据应该在业务对象里保存唯一 ID而不是依赖索引位置。5. 避坑与排查自定义 Model 从崩溃到卡顿的 5 个自查点5.1 启动就闪退断点指向 data()先查 isValid 与边界现象程序启动后表格一显示还没点击任何按钮就直接段错误断点停在data()里访问m_devices.at(index.row())的地方。原因View 初始化阶段会用一些特殊索引查询数据比如行首、列首的父索引如果没有对index.isValid()做判断就直接取 row非法索引的 row 值可能是 -1 或任意数据下标越界访问容器就崩了。解决把if (!index.isValid()) return {};当作data()的第一行并且对 row 和 column 的上下界都做检查。不要只靠 isValid因为 isValid 只表示索引本身被合法创建不代表 row 一定落在你容器的范围内。这个检查会成为肌肉记忆后面写任何 Model 接口都默认先加。5.2 插入数据后界面不刷新甚至行内容错位现象调用insertRow()后数据容器里确实多了一条记录但 QTableView 的行数没变或者行内容对应错位越看越乱。原因在实现insertRows()时先写了m_devices.insert(...)再调用beginInsertRows()顺序恰好反了。View 在 begin 阶段做行映射调整时Model 的容器还没变化后续查询到的 rowCount 和 View 内部布局不一致于是显示错乱。解决严格执行beginInsertRows() → 操作容器 → endInsertRows()的顺序。删除同理beginRemoveRows()必须先于容器操作。如果改动已经写反了把两行交换即可万一是在条件分支里提前 return 导致 end 没被调用还会留下一个半开的事务状态后续所有插入信号都异常这种情况最好在一处统一出口。5.3 表格能显示但不能编辑flags() 缺了 ItemIsEditable现象双击单元格没有编辑框输入框不出现整个表格看起来像只读的但setData()明明已经实现了。原因View 决定是否进入编辑状态时会先查index.flags()是否带Qt::ItemIsEditable。如果flags()返回了Qt::ItemIsEnabled | Qt::ItemIsSelectable即使setData()写得再完整View 也不会进入编辑模式。解决在flags()里按列或者按业务状态追加Qt::ItemIsEditable。同时确认setData()里对role Qt::EditRole做了判断并且编辑成功后返回 true。这三个条件缺一不可任何一个没满足界面表现都是“不能编辑”。5.4 数据一多滚动卡顿data() 里做了重活现象表格只有几千行滚动时就明显掉帧CPU 占用居高不下把data()里打断点发现每次滚动都在反复执行。原因View 在绘制时会对可见区域以及周边若干行都调用data()如果每调用一次都做字符串格式化、时间转换、甚至查一次数据库滚动时这些操作会被放大数倍。常见的重活包括QDateTime::fromSecsSinceEpoch格式化、正则表达式处理、QString::arg多层拼接。解决把耗时结果缓存到 Model 的成员变量里比如用QHashint, QString存已格式化好的运行时长数据真正变化时清理对应缓存。另一种方案是只在 DisplayRole 上做最轻量的计算把复杂逻辑挪到 ToolTipRole 或通过其他接口按需触发。最简单的验证方式是在data()里加一个计数器看编译为 Release 后滚动 10 行会调用多少次超过预期就说明该优化了。5.5 setData 改了值但界面没动静dataChanged 的范围和角色参数现象编辑框能打开输入新值后回车关闭界面上格子还是旧文本但下次刷新或重开窗口时数据其实已经变了。原因setData()里虽然调用了emit dataChanged(index, index)但仔细一看第三个参数传了QVectorint{ Qt::EditRole }。View 绘制时关心的是 DisplayRole而 roles 参数限制了通知只对 EditRole 生效于是现有格子不会重画。解决最稳妥的是不发 roles 参数即emit dataChanged(index, index)让 View 把所有角色都视为可能有变化。如果数据量特别大需要精确到角色至少要把 DisplayRole 一起列进去。另一个相关坑是只传了左上角索引dataChanged(index, index)但这一行有多个列都变了记得把右下角索引扩展到index(row, columnCount() - 1)。6. 进阶树形 Model 的索引递归与 QSortFilterProxyModel 排序过滤6.1 从二维表到树parent() 与 index() 的递归心法业务复杂度上去之后二维表就不够了设备可能按机房分组分组下再挂设备这时候需要切换到 QAbstractItemModel。树形模型的核心变化是每个节点都有自己的父子关系index()要根据父索引找到某个孩子parent()要能从孩子索引找到父节点的索引。QModelIndex TreeModel::parent(const QModelIndex index) const { if (!index.isValid()) return QModelIndex(); TreeNode* node static_castTreeNode*(index.internalPointer()); if (!node || !node-parent) return QModelIndex(); // 把父节点在爷节点里的行号取出来 TreeNode* parentNode node-parent; int row parentNode-parent-children.indexOf(parentNode); return createIndex(row, 0, parentNode); }实现树形 Model 有一个口诀createIndex()的第三参数必须存原生的节点指针parent()里通过internalPointer()把它取出来再反查父节点位置。只要指针在节点创建时没存错、节点删除时没有残留索引引用它整个树结构就是自洽的。展开、折叠异常时优先检查是不是 index 和 parent 的递归关系写成了环。6.2 排序过滤用 QSortFilterProxyModel别在 Model 里硬来写完树形或表格 Model 后第一个想加的功能多半是点击列头排序。直接修改 Model 里的数据顺序看起来很直接但会带来一个副作用业务容器里的下标和 View 的行号永久错位后续按行号更新数据时很容易更新到错误的记录。[ \text{proxyModel} \to \text{setSourceModel(sourceModel)} ]更稳的做法是在 Model 外包一层 QSortFilterProxyModel排序和过滤只发生在代理层源 Model 里的数据顺序保持不变QSortFilterProxyModel* proxy new QSortFilterProxyModel(this); proxy-setSourceModel(model); // model 是业务数据源 ui-tableView-setModel(proxy); // View 只和 proxy 打交道 ui-tableView-setSortingEnabled(true);// 点击表头时由 proxy 做排序QSortFilterProxyModel默认按 DisplayRole 排序如果想按内部数值排序可以setSortRole(Qt::UserRole)然后在data()里针对 UserRole 返回原始数值。过滤功能同理重写filterAcceptsRow()决定哪些行显示源 Model 的数据一条都不动。选中行映射、编辑联动这些复杂场景代理层也帮不了太多但至少排序过滤不会再污染业务容器这才是“后悔药”的价值。以前做一个设备管理项目我在 Model 内部直接做了个排序结果业务逻辑里按行号更新状态时生产了一堆错位数据最后全部迁到 QSortFilterProxyModel 才稳住。现在做自定义 Model我的习惯是先画数据形态再定基类涉及排序过滤第一时间把 proxy 挂上索引存任何地方都优先用 QPersistentModelIndex。这套做法少踩了很多坑希望帮到你。本文还有配套的精品资源点击获取

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

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

免费获取报价 →
↑