从 Web 前端到桌面客户端 · 用「拉模式」思维理解百万行数据为何能流畅渲染
过去七天我们打通了 RAII、移动语义、信号槽、事件循环、多线程、内存管理——语言与运行时的地基已经扎实。今天进入客户端架构的第一道大题:数据与界面如何解耦。Qt 的 Model/View 是列表、表格、树等一切数据型控件的骨架,也是 MVVM 模式在 Qt Widgets 世界里的雏形。它是「百万行数据不卡顿」这一桌面客户端核心体验的技术底座,也是你从「写控件」走向「设计架构」的分水岭。
问题从哪来?初学 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 里的标准姿势。
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 控件。
append / setData),禁止外部代码直接修改内部容器——把内部数据设为 private 是第一步。beginResetModel。重置会清掉滚动位置、展开状态、选中项,大表还会整表重绘导致明显卡顿。避免:能细粒度就细粒度(dataChanged / 行列级信号);只有「整个数据源被替换」这种场景才用 reset。dataChanged 会导致视图在错误线程重绘,轻则偶发崩溃,重则未定义行为。避免:工作线程算完后,通过队列连接(Qt::QueuedConnection)或 QMetaObject::invokeMethod(..., Qt::QueuedConnection) 把更新送回主线程。QPersistentModelIndex,或在回调现场用 index(row, col) 重新获取。data() 里做重活。data() 是热路径——滚动时被高频调用,里面做数据库查询、网络请求、复杂计算会让界面卡死。避免:data() 保持「纯映射」,数据提前在外部准备好。QAbstractListModel / QAbstractTableModel。什么时候不用:固定的几个静态条目(如设置对话框里 5 个选项),QListWidget 更省事。Trade-off:Model/View 样板代码多、学习曲线陡,换来的是 O(可见行) 的渲染复杂度和清晰的职责边界——对客户端架构师,这笔账永远划算。canFetchMore() / fetchMore() 分批取数,配合 loading 状态。永远不要一次性把几百万行灌进内存。QSortFilterProxyModel,不要在业务代码里手工排序后再塞进 Model。什么时候不用:数据本身已有序且无需过滤,代理就是纯开销。dataChanged(给出行列范围)或一次 reset,减少视图重绘次数——和 Web 端「减少 DOM 操作、批量更新」是同一原则,你已有的性能直觉直接迁移。QWidget 指针,保持纯数据逻辑,这样它天然可单元测试。同一个内核:数据驱动视图。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」即可。
为什么 TL 必须懂:Model/View 是客户端架构的第一道分层课。团队里没人真正理解它,项目必然退化成「到处都是 Widget、业务逻辑塞满构造函数」的大泥球,最后无人敢改、新人接手即崩溃。你在前端防过的「组件越写越大」的坑,在这里以另一种形式重演。
Code Review 关注点:① begin/end 是否配对且包住真实修改;② 是否滥用 reset;③ data() 里有没有耗时操作;④ Model 里是否出现 QWidget / UI 依赖;⑤ 跨线程更新是否走了信号桥接。这五条可以写进团队的 Review Checklist。
定规范:数据访问收敛到 Model 公开接口;View 层禁止直接触碰底层数据结构;Model 类统一 XxxModel 命名;新增数据字段必须先改 Model 再谈 UI。培养新人时,让他先画「数据 → Model → View」数据流图再动手,评审时用一句灵魂拷问逼出架构思维:「如果数据有 100 万行,这个方案还成立吗?」
examples/widgets/itemviews——边改边跑,最快的上手路径,比任何教程都直观。为什么 beginInsertRows() 必须在真正插入之前调用?如果先改数据再通知,视图会发生什么?
为什么 QModelIndex 由 Model 生成,而不是 View 自己构造?它内部那个「指针」到底指向什么?
一个 100 万行的表格,滚动时 View 大约会调用多少次 rowCount() / data()?为什么不会卡?
dataChanged 与 beginResetModel 的性能差异本质是什么?什么场景下必须用后者?
如果要做「多线程下载任务列表」,Model 该怎么设计,才能不卡 UI 又能实时刷新每个任务的进度?
用 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 也能编译)。
append 改成「先 m_logs.append(s) 再 beginInsertRows/endInsertRows」,运行观察结果(典型表现是崩溃或显示错乱),写下一句话结论。QSortFilterProxyModel 子类按行号倒序排列,观察滚动与更新行为的变化。