资讯详情

资讯详情

Qt中MVD模式的具体使用

前言初学者用 Qt 做列表和表格第一站几乎都是QListWidget、QTableWidget、QTreeWidget这三件套。它们确实好上手addItem(hello)一行就出效果。但当数据量上到几万行、需要排序过滤、需要多个视图共享同一份数据时这套 Widget 就会开始卡、开始乱、开始难以维护。这时候就该上MVD 模式Model-View-Delegate模型-视图-委托。它不是 Qt 发明的而是 MVCModel-View-Controller在 Qt 里的落地变体Qt 把 Controller 的职责拆给了 View 和 Delegate并用信号槽做胶水。本文讲清三个角色各自该干什么、QAbstractItemModel的核心接口为什么必须那么实现、怎么写出真正能用的自定义 Model 和 Delegate以及新手必踩的几个坑。一、为什么要 MVD从 QTableWidget 说起QTableWidget是数据 视图合体的类每个单元格背后其实是一个QTableWidgetItem对象。一千万行数据 一千万个堆对象内存直接爆炸而且模型层被视图层绑死没法在后台线程改数据、没法把同一份数据同时展示成表格和图表。MVD 的做法是把这三件事分开角色基类职责类比Model模型QAbstractItemModel持有数据、回答第 r 行 c 列是什么数据库的表View视图QAbstractItemView决定布局、处理滚动和鼠标显示器Delegate委托QStyledItemDelegate负责每个格子的绘制和编辑画师 编辑器核心设计是View 不持有数据它只在需要画某个格子时向 Model 要一次数据。这个按需索取让一千万行也能秒开——屏幕上只显示得出几十行View 就只问那几十行。理解这个拉取pull模型后面所有接口设计就都顺理成章了。三者的职责边界Model数据的获取与存储内存/SQLite/内存映射都行View 完全不关心、结构查询几行几列、内容查询某个索引的文本/图标/颜色、数据变化时发信号。View计算 item 矩形、处理滚动选择键盘导航、调用 Delegate 画格子。绝不直接改数据所有修改都走 Model。Delegatepaint()绘制、sizeHint()给尺寸、createEditor()/setEditorData()/setModelData()负责编辑。容易忽略的点只显示文本和图标的场景根本不用写 DelegateQStyledItemDelegate默认实现就够了。很多人一上来就写 Delegate属于过度设计。二、QModelIndexMVD 世界的指针贯穿 MVD 的核心概念是QModelIndex。它不是指针而是轻量的临时句柄记录哪个 Model 的哪一行哪一列QModelIndex idx model-index(row, column, parentIndex);int r idx.row();int c idx.column();QModelIndex p idx.parent();void *raw idx.internalPointer(); // 给自定义 Model 挂自己的节点指针三条铁律QModelIndex是临时的数据一改就可能失效不能长期保存要长期保存用QPersistentModelIndex判断有效性用isValid()别用! nullptr它是值类型internalPointer()是自定义 Model 挂载数据的钩子这是标准做法。三、自定义 Model 的核心接口继承QAbstractItemModel需实现 5 个纯虚函数加若干可选函数函数必须作用rowCount(parent)✅某父节点下有几行columnCount(parent)✅有几列data(index, role)✅取数据最核心index(row, col, parent)✅构造索引parent(index)✅取父索引列表/表格返回空headerData()建议表头文字flags()建议控制是否可选中/可编辑setData()按需写回数据data() 里的 role 是什么data()第二个参数role角色是最优雅的设计之一。同一个数据项View 会用不同 role 反复问你要东西role用途Qt::DisplayRole显示文本Qt::DecorationRole图标/颜色块Qt::EditRole编辑框里的原始值非格式化文本Qt::ToolTipRole悬浮提示Qt::TextAlignmentRole对齐方式Qt::ForegroundRole/BackgroundRole前景/背景色Qt::StateRole复选框状态Qt::UserRole起自定义角色关键认知DisplayRole和EditRole常常不同。显示1,234.00编辑时应该给1234.0这样编辑器里不会出现逗号和货币符号。四、实战一个自定义列表模型实现一个管理学生成绩的模型支持增删行和改分数。cmake_minimum_required(VERSION 3.16)project(MvdDemo LANGUAGES CXX)set(CMAKE_CXX_STANDARD 17)set(CMAKE_AUTOMOC ON)find_package(Qt6 REQUIRED COMPONENTS Widgets)add_executable(MvdDemo main.cpp studentmodel.cpp studentmodel.hscoredelegate.cpp scoredelegate.h)target_link_libraries(MvdDemo PRIVATE Qt6::Widgets)qmake 版本QT widgetsCONFIG c17TARGET MvdDemoSOURCES main.cpp studentmodel.cpp scoredelegate.cppHEADERS studentmodel.h scoredelegate.h模型声明与实现StudentModel继承QAbstractTableModel私有成员只有一个QVectorStudent m_students并声明enum Column { ColName 0, ColScore 1, ColCount 2 }。核心实现如下。// studentmodel.cpp#include studentmodel.h#include QBrush#include QFontint StudentModel::rowCount(const QModelIndex parent) const{if (parent.isValid()) return 0; // 表格模型父索引有效时无子行return m_students.size();}int StudentModel::columnCount(const QModelIndex parent) const{if (parent.isValid()) return 0;return ColCount;}QVariant StudentModel::data(const QModelIndex index, int role) const{// 防御性检查任何一步缺失都会让视图读到垃圾数据if (!index.isValid()) return {};if (index.row() 0 || index.row() m_students.size()) return {};const Student s m_students.at(index.row());switch (role) {case Qt::DisplayRole:if (index.column() ColName) return s.name;if (index.column() ColScore) return QStringLiteral(%1 分).arg(s.score);break;case Qt::EditRole: // 编辑框里要的是原始值if (index.column() ColName) return s.name;if (index.column() ColScore) return s.score;break;case Qt::TextAlignmentRole:return index.column() ColScore ? int(Qt::AlignCenter): int(Qt::AlignVCenter | Qt::AlignLeft);case Qt::ForegroundRole: // 不及格标红if (index.column() ColScore s.score 60) return QBrush(Qt::red);break;case Qt::FontRole:if (index.column() ColName) { QFont f; f.setBold(true); return f; }break;case Qt::ToolTipRole:return QStringLiteral(%1 的成绩是 %2).arg(s.name).arg(s.score);case Qt::UserRole: // 自定义角色原始分数return s.score;default:break;}return {};}QVariant StudentModel::headerData(int section, Qt::Orientation o, int role) const{if (role ! Qt::DisplayRole) return {};if (o Qt::Horizontal) {if (section ColName) return QStringLiteral(姓名);if (section ColScore) return QStringLiteral(成绩);} else {return section 1; // 垂直表头显示行号}return {};}Qt::ItemFlags StudentModel::flags(const QModelIndex index) const{if (!index.isValid()) return Qt::NoItemFlags;return Qt::ItemIsEnabled | Qt::ItemIsSelectable | Qt::ItemIsEditable;}bool StudentModel::setData(const QModelIndex index, const QVariant value, int role){if (!index.isValid() || role ! Qt::EditRole) return false;if (index.row() m_students.size()) return false;Student s m_students[index.row()];if (index.column() ColName) {s.name value.toString();} else if (index.column() ColScore) {bool ok false;int v value.toInt(ok);if (!ok) return false;s.score qBound(0, v, 100); // 约束范围} else {return false;}// 必须发这个信号否则视图不会重绘该格emit dataChanged(index, index, { role, Qt::DisplayRole });return true;}void StudentModel::addStudent(const Student s){const int row m_students.size();// 增删行必须用 begin/end 包裹否则视图与模型失步并崩溃beginInsertRows(QModelIndex(), row, row);m_students.append(s);endInsertRows();}void StudentModel::removeStudent(int row){if (row 0 || row m_students.size()) return;beginRemoveRows(QModelIndex(), row, row);m_students.remove(row);endRemoveRows();}自定义 Delegate把分数画成进度条只需重写paint()和sizeHint()类声明就是标准继承QStyledItemDelegate此处略。// scoredelegate.cpp#include scoredelegate.h#include studentmodel.h#include QPainter#include QApplication#include QStyleOptionProgressBarvoid ScoreDelegate::paint(QPainter *painter, const QStyleOptionViewItem option,const QModelIndex index) const{if (index.column() ! StudentModel::ColScore) {QStyledItemDelegate::paint(painter, option, index); // 其他列走默认return;}// 借系统样式画进度条省得自己算圆角和填充QStyleOptionProgressBar bar;bar.rect option.rect.adjusted(4, 6, -4, -6);bar.minimum 0;bar.maximum 100;bar.progress index.data(Qt::UserRole).toInt();bar.text QString::number(bar.progress);bar.textVisible true;const QWidget *w option.widget;(w ? w-style() : QApplication::style())-drawControl(QStyle::CE_ProgressBar, bar, painter, w);}QSize ScoreDelegate::sizeHint(const QStyleOptionViewItem option,const QModelIndex index) const{QSize s QStyledItemDelegate::sizeHint(option, index);s.setHeight(qMax(s.height(), 28)); // 给进度条留出高度return s;}主程序视图 排序过滤// main.cpp#include studentmodel.h#include scoredelegate.h#include QApplication#include QTableView#include QHeaderView#include QSortFilterProxyModel#include QLineEdit#include QVBoxLayout#include QPushButton#include QWidgetint main(int argc, char *argv[]){QApplication app(argc, argv);auto *model new StudentModel;model-addStudent({QStringLiteral(张三), 92});model-addStudent({QStringLiteral(李四), 55});model-addStudent({QStringLiteral(王五), 78});model-addStudent({QStringLiteral(赵六), 43});// 代理模型不改一行 StudentModel 就拿到排序 过滤auto *proxy new QSortFilterProxyModel;proxy-setSourceModel(model);proxy-setFilterKeyColumn(StudentModel::ColName);proxy-setFilterCaseSensitivity(Qt::CaseInsensitive);proxy-setSortRole(Qt::UserRole); // 按原始分数排序而非 92 分auto *view new QTableView;view-setModel(proxy);view-setItemDelegateForColumn(StudentModel::ColScore, new ScoreDelegate(view));view-setSelectionBehavior(QAbstractItemView::SelectRows);view-setAlternatingRowColors(true);view-horizontalHeader()-setSectionResizeMode(StudentModel::ColName,QHeaderView::Stretch);auto *filterEdit new QLineEdit;filterEdit-setPlaceholderText(QStringLiteral(按姓名过滤…));QObject::connect(filterEdit, QLineEdit::textChanged,proxy, QSortFilterProxyModel::setFilterFixedString);auto *btnDel new QPushButton(QStringLiteral(删除当前行));QObject::connect(btnDel, QPushButton::clicked, []{const QModelIndex cur view-currentIndex();if (!cur.isValid()) return;// 关键把 proxy 索引映射回源索引model-removeStudent(proxy-mapToSource(cur).row());});auto *w new QWidget;auto *layout new QVBoxLayout(w);layout-addWidget(filterEdit);layout-addWidget(view);layout-addWidget(btnDel);w-resize(480, 360);w-show();return app.exec();}五、MVD vs Item Widgets怎么选维度Item WidgetsMVDQTableView 自定义 Model上手成本极低较高要写 5 个虚函数大数据量卡顿每项一个对象流畅按需取数排序/过滤受限QSortFilterProxyModel一秒接入多视图共享做不到天然支持数据来自数据库要手动同步可写专门的 Model 直连适合场景几十行的配置列表上千行以上、要排序过滤、多视图实用建议行数少于几百、又不涉及排序过滤就用 Item Widgets别为架构优雅硬上 MVD。反之只要满足大数据量 / 需要排序过滤 / 多视图共享中任意一条就果断用 MVD。常见坑点坑点 1增删行不写 begin/end 包裹这是最严重的坑会直接导致视图持有失效索引而崩溃。❌ 错误写法void StudentModel::addStudent(const Student s){m_students.append(s);emit dataChanged(...); // 视图完全不知道行数变了}✅ 正确写法const int row m_students.size();beginInsertRows(QModelIndex(), row, row); // 通知视图我要插了m_students.append(s);endInsertRows(); // 通知视图插完了这两个调用之间是视图冻结期视图不会去问 Model 要数据保证一致性。绝对不要在这期间发别的信号或做耗时操作。坑点 2忘了在 setData 里发 dataChanged❌ 错误写法bool StudentModel::setData(...){m_students[index.row()].score value.toInt();return true; // 数据改了视图不知道 → 界面不刷新}✅ 正确写法m_students[index.row()].score value.toInt();emit dataChanged(index, index, { Qt::DisplayRole, Qt::EditRole });return true;第三个参数是 roles 列表明确告诉视图哪些 role 变了可以少刷新。不确定时省略它通知所有 role也不错只是效率略低。坑点 3DisplayRole 返回了格式化文本给 EditRole❌ 错误写法case Qt::DisplayRole:case Qt::EditRole:return QStringLiteral(%1 分).arg(s.score); // 编辑框里出现 92 分用户双击编辑时输入框里显示92 分想改成88得先删掉分字而且toInt()转换会失败。✅ 正确写法DisplayRole返回%1 分EditRole返回纯数字s.score。坑点 4proxy 和源模型索引混用视图绑的是proxy它的currentIndex()是代理索引行号未必等于源模型行号可能被过滤、被排序。❌ 错误写法model-removeStudent(view-currentIndex().row()); // 排过序就删错行了✅ 正确写法先映射const QModelIndex src proxy-mapToSource(view-currentIndex());model-removeStudent(src.row());反过来要用proxy-mapFromSource()。坑点 5跨线程直接在 Model 里改数据Model 通常归属主线程。❌ 工作线程里直接m_model-addStudent(...)会破坏视图内部状态表现为随机崩溃。✅ 正确做法是工作线程只emit studentLoaded(name, score)由主线程的槽去调addStudent()——跨线程时AutoConnection会自动走QueuedConnection安全。坑点 6树模型的 index() 漏了父指针写树模型时index()要把父节点的internalPointer()存进新索引return createIndex(row, col, parentNode-children.at(row));// ^^^^^^^^^^^^^^^^^^^^^ 千万别漏漏了这一项parent()就再也找不回去视图会显示成一片树根。总结MVD 的学习曲线主要卡在为什么要这么绕上。一旦接受View 按需向 Model 拉数据、改动必须通过信号通知这个设定剩下就是照接口填空要点正确做法数据在 ModelView 不持有数据internalPointer()挂自己的节点增删行beginInsertRows/endInsertRows成对包裹改数据setData()里emit dataChanged()排序过滤用QSortFilterProxyModel别改 Model索引转换mapToSource()/mapFromSource()角色区分DisplayRole给人看EditRole给机器用线程只能在主线程改 Model再补一句 Delegate 的经验只想改颜色字体用Qt::ForegroundRole/FontRole就够了不必写 Delegate只有要画进度条、星级、缩略图这类非标准内容时写 Delegate 才划算。而写了 Delegate 之后createEditor()/setModelData()那一套记得配套实现否则格子会变成能看不能编辑。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →