今日主题:一句话定义
模型-视图架构(Model-View Architecture):把"数据的持有与变更"和"数据的呈现与交互"拆成两个互不认识的协作方,中间只靠一套约定好的查询与通知协议同步——模型回答"你有多少行、多少列、第 r 行第 c 列在某个角色下是什么值",视图负责"把这些值画到屏幕上并接收鼠标键盘",代理模型负责"换个角度看成什么样(排序/筛选/分组)",委托负责"单个格子怎么画、怎么编辑"。Qt 把这套协议固化成了可组合的基础设施,于是"一份数据、多种视图、任意排序筛选"不再需要你写重复代码。
为什么需要这个模式:先看一段坏代码
假设你要做一个"我的音乐库"窗口,最直觉的写法是直接用 QTableWidget,把数据一条条塞进单元格里:
// ❌ 坏味道:数据、业务规则、渲染、导出全部糊在 MainWindow 里
void MainWindow::loadSongs()
{
ui->tableWidget->setRowCount(4);
const QStringList titles{ "City of Stars", "Yesterday",
"Bohemian Rhapsody", "Imagine" };
const QList<int> ratings{ 5, 4, 5, 3 };
for (int i = 0; i < titles.size(); ++i) {
ui->tableWidget->setItem(i, 0, new QTableWidgetItem(titles[i]));
// 评分明明是 int,却被迫变成字符串存进单元格
ui->tableWidget->setItem(i, 1, new QTableWidgetItem(QString::number(ratings[i])));
}
// 点表头排序 —— 把字符串当数字排,得到 "3","4","5" 看着对,
// 一旦评分变成 "9" 和 "100",顺序就彻底错了:'1' < '9'
ui->tableWidget->sortItems(1, Qt::DescendingOrder);
}
// 想导出 CSV?只能反向遍历 widget,把 UI 当成数据库读
void MainWindow::exportCsv(const QString& path)
{
QFile f(path);
f.open(QIODevice::WriteOnly);
for (int r = 0; r < ui->tableWidget->rowCount(); ++r) {
QStringList row;
for (int c = 0; c < ui->tableWidget->columnCount(); ++c)
row << ui->tableWidget->item(r, c)->text();
// 如果某一格是 nullptr(比如被跳过没填),这里直接崩溃
f.write((row.join(',') + "\n").toUtf8());
}
}
这段代码的问题,逐个说清楚:
1. 数据的唯一真相变成了 widget。UI 的生命周期等于数据的生命周期:窗口关掉,数据就没了;想做"撤销",你得从 UI 反读回来。
2. 数据取不出来。要导出 CSV、要把同一份数据喂给图表、要换成 QML 界面,你都只能遍历 QTableWidgetItem——而它本来是为显示服务的私有存储。
3. 排序/筛选语义失控。sortItems 用的是"显示文本"的字典序,数字列被当成字符串排,这是最经典的隐性 bug。
4. 性能随行数爆炸。每个单元格背后是一个 QTableWidgetItem、可能还有一个编辑用 QWidget。10 万行日志会吃掉几百 MB 内存和好几秒构建时间。而模型-视图下视图只为"可见的那几十行"调用 data()。
5. 无法单元测试。业务规则(评分必须在 0~5)散落在 cell 赋值和事件处理里,想测它必须先创建一个 GUI。
- SRP(单一职责):MainWindow 同时是数据容器、视图渲染器、输入控制器、导出器,四种变化原因挤在一个类里。
- OCP(开闭原则):想新增一种展示(树形分组、卡片列表),必须修改"持有数据的那个类"。
- DIP(依赖倒置):高层业务逻辑("评分范围是 0~5")直接依赖具体 GUI 类型,抽象边界根本不存在。
- ISP(接口隔离):视图被迫接受 widget 的全部接口(滚动、编辑、拖拽、布局……),而它真正需要的只有"取第 r 行第 c 列"。
核心思想:把"回答"和"画画"彻底拆开
模型-视图架构的整个思想,可以压缩成"模型只回答四个问题,别的什么都不做":
① 你有多少行?多少列?(rowCount() / columnCount())
② 第 r 行第 c 列,在"某个角色"下是什么值?(data(index, role))——注意"角色"这个设计:同一份数据,给显示用是文本,给编辑用是原始值,给排序用可能是数值,给委托用可能是自定义语义。
③ 表头叫什么?(headerData())
④ 用户改了一个值,写回去。(setData(),写入成功后发信号通知。)
视图只做一件相反的事:把模型"拉"出来的数据画到屏幕上,并把用户的鼠标键盘事件"翻译"成对模型的查询或写回。视图里不该有业务规则,模型里不该出现任何绘制代码。
UML / 角色关系与职责
Qt 的模型-视图不是"两个类",而是四层协作 + 一个值对象索引。关系如下(文字版结构图):
┌───────────────────────────────┐
│ QAbstractItemModel │ 数据真相(接口)
│ rowCount / columnCount │
│ data(index, role) │
│ dataChanged / rowsInserted ──┼──▶ 信号:通知视图
└───────────────▲───────────────┘
▲ │ 继承
继承 │ ├── QStandardItemModel (通用容器实现,适合原型)
┌─────────────────────┴──┐ ├── QFileSystemModel (文件系统即数据源)
│ QAbstractProxyModel │ └── SongModel / LogModel (你的业务模型)
│ QSortFilterProxyModel │──引用──▶ 源 Model(源数据一个字节都不改)
└───────────┬────────────┘
│ 被 setModel() 引用(视图只认识"模型接口")
▼
┌────────────────────────────────────────────┐
│ QAbstractItemView │ 几何 + 交互状态
│ QTableView / QTreeView / QListView / │ 滚动、选中、展开、编辑触发策略
│ QColumnView / QComboBox(内部也用) │
└───────────────────┬────────────────────────┘
│ setItemDelegateForColumn / setItemDelegate
▼
┌────────────────────────────────────────────┐
│ QStyledItemDelegate(QAbstractItemDelegate)│ 单格呈现 + 编辑
│ paint() / sizeHint() / createEditor() / │
│ setEditorData() / setModelData() │
└────────────────────────────────────────────┘
各角色的职责边界(背下来,这是面试区分度最高的部分):
- QAbstractItemModel(模型):唯一的"数据真相"。不只提供读取,还负责结构变更的事务通知(
beginInsertRows/endInsertRows、beginRemoveRows/endRemoveRows、beginResetModel/endResetModel)和内容变更通知(emit dataChanged(topLeft, bottomRight, roles))。它完全不认识 QWidget。 - QModelIndex(索引):轻量值对象,封装
(row, column, internalPointer) + 所属 model 指针。它是"视图视角的游标",不持有所有权;结构变化后会失效,长期持有必须用QPersistentModelIndex(由模型负责在插入删除时更新它)。 - Qt::ItemDataRole(角色):同一个
data()调用按用途返回不同形态——DisplayRole显示文本、EditRole编辑时的原始值、DecorationRole图标、ToolTipRole提示、TextAlignmentRole对齐、ForegroundRole前景色,以及从Qt::UserRole起的自定义角色(例如RatingRole给委托画星星、SortRole给代理排序用)。 - QAbstractItemView(视图):只管"哪个格子在哪、谁被选中、什么时候开始编辑、滚动条范围多大、展开哪些节点"。它不关心一个格子里画的是文字还是星星。
- QStyledItemDelegate(委托):只管"单个格子画成什么样(
paint)、占多大(sizeHint)、用什么编辑器(createEditor)、编辑值怎么进怎么出(setEditorData`/`setModelData)"。 - QAbstractProxyModel(代理模型):它本身也是一个模型,插在视图与真实模型之间,把源索引与自身索引做双向映射(
mapToSource`/`mapFromSource),从而在不改动源数据的前提下提供排序、筛选、去重、分组、拼接等能力,并且可以层层叠加。 - 同步协议:视图→模型是拉(调用
data());模型→视图是推(信号槽)。一拉一推,就是全部。
最小 C++ 示例:不依赖 Qt 的 Model-View 骨架
先用纯 C++17 把这套骨架跑通——理解了它,Qt 的那一堆类只是把同样的接口换成了 QModelIndex 版本。这段代码可以直接编译:
// demo_model_view.cpp —— 纯 C++17,不依赖 Qt
// 编译 & 运行: g++ -std=c++17 -O2 -o demo demo_model_view.cpp && ./demo
#include <algorithm>
#include <iostream>
#include <numeric>
#include <string>
#include <vector>
// ============ Model 接口:只回答"有多少行/列、取什么值" ============
class IModel {
public:
virtual ~IModel() = default;
virtual int rowCount() const = 0;
virtual int columnCount() const = 0;
virtual std::string header(int column) const = 0;
virtual std::string data(int row, int column) const = 0;
};
// ============ 具体 Model:持有数据,别的什么都不做 ============
class SongModel final : public IModel {
public:
struct Song { std::string title; int rating; }; // 业务数据结构,与 UI 无关
void addSong(std::string title, int rating) {
songs_.push_back(Song{std::move(title), rating});
// 真实 Qt 实现里,这里前后要包 beginInsertRows / endInsertRows
}
int rowCount() const override { return static_cast<int>(songs_.size()); }
int columnCount() const override { return 2; }
std::string header(int column) const override {
return column == 0 ? "歌名" : "评分";
}
std::string data(int row, int column) const override {
const Song& s = songs_.at(static_cast<std::size_t>(row));
return column == 0 ? s.title : std::to_string(s.rating);
}
private:
std::vector<Song> songs_; // 数据在这里,视图看不见它
};
// ============ 代理 Model:包装另一个 Model,不改源数据一个字节 ============
class SortProxy final : public IModel {
public:
explicit SortProxy(const IModel& source) : source_(source) {
order_.resize(static_cast<std::size_t>(source_.rowCount()));
std::iota(order_.begin(), order_.end(), 0); // 初始顺序 = 源顺序
}
// 只重排"行号映射表",源数据依然是原始插入顺序
void sortBy(int column, bool ascending) {
std::stable_sort(order_.begin(), order_.end(),
[this, column, ascending](int a, int b) {
return ascending ? lessThan(a, b, column)
: lessThan(b, a, column);
});
}
int rowCount() const override { return source_.rowCount(); }
int columnCount() const override { return source_.columnCount(); }
std::string header(int column) const override { return source_.header(column); }
std::string data(int row, int column) const override {
return source_.data(order_.at(static_cast<std::size_t>(row)), column);
}
private:
bool lessThan(int a, int b, int column) const {
if (column == 1) // 关键:评分列按"数值"比,避免 "9" > "100" 的字典序陷阱
return std::stoi(source_.data(a, column)) < std::stoi(source_.data(b, column));
return source_.data(a, column) < source_.data(b, column);
}
const IModel& source_; // 不拥有数据,只引用
std::vector<int> order_; // 索引映射表:视图行号 -> 源行号
};
// ============ View 接口:只负责"怎么呈现" ============
class IView {
public:
virtual ~IView() = default;
virtual void render(const IModel& model, const std::string& title) const = 0;
};
// 视图 A:控制台友好格式
class ConsoleView final : public IView {
public:
void render(const IModel& model, const std::string& title) const override {
std::cout << "=== " << title << " ===\n";
for (int r = 0; r < model.rowCount(); ++r)
std::cout << model.data(r, 0) << " (" << model.data(r, 1) << ")\n";
std::cout << '\n';
}
};
// 视图 B:CSV 导出格式 —— 同一个 Model,完全不同的呈现,零改动 Model
class CsvView final : public IView {
public:
void render(const IModel& model, const std::string& title) const override {
std::cout << "=== " << title << " ===\n";
for (int c = 0; c < model.columnCount(); ++c)
std::cout << model.header(c) << (c + 1 == model.columnCount() ? '\n' : ',');
for (int r = 0; r < model.rowCount(); ++r) {
for (int c = 0; c < model.columnCount(); ++c)
std::cout << model.data(r, c)
<< (c + 1 == model.columnCount() ? '\n' : ',');
}
std::cout << '\n';
}
};
int main() {
SongModel model; // 唯一的数据真相
model.addSong("Yesterday", 88);
model.addSong("City of Stars", 95);
model.addSong("Bohemian Rhapsody", 92);
CsvView csv;
csv.render(model, "原始数据(CSV 视图)"); // 视图 A 看源数据
SortProxy proxy(model); // 排序发生在视图层
proxy.sortBy(1, /*ascending=*/false); // 按评分降序
ConsoleView console;
console.render(proxy, "按评分降序(控制台视图)"); // 视图 B 看同一份数据的另一种视角
return 0;
}
程序输出:
=== 原始数据(CSV 视图) ===
歌名,评分
Yesterday,88
City of Stars,95
Bohemian Rhapsody,92
=== 按评分降序(控制台视图) ===
City of Stars (95)
Bohemian Rhapsody (92)
Yesterday (88)
请注意几个关键点:两个视图共享同一个 SongModel 实例;排序只改动了 order_ 这张映射表,源数据在内存里的顺序从未变化;新增一种视图(比如 JSON 视图)完全不需要碰 Model。把这三个事实平移到 Qt,就是 QTableView + QSortFilterProxyModel + QAbstractItemModel 的关系。
Qt 实战示例:音乐库(Model + Proxy + Delegate 全链路)
需求:一个音乐库窗口,三列(歌名 / 歌手 / 评分),评分用★星形绘制并能双击用微调框编辑,顶部搜索框实时筛选,评分列按数值降序排序。完整工程四个文件 + CMake。
songmodel.h —— Model 声明
#pragma once
#include <QAbstractTableModel>
#include <QString>
#include <QVector>
// 一条业务数据:与任何 UI 类型无关,可以被测试代码直接构造
struct Song {
QString title;
QString artist;
int rating = 0; // 0 ~ 5 星
};
// ------------------------------------------------------------------
// SongModel:只做两件事 —— 回答视图的提问、在数据变化时通知视图
// ------------------------------------------------------------------
class SongModel : public QAbstractTableModel
{
Q_OBJECT // 必须:需要 moc 生成元对象,信号槽才能工作
public:
enum Column { TitleColumn = 0, ArtistColumn, RatingColumn, ColumnCount };
enum Role { RatingRole = Qt::UserRole + 1 }; // 自定义角色
static constexpr int MinRating = 0;
static constexpr int MaxRating = 5;
explicit SongModel(QObject *parent = nullptr) : QAbstractTableModel(parent) {}
// ---- 视图会问的三个基本问题 ----
int rowCount(const QModelIndex &parent = QModelIndex()) const override
{ return parent.isValid() ? 0 : m_songs.size(); }
int columnCount(const QModelIndex &parent = QModelIndex()) const override
{ return parent.isValid() ? 0 : int(ColumnCount); }
QVariant data(const QModelIndex &index, int role) const override;
QVariant headerData(int section, Qt::Orientation orientation, int role) const override;
// ---- 写回路径 ----
bool setData(const QModelIndex &index, const QVariant &value,
int role = Qt::EditRole) override;
Qt::ItemFlags flags(const QModelIndex &index) const override;
// ---- 结构变更:必须用 begin/end 包起来,否则视图索引会失效 ----
void addSong(const Song &song);
void removeSong(int row);
private:
QVector<Song> m_songs; // 私有:外部(包括视图)无法直接触碰
};
songmodel.cpp —— Model 实现
#include "songmodel.h"
#include <algorithm> // std::clamp
QVariant SongModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid() || index.row() >= m_songs.size())
return {};
const Song &song = m_songs.at(index.row());
// 核心思想落点:同一份数据,按"角色"回答不同的表现形态
switch (role) {
case Qt::DisplayRole: // 显示用
case Qt::EditRole: // 编辑用(原始类型)
switch (index.column()) {
case TitleColumn: return song.title;
case ArtistColumn: return song.artist;
case RatingColumn: return song.rating; // 返回 int,不是 QString!
default: break;
}
break;
case Qt::TextAlignmentRole:
return (index.column() == RatingColumn)
? QVariant(int(Qt::AlignCenter))
: QVariant(int(Qt::AlignLeft | Qt::AlignVCenter));
case Qt::ToolTipRole:
return QStringLiteral("%1 — %2(%3 星)")
.arg(song.title, song.artist).arg(song.rating);
case RatingRole: // 自定义角色:喂给委托画星星
return song.rating;
default:
break;
}
return {};
}
QVariant SongModel::headerData(int section, Qt::Orientation orientation, int role) const
{
if (role != Qt::DisplayRole || orientation != Qt::Horizontal)
return {};
switch (section) {
case TitleColumn: return QStringLiteral("歌名");
case ArtistColumn: return QStringLiteral("歌手");
case RatingColumn: return QStringLiteral("评分");
default: return {};
}
}
bool SongModel::setData(const QModelIndex &index, const QVariant &value, int role)
{
if (!index.isValid() || role != Qt::EditRole || index.row() >= m_songs.size())
return false;
Song &song = m_songs[index.row()];
switch (index.column()) {
case TitleColumn: song.title = value.toString(); break;
case ArtistColumn: song.artist = value.toString(); break;
case RatingColumn: {
bool ok = false;
const int r = value.toInt(&ok);
if (!ok)
return false;
// 业务规则集中在这一处,可被单元测试直接覆盖
song.rating = std::clamp(r, int(MinRating), int(MaxRating));
break;
}
default:
return false;
}
// 内容变化(不是结构变化)→ 发 dataChanged,视图只重绘这一格
emit dataChanged(index, index,
{ Qt::DisplayRole, Qt::EditRole, Qt::ToolTipRole, RatingRole });
return true;
}
Qt::ItemFlags SongModel::flags(const QModelIndex &index) const
{
if (!index.isValid())
return Qt::NoItemFlags;
return Qt::ItemIsEnabled | Qt::ItemIsSelectable | Qt::ItemIsEditable;
}
void SongModel::addSong(const Song &song)
{
const int row = m_songs.size();
beginInsertRows(QModelIndex(), row, row); // ① 通知:我要开始插了
m_songs.append(song); // ② 真正改内部数据
endInsertRows(); // ③ 通知:插完了,视图重建索引
}
void SongModel::removeSong(int row)
{
if (row < 0 || row >= m_songs.size())
return;
beginRemoveRows(QModelIndex(), row, row);
m_songs.remove(row);
endRemoveRows();
}
songdelegate.h / songdelegate.cpp —— 委托:把评分画成星星,并用微调框编辑
// ---------------- songdelegate.h ----------------
#pragma once
#include <QStyledItemDelegate>
class SongDelegate : public QStyledItemDelegate
{
Q_OBJECT
public:
using QStyledItemDelegate::QStyledItemDelegate; // 继承构造函数
void paint(QPainter *painter, const QStyleOptionViewItem &option,
const QModelIndex &index) const override;
QSize sizeHint(const QStyleOptionViewItem &option,
const QModelIndex &index) const override;
QWidget *createEditor(QWidget *parent, const QStyleOptionViewItem &option,
const QModelIndex &index) const override;
void setEditorData(QWidget *editor, const QModelIndex &index) const override;
void setModelData(QWidget *editor, QAbstractItemModel *model,
const QModelIndex &index) const override;
};
// ---------------- songdelegate.cpp ----------------
#include "songdelegate.h"
#include "songmodel.h"
#include <QApplication>
#include <QPainter>
#include <QSpinBox>
#include <QStyle>
void SongDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option,
const QModelIndex &index) const
{
// 只有评分列走自定义绘制,其它列老老实实交回基类
if (index.column() != SongModel::RatingColumn) {
QStyledItemDelegate::paint(painter, option, index);
return;
}
QStyleOptionViewItem opt = option;
initStyleOption(&opt, index); // 先让基类把选中背景、分隔线等标准样式准备好
opt.text.clear(); // 文本由我们自己画,清掉防止重复渲染
QApplication::style()->drawControl(QStyle::CE_ItemViewItem, &opt, painter);
const int rating = index.data(SongModel::RatingRole).toInt();
const int star = qBound(12, opt.rect.height() - 6, 22);
painter->save();
painter->setRenderHint(QPainter::Antialiasing, true);
QFont f = painter->font();
f.setPointSizeF(star * 0.55);
painter->setFont(f);
for (int i = 0; i < SongModel::MaxRating; ++i) {
const QRect cell(opt.rect.left() + 4 + i * star, opt.rect.top() + 3, star, star);
painter->setPen(i < rating ? QColor(0xE8, 0x8A, 0x1E) // 实星:暖橙
: QColor(0xCC, 0xCC, 0xCC)); // 空星:浅灰
painter->drawText(cell, Qt::AlignCenter, QStringLiteral("★"));
}
painter->restore();
}
QSize SongDelegate::sizeHint(const QStyleOptionViewItem &option,
const QModelIndex &index) const
{
QSize s = QStyledItemDelegate::sizeHint(option, index);
if (index.column() == SongModel::RatingColumn)
s = s.expandedTo(QSize(SongModel::MaxRating * 20 + 10, 28));
return s;
}
QWidget *SongDelegate::createEditor(QWidget *parent, const QStyleOptionViewItem &option,
const QModelIndex &index) const
{
if (index.column() != SongModel::RatingColumn)
return QStyledItemDelegate::createEditor(parent, option, index);
auto *spin = new QSpinBox(parent); // 编辑器属于视图,不归模型管
spin->setRange(SongModel::MinRating, SongModel::MaxRating);
spin->setSuffix(QStringLiteral(" 星"));
return spin;
}
void SongDelegate::setEditorData(QWidget *editor, const QModelIndex &index) const
{
if (auto *spin = qobject_cast<QSpinBox *>(editor)) {
spin->setValue(index.data(SongModel::RatingRole).toInt());
return;
}
QStyledItemDelegate::setEditorData(editor, index);
}
void SongDelegate::setModelData(QWidget *editor, QAbstractItemModel *model,
const QModelIndex &index) const
{
if (auto *spin = qobject_cast<QSpinBox *>(editor)) {
spin->interpretText(); // 保证手输未回车的内容也算数
model->setData(index, spin->value(), Qt::EditRole); // 唯一写回入口
return;
}
QStyledItemDelegate::setModelData(editor, model, index);
}
main.cpp —— 组装:Model → Proxy → View → Delegate
#include "songdelegate.h"
#include "songmodel.h"
#include <QApplication>
#include <QHeaderView>
#include <QLineEdit>
#include <QSortFilterProxyModel>
#include <QTableView>
#include <QVBoxLayout>
#include <QWidget>
int main(int argc, char *argv[])
{
QApplication app(argc, argv);
// ① 一份数据(唯一真相),与 UI 完全解耦
SongModel model;
model.addSong({QStringLiteral("City of Stars"), QStringLiteral("Ryan Gosling"), 5});
model.addSong({QStringLiteral("Yesterday"), QStringLiteral("The Beatles"), 4});
model.addSong({QStringLiteral("Bohemian Rhapsody"), QStringLiteral("Queen"), 5});
model.addSong({QStringLiteral("Imagine"), QStringLiteral("John Lennon"), 3});
// ② 代理模型:筛选 + 排序,源数据一个字节都不动
QSortFilterProxyModel proxy;
proxy.setSourceModel(&model);
proxy.setFilterCaseSensitivity(Qt::CaseInsensitive);
proxy.setFilterKeyColumn(-1); // -1 = 所有列都参与匹配
proxy.setSortRole(SongModel::RatingRole); // 关键:按数值角色排序而非字符串
proxy.sort(SongModel::RatingColumn, Qt::DescendingOrder);
// ③ 视图:只管画和收事件
QTableView table;
table.setModel(&proxy);
table.setItemDelegateForColumn(SongModel::RatingColumn, new SongDelegate(&table));
table.setSelectionBehavior(QAbstractItemView::SelectRows);
table.setEditTriggers(QAbstractItemView::DoubleClicked
| QAbstractItemView::SelectedClicked);
table.horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch);
table.verticalHeader()->setVisible(false);
table.setAlternatingRowColors(true);
// ④ 搜索框 → 直接改代理模型的过滤条件,表格自动刷新
QLineEdit search;
search.setPlaceholderText(QStringLiteral("搜索歌名或歌手…"));
QObject::connect(&search, &QLineEdit::textChanged,
&proxy, &QSortFilterProxyModel::setFilterFixedString);
QWidget window;
auto *layout = new QVBoxLayout(&window);
layout->addWidget(&search);
layout->addWidget(&table);
window.resize(680, 420);
window.setWindowTitle(QStringLiteral("我的音乐库 — Model/View 演示"));
window.show();
return app.exec();
}
CMakeLists.txt(Qt6 + C++17)
cmake_minimum_required(VERSION 3.16)
project(SongLibrary LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_AUTOMOC ON) # Q_OBJECT 需要 moc 预处理
find_package(Qt6 REQUIRED COMPONENTS Widgets)
add_executable(songlibrary
main.cpp
songmodel.cpp
songdelegate.cpp
)
target_link_libraries(songlibrary PRIVATE Qt6::Widgets)
构建与运行:
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -j
./build/songlibrary
QSortFilterProxyModel 是 Qt 提供的现成代理模型——排序和筛选你一行都没自己写;
② QStyledItemDelegate 是 Qt 提供的现成委托——你只覆盖了"评分列",其它列靠基类;
③ QLineEdit 与 proxy 之间只用了一个信号槽,就完成了"搜索即刷新",没有任何手动同步 UI 的代码。
代码执行流程:双击评分列改成 2 星,全链路发生了什么
① 触发编辑(视图 + 委托)
用户双击第 3 行"评分"列 → QTableView 用它的 edit trigger 策略判断"这次点击该进入编辑" → 取出该格的 QModelIndex(注意:这是代理的 index)→ 调用 SongDelegate::createEditor(parent, option, index) → 委托发现列号是 RatingColumn,返回一个 QSpinBox(0~5);视图把编辑器放到格子矩形上,并回调 setEditorData(),委托用 index.data(RatingRole) 也就是 5 初始化它。
② 提交编辑(委托 → 代理 → 模型)
用户改成 2 并按回车/失焦 → 视图回调 setModelData(editor, model, index)。这里的 model 参数是代理模型。委托调用 proxy->setData(index, 2, Qt::EditRole);QSortFilterProxyModel 通过 mapToSource(index) 把它翻译成源模型的 index,转发给 SongModel::setData()。
③ 校验与写入(模型)
SongModel::setData 检查 index 有效、role 是 EditRole、行号在范围内 → 把 2 用 std::clamp 夹到 0~5 → 写入 m_songs[row].rating → emit dataChanged(index, index, {DisplayRole, EditRole, ToolTipRole, RatingRole})。整个过程没有一行代码去操作任何 widget。
④ 通知与重绘(模型 → 代理 → 视图 → 委托)
dataChanged 被 Qt 的信号槽广播给所有挂在同一模型上的对象:代理模型收到后重新评估这一行在排序/筛选后是否还应该在这个位置(评分从 5 降到 2,它可能要被挪到后面);视图收到后按需要重绘对应区域 → SongDelegate::paint() 重新读 RatingRole → 画 2 颗实星 + 3 颗空星。如果此时搜索框有内容,代理还会重新做一次子串匹配。
整条链路的关键特征:没有任何"手动刷新界面"的调用(没有 update()、没有重新 setItem、没有遍历表格重填)。这就是"数据驱动界面"和"命令式改界面"的根本差别。
为什么这样设计:解耦点、扩展点与收益
解耦点一:数据不知道视图存在。SongModel 只 #include <QAbstractTableModel>,它的头文件里找不到 QWidget、QTableView、QPainter 的任何痕迹。这意味着模型可以在没有图形界面的环境里被构造、被断言、被序列化。
解耦点二:视图不缓存数据。视图只在需要绘制时调用 data(),因此"屏幕上有几行"与"内存里有几行数据"完全无关。这就是 Qt 表格能轻松展示百万行的原因——一万行还是十万行,视图只为可见的约 30 行取数据。
解耦点三:写回只有一条路径。所有数据修改都必须经过 setData()(或 begin/end 包裹的结构操作)。业务规则(评分 0~5、必填校验、变更日志)可以集中在这一个函数里,而不是散落在十几个事件处理函数中。
- OCP(开闭原则):新增"按歌手分组展示"只需再加一层代理模型或换一个视图,Model 与既有的 View 都不改。
- DIP(依赖倒置):业务逻辑依赖
QAbstractItemModel这个抽象,而不是具体 widget。 - 组合优于继承:View + Proxy + Delegate 三者按需拼装;不会为了"带排序的表格"而派生
SortedTableWidget、为了"带筛选的树"再派生FilteredTreeWidget,避免类爆炸。 - 可测试性:
SongModel是纯 QObject,用 QTest 直接setData再断言data()返回值,或用QSignalSpy断言dataChanged/rowsInserted被正确发出——全程不需要创建窗口。这是本模式最重要的工程收益。 - 可复用性:同一个 Model 可以同时挂在
QTableView(桌面)、QCompleter(补全)、QComboBox(下拉)、QML 的ListView(移动/嵌入式)上,以及 CSV/JSON 导出器上。 - 可观察性:所有变更都走信号,加一个"变更审计"日志只需
connect到dataChanged/rowsInserted,无需侵入任何业务代码。
不使用会怎样:四种具体恶化路径
① if-else / switch 膨胀。用 widget 直接存数据时,"评分列怎么显示"这件事会同时出现在四个地方:数据装载循环里(决定塞什么文本)、排序比较逻辑里(把字符串转回数字)、颜色/图标设置里(自定义 Qt::UserRole)、导出函数里(再解析一次文本)。每新增一种呈现形态(改成进度条、改成颜色条、加上"未评分"灰色态),四处都要改,漏一处就是不一致 bug。
② 类膨胀。同一份音乐数据要展示成"表格 + 树(按歌手分组)+ 下拉选择"三种形态。在无模式方案下,你需要三套装载代码、三套排序代码、三套导出代码;在 Model/View 下是:一个 Model,一个代理,三个视图各三行代码。
③ 耦合导致的"改不动"。业务同事要求"评分超过 4 星的歌名加粗显示"。如果数据在 widget 里,你只能在装载时判断一次——用户之后编辑评分,加粗不会更新,除非在编辑回调里再写一遍刷新逻辑。QStyledItemDelegate 的方案里,paint() 每次重绘都重新读数据,天然正确。
④ 隐性性能与正确性 bug。字符串排序("9" > "100")是经典正确性 bug;QTableWidget 每格一个 QTableWidgetItem + 拖拽/编辑状态,10 万行时内存和构建时间都难以接受;CSV 导出遍历 item(r, c) 遇到未填充的空指针会直接崩溃。这些都不是"写得不够仔细",而是架构选错导致的必然结果。
beginInsertRows/endInsertRows 而直接改内部容器,会出现"幽灵行"——视图的行数缓存还是旧的,于是要么显示不存在的行(读到越界数据或崩溃),要么新行看不见。这类 bug 往往在"数据量大时才偶发",排查成本极高。记住:改结构用 begin/end,改内容用 dataChanged。
何时使用 / 何时不要用
✅ 适合使用的场景:
1. 表格 / 列表 / 树类数据展示,尤其是数据会被增删改的(日志查看器、任务管理器、文件浏览器、属性编辑器、调试面板)。
2. 同一份数据需要多个视图:表格 + 图表 + CSV 导出 + 状态栏统计,或者同时展示在桌面窗口和 QML 界面里。
3. 数据量较大或动态增删:需要"只为可见行取数据"的懒加载与增量刷新能力(千万级日志、内存快照分析)。
4. 需要对同一数据做不同排序/筛选/分组:典型做法是源 Model 只读,前面串一到两层 QSortFilterProxyModel,每种视角一个视图。
5. 需要把 C++ 数据暴露给 QML:QAbstractListModel + 重写 roleNames() 是 QML ListView/Repeater/TableView 的标准数据后端,比 QVariantList 属性高效得多。
6. 业务逻辑需要被单元测试:把规则放进 Model,测试不再依赖 GUI。
❌ 不适合使用的场景:
1. 一次性输出:只是想打印几行文本或显示一段说明,用 QLabel/QTextEdit 就够了。
2. 纯表单界面:一堆 QLineEdit + QComboBox 的配置对话框,用 QFormLayout 加几个成员变量比写 Model 直白得多。
3. 交互高度异构的"行":每一行的操作按钮、布局、行为都完全不同(本质不是一个同质集合),那应该是自定义 widget 的场景,硬套 Model 只会把 data() 写成类型分派地狱。
4. 静态小数据集:一个 5 个选项、永不变化的 QComboBox,addItem 五次最快。
另一个常见过度设计是"给每个字段都加自定义角色"。角色只在视图或委托真的需要不同形态时才加(如 RatingRole 给委托画星星、SortRole 给代理按数值排)。纯粹跟风加角色,只会让
data() 的 switch 越来越长。
与其他模式/架构的区别(最易混淆的四个)
| 对比对象 | 核心差别 | 什么时候用哪个 |
|---|---|---|
| MVC / MVVM (Day 24 / Day 25) |
MVC 把用户输入也划给独立的 Controller;Qt 的模型-视图其实是 Model-View-Delegate:没有独立 Controller 类,输入处理分散在 View 与 Delegate 中。MVVM 则把"视图状态"再抽成 ViewModel,靠双向绑定(Qt 里是 Q_PROPERTY + 信号槽)粘合。 |
列表/表格/树类数据展示用 Qt 原生 Model/View;界面状态复杂、需要与业务数据双向同步的表单/仪表盘场景,考虑 MVVM(ViewModel + 绑定)。 |
| 观察者模式 (Day 18) |
观察者是一种机制(订阅 → 通知);模型-视图是用观察者搭起来的一整套架构约定:包含角色的语义、索引映射、结构变更事务、代理链。模型-视图的同步部分"就是"观察者,但反过来不成立。 | 只需要"一变就通知"用观察者/信号槽;需要"数据与呈现分家 + 多视图 + 排序筛选"才上 Model/View。 |
| 代理模式 / 装饰器 (Day 12 / Day 9) |
QSortFilterProxyModel 名字里有 Proxy,但它不是 GoF 代理(控制"访问":鉴权、延迟加载、远程代理),语义更接近装饰器 / 管道:包装一个 Model 并增加能力(排序、筛选、转置、拼接),而且可以层层叠加。真正需要"代理"语义时,应继承 QAbstractProxyModel 或使用 QIdentityProxyModel 自己做延迟加载与权限检查。 |
要给数据换视角(排序/筛选/去重)→ 用现成的 QSortFilterProxyModel;要做懒加载、权限过滤、远程数据代理 → 自定义 QAbstractProxyModel。 |
| 迭代器模式 (Day 15) |
QModelIndex 很像游标,但它是"视图视角的索引",不是数据结构的迭代器:它只对创建它的 model 有效,结构变更后会失效,且不能跨线程长期持有。它与 std::vector::iterator 没有可比性。 |
遍历业务容器用迭代器(或范围 for);向视图暴露"第 r 行第 c 列"用 QModelIndex。 |
Qt 源码与框架自身的体现
Qt 是把这个模式用到"自成一套子系统"的程度,下面这些全都是同一个协议的实例:
- QFileSystemModel:把整个文件系统封装成 Model,于是
QTreeView+ 它就成了文件浏览器,而且排序、筛选、图标、延迟加载(目录按需读取)全部免费获得。这是"数据源换成另一个实现,视图代码一行不变"的最佳例证。 - QStandardItemModel:官方提供的通用容器实现(内部是一棵 QStandardItem 树)。它证明了这套协议可以被实现成"通用数据结构容器"。缺点是每个 item 都是重量级对象,大数据量下应改用自定义 Model 直接暴露你的
QVector/数据库游标。 - QCompleter:接受任意
QAbstractItemModel作为数据源。所以同一个SongModel可以同时喂给QTableView、QCompleter和QComboBox,三处展示同一份内存数据,零拷贝、零同步代码。 - beginInsertRows / endInsertRows 的"事务"约定:这是 Qt 内部最严格的契约之一。视图与代理在
end*Rows()里才重建索引映射与布局缓存;QPersistentModelIndex也正是在这些回调里被框架自动更新的——框架代替你管理游标有效性,这正是模型-视图相对"自己写通知逻辑"的隐藏价值。 - QAbstractItemView 的编辑触发策略(
EditTriggers、SelectionBehavior、setIndexWidget)都在视图层,而"用什么编辑器、怎么校验"在委托层——职责边界清晰到可以单独替换任意一层。 - QStyledItemDelegate 内部再依赖 QStyle:委托负责"哪里画什么",QStyle 负责"当前平台/皮肤下这个控件长什么样"。这又是一次同构的分离(内容 vs 外观),Qt 里这种"再分一层"的做法随处可见。
- QML 侧:
QAbstractListModel子类重写roleNames()后,C++ 数据结构就以"角色名"的形式暴露给 QML:model.title/model.rating。同一份 C++ 数据既能在 QMLListView里以卡片形式滚动,也能在QTableView里以表格形式编辑。 - QColumnView / QHeaderView / QListView 的 section 模型:Qt 内部大量使用"小 Model"来驱动子部件(例如表头的状态、列视图的分栏),说明这套协议不只是给"业务表格"用的通用基础件。
面试常见问题(Qt 方向高频)
beginInsertRows/endInsertRows,或者发 dataChanged?不调用会怎样?
A:视图并不持有数据,它维护的是"模型行号 → 屏幕位置"的索引映射与布局缓存。这让索引映射的下标完全错误。要么视图按旧结构去问新行数,导致越界读取、崩溃;要么新插入的数据行"看不见"(幽灵行的反面)。dataChanged 只告诉视图"某些单元格的内容变了",不改结构;改结构用 begin/end,改内容用 dataChanged,两者不能混用。这是模型-视图的"事务边界"。
QSortFilterProxyModel 排序时为什么会出现"数字按字符串排"的 bug?
A:代理默认取 Qt::DisplayRole(或 Qt::EditRole)的值并按 QVariant 的比较规则排序;对 QString 就是字典序,所以 "9" > "100"。解决办法是 setSortRole(自定义角色),让 data() 在那个角色下返回真正的数值类型(int/double)。这恰好解释了"角色"这个设计的必要性:展示形态与排序依据可以不同。同理,筛选时 setFilterKeyColumn(-1) 表示所有列参与匹配。
QModelIndex 能跨线程使用或长期保存吗?
A:都不能。它是轻量值对象,只对创建它的 model 有效,而且行号在结构变化后会失效(典型 bug:把 index 存进成员变量,用户删了一行后再用它去 model->data(),读到错行的数据)。要长期持有必须用 QPersistentModelIndex,框架会在插入/删除/移动时自动更新它。跨线程访问 Model 必须走队列连接(信号槽自动排队)或自己加锁——Qt 的 Model 不是线程安全的,正确做法是"工作线程算好数据 → 通过信号把结果交给 GUI 线程的 Model 写入"。
QTableWidget 而不是 QTableView + 自定义 Model?
A:数据行数很少(几十行以内)、只有一种视图、不打算复用数据、不打算做单元测试时,QTableWidget 出活更快,没必要上重炮。一旦出现以下任意一条就该换:需要导出/多视图共享、需要严格控制排序语义(数值 vs 字符串)、数据量上千行、需要单元测试覆盖业务规则。判断标准不是"哪个更高级",而是"这份数据是否需要脱离某个具体 widget 独立存在"。
A:View 管"几何与交互状态"——哪些格子在哪、谁被选中、滚动条范围、什么时候开始编辑、展开哪些节点;Delegate 管"单格的呈现与编辑"——画成什么样(paint)、占多大(sizeHint)、用什么编辑器(createEditor)、值怎么进怎么出(setEditorData`/`setModelData)。因此"同一列不同行用不同控件"(前两行下拉、后面文本框)应该写在委托里按 index 分派,而不是给视图加子类。这也解释了为什么自定义绘制只覆盖一列时,其它列要显式回落到 QStyledItemDelegate::paint()。
今日练习(15~30 分钟)
需求:日志查看器。已有数据结构和容器:
struct LogEntry {
QDateTime time;
QString level; // "ERROR" / "WARN" / "INFO"
QString message;
};
QVector<LogEntry> logs; // 由你填充若干条假数据(至少 12 条,含三种级别)
用 Model/View 完成以下四件事:
1. 实现 LogModel : QAbstractTableModel,三列(时间 / 级别 / 消息)。时间列的 DisplayRole 返回格式化后的字符串(yyyy-MM-dd HH:mm:ss),而 EditRole 返回原始的 QDateTime——体会"同一份数据不同角色"的意义。
2. 在 Model 上暴露自定义角色 LevelRole,返回未格式化的级别原文,并通过重写 Qt::ForegroundRole 让 ERROR 显示红色、WARN 显示橙色、INFO 显示灰色。
3. 用 QSortFilterProxyModel + 一个 QComboBox(选项:全部 / ERROR / WARN / INFO)实现按级别筛选。
4. 让"级别"列按严重程度排序(ERROR > WARN > INFO),而不是字母序。提示:可以在 Model 里再加一个角色返回严重度整数(ERROR=2, WARN=1, INFO=0),然后 proxy.setSortRole(那个角色),并 proxy.sort(LevelColumn, Qt::DescendingOrder)。
· 先让 Model 能被
QTableView 正确显示(能看到 12 行 3 列),再动 proxy,最后再去处理颜色/委托。每加一层都用 qDebug() << proxy.rowCount(); 验证过滤结果是否符合预期——这是排查代理链问题最快的手段。· 进阶(可选):当筛选条件超过两个(级别 + 关键字子串)时,试试"串两层 proxy"和"一层 proxy + 子类覆盖
filterAcceptsRow()"两种写法,比较哪种更好维护。只给需求和提示,不给完整答案——自己写完再回头看本文第 ⑥ 节的对照。
今日总结
代码特征信号(看到这些就知道是模型-视图):
1. 出现 QAbstractTableModel / QAbstractListModel 子类,且重写了 rowCount / columnCount / data(index, role) 三件套。
2. view->setModel(model);而数据变化处出现 beginInsertRows/endInsertRows 或 emit dataChanged(...)。
3. data() 里出现 switch (role) —— 说明"同一份数据多种表现形态"这一核心思想落地了。
4. setItemDelegateForColumn(...) + 一个 QStyledItemDelegate 子类里同时存在 paint 与 createEditor。
5. 视图被包在 QSortFilterProxyModel 后面,且显式设置了 setSortRole / setFilterKeyColumn —— 这是"数据不动、视角可变"的标志。