写 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.qml:
import 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则是模型角色(role),QML 会自动把它们当成委托上下文中的可用变量。这是理解 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是列表高亮条。最常见的用法是配currentIndex:
ListView { 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 的单元格是等高等宽的,你要么接受单元格内裁切,要么自己用Column+Repeater手动按列分配数据,后者写起来自由度更高,但性能就要自己负责了。我的经验是:数据量小(几百条)直接手写瀑布流没问题;数据量大,还是去调服务端返回规整的缩略图尺寸比较靠谱。
4. TableView 表格视图
4.1 版本差异,先看清你用哪个 TableView
问 QML TableView 怎么写的人,有一大半其实卡在版本上。因为 Qt 生态里出现过两个完全不同的 TableView:
- Qt Quick Controls 1 的 TableView(
import QtQuick.Controls 1.x):老牌组件,功能全,但依赖旧架构,Qt 5.12 以后逐渐被边缘化,Qt 6 里直接移除。 - QtQuick 模块自带的 TableView(
import QtQuick,Qt 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 条,视图可能不会自动清空,界面上还残留着最后一条数据。如果碰到这种诡异现象,最简单的处理就是整体替换 model:
listView.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 }; QHash<int, 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 的数组变化不会通知 QML,ListView 不会自动加一行。正确姿势是整数组重新赋值:
items = items.concat([{ name: "C" }])这其实是 QML 的一个“特性”:数组本身作为一个整体参与绑定,只有整体替换才会触发绑定更新。所以我从不建议在 QML 里用 JS 数组当长列表的 model,除非你非常清楚自己每次都是重新赋值。
另一个容易忽略的是模型被多个视图共享。你用同一个 ListModel 挂两个 ListView,在某一个视图上执行remove后,另一个视图理论上会自动同步。但如果你在删除时用了错误的索引(比如视图排序和模型顺序不一致),就会出现“删了 B,A 消失了”的错位。这种情况要多确认模型排序与视图排序是否一致,不是视图刷新慢,而是你删错了对象。
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 条不刷新 / 忘记发信号 | 重新赋值 model;C++ 模型确认dataChanged |
| 高亮不跟随 | 没设highlightFollowsCurrentItem或 currentIndex 更新方式不对 | 显式设置该属性;用positionViewAtIndex定位 |
model.get()返回的对象改了没效果 | 返回的是副本引用,不是模型内部对象 | 使用setProperty或set()修改 |
| 键盘上下键无法移动 currentIndex | ListView 没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 工程里跑,建议你动手改一改委托样式、加一加动画,亲手制造几个坑再填平,比看十遍文章都管用。