Qt Model/View 架构:数据与视图的解耦艺术

从 Web 前端到桌面客户端 · 用「拉模式」思维理解百万行数据为何能流畅渲染

01

今日主题

过去七天我们打通了 RAII、移动语义、信号槽、事件循环、多线程、内存管理——语言与运行时的地基已经扎实。今天进入客户端架构的第一道大题:数据与界面如何解耦。Qt 的 Model/View 是列表、表格、树等一切数据型控件的骨架,也是 MVVM 模式在 Qt Widgets 世界里的雏形。它是「百万行数据不卡顿」这一桌面客户端核心体验的技术底座,也是你从「写控件」走向「设计架构」的分水岭。

02

核心知识

问题从哪来?初学 Qt 时我们常用 QListWidget / QTableWidget:每加一行就 new 一个 QListWidgetItem 控件对象,数据被复制进 item,控件与数据焊死在一起。10 万行 = 10 万个对象,创建慢、内存膨胀、滚动卡顿;更致命的是数据结构一旦变化,所有操作 UI 的代码都要跟着改。Model/View 正是为了根治这两个问题而生。

三件套的分工。Model 只回答「数据怎么访问」,View 只负责「怎么画」,Delegate 负责「怎么编辑和定制绘制」。View 不持有任何数据,它通过一组接口向 Model 提问:rowCount() 回答「有多少行」,data(index, role) 回答「这一格在某个角色下是什么」。三者之间没有直接引用关系,只通过接口与信号通信——这就是教科书意义上的「面向接口编程」。

拉模式:百万行不卡的底层原理。浏览器端的虚拟滚动要靠框架实现,Qt 里是免费的:QListView / QTableView 只渲染视口内可见的行,需要画哪一行,才去调哪一行的 data()。屏幕上有 30 行,就只取 30 行——渲染复杂度是 O(可见行) 而不是 O(总行数)。这就是「100 万行的表也能丝滑滚动」的全部秘密,没有什么魔法,只是 View 从不遍历全部数据。

Role:一份数据,多种投影。DisplayRole 是显示文本,EditRole 是编辑时的值,DecorationRole 是图标,ToolTipRole 是提示。同一个底层字段,通过不同 role 投影出不同的呈现——相当于给同一份数据定义了多个「视图协议」,数据层完全不知道界面长什么样。

QModelIndex:数据的地址。它是 row + column + 内部指针 的轻量组合,由 Model 创建、View 消费,用于定位「哪一个数据项」。注意它会失效:任何一次插入/删除后,旧 index 都可能指向错误位置。需要长期持有的场景必须用 QPersistentModelIndex。

通知机制:先声明,后修改。这是 Model/View 最容易被忽略、也最要命的部分。修改数据必须成对调用:beginInsertRows() → 真正插入 → endInsertRows();删除、单格更新、全量重置分别对应 beginRemoveRows、dataChanged()、beginResetModel。为什么 begin 必须在修改之前调用?因为 begin 系列会通知所有关联视图「我要动了」,让它们先把内部索引、展开状态、选择状态准备好;如果你先改了数据再通知,视图手里的 index 已经全部错位,轻则显示错乱,重则越界崩溃。视图收到 end 信号后并不立即重绘,而是等事件循环空闲时统一刷新——这和浏览器把多次 DOM 修改合并到一帧渲染是同一个思想。

代理层:中间插入一层。QSortFilterProxyModel 可以插在 Model 与 View 之间,排序、过滤都不触碰原始数据,与数据库中的 View 概念同源。派生数据与源数据解耦,是「职责分离」在 Qt 里的标准姿势。

03

实际案例

Qt Creator(Qt 官方 IDE)。它的文件浏览器直接用 QFileSystemModel——一个 Model 同时喂给左侧树形视图和路径选择下拉框;Locator 快速定位(Ctrl+K)是一个自定义只读 Model 配 QListView,配合增量过滤实时缩小候选集。因为分层清晰,Qt Creator 可以随意增加「构建问题」「搜索结果」等新视图面板,数据层完全不用动——这就是 Model/View 带来的可扩展性红利。

Qt Designer 的属性编辑器。上千个属性的名称、类型、当前值、编辑控件,本质上就是一个 QTreeView + 一个自定义 Model,靠不同 role 区分「属性名 / 属性值 / 是否可编辑」。没有 Model/View,这套属性面板会是一坨无法维护的控件泥潭。

VSCode 的列表虚拟化。VSCode 的 List 组件(monaco 的 vs/base/browser/ui/list)只渲染视口内可见行,滚动时复用 DOM 节点、动态调整偏移。这和 Qt View 的按需取数是同一个「虚拟化」思想——区别在于浏览器里你要自己实现,而 Qt 框架替你做好了。

JetBrains IDEA 的懒加载树。IDEA 的工程树「展开节点才加载子节点」,等价于 Qt 的 canFetchMore() / fetchMore() 协议——数据源很大时按需取数,绝不一次性灌入。

