1. 模型/视图架构与MVC的区别核心在于解耦程度很多人一看到Qt的模型/视图框架Model/View Framework就联想到经典的MVCModel-View-Controller模式然后开始纠结“Controller去哪了”。其实Qt的这套设计不是对MVC的简单复制而是一种更彻底的解耦方案更适合处理UI与数据分离的场景。传统MVC通常将数据、视图和控制器三者紧密绑定。控制器负责接收用户输入更新模型并通知视图刷新。视图和控制器往往成对出现一个视图对应一个控制器。这种结构在小型、固定的UI组件中很直观但当数据需要在多个不同视图比如一个表格、一个树形、一个列表同时显示同一份数据中展示时就有点力不从心因为每个视图都需要自己的控制器逻辑容易造成代码重复和状态不一致。Qt的模型/视图架构则把“控制器”的功能拆散了分给了视图View和委托Delegate。模型Model只负责数据本身。它不知道数据会被如何显示也不知道用户会怎么操作。它的核心职责是存储数据、提供标准接口QAbstractItemModel供外界查询和修改数据、并在数据变化时发出信号。视图View负责数据的可视化呈现和基本的用户交互如滚动、选择。它从模型获取数据但不直接修改数据。当用户点击一个单元格准备编辑时视图会创建一个委托。委托Delegate这才是原来“控制器”的精华部分。它负责两件事1.渲染Paint决定某个数据项在视图中的具体绘制样式比如数字右对齐、日期特殊格式。2.编辑Edit当用户双击单元格时委托负责创建并管理一个编辑器部件如QLineEdit、QSpinBox处理编辑过程中的所有事件并在编辑完成后将新值通过标准接口写回模型。最关键的区别在Qt的架构里一个模型可以连接多个不同的视图和委托。你可以用一个QFileSystemModel同时驱动一个QListView图标视图和一个QTreeView树形视图它们显示的是同一份文件系统数据。编辑其中一个视图的数据另一个视图会通过模型的信号自动同步更新。这种“一对多”的关系是传统MVC不太容易优雅实现的。所以面试时如果被问到区别不要只说“Qt用委托代替了控制器”。更准确的回答是Qt的模型/视图架构通过将“数据呈现”View和“数据交互控制”Delegate从模型中彻底分离并标准化了数据访问接口QAbstractItemModel实现了数据与UI的高度解耦使得同一份数据能被多种不同形式的视图共享和同步极大地提高了灵活性和复用性。2. 自定义Model必须实现哪些核心方法它们各自的作用是什么当你需要显示的数据无法用Qt内置的QStandardItemModel、QFileSystemModel或QSqlTableModel等满足时就必须自己继承QAbstractItemModel或其子类QAbstractListModel、QAbstractTableModel来实现一个自定义Model。这不是一件轻松的事但只要你搞清楚以下几个核心方法的职责就能理清头绪。自定义Model的核心是实现QAbstractItemModel定义的纯虚函数pure virtual functions。根据模型的复杂度是列表、表格还是树需要实现的方法数量不同。下面以最通用的QAbstractItemModel为例列出必须实现的几个核心方法及其作用。2.1 数据维度和索引rowCount(),columnCount(),index(),parent()这四个方法共同定义了模型的数据结构是视图理解你数据形状的基础。rowCount(const QModelIndex parent)和columnCount(const QModelIndex parent)作用告诉视图在给定的父节点parent下有多少行、多少列数据。参数parent是一个QModelIndex对象代表父节点。如果parent是无效索引QModelIndex()则表示请求顶层项目的行/列数。实现要点对于列表模型继承QAbstractListModel通常只需实现rowCount且columnCount默认返回1。对于表格模型继承QAbstractTableModel需要实现rowCount和columnCount且parent参数通常无效因为表格是扁平的。对于树形模型继承QAbstractItemModel需要根据parent参数返回对应父节点下的子节点数量。这是最复杂的情况。index(int row, int column, const QModelIndex parent)作用这是整个框架的核心枢纽。视图通过调用它传入行号、列号和父节点索引来请求一个代表特定数据项的QModelIndex对象。你可以把这个QModelIndex理解为一个“数据指针”或“句柄”它不存储数据本身但包含了定位数据所需的信息行、列、父节点以及一个指向创建它的模型的指针。实现要点你必须检查row和column是否在有效范围内然后使用createIndex(row, column, internalPointer)创建一个索引。internalPointer是一个void*类型你可以用它存储任何能帮你定位到底层数据的内部指针例如指向你自定义数据结构中某个节点的指针。对于简单的列表或表格这个参数可以传nullptr。parent(const QModelIndex child)作用给定一个子节点的索引返回其父节点的索引。这是实现树形结构的关键。实现要点如果child指向一个顶层节点没有父节点则必须返回一个无效的QModelIndexQModelIndex()。你需要根据child索引中存储的信息比如internalPointer找到其父节点然后创建并返回父节点的QModelIndex。对于列表或表格这种非层次结构所有项目的父节点都是顶层因此此方法直接返回QModelIndex()即可。为什么需要index()和parent()因为视图对数据的访问是完全抽象的。视图不知道你的数据是存在QListQString里还是存在一个复杂的树节点结构里。它只会说“给我第2行第1列父节点是A的数据项”。你的index()方法就需要根据这个抽象的请求生成一个对应的“句柄”QModelIndex。反过来当视图拿到一个“句柄”想知道它的爸爸是谁时就调用parent()。这套机制让视图能以统一的方式遍历任何结构的数据。2.2 数据获取与角色data(const QModelIndex index, int role)这是视图获取数据显示内容的方法被调用的频率极高。作用根据给定的索引index和角色role返回该数据项在该角色下应该显示的值包装在QVariant中。参数index由你的index()方法创建的索引指向具体的数据项。role一个枚举值Qt::ItemDataRole表示请求数据的“角色”。常见的角色有Qt::DisplayRole显示用的文本这是最常用的角色。Qt::EditRole编辑时使用的数据可能与显示格式不同。Qt::DecorationRole图标。Qt::ToolTipRole、Qt::StatusTipRole、Qt::WhatsThisRole提示信息。Qt::TextAlignmentRole文本对齐方式。Qt::BackgroundRole、Qt::ForegroundRole背景色和前景色。Qt::CheckStateRole用于复选框的勾选状态。实现要点首先必须检查index是否有效index.isValid()。无效索引通常返回一个空的QVariant()。根据index定位到你的实际数据对象这通常需要用到创建索引时传入的internalPointer或者根据行、列号计算。根据role参数返回对应的数据。例如对于DisplayRole返回一个QString对于CheckStateRole返回Qt::Checked或Qt::Unchecked。对于不支持的role返回空的QVariant()。示例一个简单的字符串列表模型QVariant MyListModel::data(const QModelIndex index, int role) const { if (!index.isValid() || index.row() m_dataList.size()) return QVariant(); if (role Qt::DisplayRole || role Qt::EditRole) { // 返回列表中对应行的字符串 return m_dataList.at(index.row()); } else if (role Qt::TextAlignmentRole) { return Qt::AlignCenter; // 所有数据居中显示 } // 其他角色不处理返回空QVariant return QVariant(); }2.3 数据编辑支持setData()和flags()如果你希望模型的数据可以被视图编辑比如用户双击单元格修改就需要实现这两个方法。setData(const QModelIndex index, const QVariant value, int role)作用将value以特定role设置到index指定的数据项中。实现要点检查index有效性、role是否支持通常是Qt::EditRole。修改底层数据例如更新m_dataList[index.row()]。最关键的一步在数据修改成功后必须发射dataChanged()信号。这个信号通知所有连接到这个模型的视图“index附近的数据变了请刷新”。信号参数通常包括发生变化的左上角和右下角索引对于单个单元格两者相同。返回true表示成功false表示失败。示例bool MyListModel::setData(const QModelIndex index, const QVariant value, int role) { if (!index.isValid() || role ! Qt::EditRole) return false; if (index.row() 0 index.row() m_dataList.size()) { m_dataList[index.row()] value.toString(); // 发出数据变化信号通知视图更新 emit dataChanged(index, index, {role}); return true; } return false; }flags(const QModelIndex index)作用返回给定数据项的属性标志告诉视图这个项目能做什么可选、可编辑、可拖放等。实现要点通常先调用基类的flags()获取默认标志如Qt::ItemIsEnabled,Qt::ItemIsSelectable然后根据你的需求用|运算符添加其他标志。Qt::ItemIsSelectable项目可选。Qt::ItemIsEditable项目可编辑必须设置此项setData才会被调用。Qt::ItemIsUserCheckable项目可显示复选框。Qt::ItemIsDragEnabled,Qt::ItemIsDropEnabled支持拖放。示例Qt::ItemFlags MyListModel::flags(const QModelIndex index) const { if (!index.isValid()) return Qt::NoItemFlags; // 基础标志可选、可用 Qt::ItemFlags defaultFlags QAbstractListModel::flags(index); // 添加可编辑标志 return defaultFlags | Qt::ItemIsEditable; }2.4 表头数据headerData()这个方法不是纯虚函数但有默认实现返回空。如果你的视图如QTableView、QTreeView需要显示表头文字就需要重写它。作用返回水平或垂直表头在指定区域section和角色role下的数据。参数section表头的第几列水平或第几行垂直。orientationQt::Horizontal水平表头或Qt::Vertical垂直表头。role通常为Qt::DisplayRole请求显示的文本。示例QVariant MyTableModel::headerData(int section, Qt::Orientation orientation, int role) const { if (role ! Qt::DisplayRole) return QVariant(); if (orientation Qt::Horizontal) { // 为水平表头的每一列设置标题 return QString(Column %1).arg(section); } else if (orientation Qt::Vertical) { // 为垂直表头行号设置标题 return QString(Row %1).arg(section); } return QVariant(); }2.5 动态修改数据结构insertRows(),removeRows()对于列表/表格如果你的模型支持通过视图插入或删除行例如QListView的上下文菜单有“添加行”、“删除行”选项就需要实现这些方法。它们不是纯虚函数但默认实现返回false表示不支持。insertRows(int row, int count, const QModelIndex parent)作用在指定父节点下的row位置插入count行。实现流程关键在修改底层数据结构之前必须调用beginInsertRows(parent, row, rowcount-1)。这个信号通知所有视图“我要开始插入了请做好准备”。执行实际的插入操作例如向m_dataList的指定位置插入空字符串。插入完成后必须调用endInsertRows()。这个信号通知视图“插入完成了现在可以更新显示了”。示例bool MyListModel::insertRows(int row, int count, const QModelIndex parent) { // 对于简单列表模型我们忽略parent只处理顶层 if (parent.isValid()) return false; beginInsertRows(parent, row, row count - 1); // 重要必须先调用 for (int i 0; i count; i) { m_dataList.insert(row, QString()); // 插入空数据 } endInsertRows(); // 重要最后调用 return true; }removeRows(int row, int count, const QModelIndex parent)作用移除指定父节点下从row开始的count行。实现流程与插入类似调用beginRemoveRows()和endRemoveRows()包裹实际的删除操作。为什么需要begin/end这保证了视图在数据变化过程中能保持正确的状态例如保持当前选中的项目并且能高效地进行批量更新。总结一下一个最简单的只读列表模型至少需要实现rowCount(),data(),index()对于QAbstractListModelindex()有默认实现通常够用。一个可编辑的表格模型则需要额外实现setData(),flags(), 可能还有headerData()。一个树形模型则是最复杂的必须完整实现index()和parent()来构建层次关系。3. 从理论到实践如何按步骤实现一个自定义Model理解了核心方法我们来看一个完整的、可编辑的字符串列表模型的实现步骤。我会用QAbstractListModel作为基类因为它为非层次结构数据提供了很多默认实现让我们省事不少。3.1 第一步定义模型类并声明必要方法首先继承QAbstractListModel并声明我们需要的核心方法。注意由于我们使用列表模型columnCount()和parent()已经有了合适的默认实现列数为1所有项目父节点均为顶层。// mylistmodel.h #ifndef MYLISTMODEL_H #define MYLISTMODEL_H #include QAbstractListModel #include QStringList class MyListModel : public QAbstractListModel { Q_OBJECT // 必须包含因为我们要用信号槽 public: explicit MyListModel(const QStringList strings, QObject *parent nullptr); // 必须重写的纯虚函数 int rowCount(const QModelIndex parent QModelIndex()) const override; QVariant data(const QModelIndex index, int role Qt::DisplayRole) const override; // 为使模型可编辑而重写的函数 bool setData(const QModelIndex index, const QVariant value, int role Qt::EditRole) override; Qt::ItemFlags flags(const QModelIndex index) const override; // 为支持插入/删除行而重写的函数可选但很实用 bool insertRows(int row, int count, const QModelIndex parent QModelIndex()) override; bool removeRows(int row, int count, const QModelIndex parent QModelIndex()) override; // 可选添加表头支持 QVariant headerData(int section, Qt::Orientation orientation, int role Qt::DisplayRole) const override; private: QStringList m_dataList; // 底层数据存储 }; #endif // MYLISTMODEL_H3.2 第二步实现数据维度和访问rowCount,data这是模型的基础让视图能“看到”数据。// mylistmodel.cpp #include mylistmodel.h MyListModel::MyListModel(const QStringList strings, QObject *parent) : QAbstractListModel(parent), m_dataList(strings) { } int MyListModel::rowCount(const QModelIndex parent) const { // 对于列表模型只有顶层项目。如果parent有效说明请求的是子项目数量我们返回0。 if (parent.isValid()) return 0; // 返回列表中的字符串数量 return m_dataList.size(); } QVariant MyListModel::data(const QModelIndex index, int role) const { // 1. 检查索引是否有效 if (!index.isValid()) return QVariant(); // 2. 检查行号是否在合法范围内 if (index.row() 0 || index.row() m_dataList.size()) return QVariant(); // 3. 根据角色返回数据 if (role Qt::DisplayRole || role Qt::EditRole) { // 返回对应行的字符串 return m_dataList.at(index.row()); } // 可以添加对其他角色的支持例如 // else if (role Qt::ToolTipRole) { // return QString(Tooltip for item at row %1).arg(index.row()); // } // else if (role Qt::TextAlignmentRole) { // return Qt::AlignCenter; // } // 4. 对于不处理的角色返回空QVariant return QVariant(); }3.3 第三步实现编辑支持flags,setData现在让模型变得可编辑。Qt::ItemFlags MyListModel::flags(const QModelIndex index) const { if (!index.isValid()) return Qt::NoItemFlags; // 获取基类的基础标志通常是ItemIsEnabled和ItemIsSelectable Qt::ItemFlags defaultFlags QAbstractListModel::flags(index); // 添加可编辑标志 return defaultFlags | Qt::ItemIsEditable; } bool MyListModel::setData(const QModelIndex index, const QVariant value, int role) { if (!index.isValid() || role ! Qt::EditRole) return false; if (index.row() 0 || index.row() m_dataList.size()) return false; // 修改底层数据 m_dataList[index.row()] value.toString(); // 关键发出数据已改变信号通知所有关联的视图更新显示。 // 参数说明变化的起始索引和结束索引这里只变了一个单元格所以都是index // 第三个参数是发生变化的角色列表这里我们只改了EditRole但通常也通知DisplayRole一起更新。 emit dataChanged(index, index, {Qt::DisplayRole, Qt::EditRole}); return true; }注意dataChanged信号是连接模型和视图的“生命线”。没有它即使你底层数据改了视图也不会刷新。参数中的角色列表很重要如果只填{Qt::EditRole}那么只关心EditRole的视图组件比如自定义委托会更新而依赖DisplayRole的普通显示可能不会更新。所以通常把相关的角色都通知一遍更安全。3.4 第四步实现动态增删行insertRows,removeRows这是为了让视图的上下文菜单或程序逻辑能动态修改模型结构。bool MyListModel::insertRows(int row, int count, const QModelIndex parent) { // 我们这是一个简单列表忽略parent非树形结构 if (parent.isValid()) return false; // 检查插入位置是否合法 if (row 0 || row m_dataList.size()) return false; // 关键步骤1通知视图即将插入行 beginInsertRows(parent, row, row count - 1); // 实际插入数据这里插入空字符串 for (int i 0; i count; i) { m_dataList.insert(row, QString()); } // 关键步骤2通知视图插入完成 endInsertRows(); return true; } bool MyListModel::removeRows(int row, int count, const QModelIndex parent) { if (parent.isValid()) return false; if (row 0 || (row count) m_dataList.size()) return false; // 关键步骤1通知视图即将移除行 beginRemoveRows(parent, row, row count - 1); // 实际移除数据 for (int i 0; i count; i) { m_dataList.removeAt(row); // 注意从row开始连续移除count次 } // 关键步骤2通知视图移除完成 endRemoveRows(); return true; }beginInsertRows/endInsertRows和beginRemoveRows/endRemoveRows是必须成对出现的。它们的作用不仅仅是发送信号。在begin和end之间模型处于一个“不稳定”状态视图会暂停对这部分数据的请求比如避免在删除过程中去获取正在被删除的数据。这保证了操作的原子性和视图的正确性。3.5 第五步使用自定义模型模型写好了使用起来和内置模型一样简单。// main.cpp 示例 #include QApplication #include QListView #include mylistmodel.h int main(int argc, char *argv[]) { QApplication app(argc, argv); // 1. 准备数据 QStringList data; data Apple Banana Cherry Date; // 2. 创建我们的自定义模型 MyListModel *model new MyListModel(data); // 3. 创建一个视图比如列表视图并设置模型 QListView *view new QListView; view-setModel(model); // 关键将模型与视图绑定 view-setWindowTitle(Custom List Model Demo); view-resize(300, 200); view-show(); // 4. 现在视图会自动调用模型的 rowCount(), data() 等方法来显示数据。 // 由于我们实现了 setData() 和 flags()视图中的项目默认就是可编辑的。 // 试试双击列表项进行编辑吧 return app.exec(); }编译运行这个程序你会看到一个包含四个水果名称的列表。双击任何一个项目就可以直接编辑。这就是一个功能完整的自定义可编辑列表模型。4. 避坑指南与高级话题实现自定义模型时除了把上述方法写对还有一些细节和高级功能需要特别注意。4.1 性能考虑懒加载与canFetchMore/fetchMore如果你的模型数据量非常大比如数据库中有百万行一次性在rowCount()中返回所有行数并在data()中为所有可见和不可见项准备数据会严重拖慢程序启动和滚动速度。Qt提供了**懒加载Lazy Loading**机制canFetchMore()和fetchMore()。bool canFetchMore(const QModelIndex parent)视图在需要显示更多数据前会调用此方法。如果还有更多数据可以加载返回true。void fetchMore(const QModelIndex parent)当canFetchMore返回true时视图会调用此方法。你应该在这里执行实际的数据加载操作并在加载前后调用beginInsertRows()和endInsertRows()将新加载的行“插入”到模型中。示例场景一个文件系统树形模型展开一个包含成千上万文件的目录时不要一次性加载所有文件。可以在canFetchMore中返回true然后在fetchMore中分批加载比如一次加载100个加载一批插入一批用户体验会流畅很多。4.2 树形模型QAbstractItemModel的实现难点实现树形模型是自定义模型中最复杂的部分核心难点在于index()和parent()的逻辑。内部指针Internal Pointer在index()中createIndex(row, column, internalPointer)的第三个参数至关重要。对于树形结构你通常需要传递一个指向你内部树节点对象的指针转换为void*。这样在data()、parent()等方法中可以通过index.internalPointer()取回这个指针从而快速定位到数据节点。parent()的实现给定一个子节点索引你需要通过index.internalPointer()拿到子节点对象。找到该子节点的父节点对象。根据父节点对象确定其在祖父节点下的行号这可能需要遍历兄弟节点。使用createIndex(parentRow, 0, parentNodePtr)创建并返回父节点的索引。如果该节点是根节点则返回QModelIndex()。rowCount()和columnCount()这两个方法现在必须考虑parent参数。只有当parent对应的节点是分支节点有子节点时才返回其子节点数量如果是叶子节点返回0。一个常见的错误是忘记在parent()中处理根节点情况导致视图无法正确展开树形结构。4.3 信号发射的时机与范围dataChanged当一个或多个现有数据项的值发生变化时发射。参数中的topLeft和bottomRight定义了发生变化的矩形区域对于列表行相同列为0。确保在修改底层数据后、返回前发射。layoutAboutToBeChanged/layoutChanged当模型的整体结构发生重大变化如排序、大批量插入/删除导致索引大面积失效而不仅仅是数据值变化时使用。你需要在这两个信号之间更新所有持久化索引QPersistentModelIndex。rowsAboutToBeInserted/rowsInserted这些信号由beginInsertRows()和endInsertRows()自动发射你不需要手动发射。对于beginRemoveRows等也是如此。modelReset当模型被完全重置例如重新从数据库加载所有数据时使用。发射此信号前视图会认为所有旧索引都失效了。这是一种“简单粗暴”但有效的更新方式适用于数据完全刷新的场景。4.4 委托Delegate的自定义即使模型提供了Qt::EditRole数据视图默认使用的编辑控件如QLineEdit可能也不符合你的要求。例如你想让某一列用QSpinBox编辑另一列用QComboBox。这时你需要自定义委托继承QStyledItemDelegate推荐或QItemDelegate并重写createEditor()创建编辑器部件如QSpinBox。setEditorData()将模型中的数据Qt::EditRole设置到编辑器。setModelData()将编辑器中的数据写回模型。updateEditorGeometry()设置编辑器在视图中的位置和大小。然后将自定义委托通过view-setItemDelegate()或view-setItemDelegateForColumn()设置给特定的视图或列。4.5 使用QAbstractProxyModel进行数据转换有时你不想修改原始模型但又想对视图呈现的数据进行排序、过滤或重新组织。这时应该使用代理模型Proxy Model例如QSortFilterProxyModel。排序设置setSortRole和setSortCaseSensitivity等然后调用sort()。过滤重写filterAcceptsRow()或filterAcceptsColumn()方法决定哪些行/列应该显示。优点代理模型位于原始模型和视图之间。你可以对同一个原始模型应用多个不同的代理模型从而得到不同排序或过滤效果的视图而原始数据始终保持不变。最后一点建议在实现自定义模型时先用QStandardItemModel或QStringListModel等内置模型快速搭建原型验证你的视图和业务逻辑。当确实需要更高的性能、更特定的数据结构或更复杂的行为时再着手实现自定义模型。并且务必为你的模型编写单元测试特别是index()和parent()这种容易出错的逻辑。理解Qt模型/视图框架的关键在于接受这种“数据与显示分离”的思想。模型只关心数据是什么、在哪里、怎么改视图只关心怎么画、怎么交互委托负责把交互转换成对模型的修改。各司其职通过标准接口通信这正是构建复杂、灵活GUI应用的强大武器。