可视元素与布局原理
# 第 5 章 可视元素与布局原理
本章定位:从"摆控件"到"懂布局引擎"。前面你学会了语法和绑定,本章系统掌握 Item/Rectangle/Text 等可视元素的特性、anchors 布局约束求解、以及 RowLayout/GridLayout 的动态计算——理解这些,才写得出一套代码适配 480p 到 4K 所有分辨率。
# 目录介绍
# 5.1 案例引入
# 5.1.1 布局灾难
某车机 HMI 工程师在 1920×720 横屏上设计了仪表盘,老板说"中控也要跑这套 UI,但是竖屏 720×1280"。他以为改个 width/height 就行:
Rectangle {
id: root
width: 1920; height: 720
Rectangle {
id: speedPanel
x: 20; y: 20
width: 400; height: 300 // ⚠️ 硬编码
}
Rectangle {
id: mapPanel
x: 440; y: 20
width: parent.width - 460; height: 680 // 半硬编码
}
Rectangle {
id: statusBar
x: 20; y: 640
width: parent.width - 40; height: 60 // ⚠️ 硬编码
}
}
切到竖屏 width: 720; height: 1280:
speedPanel仍然在 (20,20) OKmapPanel的x: 440跟speedPanel重叠了(speedPanel 宽 400,间距只有 20)statusBar的y: 640差得远——竖屏高度 1280,statusBar 悬在半空- 更糟:没人敢改这些硬编码数字——它们来自半年前离职的工程师
# 5.1.2 根因分析
这个布局如果用 QML 的 anchors 重写:
Rectangle {
id: root
width: 720; height: 1280
Rectangle {
id: speedPanel
anchors { left: parent.left; top: parent.top; margins: 20 }
width: parent.width * 0.3; height: 300
}
Rectangle {
id: mapPanel
anchors {
left: speedPanel.right; leftMargin: 20
top: parent.top; topMargin: 20
right: parent.right; rightMargin: 20
bottom: statusBar.top; bottomMargin: 20
}
// ← 无硬编码坐标,所有关系由 anchors 约束求解
}
Rectangle {
id: statusBar
anchors {
left: parent.left; right: parent.right
bottom: parent.bottom
margins: 20
}
height: 60
}
}
切到竖屏 → 一行不改。anchors 引擎自动重新求解所有约束。
# 5.1.3 三大问题
| 问题 | 在哪节回答 |
|---|---|
| Item 的 x/y/width/height 在 Scene Graph 中对应什么? | §5.2 |
anchors.left: parent.left 背后到底做了什么计算? | §5.4 |
| Positioners 和 Layouts 该选哪个?性能差多少? | §5.5 + §5.6 |
# 5.2 Item 基类
# 5.2.1 几何属性
所有 QML 可视元素的共同祖先都是 Item(C++ 侧 = QQuickItem):
Item {
x: 10; y: 20 // 相对父元素的坐标
width: 100; height: 50 // 尺寸
z: 3 // 堆叠顺序(越大越靠前,默认 0)
// 变换属性
rotation: 45 // 旋转角度
scale: 1.5 // 缩放(1.0 = 原大小)
transformOrigin: Item.Center // 变换原点
// 隐式尺寸(由内容决定,优先于显式 width/height)
implicitWidth: 200
implicitHeight: 100
}
几何属性的底层映射:
Item { x: 10; y: 20; width: 100; height: 50 }
↓ QQmlEngine 创建
QQuickItem* item = new QQuickItem();
item->setX(10);
item->setY(20);
item->setWidth(100);
item->setHeight(50);
↓ Sync 阶段
QSGTransformNode* transformNode = new QSGTransformNode();
transformNode->setMatrix(QMatrix4x4(...)); // 由 x/y/rotation/scale 合成
implicitWidth/Height 的作用——当你不显式指定尺寸时,引擎用隐式尺寸作为回退:
// Text 的 implicitWidth = 文本渲染后的像素宽度
Text {
text: "Hello World" // implicitWidth ≈ 110(取决于字体和大小)
// 外部没写 width → 引擎用 implicitWidth
}
// 组合使用
Item {
width: Math.max(100, child.implicitWidth) // 至少 100,但内容多时跟着宽
Rectangle { id: child; implicitWidth: 80; implicitHeight: 40 }
}
# 5.2.2 可见裁剪
| 属性 | 作用 | Scene Graph 影响 | 性能代价 |
|---|---|---|---|
visible: false | 不渲染、不占布局空间 | QSGNode 被移除出渲染树 | 无渲染开销,但仍占用对象树 |
opacity: 0.5 | 半透明渲染 | 强制创建 QSGOpacityNode → 打断批处理 | 高(触发独立 batch) |
clip: true | 裁剪超出边界的子元素 | 创建 QSGClipNode → scissor test | 高(每个 clip 一个独立 batch) |
enabled: false | 不响应事件 | 不影响渲染树 | 无渲染开销 |
// ❌ 误区:visible: false 不等于"删除对象"
Rectangle {
id: heavyPanel
visible: false // 对象仍存在!绑定仍在求值!只是不画
width: complexCalculation() // ← 还在算,浪费 CPU
}
// ✅ 按需创建/销毁
Loader {
active: needPanel // false = 彻底销毁子对象树
sourceComponent: heavyPanelComponent
}
# 5.2.3 SG 映射
每个 Item 在 Scene Graph 中映射为一个 QSGTransformNode:
QML 对象树 Scene Graph 渲染树
Item (id: root) QSGRootNode ←─ 根
├─ Rectangle (x:10, y:20) QSGTransformNode (translate(10,20))
│ └─ ... ├─ QSGGeometryNode (顶点+材质)
│ └─ ...
├─ Item (rotation: 45) QSGTransformNode (rotate(45°))
│ └─ ... └─ ...
└─ MouseArea ← 无视觉,不进入渲染树
关键:QML 的
x/y/rotation/scale不是直接作用在 QSGGeometryNode 上——而是通过一个夹层QSGTransformNode套在子节点树上。这就是为什么同一父级 Item 下的子元素可以共享变换。
# 5.3 基础元素
# 5.3.1 四种元素
Rectangle —— 最基础的绘制单元:
Rectangle {
width: 200; height: 100
color: "steelblue" // 纯色填充
gradient: Gradient { // 渐变(与 color 二选一)
GradientStop { position: 0; color: "white" }
GradientStop { position: 1; color: "black" }
}
border { width: 2; color: "#333" } // 边框
radius: 12 // 圆角(0 = 直角)
}
Text —— 文本渲染:
Text {
text: "Hello QML"
font { family: "Arial"; pixelSize: 24; bold: true }
color: "#333"
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight // 超出宽度时加省略号
wrapMode: Text.WordWrap // 自动换行
lineHeight: 1.5
}
Image —— 图片加载与缩放:
Image {
source: "qrc:/images/logo.png" // qrc 资源
// source: "file:///usr/share/bg.jpg" // 本地文件
// source: "https://cdn.example.com/..." // 网络图片(异步)
fillMode: Image.PreserveAspectFit // 等比缩放 | PreserveAspectCrop | Stretch | Tile
asynchronous: true // ⚡ 异步加载,不阻塞 GUI
cache: true // 启用像素缓存
mipmap: true // 缩小图片时生成 mipmap(抗锯齿)
}
TextInput —— 单行文本输入:
TextInput {
width: 200
text: "initial"
font.pixelSize: 16
maximumLength: 20
validator: IntValidator { bottom: 0; top: 100 }
echoMode: TextInput.Password // 密码模式
onAccepted: console.log("回车确认:", text)
}
# 5.3.2 底层节点
| QML 元素 | C++ 类 | Scene Graph 节点 | 主要开销 |
|---|---|---|---|
Rectangle | QQuickRectangle | QSGGeometryNode + QSGFlatColorMaterial | 4 个顶点,1 个三角形 strip |
Text | QQuickText | QSGGeometryNode + 纹理材质 | 每个字符若干顶点 |
Image | QQuickImage | QSGGeometryNode + QSGTextureMaterial | 1 次纹理绑定 |
TextInput | QQuickTextInput | QSGGeometryNode + 纹理(光标+选中范围额外节点) | 文本 + 光标矩形 |
Item | QQuickItem | 仅 QSGTransformNode(无绘制) | 仅变换矩阵 |
# 5.4 锚布局原理
# 5.4.1 约束求解
写 anchors.left: parent.left 时,QML 引擎不是简单设 x = 0——它在内部建立了一个约束方程组:
Rectangle {
id: child
anchors {
left: parent.left; leftMargin: 10 // 约束①: child.x = parent.x + 10
right: parent.right; rightMargin: 10 // 约束②: child.x + child.width = parent.x + parent.width - 10
top: parent.top; topMargin: 10 // 约束③: child.y = parent.y + 10
}
height: 50 // 约束④: child.height = 50
}
引擎求解步骤:
① + ② → child.x 和 child.width 被唯一确定:
child.width = (parent.width - 10 - 10) - 0 = parent.width - 20
child.x = parent.x + 10
③ + ④ → child.y 被唯一确定:
child.y = parent.y + 10
约束求解发生在:
- QML 对象首次创建时(
QQmlComponent::create()) - 任何锚点依赖的属性变化时(如
parent.width改变) - 渲染 Sync 阶段之前(确保本帧渲染用的是最新布局)
# 5.4.2 优先级冲突
当多个约束同时作用于同一个属性时:
Rectangle {
id: child
anchors {
left: parent.left
right: parent.right // left + right 同时设 → 推导 width
horizontalCenter: parent.horizontalCenter // ← 与 left+right 冲突!
}
}
// → QML 引擎打印 warning: "Conflicting anchors"
// → 实际结果:horizontalCenter 被忽略,left+right 生效
解析优先级(引擎内部遍历顺序):
| 优先级 | 约束组 | 确定属性 |
|---|---|---|
| 1 | left + right | x + width(两个方程联立) |
| 2 | horizontalCenter | x(一个方程) |
| 3 | top + bottom | y + height |
| 4 | verticalCenter | y |
| 5 | baseline | y(与 Text 基线对齐) |
| 6 | margins | 偏移量 |
铁律:anchors.fill: parent = left+right+top+bottom 四锚全绑 → x/y/width/height 全部由约束确定,显式写的 width: 100 会被覆盖。
# 5.4.3 锚点对比
| 维度 | anchors | x/y 绝对坐标 |
|---|---|---|
| 父元素尺寸变化时 | ✅ 自动跟随 | ❌ 不跟随(硬编码) |
| 性能 | 有约束求解开销(~µs 级) | 零开销 |
| 可读性 | 高(声明关系) | 低(魔法数字) |
| 适合场景 | 需要自适应布局 | 固定尺寸、Canvas 内部绘制 |
// 场景:需要自适应 → anchors
Rectangle {
anchors { fill: parent; margins: 10 }
}
// 场景:Canvas 内部的固定像素绘制 → 绝对坐标
Canvas {
onPaint: {
var ctx = getContext("2d")
ctx.fillRect(50, 50, 100, 100) // 绝对坐标,OK
}
}
# 5.4.4 锚布局案例
用一个登录页面演示 anchors 的完整约束链:
import QtQuick
import QtQuick.Controls
ApplicationWindow {
width: 400; height: 600; visible: true
Column {
anchors.centerIn: parent
spacing: 16
// Logo
Rectangle {
width: 80; height: 80; radius: 40; color: "#2196F3"
anchors.horizontalCenter: parent.horizontalCenter
}
// 标题
Text {
text: "欢迎登录"; font.pixelSize: 24; font.bold: true
anchors.horizontalCenter: parent.horizontalCenter
}
// 输入框组
Column {
anchors.horizontalCenter: parent.horizontalCenter
spacing: 8
TextField {
width: 280; placeholderText: "用户名"
}
TextField {
width: 280; placeholderText: "密码"; echoMode: TextInput.Password
}
}
// 按钮
Button {
text: "登录"; width: 280
anchors.horizontalCenter: parent.horizontalCenter
}
}
}
这个布局在 720p → 4K 上一直居中,零代码修改。
# 5.5 定位器
# 5.5.1 四种定位器
Positioners 是轻量级的排列容器——仅做位置计算,不参与布局协商:
// Row:水平排列
Row {
spacing: 8
Rectangle { width: 50; height: 50; color: "red" }
Rectangle { width: 50; height: 50; color: "green" }
Rectangle { width: 50; height: 50; color: "blue" }
}
// Column:垂直排列
Column {
spacing: 8
Text { text: "Item 1" }
Text { text: "Item 2" }
Text { text: "Item 3" }
}
// Grid:网格排列
Grid {
columns: 3; spacing: 8
Repeater {
model: 9
Rectangle { width: 50; height: 50; color: "steelblue" }
}
}
// Flow:自动换行排列(类似 CSS flow layout)
Flow {
width: 200; spacing: 8
Repeater {
model: 10
Rectangle { width: 60; height: 30; color: "orange" }
}
}
Positioners 的底层实现:
Row { spacing: 8; Repeater { ... } }
↓
QQmlComponent 创建 Row 对象(QQuickPositioner 子类)
↓
componentComplete() 阶段:
遍历所有子元素 → 按顺序计算每个子元素的 x/y
child[0].x = 0
child[1].x = child[0].width + 8
child[2].x = child[1].x + child[1].width + 8
...
Positioners 做的是一次性位置计算——子元素尺寸变化时重新计算,但不做动态的"谁该占多少空间"的协商。
# 5.5.2 复杂度控制
Positioners 在嵌入式上有天然优势——零额外对象树开销:
| 容器 | 额外 QObject 数 | 适用场景 |
|---|---|---|
Row / Column | 1 个 | 固定数量子元素、简单排列 |
Grid | 1 个 | 网格排布 |
Layout 系列 | 每个子元素 +1 (LayoutAttached) | 需要动态协商尺寸 / 权重分配 |
# 5.6 布局管理器
# 5.6.1 三种布局
Layouts 比 Positioners 多一个核心能力:子元素可以声明自己想要的尺寸偏好:
import QtQuick.Layouts
RowLayout {
spacing: 8
Rectangle {
Layout.fillWidth: true // 尽量占满剩余宽度
Layout.preferredWidth: 100 // 但理想宽度 100
color: "red"; height: 50
}
Rectangle {
Layout.fillWidth: false
Layout.preferredWidth: 80
color: "green"; height: 50
}
Rectangle {
Layout.fillWidth: true
Layout.minimumWidth: 50
color: "blue"; height: 50
}
}
Layout 附加属性:
| 属性 | 含义 | 默认值 |
|---|---|---|
Layout.fillWidth | 填满剩余宽度 | false |
Layout.fillHeight | 填满剩余高度 | false |
Layout.preferredWidth | 理想宽度(有足够空间时) | implicitWidth |
Layout.minimumWidth | 最小宽度(不可压缩至更小) | 0 |
Layout.maximumWidth | 最大宽度(不可超出) | Infinity |
Layout.alignment | 在分配空间内的对齐 | Qt.AlignLeft | Qt.AlignVCenter |
Layout.row / Layout.column | GridLayout 中的行列索引 | 按声明顺序 |
Layout 的协商过程(以 RowLayout 为例):
Step 1: 收集——遍历所有子元素,收集每个 Layout.preferredWidth
Step 2: 分配——总宽度 = parent.width - spacing × (count - 1) - margins
按优先级: minimumWidth → preferredWidth → fillWidth 比例分配剩余
Step 3: 应用——给每个子元素 setWidth(calculated),触发 Scene Graph 更新
# 5.6.2 选择策略
| 场景 | 推荐 | 理由 |
|---|---|---|
| 固定数量子元素,尺寸确定 | Row / Column | 零额外开销 |
| 需要子元素自动填满空间 | RowLayout / ColumnLayout | Layout 协商机制 |
| 简单网格排列 | Grid | 轻量 |
| 复杂网格(带跨行、跨列) | GridLayout | Layout.row/column 支持 |
| 子元素数量动态变化 | Layouts | Layout 更灵活 |
| 嵌入式高分屏 ListView delegate 内 | Row / Column | 对象树开销最小 |
# 5.7 多屏仪表盘
用本章的布局技术实现一套适配 800×480 到 1920×720 的仪表盘:
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
ApplicationWindow {
width: 800; height: 480; visible: true
property real speed: 80; property real rpm: 3000
ColumnLayout {
anchors.fill: parent
anchors.margins: 12
spacing: 12
// ───── 顶部:速度(自适应宽度) ─────
RowLayout {
Layout.fillWidth: true
spacing: 12
Rectangle {
Layout.fillWidth: true; Layout.preferredHeight: 120
radius: 12; color: "#1a1a2e"
Text {
anchors.centerIn: parent
text: speed.toFixed(0) + " km/h"
font.pixelSize: parent.height * 0.35
color: speed > 120 ? "red" : "white"
}
}
Rectangle {
Layout.fillWidth: true; Layout.preferredHeight: 120
radius: 12; color: "#1a1a2e"
Text {
anchors.centerIn: parent
text: "RPM " + rpm.toFixed(0)
font.pixelSize: parent.height * 0.25
color: rpm > 5000 ? "orange" : "white"
}
}
}
// ───── 中间:地图区域(填满剩余空间) ─────
Rectangle {
Layout.fillWidth: true; Layout.fillHeight: true
radius: 12; color: "#2d2d44"
Text {
anchors.centerIn: parent
text: "地图区域"; color: "#888"
}
}
// ───── 底部:状态栏 ─────
Row {
Layout.fillWidth: true
spacing: 8
Rectangle { width: parent.width/4; height: 40; radius: 8; color: "#16213e"
Text { anchors.centerIn: parent; text: "油量 85%"; color: "white" } }
Rectangle { width: parent.width/4; height: 40; radius: 8; color: "#16213e"
Text { anchors.centerIn: parent; text: "里程 12.3k"; color: "white" } }
Rectangle { width: parent.width/4; height: 40; radius: 8; color: "#16213e"
Text { anchors.centerIn: parent; text: "温度 26°C"; color: "white" } }
Rectangle { width: parent.width/4; height: 40; radius: 8; color: "#16213e"
Text { anchors.centerIn: parent; text: "连接 OK"; color: "white" } }
}
}
}
案例知识融合:本案例集成了本章全部概念——①外层 ColumnLayout 纵向分割(顶部速度 / 中地图 / 底部状态栏);②RowLayout 让两个速度面板水平平分宽度;③内层 Row 在底部状态栏按 parent.width / 4 四等分(Row 不做协商,直接用父宽度算);④所有尺寸以父元素为参考,无硬编码——整段代码无缝适应 720p / 1080p / 4K。
# 5.8 新手陷阱
| # | 陷阱 | 说明 | 修复 |
|---|---|---|---|
| 1 | anchors.fill 后仍显式写 width | width: 100 会被 anchors 覆盖——无声失效 | 二选一:要么 anchors 控制尺寸,要么显式写 width/height |
| 2 | clip: true 滥用 | 每个 clip 创建一个独立 QSGClipNode → 打断批处理 | 仅在内容真的溢出时才加 clip |
| 3 | Layout 的 fillWidth 和 preferredWidth 混淆 | fillWidth: true + preferredWidth 是"先分 preferred,再分剩余"——不是"占比" | 用 Layout.preferredWidth 设初始值、Layout.fillWidth 决定是否抢剩余 |
| 4 | Positioner 内 Item 不设置尺寸 | Row/Column 不会给子元素分配宽高——没有显式 width 的子元素可能被隐式设为 0 | 子元素必须设 size |
| 5 | z 值不是全局排序 | z 只在同级 Item 之间比较——不同父元素下的 z 互不影响 | 需要跨父级的层级用 Item 扁平化或 parent 重分配 |
# 5.9 训练题
训练题:锚点约束冲突修复
以下 QML 有布局冲突。找出问题并修复:
Rectangle {
width: 400; height: 400
Rectangle {
id: box
width: 100
anchors {
left: parent.left
right: parent.right
horizontalCenter: parent.horizontalCenter // ← 冲突!
top: parent.top
}
height: 50
color: "steelblue"
}
}
答案
anchors.left + anchors.right 已经同时确定了 box 的 x 和 width。再加 horizontalCenter 产生冲突。修复:去掉 horizontalCenter: parent.horizontalCenter(或去掉 left+right,仅保留 horizontalCenter + width: 100)。
# 5.10 速查表
| 概念 | 一句话 |
|---|---|
| Item | 所有可视元素的基类,x/y/width/height/z/rotation/scale |
| implicitWidth | 由内容决定的自然宽度——不显式写 width 时使用 |
| anchors | 约束式布局:left/right/top/bottom/centerIn/fill/margins |
| Positioners | 轻量排列容器(Row/Column/Grid/Flow)——一次性位置计算 |
| Layouts | 动态协商容器(RowLayout/ColumnLayout/GridLayout)——子元素可声明 size 偏好 |
| clip | 裁剪超出边界的内容——代价是独立 QSGClipNode,打断批处理 |
| z | 同级 Item 的堆叠顺序——越大越靠前 |
| Layout.fillWidth | 声明"我要填满剩余宽度" |
| Layout.preferredWidth | 声明"有足够空间时我理想是这么宽" |
核心哲学:
QML 的布局系统不是"又一套 CSS"——
它是基于 QQuickItem 属性系统 + Scene Graph 变换链的
声明式约束求解引擎。
用好 anchors 和 Layouts = 一套代码适配所有分辨率。