反例教训。早年一些工具类客户端用 QTableWidget 直接塞 10 万行数据:启动慢、滚动如幻灯片、内存飙升。重构为 Model/View 后,控件对象数从 10 万降到屏幕上的 30 个,流畅度与内存占用是量级改善。这个案例应该刻进每个客户端开发者的肌肉记忆:行数一多,绝不用 item-based 控件。

04

常见错误

  • begin/end 不配对或顺序颠倒。视图的索引重映射依赖这一对信号,少一个就破坏了视图内部状态机。避免:把「改数据」严格夹在 begin/end 之间,中间不允许提前 return——可以用立即执行的 lambda 或 RAII 守卫包裹,这与我们第一天学的 RAII 思想一脉相承。
  • 改了数据却不通知。Qt 没有 Vue 那样的 Proxy 自动依赖收集,一切通知靠自觉。避免:写操作统一收敛到 Model 的公开方法(append / setData),禁止外部代码直接修改内部容器——把内部数据设为 private 是第一步。
  • 图省事全表 beginResetModel。重置会清掉滚动位置、展开状态、选中项,大表还会整表重绘导致明显卡顿。避免:能细粒度就细粒度(dataChanged / 行列级信号);只有「整个数据源被替换」这种场景才用 reset。
  • 跨线程碰 Model。Model 信号默认与视图在同一线程直连,在数据线程里 emit dataChanged 会导致视图在错误线程重绘,轻则偶发崩溃,重则未定义行为。避免:工作线程算完后,通过队列连接(Qt::QueuedConnection)或 QMetaObject::invokeMethod(..., Qt::QueuedConnection) 把更新送回主线程。
  • 缓存 QModelIndex。一次插入/删除后所有旧 index 全部失效,跨信号保存必出问题。避免:用 QPersistentModelIndex,或在回调现场用 index(row, col) 重新获取。
  • 在 data() 里做重活。data() 是热路径——滚动时被高频调用,里面做数据库查询、网络请求、复杂计算会让界面卡死。避免:data() 保持「纯映射」,数据提前在外部准备好。
05

最佳实践

  • 数据驱动场景一律 Model/View。行数可能超过几十、结构会变、或有多视图共享同一份数据时,用 QAbstractListModel / QAbstractTableModel。什么时候不用:固定的几个静态条目(如设置对话框里 5 个选项),QListWidget 更省事。Trade-off:Model/View 样板代码多、学习曲线陡,换来的是 O(可见行) 的渲染复杂度和清晰的职责边界——对客户端架构师,这笔账永远划算。
  • 大列表懒加载。几千行以上实现 canFetchMore() / fetchMore() 分批取数,配合 loading 状态。永远不要一次性把几百万行灌进内存。
  • 排序过滤放代理层。用 QSortFilterProxyModel,不要在业务代码里手工排序后再塞进 Model。什么时候不用:数据本身已有序且无需过滤,代理就是纯开销。
  • 批量更新合并通知。同一批修改尽量合并成一次 dataChanged(给出行列范围)或一次 reset,减少视图重绘次数——和 Web 端「减少 DOM 操作、批量更新」是同一原则,你已有的性能直觉直接迁移。
  • 线程边界清晰。网络/磁盘读取放工作线程,Model 的更新统一回主线程执行;Model 内部不持有任何 QWidget 指针,保持纯数据逻辑,这样它天然可单元测试。
06

与 Web 技术的联系

同一个内核:数据驱动视图。React「状态变 → 重新渲染」,Qt「数据变 → 发信号 → 视图重绘」,本质都是单向数据流。你在 Vue/React 里建立的「数据是唯一真相来源」的信念,在 Qt 里完全成立,只是实现方式不同。

最大差异:拉 vs 推。React 是推——状态变了,整个组件树重新执行 render;Qt 是拉——View 只在绘制可见区域时向 Model 要数据。所以 Qt 里「百万行」是常规操作,而 React 渲染百万节点必须上虚拟滚动(react-window / vue-virtual-scroller)。你以前写虚拟滚动时「只渲染可视区」的手工直觉,在 Qt 里是框架白送的。

Vue 响应式 vs Qt 手动通知。Vue 的 Proxy 自动收集依赖、自动派发更新;Qt 必须手动 beginInsertRows / dataChanged。前者省心但隐式,后者啰嗦但显式可控——这是两种工程文化的缩影:JS 生态倾向约定与魔法,C++/Qt 倾向显式与零隐藏。从 Vue 过来的人最容易犯的错就是「以为改了数据界面会自动变」,记住:Qt 里没有魔法,通知必须自己发。

一组一一对应的概念。computed / memoized selector ≈ QSortFilterProxyModel(派生数据,源变自动更新);React 的 key ≈ QPersistentModelIndex(识别「同一个数据项」);浏览器把多次 DOM 修改合并到一帧渲染 ≈ Qt 在事件循环空闲时统一重绘。你之前学的「减少回流、批量 DOM 更新」,直接翻译成「减少信号次数、批量 dataChanged」即可。

07

管理者视角

