QML三兄弟:ListView、GridView、TableView全解与性能调优
写 UI 这件事只要做到 Qt 这一层很多人第一反应还是 QWidget 加样式表一顿捯饬。但真要让界面“活”起来——列表能滚、表格能点、网格能拖——你会发现 QML 才是效率最高的那条路。QML 里负责列表、表格和网格视图的三件套也就是ListView、TableView、GridView是我个人觉得所有 Qt 界面开发里性价比最高的东西语法直白、效果直观、改起来也不心疼。这篇文章想把这三兄弟一次性讲透。不光是“怎么用”重点放在“什么时候用哪个”“为什么这么写”“踩过哪些文档里不会写的坑”上。我不打算从零介绍 QML 基础语法但哪怕你只是刚接触 Qt跟着每一段代码敲一遍也能把一个能跑、能点、能增删数据的完整界面搭出来。核心概念就是 Qt 全家桶里的那套 MVD 体系——Model、View、Delegate搞懂它List、Grid、Table 就都是同一个套路的不同造型。1. 先把三兄弟认清楚ListView、GridView、TableView 各管一摊1.1 三个视图的适用场景很多新手上来就问“列表和网格到底啥区别我该用哪个”。我的判断标准很简单数据和展示长什么样决定了你选哪个视图。ListView一列数据从上往下也可以横向排。联系列表、聊天消息、设置项、历史记录、通知流基本都属于这一类。它的特点是单维度滚动在纵向空间里做无限延伸。GridView多列网格布局。图片画廊、图标面板、商品卡片、缩略图浏览器典型场景就是这种“卡片式”铺开的内容。它本质上是在二维平面里按格子摆放数据。TableView行列交叉的二维表格。带表头、要对齐、要纵向横向都能滚比如报表、资产清单、成绩单、配置表。它比 GridView 多一层列是有意义的每一列代表同一类字段。这个区分特别重要因为它直接决定了你的数据建模方式。列表只需要一个字段维度网格可能要两个表格则需要明确的字段映射。很多人把 TableView 当 GridView 硬写写到一半发现列宽没法单独控制那就说明一开始就选错了。1.2 模型-视图-委托MVD到底在说什么MVD 是理解这三个视图的关键但我观察过不少初学者常把 Model 和 View 搞混。打个比方Model 是仓库里的货View 是货架Delegate 是货架上的摆货方式。Model只管数据联系人姓名、电话、头像路径一份 ListModel 就是一堆记录。View只管摆放规则ListView 告诉我“从上往下放”GridView 告诉我“按格子放”TableView 告诉我“按行列交叉放”。Delegate负责“每一格长什么样”背景色、文字字号、图片圆角、鼠标悬停效果。这套分工最大的好处是解耦。同一个 Model你可以同时挂一个 ListView、一个 GridView、一个 TableView数据改一处三个视图跟着变完全不用改数据层的代码。这在后面综合示例里会直接演示。1.3 为什么 QML 比 QWidget 更适合做列表类界面我不是说 QWidget 不行而是从 5.15 之后我越来越倾向在需要灵活界面的场景直接用 QML。原因有三一是声明式语法省掉大量状态管理。QWidget 里你要手动维护滚动位置、选中项、数据到控件的映射经常为了一个小小的列表更新写一堆信号槽。QML 里视图和数据绑定是自动的数据变了界面跟着变这是语言层面的行为不是你在回调里手动刷的。二是委托渲染效率跟得上。列表页的每个条目本质是一个轻量组件Qt Quick 会做批量实例化和回收几千条数据的列表滚动起来依然丝滑。这一点等价功能用 QWidget 写得自己调 itemDelegate、缓存策略工作量完全不在一个量级。三是动画和交互相当好做。列表增删时做个淡入淡出、网格切换时做平移动画在 QWidget 里要动 Timer、PropertyAnimation 折腾半天QML 里几行状态动画就搞定。后面示例里我会展示列表和网格的切换那种“行云流水”的体验用 QML 几乎是白送的。2. ListView 手把手实战2.1 一个最小可运行的列表先来一个能跑的最小例子。新建一个空 QML 工程把下面这块贴进main.qmlimport QtQuick import QtQuick.Controls ApplicationWindow { width: 320 height: 480 visible: true title: 联系人列表 ListView { anchors.fill: parent model: ListModel { ListElement { name: 张三; phone: 13800000001 } ListElement { name: 李四; phone: 13800000002 } ListElement { name: 王五; phone: 13800000003 } } delegate: Rectangle { width: parent.width height: 50 color: index % 2 0 ? #f8f8f8 : #ffffff Text { anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter anchors.leftMargin: 12 text: name phone font.pixelSize: 16 } } } }跑起来你就能看到一个带斑马纹的联系人列表每行显示姓名和电话。这段代码虽然短但已经把 MVD 三个角色全占齐了model里的ListElement是数据ListView是视图delegate里的Rectangle是条目外观。注意delegate里直接用到了name、phone和index这三个变量哪来的index是视图自动注入的当前条目序号name、phone则是模型角色roleQML 会自动把它们当成委托上下文中的可用变量。这是理解 QML 委托的基础。2.2 委托delegate里那些文档没说透的细节委托是最有发挥空间、也是最容易踩坑的地方。先说几个我吃过亏的点。第一不要在委托里用普通属性保存“派生状态”。举个例子你想实现“点击切换选中样式”新手很容易在 delegate 里写delegate: Item { property bool selected: false MouseArea { anchors.fill: parent onClicked: selected !selected } }问题在于委托是会被回收复用的。滚出去再滚回来这个selected状态可能已经被另一个数据项继承了于是你看到“一条从没点过的记录莫名高亮着”。正确的做法是用ListView的currentIndex配合ListView.isCurrentItem后面会演示。第二委托里的 id 作用域没有想象中那么隔离。QML 委托虽然每个条目实例化一次但同一个 delegate 的所有实例共享同一个作用域。如果委托里有个 id 叫做root多个条目之间通过根对象找属性可能会互相串。我的习惯是尽量少在委托中使用 id实在需要就用required property显式声明模型角色必要时用组件内的明确路径。第三使用required property可以显著增强可读性。比如delegate: Item { required property string name required property string phone required property int index // ... }这样委托的依赖一目了然而且万一模型里没有这个角色QML 会直接给出更明确的报错而不是在运行时静默给你个 undefined。2.3 滚动体验、高亮和定位list 能滚起来之后下一步就是让它“好用”。三个属性是我每次必调的。spacing控制条目的距离。注意它加在条目之间不会在首尾两端产生多余空隙。如果你需要首尾也有留白用header、footer组件或者内容周围留白而不是硬调spacing。highlight是列表高亮条。最常见的用法是配currentIndexListView { highlight: Rectangle { color: #ffeecc radius: 4 } highlightFollowsCurrentItem: true focus: true Keys.onDownPressed: currentIndex Math.min(currentIndex 1, count - 1) }这里focus和按键处理能让列表在键盘上下键时移动高亮用桌面端测试时特别方便。高亮条通常放在内容下方所以要让它在视觉上“衬底”内容组件不能把高亮完全盖住。snapMode是另一个分水岭。ListView.SnapToItem会让滚动停止时自动吸附到最近的条目上做图片轮播、横向分页推荐列表时非常关键。但要配合clip: true和合适的boundsBehavior否则快速滑动时会出现末尾回弹的怪现象。最后提一个定位属性positionViewAtIndex。你在代码里通过currentIndex跳转到第 N 条时视图不一定真的滚到那条目位置必须显式调用listView.positionViewAtIndex(targetIndex, ListView.Center)第三个参数控制条目对齐到视图的顶部、中间还是底部。这个函数在做“定位到某条记录”的需求里几乎天天用但很多文档示例不会特意提容易把人坑在“currentIndex 变了屏幕却纹丝不动”的困惑里。3. GridView 网格视图3.1 从 ListView 切换过来只改几个属性如果你想在“列表”和“网格”两种形态之间切换最偷懒的方式是准备两个视图用StackLayout或者控制visible切换。但如果你想用一个视图横跨两种形态那就得先接受 GridView 与 ListView 的几个差异。最核心的差异GridView 没有“列表单项自动撑满宽度”这个行为。ListView 里你写width: parent.width委托会填满列表内容宽度GridView 则相反每个条目被放进一个固定单元格单元格尺寸由cellWidth和cellHeight决定。委托本身的宽高如果小于单元格剩余区域就是留白如果大于单元格视图会按单元格切割多出来的部分会被裁掉。从 ListView 改成 GridView我一般改四个地方组件类型、cellWidth、cellHeight、以及委托内部的布局从“横向排布”改成“纵向卡片”。Model 完全不用动这就是 MVD 的威力。GridView { anchors.fill: parent model: contactModel cellWidth: 140 cellHeight: 160 delegate: Rectangle { width: 120 height: 140 anchors.horizontalCenter: parent.horizontalCenter anchors.top: parent.top anchors.topMargin: 8 radius: 8 // 卡片内容... } }注意这里让委托小于单元格再用anchors居中而不是直接填满这样卡片之间能留出自然间隙视觉比spacing更好控制。3.2 单元格尺寸的讲究cellWidth和cellHeight可以绑定到视图宽度来计算最常见的写法是“每行固定两列”cellWidth: Math.floor(width / 2)但这里有个循环绑定警告的风险。如果cellWidth引用了width而width又随着窗口拉伸变化QML 会计算出一个“你盯着它改它却跳来跳去”的不稳定布局。更稳的做法是外层用一个 Item 计算好列宽或者依赖布局容器给 GridView 一个固定宽度的区域让cellWidth只做一次除法计算。另外GridView 的滚动方向因flow而异。默认GridView.FlowTopToBottom是先把一列填满再排下一列contentY是主滚动轴如果设成FlowLeftToRight主滚动轴就变成横向需要监听的是contentX。做横向翻页相册的时候记得配合snapMode: GridView.SnapToItem和orientation相关的设置一起测试否则滚动完停在两个格子中间感觉非常别扭。3.3 分组与瀑布流扩展网格不一定总是一张“平铺明细”。常见需求是“按类别分组”GridView 和 ListView 一样支持section属性GridView { section.property: category section.criteria: ViewSection.FullString section.delegate: Rectangle { width: gridView.width height: 28 color: #eee Text { text: section } } }section.property指定按模型里的哪个字段分组section.delegate渲染分组头。这个方案简单有效但分组头在滚动时不会吸附在顶部如果你要的是“表头悬浮”效果就得另想办法。至于瀑布流想用 GridView 做“图片长短不一、错落排列”的 Masonry 布局说实话不太合适。GridView 的单元格是等高等宽的你要么接受单元格内裁切要么自己用ColumnRepeater手动按列分配数据后者写起来自由度更高但性能就要自己负责了。我的经验是数据量小几百条直接手写瀑布流没问题数据量大还是去调服务端返回规整的缩略图尺寸比较靠谱。4. TableView 表格视图4.1 版本差异先看清你用哪个 TableView问 QML TableView 怎么写的人有一大半其实卡在版本上。因为 Qt 生态里出现过两个完全不同的 TableViewQt Quick Controls 1 的 TableViewimport QtQuick.Controls 1.x老牌组件功能全但依赖旧架构Qt 5.12 以后逐渐被边缘化Qt 6 里直接移除。QtQuick 模块自带的 TableViewimport QtQuickQt 5.15 引入基于Flickable重新实现虚拟化性能好是现在和未来的主力。所以当你百度到一份TableView { model: xxx }的旧代码时先看一眼 import 语句。如果是import QtQuick.Controls 1.4请直接放弃转头用import QtQuick的新方案。新版 TableView 的用法和我前面讲的 ListView 更像——一个 delegate 负责所有单元格通过row、column区分位置。4.2 TableModel 表格的完整写法新版 TableView 推荐配合TableModel使用它能明确声明哪些字段对应哪些列。一个完整例子import QtQuick import QtQuick.Controls ApplicationWindow { width: 480 height: 320 visible: true TableView { anchors.fill: parent columnWidthProvider: function (column) { return column 2 ? 160 : 120 } rowHeightProvider: function (row) { return 40 } model: TableModel { TableModelColumn { display: name } TableModelColumn { display: score } TableModelColumn { display: remark } rows: [ { name: 小明, score: 92, remark: 优秀 }, { name: 小红, score: 88, remark: 良好 }, { name: 小刚, score: 74, remark: 及格 } ] } delegate: Rectangle { implicitHeight: 40 border.color: #ddd color: row % 2 0 ? #fafafa : #ffffff Text { anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter anchors.leftMargin: 8 text: display font.pixelSize: 14 } } } }这里的关键是TableModelColumn { display: name }它把模型字段name映射成第一列并让委托里的display变量自动变成该单元格的显示文本。columnWidthProvider按列返回列宽rowHeightProvider按行返回行高两者都是函数式写法可以用条件返回不同值。如果你只有一个ListModel想硬塞进 TableView委托里也能拿到model.name这类角色值但每个单元格都是同一个委托你需要靠column序号去决定“这一格该显示哪段内容”代码会变得又臭又长。我的建议是正经表格就配合TableModel使用省心得多。4.3 表头怎么加、滚动怎么同步新版 TableView 默认没有表头很多人第一次跑起来会愣住“我的列名呢”你需要自己写一个表头行放在表格上方。Column { anchors.fill: parent Row { width: parent.width height: 36 Repeater { model: 3 delegate: Rectangle { width: tableView.columnWidthProvider(index) height: parent.height color: #e8e8e8 border.color: #ccc Text { anchors.centerIn: parent text: [姓名, 成绩, 备注][index] font.bold: true } } } } TableView { id: tableView width: parent.width height: parent.height - 36 // ... } }表头列宽和表格列宽要使用同一个columnWidthProvider否则两边对不上。横向滚动时表头必须跟表格同步一种做法是给表头外层包一个Flickable用行为绑定把它的contentX同步到表格的contentX如果你不需要横向滚动直接固定列宽不做同步也够用。我实际项目中用的做法是把表头和表格放进同一个横向Flickable表头不参与纵向滚动表格不参与横向滚动但两者共享同一个横向滚动容器。这套方案初看绕写多了就会发现比到处同步contentX稳得多。5. 数据驱动视图模型对接与刷新5.1 ListModel 的增删改查视图是皮模型是骨。ListModel 的增删改查是 QML 开发的基本功方法不多但几个容易忽略的细节值得强调。// 增追加到末尾 contactModel.append({ name: 赵六, phone: 13800000004 }) // 增插入到指定位置 contactModel.insert(1, { name: 钱七, phone: 13800000005 }) // 改修改某项属性 contactModel.setProperty(0, name, 张三丰) // 删删除指定下标 contactModel.remove(1) // 查获取某条数据注意 get 返回的是对象引用不要保留跨事件周期使用 var person contactModel.get(0)这里有个老坑在 Qt 5.x 的某些版本里ListModel 一旦删到 0 条视图可能不会自动清空界面上还残留着最后一条数据。如果碰到这种诡异现象最简单的处理就是整体替换 modellistView.model emptyModel listView.model contactModel或者干脆新建一个空的 ListModel 赋上去再赋回来强制视图刷新。新旧版本的 Qt 行为略有不同我建议你在目标 Qt 版本上实测一下免得上线后才发现清理数据后界面“甩不掉”。5.2 C 端模型接入 QML数据量一大或者业务逻辑复杂就会把模型放到 C 侧。最轻量的方式是丢一个QStringListModel或者自定义的QAbstractListModel到上下文里// main.cpp QQmlApplicationEngine engine; QStringListModel *model new QStringListModel({第一项, 第二项, 第三项}, engine); engine.rootContext()-setContextProperty(stringModel, model);ListView { model: stringModel delegate: Text { text: model.display } }但真正复杂的业务模型核心是覆盖roleNames()并正确发出dataChanged。例如class ContactModel : public QAbstractListModel { Q_OBJECT public: enum Roles { NameRole Qt::UserRole 1, PhoneRole }; QHashint, QByteArray roleNames() const override { return { { NameRole, name }, { PhoneRole, phone } }; } int rowCount(const QModelIndex parent) const override { ... } QVariant data(const QModelIndex index, int role) const override { ... } };roleNames()返回的字符串就是 QML 委托里能直接使用的角色名。很多新手在 QML 端拿不到数据八成是这个函数没写或者 role 枚举冲突。改数据后必须发dataChanged否则 QML 界面不会刷新emit dataChanged(index, index, { NameRole, PhoneRole });忽略这个信号是“C 数据改了QML 界面纹丝不动”的头号原因。5.3 数据改了视图没动静QML 本身是绑定驱动的但有些写法会让视图“看起来没刷新”。最常见的是把 JavaScript 数组当成 model 用。property var items: [{ name: A }, { name: B }] ListView { model: items }如果你在别处items.push(...)JS 的数组变化不会通知 QMLListView 不会自动加一行。正确姿势是整数组重新赋值items items.concat([{ name: C }])这其实是 QML 的一个“特性”数组本身作为一个整体参与绑定只有整体替换才会触发绑定更新。所以我从不建议在 QML 里用 JS 数组当长列表的 model除非你非常清楚自己每次都是重新赋值。另一个容易忽略的是模型被多个视图共享。你用同一个 ListModel 挂两个 ListView在某一个视图上执行remove后另一个视图理论上会自动同步。但如果你在删除时用了错误的索引比如视图排序和模型顺序不一致就会出现“删了 BA 消失了”的错位。这种情况要多确认模型排序与视图排序是否一致不是视图刷新慢而是你删错了对象。6. 性能优化与高频踩坑排查6.1 委托回收机制与性能红线Qt Quick 的视图对委托做的是“按需实例化 回收复用”它只创建可见区域外加cacheBuffer范围内的委托滚出范围的委托会被销毁或回收。这个机制让几千条数据的列表也能流畅滚动但前提是——你的委托足够轻。我踩过的性能红线有这么几条不要在 delegate 里做同步图片加载。用Image { asynchronous: true }加sourceSize限定加载尺寸否则滚动时每张图重新解码卡到怀疑人生。不要放重量级组件嵌套。比如每个条目里都塞一个Flickable、WebEngineView那基本上是在挑战视图的回收机制。不要频繁使用Layout容器做复杂布局。RowLayout和ColumnLayout在动态创建时开销明显高于手写Row/Column和anchors。条目数量大时这个差距会被放大。cacheBuffer默认值是十几像素到几百像素不等如果滚动出现白屏闪烁可以适当调大但别一上来就给个 5000那是拿内存换体验滚动反而可能更卡。我一般从 500 起步实测观察动画帧率再微调。6.2 大数据量下的调优方向数据量到了一万条以上纯粹的 ListModel 加 delegate 可能就不够用了这时优先考虑三件事模型放 C 并实现分页。QAbstractListModel可以精确控制返回给视图的行数比如刚开始只暴露前 100 条滚动接近底部时再通过信号让 C 端加载更多。这是最可靠的方案。避免每帧都做全量数据遍历。QML 端如果用model.get(index).xxx频繁访问数据量大时会拖慢滚动尽量通过委托的角色绑定一次性取值。使用RecyclerView思路的虚拟化。TableView本身就是虚拟化的ListView 的委托回收机制也算一种软虚拟化。手写Repeater和Positioner的方案在大数据量场景别碰。工具上我习惯用qmlprofiler看一下到底哪一行慢。Quit Live Preview 和成熟的 profiling 工具对比后你会发现很多卡顿其实是某个不起眼的Component.onCompleted里做了重活而不是组件本身的问题。6.3 高频错误速查表我在带新人时整理过一份 QML 视图问题速查表这几条基本覆盖了 QML 视图开发的大部分报错和“灵异现象”现象常见原因处理方式启动时 qml 编译错误import 写错、组件文件名大小写不对、类型不存在看 control 台QQmlComponent报错行号用qml单文件工具逐个文件验证控制器点击事件报错后整界面“点不动”事件处理 JS 抛异常中断了传递在 JS 里加 try/catch排查undefined取值必要时重载组件滚动卡顿、掉帧delegate 过重、cacheBuffer过大、图片未异步简化委托、调小缓存、Image.asynchronous加载删除数据后界面不更新ListModel 在旧 Qt 版本中删到 0 条不刷新 / 忘记发信号重新赋值 modelC 模型确认dataChanged高亮不跟随没设highlightFollowsCurrentItem或 currentIndex 更新方式不对显式设置该属性用positionViewAtIndex定位model.get()返回的对象改了没效果返回的是副本引用不是模型内部对象使用setProperty或set()修改键盘上下键无法移动 currentIndexListView 没focus或焦点被条目内 MouseArea 抢走给视图设focus: true必要时用activeFocus配合Keys其中“点击事件报错之后界面失去响应”这个坑最隐蔽。QML 的 JS 异常不像 C 那么明显控制台打了一堆红色但界面看起来只是“按钮没反应”。我在一个项目里排查过整整一天最后发现是一个模型字段在某个特殊数据下是undefined委托里访问model.xxx.length直接抛异常导致后续的点击处理全部被吞掉。从那以后凡是在委托里处理数据我都习惯先用required property声明角色再在Component.onCompleted里做一次空值校验把潜在异常提前暴露出来。7. 综合示例一个可切换模式的联系人界面7.1 界面结构与共用模型理论讲完来做一个实际能跑的东西。需求很简单一个联系人管理界面上面一个输入框和两个按钮中间区域既能以列表形式展示联系人也能一键切到网格卡片形式点条目下面的删除按钮可以移除联系人。顶层结构用ApplicationWindow顶部放一个RowLayout做工具栏下面放一个StackLayout装两个视图。关键是模型放在外面让两个视图共享同一份数据import QtQuick import QtQuick.Controls import QtQuick.Layouts ApplicationWindow { id: window width: 480 height: 640 visible: true title: 联系人管理 ListModel { id: contacts ListElement { name: 张三; phone: 13800000001; category: 同事 } ListElement { name: 李四; phone: 13800000002; category: 同事 } ListElement { name: 王五; phone: 13800000003; category: 朋友 } } // ... }这里每个ListElement有三个字段列表视图用名字和电话网格视图用名字加分类标签后面的删除按钮则依赖index定位。7.2 两种视图共用一份数据工具栏里的“切换视图”按钮只负责切换StackLayout的索引不必碰数据。两个视图的model都指向contacts修改其中一个视图的数据另一个视图也会自动刷新RowLayout { id: toolbar anchors.top: parent.top anchors.left: parent.left anchors.right: parent.right anchors.margins: 8 TextField { id: nameInput Layout.fillWidth: true placeholderText: 输入联系人姓名 } Button { text: 添加 onClicked: { if (nameInput.text.length 0) return contacts.append({ name: nameInput.text, phone: 13800000000, category: 未分组 }) nameInput.text } } Button { text: 切换视图 onClicked: contentStack.currentIndex contentStack.currentIndex 0 ? 1 : 0 } } StackLayout { id: contentStack anchors.top: toolbar.bottom anchors.left: parent.left anchors.right: parent.right anchors.bottom: parent.bottom ListView { id: listView model: contacts clip: true delegate: Rectangle { required property var model required property int index width: listView.width height: 56 color: #fafafa border.color: #ddd radius: 4 Text { anchors.left: parent.left anchors.leftMargin: 12 anchors.verticalCenter: parent.verticalCenter text: model.name model.phone font.pixelSize: 15 } Text { anchors.right: parent.right anchors.rightMargin: 12 anchors.verticalCenter: parent.verticalCenter text: model.category color: #888 font.pixelSize: 12 } } } GridView { id: gridView model: contacts clip: true cellWidth: 160 cellHeight: 120 delegate: Rectangle { required property var model required property int index width: 140 height: 100 anchors.horizontalCenter: parent.horizontalCenter anchors.top: parent.top anchors.topMargin: 10 radius: 8 color: #e8f5e9 Text { anchors.centerIn: parent text: model.name \n model.category horizontalAlignment: Text.AlignHCenter font.pixelSize: 14 } Button { anchors.bottom: parent.bottom anchors.horizontalCenter: parent.horizontalCenter text: 删除 onClicked: contacts.remove(index) } } } }这里两个视图的委托都通过required property var model拿到了整条数据记录这样做比逐个声明name、phone省事但要注意model里是角色集合不能直接model contacts.get(index)这样比较因为模型角色访问是依赖上下文的。7.3 完善交互选中、删除、空状态删除按钮用contacts.remove(index)会把数据从共享模型里移除ListView 和 GridView 都会自动更新。但这里有个小问题删除后index会整体前移如果用户连续快速点删除第二下点的可能已经不是你刚才看到的那条了。更稳的方案是给联系人加一个唯一 id删除时按 id 匹配function removeById(id) { for (var i 0; i contacts.count; i) { if (contacts.get(i).id id) { contacts.remove(i) return } } }这个“按索引删不如按 id 删”的经验是我在做聊天列表和任务列表时总结出来的索引一变就容易删错人实属高发坑。再补一个空状态。当模型删到 0 条时两个视图都是一片空白用户会以为操作出错了。我习惯给StackLayout上面叠一层空状态提示Label { anchors.centerIn: parent text: 暂无联系人请添加 visible: contacts.count 0 color: #999 }这层提示放在StackLayout后面通过contacts.count 0控制显示。代码量不大但对用户体验的提升非常明显。最后提一句这个示例里切换视图使用的是StackLayout因为它会保留两个视图的状态切换瞬间很快。如果项目里数据量大到需要节省内存可以换成两个视图都挂载但用visible控制或者在切换时才加载视图。对于绝大多数业务场景StackLayout的“预创建两个视图”其实根本不是问题反而因为避免反复实例化滚动位置还能被保留。我在实际带项目时发现很多“视图不刷新”“滚动卡顿”“点击失灵”的疑难杂症根源都不在视图本身而在对 MVD 的理解深度。把这套“模型管数据、视图管布局、委托管外观”的边界划清楚写出来的 QML 界面不仅好维护性能也天然差不到哪里去。文章里的示例可以直接复制到一个空 Qt Quick 工程里跑建议你动手改一改委托样式、加一加动画亲手制造几个坑再填平比看十遍文章都管用。