为什么 TL 必须懂:Model/View 是客户端架构的第一道分层课。团队里没人真正理解它,项目必然退化成「到处都是 Widget、业务逻辑塞满构造函数」的大泥球,最后无人敢改、新人接手即崩溃。你在前端防过的「组件越写越大」的坑,在这里以另一种形式重演。

Code Review 关注点:① begin/end 是否配对且包住真实修改;② 是否滥用 reset;③ data() 里有没有耗时操作;④ Model 里是否出现 QWidget / UI 依赖;⑤ 跨线程更新是否走了信号桥接。这五条可以写进团队的 Review Checklist。

定规范:数据访问收敛到 Model 公开接口;View 层禁止直接触碰底层数据结构;Model 类统一 XxxModel 命名;新增数据字段必须先改 Model 再谈 UI。培养新人时,让他先画「数据 → Model → View」数据流图再动手,评审时用一句灵魂拷问逼出架构思维:「如果数据有 100 万行,这个方案还成立吗?」

08

延伸阅读

  • Qt 官方文档《Model/View Programming》——权威定义与完整协议说明,必读第一站。
  • 《C++ GUI Programming with Qt 4》(Blanchette & Summerfield)第 5 章——把 Model/View 讲得最透彻的经典教材,原理、陷阱、示例俱全。
  • GoF《设计模式》Observer 章节——理解「通知-订阅」机制的设计源头,看 Qt 如何落地这个模式。
  • KDAB 博客的 Model/View 系列文章——Qt 专家级工程经验,专讲性能与陷阱,含金量极高。
  • Qt 自带示例 examples/widgets/itemviews——边改边跑,最快的上手路径,比任何教程都直观。
09

今日思考题

为什么 beginInsertRows() 必须在真正插入之前调用?如果先改数据再通知,视图会发生什么?

为什么 QModelIndex 由 Model 生成,而不是 View 自己构造?它内部那个「指针」到底指向什么?

一个 100 万行的表格,滚动时 View 大约会调用多少次 rowCount() / data()?为什么不会卡?

dataChanged 与 beginResetModel 的性能差异本质是什么?什么场景下必须用后者?

如果要做「多线程下载任务列表」,Model 该怎么设计,才能不卡 UI 又能实时刷新每个任务的进度?

10

今日实践任务

🎯 任务:实时日志查看器(30~60 分钟)

用 QAbstractListModel + QListView 做一个实时日志列表:QTimer 每秒插入一条带时间戳的日志(演示 beginInsertRows),每 5 秒用 dataChanged 更新最后一行。这是 Model/View 的最小闭环,跑通它你就掌握了 90% 的日常用法。

// logview.cpp — Qt6 实时日志列表:Model/View 最小闭环
#include <QApplication>
#include <QListView>
#include <QAbstractListModel>
#include <QTimer>
#include <QDateTime>

class LogModel : public QAbstractListModel {
public:
    int rowCount(const QModelIndex&) const override { return m_logs.size(); }

    QVariant data(const QModelIndex& i, int role) const override {
        if (!i.isValid() || role != Qt::DisplayRole) return {};
        return m_logs.at(i.row());      // 纯映射,不做计算
    }

    void append(const QString& s) {     // 先声明,后修改
        beginInsertRows({}, m_logs.size(), m_logs.size());
        m_logs.append(s);
        endInsertRows();
    }

    void touchLast(const QString& s) {  // 单格更新:dataChanged
        if (m_logs.isEmpty()) return;
        m_logs.last() = s;
        const QModelIndex i = index(m_logs.size() - 1, 0);
        emit dataChanged(i, i, {Qt::DisplayRole});
    }

private:
    QStringList m_logs;
};

int main(int argc, char** argv) {
    QApplication app(argc, argv);

    LogModel model;
    QListView view;
    view.setModel(&model);
    view.resize(440, 320);
    view.show();

    QTimer timer;
    QObject::connect(&timer, &QTimer::timeout, [&] {
        static int n = 0;
        ++n;
        model.append(QString("[%1] tick #%2")
            .arg(QDateTime::currentDateTime().toString("hh:mm:ss")).arg(n));
        if (n % 5 == 0)                 // 每 5 秒演示 dataChanged
            model.touchLast(QString("★ 第 %1 条已刷新").arg(n));
    });
    timer.start(1000);

    return app.exec();
}

编译:g++ -std=c++17 -fPIC logview.cpp -o logview $(pkg-config --cflags --libs Qt6Widgets)(或用 CMake,AUTOMOC 下无需 moc 也能编译)。

  • 步骤 1:编译运行,观察每秒插入一条日志、每 5 秒末行变为 ★。
  • 步骤 2(关键实验):把 append 改成「先 m_logs.append(s) 再 beginInsertRows/endInsertRows」,运行观察结果(典型表现是崩溃或显示错乱),写下一句话结论。
  • 步骤 3(挑战):给 view 与 model 之间插入一个 QSortFilterProxyModel 子类按行号倒序排列,观察滚动与更新行为的变化。
11

一句话总结

Model/View 的本质:视图永远不拥有数据,它只在需要时向 Model 借一眼——「拉模式」让百万行数据在屏幕上永远只剩三十行。