QML引擎与渲染原理
# 第 2 章 QML 引擎与渲染原理
本章定位:承上启下——上一章你已经知道"QML 是嵌入式 GUI 的最优解",本章解答"它为什么快,又为什么会突然变慢"。前者是 QQmlEngine + V4 + Scene Graph 三大引擎的协作;后者是 Draw Call、批处理、双线程模型这些底层概念。理解本章,才能在 ARM 板子上写出 60fps 不掉帧的界面。
# 目录介绍
- 2.1 案例引入
- 2.2 QML 引擎到底做了什么
- 2.3 Scene Graph 渲染管线
- 2.4 渲染线程与 GUI 线程
- 2.5 嵌入式渲染后端
- 2.6 性能诊断工具
- 2.7 QML 引擎底层原理
- 2.8 性能优化实践
- 2.9 本章新手陷阱 Top 5
- 2.10 训练题
- 2.11 综合思考题
- 2.12 速查表
# 2.1 案例引入
# 2.1.1 一个让人困惑的性能现象
某工程师在 ARM 板(Cortex-A53 + Mali-G31)上写了下面这段 ListView:
import QtQuick
ListView {
width: 800; height: 480
model: 500
delegate: Rectangle {
width: 100; height: 100
color: "blue"
Rectangle {
anchors.centerIn: parent
width: 50; height: 50
color: "red"
rotation: 45 // ⚠️ 罪魁祸首在这里
}
}
}
测得现象:
| 指标 | 数据 |
|---|---|
| FPS | 滑动到第 50 个 Item 后从 60 掉到 22 |
| CPU 占用 | 90% +(单核) |
| GPU 占用 | 仅 28% |
| Draw Call/帧 | 约 1000 次 |
疑惑:
- 不是 GPU 瓶颈——为什么还卡?
- QML 不是号称"硬件加速"吗?
- 问题到底出在 QML 引擎还是 Scene Graph?
# 2.1.2 揭开谜底:批处理被打断
每个 delegate 内部那个 rotation: 45 是罪魁祸首。Scene Graph 的批处理渲染要求相同材质 + 相同状态才能合并 Draw Call。rotation: 45 修改了 transform 矩阵,每个旋转 Rectangle 都打断了批处理:
不加 rotation(同材质组):
Draw Call 1: 一次画 500 个蓝色 Rectangle ✓
Draw Call 2: 一次画 500 个红色 Rectangle ✓
总共 2 个 Draw Call → CPU 几乎不动,GPU 满载
加了 rotation(每个红色 Rect 各自有不同 transform):
Draw Call 1: 画蓝色 Rect 1
Draw Call 2: 画红色旋转 Rect 1 ← 状态切换打断
Draw Call 3: 画蓝色 Rect 2
Draw Call 4: 画红色旋转 Rect 2 ← 又打断
... 重复 500 次
总共 ~1000 个 Draw Call → CPU 提交命令爆炸
所以,"GPU 利用率只有 28%"不是 GPU 慢——是 CPU 根本没能把命令喂到 GPU。
# 2.1.3 本章要回答的三个问题
| 问题 | 在哪节回答 |
|---|---|
| QML 文本是怎样变成屏幕上的像素的? | §2.2 + §2.7 |
为什么 rotation: 45 会让性能崩掉? | §2.3 批处理 |
| 嵌入式板子上如何稳定跑 60fps? | §2.5 后端 + §2.8 优化 |
读完本章你会亲手用
QSG_VISUALIZE=batches看见批处理分组,并把 1000 个 Draw Call 降到 5 个。
# 2.2 QML 引擎到底做了什么
# 2.2.1 三大引擎角色分工
Qt Quick 看似是一个"QML 引擎",其实是三个独立引擎协作:
| 引擎 | 全名 | 职责 | 线程归属 |
|---|---|---|---|
| QQmlEngine | QML 解析与对象管理引擎 | QML 文本 → AST → C++ 对象树;模块导入;绑定求值调度 | GUI 线程 |
| V4 | 内嵌 JavaScript 引擎 | 执行 QML 内的 JS 表达式与函数;JIT 编译热点代码 | GUI 线程 |
| Scene Graph | GPU 渲染引擎 | QSGNode 树管理;批处理;OpenGL/Vulkan 调用 | 独立的渲染线程 |
这就是为什么 QML 同时有"声明式属性绑定"(QQmlEngine 负责)、"内嵌 JavaScript"(V4 负责)、"硬件加速渲染"(Scene Graph 负责)——三件事由三个引擎分头干。
# 2.2.2 从 QML 文本到 C++ 对象树
QML 不是"解释执行的 HTML"——它在加载时被编译为 C++ 对象树。整个过程三步:
Step 1: 词法/语法分析(QQmlEngine)
QML 文本 → Token 流 → AST(抽象语法树)
例: Rectangle { width: 100; color: "red" }
↓
AST:
ObjectLiteral(type="Rectangle")
├── PropertyBinding(name="width", value=100)
└── PropertyBinding(name="color", value="red")
Step 2: 对象实例化(QQmlComponent)
AST → 创建真实的 C++ QObject 对象树
Rectangle → new QQuickRectangle()
width: 100 → obj->setProperty("width", 100)
⚠️ 关键:每个 QML 元素的背后都是一个真实的 C++ 类
Rectangle → QQuickRectangle
Text → QQuickText
ListView → QQuickListView
MouseArea → QQuickMouseArea
Step 3: 属性绑定求值(QQmlBinding)
建立"依赖图"——记下每个绑定依赖哪些属性
当依赖变化 → 自动重新求值
# 2.2.3 对象树 vs 渲染树
很多新手会以为"我看到的 QML 树 = GPU 画的树",这是错的。Qt Quick 内部维护两棵树:
QML 对象树(C++ QObject Tree,逻辑层):
Window
└── Rectangle (id: root)
├── Text (id: title)
├── MouseArea (id: clickArea) ← 看不到,但在对象树里
└── Rectangle (id: button)
└── Text (id: btnText)
Scene Graph 渲染树(QSGNode Tree,渲染层):
WindowNode
└── RectangleNode (root)
├── TextNode (title)
└── RectangleNode (button)
└── TextNode (btnText)
↑
MouseArea 没有视觉表现 → 不进入渲染树
| 维度 | QML 对象树 | Scene Graph 渲染树 |
|---|---|---|
| 类型 | QObject 派生 | QSGNode 派生 |
| 含什么 | 属性、信号、绑定、JS 上下文 | 顶点缓冲、材质、变换矩阵 |
| 含 MouseArea 等无视觉元素 | ✅ 有 | ❌ 没有 |
| 所在线程 | GUI 线程 | 渲染线程 |
| 谁创建 | QQmlEngine | Scene Graph 通过 updatePaintNode() |
核心要点:对象树管"逻辑",渲染树管"GPU 命令"——这是 Qt Quick 性能远超 Qt Widget 的根本原因。
# 2.2.4 属性绑定的依赖图机制
QML 里写 width: parent.width / 2 不是"赋一次值",而是注册了一个绑定:
Rectangle {
id: child
width: parent.width / 2 // 依赖 parent.width
height: child.width // 依赖 child.width
color: pressed ? "red" : "blue" // 依赖 pressed
}
QQmlEngine 内部维护的依赖图大致如下:
parent.width ──── 变化时 ────→ child.width 绑定求值
│
▼
child.width ──── 变化时 ────→ child.height 绑定求值
pressed ──── 变化时 ────→ child.color 绑定求值
每个被依赖的属性都持有一份"反向通知列表",被修改时通知所有依赖者重新求值。这就是为什么 QML 的"声明式"看起来这么神奇——本质是一个事件驱动的依赖图。
// 等价的 C++ 信号槽伪代码:
connect(parent, &QQuickItem::widthChanged, child, [child](){
child->setWidth(child->parent()->width() / 2);
});
connect(child, &QQuickItem::widthChanged, child, [child](){
child->setHeight(child->width());
});
connect(child, &QQuickItem::pressedChanged, child, [child](){
child->setColor(child->pressed() ? "red" : "blue");
});
设计哲学:QML 帮你写好了这些 connect——这就是"声明式 UI"的本质。
# 2.2.5 编译时 vs 运行时处理
import QtQuick // ── 编译时 ──:模块导入解析到 C++ 类型
Item { // ── 编译时 ──:类型查找(必须存在 QQuickItem)
width: 100 // ── 编译时 ──:属性名校验(width 是 Item 合法属性吗)
property int counter: 0 // ── 运行时 ──:用户动态属性
Component.onCompleted: { // ── 运行时 ──:V4 JIT 执行 JS
for (let i = 0; i < 10; ++i) counter++
}
Loader {
source: "Page.qml" // ── 运行时 ──:动态加载、再走一遍 §2.2.2 三步
}
}
| 在哪个阶段 | 处理什么 | 出错时 |
|---|---|---|
| 编译时 | 模块解析、类型查找、属性名校验、信号匹配 | 启动时报错 / qmlcachegen 报错 |
| 运行时 | 绑定求值、JS 执行、Loader 加载、动态对象创建 | 控制台 warning / 运行时 throw |
Qt 5.15+ 提供
qmlcachegen工具,可以把 QML 提前编译为字节码(.qmlc),启动速度提升 20%~50%——这就是把"运行时"工作前移到"构建时"的典型例子。
# 2.2.6 综合案例与思考
下面这个最小工程,把"QML 文本 → C++ 对象树"这条路径亲手走通——主程序用 C++ 启动 QML,并演示如何从 C++ 反向访问 QML 创建出来的 QObject:
// main.cpp
#include <QGuiApplication>
#include <QQmlApplicationEngine>
#include <QQmlContext>
#include <QQuickItem>
#include <QDebug>
int main(int argc, char* argv[]) {
QGuiApplication app(argc, argv);
QQmlApplicationEngine engine; // ← QQmlEngine 子类
engine.load(QUrl("qrc:/main.qml"));
if (engine.rootObjects().isEmpty()) return -1;
// 反向访问 QML 创建的对象树
QObject* root = engine.rootObjects().first(); // 顶层 Window
QObject* rect = root->findChild<QObject*>("myRect"); // 按 objectName 找
if (rect) {
qDebug() << "QML 的 Rectangle 其实是 C++ 对象:" << rect;
qDebug() << "类型:" << rect->metaObject()->className();
qDebug() << "width:" << rect->property("width").toReal();
}
return app.exec();
}
// main.qml
import QtQuick
import QtQuick.Window
Window {
visible: true; width: 400; height: 300; title: "obj-tree-demo"
Rectangle {
objectName: "myRect" // ← 给 C++ 一个抓手
anchors.fill: parent
color: "lightblue"
Text { anchors.centerIn: parent; text: "Hello QML" }
}
}
控制台典型输出:
QML 的 Rectangle 其实是 C++ 对象: QQuickRectangle(0x55c4...)
类型: QQuickRectangle
width: 400
案例知识融合:本案例把 §2.2 的三个核心点串起来:①QML 加载流程(engine.load)走完了"文本 → AST → 对象树"三步;②每个 QML 元素背后都是真实 C++ 类(QQuickRectangle);③ C++ 可以通过 findChild + objectName 反向访问 QML 对象——这就是 QML/C++ 互操作的根基。
思考题:
- 如果把
qrc:/main.qml写成不存在的路径,engine.load会怎样?为什么rootObjects()是空集合而不是 throw? rect->property("width")返回的是QVariant——为什么 QML 属性必须通过这种"动态类型"接口暴露?它和直接调QQuickRectangle::width()比有什么代价?- 把
objectName改成id: myRect后用findChild还找得到吗?为什么id不能跨 C++/QML 边界?
# 2.3 Scene Graph 渲染管线
# 2.3.1 Scene Graph 是什么
Scene Graph 是 Qt Quick 的GPU 渲染引擎——也是 Qt Quick 相对 Qt Widget 性能优势的根本来源。
没有 Scene Graph(Qt Widget):
每个 Widget 在自己的 paintEvent() 里用 QPainter 画
→ CPU 提交各自的绘制命令,没有批处理
→ Draw Call 数 = 控件数(爆炸)
有 Scene Graph(Qt Quick):
QML 对象树 ──同步──→ Scene Graph 节点树
│
▼
遍历 + 排序 + 批处理
│
▼
生成最优 GPU 命令
│
▼
提交到 OpenGL ES / Vulkan / Metal / D3D11
# 2.3.2 渲染线程的四阶段循环
渲染线程独立于 GUI 线程,每帧(≤16.6ms@60Hz)循环执行四个阶段:
┌──────────────────────────────────────────────────┐
│ Phase 1: 同步(Synchronization) │
│ GUI Thread 临时停摆,渲染线程把"变化"拷过来 │
│ 仅同步 dirty 节点(不是整棵树) │
└────────────────────┬─────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────┐
│ Phase 2: 预处理(Preprocessing) │
│ 为 Layer / ShaderEffect / opacity<1 的组合 │
│ 分配 FBO(离屏帧缓冲区) │
└────────────────────┬─────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────┐
│ Phase 3: 渲染(Rendering) │
│ ① 遍历 QSGNode 树 │
│ ② 按材质+纹理+blend 排序 │
│ ③ 合并批次 │
│ ④ 生成 OpenGL/Vulkan 命令 │
└────────────────────┬─────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────┐
│ Phase 4: 交换缓冲区(Swap) │
│ eglSwapBuffers() / vkQueuePresentKHR() │
│ 阻塞等待 VSync,然后显示新帧 │
└──────────────────────────────────────────────────┘
关键细节:只有 Phase 1 会短暂阻塞 GUI 线程(典型 < 1ms),其余三阶段渲染线程独立工作——这就是 QML 即使 JS 卡顿,动画仍能继续插值的原因(动画是 Render Thread 自己驱动)。
# 2.3.3 批处理渲染:性能命门
没有批处理时,逐节点画:
// 伪代码:朴素遍历
for (auto* node : sceneGraph) {
glUseProgram(node->shader); // 切换着色器(慢)
glBindTexture(node->texture); // 绑定纹理(慢)
glUniformMatrix4fv(node->transform);
glDrawArrays(...); // 一次画一个节点
}
// 1000 个节点 → 1000 × (切 shader + 切 texture + draw) = 极慢
Scene Graph 的批处理算法:
Step 1: 排序——按 (shader, texture, blendMode) 排
原始: [圆 A, 文字 B, 圆 C, 矩形 D, 文字 E, 圆 F]
排序: [圆 A, 圆 C, 圆 F | 矩形 D | 文字 B, 文字 E]
Step 2: 分组——相邻同状态节点合并到一个 batch
Batch 1: [A, C, F] ← 同 shader 同 texture
Batch 2: [D]
Batch 3: [B, E]
Step 3: 单批一次绘制
glUseProgram(circle_shader);
glDrawArrays(VBO_batch1, 0, A.verts + C.verts + F.verts); // 一次画三个
glUseProgram(rect_shader);
glDrawArrays(VBO_batch2, ...);
glUseProgram(text_shader);
glDrawArrays(VBO_batch3, ...);
▶ 1000 个节点 → 3~5 个 Draw Call
回到 §2.1 的案例——为什么 rotation: 45 打断批处理?看下面这张"批处理条件矩阵":
| 节点属性 | 改变后是否打断批处理 | 原因 |
|---|---|---|
| 仅位置不同(x, y) | ❌ 不打断 | transform 是顶点属性,可批量 |
| 仅大小不同(width, height) | ❌ 不打断 | 同上 |
color 不同 | ❌ 不打断 | color 是顶点属性 |
rotation、scale 不同 | ✅ 打断 | transform 矩阵不同 |
opacity < 1 不同 | ✅ 打断 | 需要 alpha blend 状态 |
纹理(source)不同 | ✅ 打断 | 必须 rebind texture |
layer.enabled: true | ✅ 打断 | 独立 FBO |
铁律:在嵌入式上做 ListView 的 delegate,能不旋转就不旋转、能不 opacity 就不 opacity、能合并 image atlas 就合并——每一条都直接换成帧率。
# 2.3.4 Draw Call 的真实代价
为什么 Draw Call 这么贵?答案是它不只是一次函数调用——它经过 OpenGL driver → kernel → GPU 命令队列,过程中要做大量状态校验。
| 操作 | 典型耗时(量级) | 主要开销 |
|---|---|---|
| 同 batch 内多画一个三角形 | ~ 1 ns | 仅多算几个顶点 |
一次 glDrawArrays 切批次 | ~ 1-10 µs | 用户态/内核态切换 + 状态校验 |
一次 glUseProgram(切 shader) | ~ 10-50 µs | shader 重链接 + uniform 重新上传 |
一次 glBindTexture(切纹理) | ~ 5-20 µs | 纹理元数据 + cache 失效 |
一次 eglSwapBuffers(VSync) | ~ 16.6 ms | 等待显示同步 |
每帧预算 16.6ms,Draw Call 上限大致只有 1000~2000 次——所以本章开头那个"1000 个 Draw Call/帧"实际是踩在悬崖边上的。
# 2.3.5 综合案例与思考
下面这个例子让你亲眼看见批处理被打断的过程。先看正例(同色矩阵):
// good.qml ── 同材质矩阵,1 个 Draw Call
import QtQuick
Window {
width: 800; height: 600; visible: true
Grid {
rows: 20; columns: 20
Repeater {
model: 400
Rectangle { width: 40; height: 40; color: "blue" } // 同色!
}
}
}
跑:
export QSG_VISUALIZE=batches # 显示批处理分组(每色一组)
./demo good.qml # 整张图 1 种颜色 → 1 个 batch
再看反例:
// bad.qml ── 每个 Rect 不同颜色 + 旋转 → 400 个 Draw Call
Repeater {
model: 400
Rectangle {
width: 40; height: 40
color: Qt.rgba(Math.random(), Math.random(), Math.random(), 1)
rotation: Math.random() * 360 // ⚠️ 每个不同 → 打断
}
}
QSG_VISUALIZE=batches 下你会看到屏幕被涂成 400 种不同色块——每种色代表一个独立批次。
案例知识融合:本案例验证了批处理的两个核心铁律:①相同 shader + 相同 texture + 相同 transform 矩阵 = 同 batch;②rotation、opacity、独立 color 都会打断 batch。QSG_VISUALIZE 是嵌入式调试时必装的"眼睛"——它把抽象的"批处理"变成可看见的色块。
思考题:
- 上面
bad.qml如果保留rotation但所有 Rect 用同一颜色,Draw Call 数会下降吗?为什么? - 同样的 400 个 Rectangle,分别用
Repeater和Grid + Repeater和ListView实现,渲染开销会一致吗?为什么 ListView 反而可能更快? - 如果把 400 个 Rect 改成 400 个 Image(同一图片),还能批处理吗?(提示:Image Atlas 机制)
# 2.4 渲染线程与 GUI 线程
# 2.4.1 双线程模型总览
┌──────────────────────────┐ ┌──────────────────────────┐
│ GUI Thread │ │ Render Thread │
│ (主线程) │ ←──→ │ (独立线程) │
│ │ 同步 │ │
│ · 事件分发 │ 阶段 │ · Scene Graph 遍历 │
│ · 属性更新 │ │ · 排序 + 批处理 │
│ · 绑定求值 │ │ · OpenGL/Vulkan 调用 │
│ · V4 JavaScript │ │ · eglSwapBuffers │
│ · QQuickItem 树管理 │ │ · 动画插值(驱动) │
│ │ │ │
│ ~ 几千 Hz 事件处理 │ │ 60 / 90 / 120 Hz │
└──────────────────────────┘ └──────────────────────────┘
↑ ↑
│ │
QObject Tree (共享) QSGNode Tree (共享)
GUI 线程独占写 Render 线程独占写
Sync 阶段做一次拷贝同步
为什么要分两个线程?答案藏在嵌入式场景里:
| 场景 | 单线程会怎样 | 双线程怎样 |
|---|---|---|
| 用户拖动滑块,JS 计算 100ms | 这 100ms 内完全不刷新 | 渲染仍跑,动画继续 |
| 加载大图,JS 阻塞 | 屏幕黑屏 | 最后一帧持续显示 |
| GPU 绘制慢(fillrate 不足) | GUI 线程也被拖死 | GUI 线程继续接受输入 |
# 2.4.2 同步阶段如何工作
// 渲染线程伪代码(每帧调用一次)
void RenderLoop::frame() {
// Phase 1: 同步——这一段会临时停 GUI Thread
guiThread.lockForSync();
{
for (QQuickItem* item : dirtyItems) {
QSGNode* node = item->updatePaintNode(...); // 在 GUI 线程内执行
// ↑ 由 QML 元素自己把"逻辑变化"翻译成"GPU 节点变化"
}
}
guiThread.unlock();
// Phase 2~4: 渲染线程独立工作,GUI 线程继续响应事件
preprocess();
renderSceneGraph();
swapBuffers();
}
关键不变量:同步阶段(Phase 1)是唯一可以同时访问对象树和节点树的时刻;其余时间 GUI 线程只能写对象树、渲染线程只能写节点树。这种"读写分离"避免了昂贵的细粒度锁。
# 2.4.3 阻塞场景的实测对比
把下面两段代码放到 Button.onClicked 里跑:
// 场景 1: 阻塞 GUI 线程 —— 同步算法
Timer {
repeat: true; interval: 16; running: true
onTriggered: {
// 用户点了按钮后 1 秒
const t0 = Date.now()
while (Date.now() - t0 < 1000) { /* 空转 1 秒 */ }
}
}
测得:
GUI 线程: 100% 跑满,按钮按下后 1s 没响应(事件队列堆积)
Render 线程:仍以 60 fps 渲染最后一帧的内容
动画(NumberAnimation):照常播放(V4 是 GUI 线程,但动画驱动在 Render 线程)❓
⚠️ 注意:纯 JS 表达式驱动的属性变化(如 onTriggered 里改 width)
在 GUI 线程被阻塞期间不会发生——因此"看上去动画在跑,
但绑定不更新"的场景会出现
// 场景 2: 让重活下沉到 WorkerScript / 线程池
WorkerScript {
id: worker
source: "heavy.mjs" // ── 在独立线程跑 JS
onMessage: (msg) => result.text = msg.data
}
Button { onClicked: worker.sendMessage({ payload: input.text }) }
测得:GUI 线程几乎零阻塞,动画与绑定全程流畅。
# 2.4.4 三种渲染循环策略
Qt Quick 支持三种渲染循环(通过 QSG_RENDER_LOOP 环境变量选择):
| 策略 | 线程模型 | 适用场景 | 在嵌入式上 |
|---|---|---|---|
| threaded | GUI + 独立 Render 线程 | 双核以上、有 GPU | ✅ 推荐(默认) |
| basic | 单线程(GUI 内做渲染) | 单核、调试 | ⚠️ 性能差但可用 |
| windows(Win 专属) | 单线程 | Windows 兼容 | — |
强制切换:
export QSG_RENDER_LOOP=basic # 单线程,便于断点调试
export QSG_RENDER_LOOP=threaded # 双线程,性能模式
./myapp -platform eglfs
嵌入式默认选 threaded——但在调试卡顿时改成 basic 能让"哪条 QML 语句导致渲染慢"的因果更清晰。
# 2.5 嵌入式渲染后端
# 2.5.1 四种渲染后端对比
| 后端 | 路径 | 启动速度 | 性能 | 适用平台 |
|---|---|---|---|---|
| EGLFS | OpenGL ES → DRM/KMS / fbdev | 极快 | ★★★★★ | ARM Linux 无桌面(嵌入式首选) |
| LinuxFB | QPainter → /dev/fb0 | 极快 | ★★ | 无 GPU 的廉价板 |
| XCB | X11 协议 | 慢 | ★★★★ | 桌面 Linux 开发调试 |
| Wayland | Wayland 协议 | 中等 | ★★★★ | 现代桌面 / 多窗口嵌入式 |
| Offscreen | 不输出 | — | — | CI 自动化测试 |
# 2.5.2 EGLFS 运行原理
EGLFS = EGL + FullScreen:一个 QML 进程独占一个屏幕,不需要任何窗口管理器。
┌─────────────────────────────────────┐
│ QML 应用进程 │
├─────────────────────────────────────┤
│ Qt Quick (Scene Graph) │
├─────────────────────────────────────┤
│ QPA: eglfs 插件 │
│ ┌─────────────────────────────────┐ │
│ │ 1. eglGetDisplay(EGL_DEFAULT) │ │ ← 打开 GPU
│ │ 2. eglInitialize / Config │ │
│ │ 3. eglCreateWindowSurface │ │ ← 绑定到 framebuffer
│ │ (target = DRM plane / │ │
│ │ fbdev /dev/fb0) │ │
│ │ 4. eglMakeCurrent │ │
│ │ 5. glDraw* + eglSwapBuffers │ │ ← 直接画到屏幕
│ └─────────────────────────────────┘ │
├─────────────────────────────────────┤
│ Linux Kernel │
│ ┌─────────────────────────────────┐ │
│ │ DRM / KMS / fbdev driver │ │
│ │ → GPU → HDMI/LVDS/MIPI 输出 │ │
│ └─────────────────────────────────┘ │
└─────────────────────────────────────┘
EGLFS 的特点:
| 维度 | 说明 |
|---|---|
| 独占显示 | 一次只有一个 EGLFS 进程能用屏幕(除非用 eglfs_kms 多 plane) |
| 无窗口管理器 | 没有标题栏、关闭按钮、最小化——节省所有开销 |
| 启动速度 | < 1 秒(X11 + GNOME 起步 5 秒) |
| 输入设备 | 直接读 /dev/input/event*(evdev) |
| 多屏支持 | QT_QPA_EGLFS_KMS_CONFIG 配置 JSON |
启动命令:
export QT_QPA_PLATFORM=eglfs
export QT_QPA_EGLFS_HIDECURSOR=1 # 隐藏鼠标
export QT_QPA_EGLFS_PHYSICAL_WIDTH=155 # 物理尺寸(mm),决定 DPI
export QT_QPA_EGLFS_PHYSICAL_HEIGHT=86
./myapp
# 2.5.3 软件渲染降级策略
不是所有嵌入式板都有 GPU——比如 STM32MP1、低端 Allwinner H 系列。此时 Qt Quick 仍能跑,但走"软件渲染后端":
// main.cpp 启动前调用
#include <QQuickWindow>
int main(int argc, char* argv[]) {
// 无 GPU 时回退到软件渲染
QQuickWindow::setSceneGraphBackend(QSGRendererInterface::Software);
QGuiApplication app(argc, argv);
QQmlApplicationEngine engine;
engine.load(QUrl("qrc:/main.qml"));
return app.exec();
}
软件后端的特点:
- 用
QPainter(CPU 光栅化)模拟 OpenGL 调用 - 没有 Scene Graph 的 GPU 批处理优化
- 性能比 GPU 后端慢 5~20 倍,但能跑
- 不支持 ShaderEffect、ParticleSystem 等需要 GPU 的元素
一条经验:512×320 以下的低分屏 + 简单 UI(按钮、文本、列表),软件后端勉强够用;任何动画密集或全屏视频场景,必须有 GPU。
# 2.5.4 选型决策树
┌── 板子有 GPU 吗?
│
┌──────────┴──────────┐
有 GPU 无 GPU
│ │
┌────┴────┐ ┌──┴──┐
有桌面? 无桌面? 屏幕 < 800×480?
│ │ │
XCB EGLFS ┌──┴──┐
Wayland (推荐) 是 否
│ │
LinuxFB 放弃 QML
软件渲染 考虑 LVGL
# 2.6 性能诊断工具
# 2.6.1 QSG_VISUALIZE 可视化调试
QSG_VISUALIZE 是 Scene Graph 内置的"X 光眼",把抽象的渲染过程画成可见的色块。
# 显示批处理分组——每色 = 一个 batch
export QSG_VISUALIZE=batches
# 显示 overdraw(过度绘制)热力图——颜色越红越糟
export QSG_VISUALIZE=overdraw
# 显示脏区域——红色 = 本帧需要重绘的部分
export QSG_VISUALIZE=changes
# 显示裁剪区域——绿色框 = 被裁剪掉的内容
export QSG_VISUALIZE=clip
./myapp -platform eglfs
| 模式 | 看什么 | 优化目标 |
|---|---|---|
batches | 色块越少越好(同色 = 同 batch) | 减少 Draw Call |
overdraw | 红色越少越好(红 = 多次重绘) | 减少层叠 |
changes | 红色越少越好(红 = 整帧重画) | 减少绑定更新 |
clip | 检查 clip: true 用得对不对 | 该裁的裁,不该裁的别裁 |
# 2.6.2 Qt Quick Profiler
Qt Creator → Analyze → QML Profiler
连接到运行中的进程(也可启动时挂上)
可视化时间线包含:
├── Painting ← 渲染时间,应 < 16.6ms
├── Compiling ← QML 编译时间(仅启动期)
├── Creating ← 对象创建时间(Loader / Component.createObject)
├── Binding ← 绑定求值时间
├── HandlingSignal ← 信号处理时间
└── JavaScript ← V4 执行时间
抓一帧后展开"Painting" 通常能直接定位是哪个 Item 拖累了 GPU。
# 2.6.3 命令行环境变量速查
# ===== Scene Graph =====
export QSG_RENDER_LOOP=threaded # 渲染循环模式
export QSG_VISUALIZE=batches # 可视化模式
export QSG_INFO=1 # 打印 Scene Graph 启动信息
# ===== 性能日志 =====
export QSG_RENDERER_DEBUG=render # 渲染细节
export QML_DISABLE_DISK_CACHE=1 # 禁用 qmlcache(调试用)
# ===== EGLFS =====
export QT_QPA_PLATFORM=eglfs
export QT_QPA_EGLFS_DEBUG=1 # 打印 EGLFS 初始化信息
export QT_QPA_EGLFS_HIDECURSOR=1 # 隐藏光标
# ===== 输入 =====
export QT_QPA_EVDEV_TOUCHSCREEN_PARAMETERS=rotate=180 # 触屏旋转
# ===== 启动时分析 =====
export QML_PROFILE_PIXMAP_CACHE=1 # 打印 PixmapCache 命中率
# 2.7 QML 引擎底层原理
这一节是"卷二"性质——前面 §2.2~§2.6 是"会用 QML"够了,本节回答"为什么这么设计"。
# 2.7.1 QQmlEngine 启动流程
QQmlApplicationEngine engine; 这一行代码背后究竟做了多少事?
// 简化版 QQmlEngine 启动流程(Qt 6.5)
QQmlEngine::QQmlEngine() {
// 1. 创建 V4 JavaScript 引擎(每个 QQmlEngine 一个 V4)
m_v4Engine = new QV4::ExecutionEngine();
// 2. 注册内置类型
qmlRegisterType<QQuickRectangle>("QtQuick", 2, 15, "Rectangle");
qmlRegisterType<QQuickText> ("QtQuick", 2, 15, "Text");
// ... 数十个内置类型
// 3. 初始化模块搜索路径
m_importPaths << "qrc:/qt-project.org/imports"
<< "/usr/lib/qt6/qml"
<< QDir::currentPath();
// 4. 创建网络访问管理器(用于 http:// QML 加载)
m_networkAccessManager = new QNetworkAccessManager(this);
// 5. 启用 qmlcache(如果存在)
QQmlEnginePrivate::enableDiskCache(this);
}
这就是为什么"应用启动时
engine.load()那一刻 CPU 占用突然飙高"——它做了大量解析、类型注册、模块查找的工作。优化手段是qmlcachegen预编译(见 §2.7.3)。
# 2.7.2 QQmlComponent 的对象树构造
engine.load(QUrl("qrc:/main.qml")) 内部的等价代码:
QQmlComponent component(&engine, QUrl("qrc:/main.qml"));
// ↑ 此时已经走完"Step 1: 词法/语法分析"
// 生成内部表示 QV4::CompiledData::CompilationUnit
QObject* root = component.create();
// ↑ "Step 2: 对象实例化"
// 遍历 CompilationUnit,对每个 ObjectLiteral:
// ① 调用对应 C++ 类的默认构造器
// ② 用 setProperty 注入属性
// ③ 用 connect 注入信号处理器
// ④ 建立绑定依赖图
if (!root) {
qWarning() << component.errors(); // 错误回填到 component
}
编译后的内部表示 CompilationUnit 是 QML 的"字节码",大致结构:
CompilationUnit {
objectTable: [
{ type: "QQuickRectangle", parent: -1, bindings: [0,1,2] },
{ type: "QQuickText", parent: 0, bindings: [3,4] },
]
bindingTable: [
{ property: "width", value: 100 },
{ property: "color", value: "red" },
...
]
signalTable: [
{ signal: "clicked", handlerFunctionIndex: 0 },
]
functionTable: [
// V4 字节码,由 V4 JIT 编译为机器码
]
}
# 2.7.3 V4 JIT 与 QML 编译缓存
V4 是 Qt 自研的 JavaScript 引擎,带 JIT:
| 阶段 | 输入 | 输出 | 触发条件 |
|---|---|---|---|
| Parser | JS 源码 | AST | 加载时一次 |
| Bytecode 编译 | AST | V4 字节码 | 加载时一次 |
| JIT 编译 | 字节码 | x86/ARM 机器码 | 函数被频繁调用(热点) |
qmlcachegen 缓存机制:
# 构建时预编译——把 QML 提前编译为字节码
qmlcachegen --resource main.qrc -o main.qmlc
# 运行时跳过 parser,直接加载字节码
# 启动速度提升 20%~50%
缓存文件名规则:
main.qml → ~/.cache/QtProject/qmlcache/main_xxxxxxxx.qmlc
SomeComponent.qml → ~/.cache/QtProject/qmlcache/SomeComponent_yyyy.qmlc
修改 QML 后缓存哈希变化、自动失效——所以开发期不需要担心缓存污染。
# 2.7.4 Scene Graph 的 QSGNode 树
每一个 QML 可视元素背后,最终都要变成一个或多个 QSGNode:
// QQuickItem 派生类暴露给 Scene Graph 的接口
class QQuickItem : public QObject {
protected:
virtual QSGNode* updatePaintNode(QSGNode* oldNode,
UpdatePaintNodeData* data);
// ↑ 渲染线程在 Sync 阶段调用这个虚函数
// 返回的 QSGNode 会挂到 Scene Graph 树上
};
// 比如 QQuickRectangle 大致这样实现:
QSGNode* QQuickRectangle::updatePaintNode(QSGNode* oldNode, ...) {
QSGGeometryNode* node = static_cast<QSGGeometryNode*>(oldNode);
if (!node) {
node = new QSGGeometryNode();
node->setGeometry(new QSGGeometry(...));
node->setMaterial(new QSGFlatColorMaterial());
}
// 更新颜色、几何
static_cast<QSGFlatColorMaterial*>(node->material())->setColor(m_color);
node->markDirty(QSGNode::DirtyMaterial);
return node;
}
QSGNode 的核心子类:
| 类 | 含义 | 例子 |
|---|---|---|
QSGGeometryNode | 一组顶点+材质——核心绘制单元 | Rectangle、Image |
QSGTransformNode | 变换矩阵节点 | rotation / scale |
QSGOpacityNode | 透明度节点 | opacity 属性 |
QSGClipNode | 裁剪节点 | clip: true |
QSGRootNode | 渲染树根 | 自动创建 |
设计精髓:Scene Graph 的 Node 是无状态的纯渲染描述——它不知道"自己是 Rectangle 还是 Text",只知道"我有这些顶点、用这个材质、套这个变换"。正是这种"统一抽象"让批处理算法能跨元素类型工作。
# 2.8 性能优化实践
# 2.8.1 减少 Draw Call
这是 §2.3 批处理铁律的实际落地。规则:
// ❌ 反例
Repeater {
model: 100
Image {
source: "icon_" + index + ".png" // 100 个不同纹理
rotation: index * 3.6 // 100 个不同 transform
opacity: 0.5 + index * 0.005 // 100 个不同 opacity
}
}
// → 100+ Draw Call
// ✅ 正例
Repeater {
model: 100
Image {
source: "icons_atlas.png" // 同一张图集
sourceClipRect: Qt.rect(
(index % 10) * 32, (index / 10) * 32, 32, 32) // 不同区域
// 不旋转,不动 opacity
}
}
// → 1 个 Draw Call
# 2.8.2 异步加载与对象池
异步加载 —— Loader 默认同步会卡 GUI 线程,改为异步:
Loader {
source: "HeavyPage.qml"
asynchronous: true // ⚡ 在独立线程编译/创建
visible: status === Loader.Ready
onStatusChanged: {
if (status === Loader.Error) console.log("加载失败")
}
}
对象池 —— ListView 内置回收复用,但 Repeater 没有;大量动态创建用:
// ListView 的 reuseItems 是 Qt 5.15+ 的内置对象池
ListView {
model: 10000
reuseItems: true // ⚡ 滑动出去的 delegate 不销毁,复用
delegate: MyDelegate { ... }
}
# 2.8.3 绑定与 JS 优化
铁律 1:避免每帧改属性
// ❌ 反例 ── 每帧重新求值整个绑定
Item {
width: someFunc(x, y, z, w) // someFunc 每次任一依赖变都重算
}
// ✅ 缓存中间值
Item {
readonly property real ratio: someComputation() // 仅依赖变时算一次
width: parent.width * ratio
}
铁律 2:避免在绑定里 new 对象
// ❌ 每次求值都创建新 QRect ── GC 压力大
Item { clipRect: Qt.rect(x, y, w, h) }
// ✅ 仅在需要时创建
property rect cachedRect: Qt.rect(x, y, w, h)
铁律 3:JS 重活下放 WorkerScript
WorkerScript {
id: hashWorker
source: "compute.mjs"
onMessage: (m) => result.text = m.hash
}
Button { onClicked: hashWorker.sendMessage({ data: input.text }) }
// compute.mjs ── 跑在独立线程
WorkerScript.onMessage = function(msg) {
const hash = sha256(msg.data) // 耗时计算
WorkerScript.sendMessage({ hash })
}
# 2.8.4 综合案例:500 Item ListView 优化
把 §2.1 的反例案例一步步优化,看每一步的 FPS 变化:
// 版本 0(反例):1000 Draw Call,22 fps
ListView {
model: 500
delegate: Rectangle {
width: 100; height: 100; color: "blue"
Rectangle {
anchors.centerIn: parent
width: 50; height: 50; color: "red"; rotation: 45
}
}
}
优化 step 1:去掉 rotation → 5 Draw Call
delegate: Rectangle {
width: 100; height: 100; color: "blue"
Rectangle {
anchors.centerIn: parent
width: 50; height: 50; color: "red"
// rotation 删了
}
}
// FPS: 22 → 58
优化 step 2:开启 reuseItems → 内存占用 ÷ 3
ListView {
reuseItems: true // ⚡ Qt 5.15+
cacheBuffer: 200 // 预渲染上下 200 像素
model: 500
...
}
// FPS: 58 → 60(稳定)
// 内存: 500 个 delegate 实例 → 实际只创建 ~12 个并复用
优化 step 3:异步加载 delegate
delegate: Loader {
asynchronous: true
sourceComponent: ItemDelegate {} // 大组件首次滑入时才加载
}
// 启动速度: 1.2s → 0.3s
优化 step 4:Image Atlas——如果 delegate 含图片,所有图打包到一张:
delegate: Image {
source: "qrc:/atlas/items.png"
sourceClipRect: Qt.rect((index%10)*64, (index/10)*64, 64, 64)
}
// Draw Call: 500 → 1(图片版本)
最终对比:
| 版本 | Draw Call | FPS | 启动 | 内存 |
|---|---|---|---|---|
| v0 反例 | ~1000 | 22 | 1.2s | 高 |
| v1 去 rotation | 5 | 58 | 1.2s | 高 |
| v2 + reuseItems | 5 | 60 | 1.2s | 低 |
| v3 + asynchronous | 5 | 60 | 0.3s | 低 |
| v4 + atlas(含图) | 1 | 60 | 0.3s | 低 |
案例知识融合:本案例把整个第 2 章的核心结论串起来——①减少 Draw Call(§2.3);②利用 reuseItems 走对象池(§2.8.2);③异步加载不阻塞 GUI(§2.4);④Image Atlas 把不同图变同 batch(§2.3.3)。这一组组合拳是嵌入式 QML 60 fps 的全部秘密。
# 2.9 本章新手陷阱 Top 5
| # | 陷阱 | 说明 | 修复 |
|---|---|---|---|
| 1 | 在 delegate 里随意加 rotation/opacity | 每个 Item 各自打断批处理 | 能不动就不动;必须动用 layer.enabled 隔离 |
| 2 | 用 Loader 同步加载大组件 | 阻塞 GUI 线程,首屏白屏 | 加 asynchronous: true |
| 3 | 在 onTriggered 写长时间循环 | V4 在 GUI 线程,会卡死交互 | 用 WorkerScript 或拆 QTimer |
| 4 | clip: true 滥用 | 每个 clip 都加一个独立 batch + scissor | 仅在内容确实超界时用 |
| 5 | 误用 Item { visible: false } | 仍占用对象树和绑定计算 | 用 Loader 按需创建,或 active: false |
# 2.10 训练题
训练题 1:用 QSG_VISUALIZE 验证批处理
要求:写两个 QML 文件,一个故意触发 100+ Draw Call,一个仅 1 个 Draw Call;分别用 QSG_VISUALIZE=batches 截图对比。
// bad.qml ── 100 个不同颜色 Rect,100 batches
import QtQuick
Window {
width: 600; height: 600; visible: true
Grid { columns: 10
Repeater { model: 100
Rectangle { width: 60; height: 60
color: Qt.rgba(Math.random(), Math.random(), Math.random(), 1) }
}
}
}
// good.qml ── 100 个同色 Rect,1 batch
import QtQuick
Window {
width: 600; height: 600; visible: true
Grid { columns: 10
Repeater { model: 100
Rectangle { width: 60; height: 60; color: "steelblue" }
}
}
}
练习重点:用工具看见抽象的"batch"概念,建立"Draw Call = 性能"的肌肉记忆。
训练题 2:双线程模型实测
要求:构造一个故意阻塞 GUI 线程 2 秒的 onClicked,观察期间动画/Render 线程是否仍工作。
import QtQuick
Window {
width: 400; height: 300; visible: true
Rectangle {
id: ball
width: 50; height: 50; radius: 25; color: "tomato"
NumberAnimation on x { from: 0; to: 350; duration: 2000; loops: Animation.Infinite }
NumberAnimation on y { from: 0; to: 250; duration: 1500; loops: Animation.Infinite }
}
Rectangle {
x: 150; y: 250; width: 100; height: 40; color: "lightgray"
Text { anchors.centerIn: parent; text: "Block 2s" }
MouseArea {
anchors.fill: parent
onClicked: {
const t0 = Date.now()
while (Date.now() - t0 < 2000) {} // ⚠️ 阻塞 GUI 线程 2 秒
console.log("done")
}
}
}
}
观察点:
- 点击按钮后,小球还在动吗?(动画在 Render 线程,还会动)
- 点击后 2 秒内能再次点击按钮吗?(不能,事件被堵在 GUI 队列)
- 控制台
done什么时候打印?(2 秒后)
练习重点:亲手验证 GUI 线程和 Render 线程的独立性。
训练题 3:用 qmlcachegen 加速启动
要求:测量大型 QML 项目启动时间,再用 qmlcachegen 预编译,对比启动耗时。
# Step 1: 测原始启动时间
time ./myapp --quit-after-startup
# Step 2: 预编译
qmlcachegen --resource main.qrc -o main.qmlc
# Step 3: 部署 .qmlc 与原 QML 同目录,再次启动
time ./myapp --quit-after-startup
# 典型结果:1200ms → 700ms(提升 ~40%)
练习重点:理解"编译时 vs 运行时"的优化迁移——把运行时解析挪到构建时。
# 2.11 综合思考题
QML 与 React 的本质区别:React 也是声明式 UI,也有"虚拟 DOM"做 diff 渲染。请对比 React 的"组件 → JSX → VDOM → 真实 DOM"流程与 QML 的"声明 → AST → QObject 树 → QSGNode 树"流程。它们在性能、内存、调试上各有什么优劣?
为什么不在 GUI 线程做渲染:理论上单线程串行执行更简单——绕过了所有同步问题。但 Qt 6 默认仍选了"GUI + Render 双线程"。从用户体验和硬件趋势两个角度论证这个决策是否正确?多核越来越普及对这个决策有什么影响?
EGLFS 的独占性该不该消除:嵌入式上 EGLFS 默认独占整个屏幕,多个应用没法同时显示。Wayland 多窗口才解决这个问题。请思考:什么样的嵌入式产品形态适合 EGLFS 独占模型?什么样的更适合 Wayland 多客户端?车机、HMI、家电分别该选哪个?
批处理算法的极限:Scene Graph 的批处理是基于"同 shader + 同 texture + 同 transform"。但现代 GPU 引入了 Instanced Rendering 与 Bindless Texture——一次 Draw Call 就能画几千个独立 transform、独立纹理的物体。Qt 7 / Qt RHI 是否会引入这类优化?这会改变第 2.3.3 节的"批处理铁律"吗?
# 2.12 速查表
| 概念 | 一句话 |
|---|---|
| QQmlEngine | QML 解析 + 模块管理 + 绑定调度 |
| QQmlComponent | AST → QObject 对象树 |
| V4 | Qt 内嵌的 JavaScript 引擎(带 JIT) |
| QObject Tree | 逻辑树(属性 / 信号 / 绑定) |
| Scene Graph | GPU 渲染树(顶点 / 材质 / 变换) |
| QSGNode | Scene Graph 的节点基类 |
| GUI Thread | 事件处理 / JS 执行 / 属性更新 |
| Render Thread | Scene Graph 遍历 / 批处理 / OpenGL 调用 |
| Sync 阶段 | 两线程唯一同时访问对象树的时刻 |
| EGLFS | 嵌入式无窗口管理器的 OpenGL 后端 |
| Draw Call | 一次 GPU 绘制命令(越少越快) |
| Batch | 同 shader + 同 texture + 同 transform 的节点组 |
| vptr / vtable | (C++ 多态机制)QML 元素本质是带 vptr 的 C++ 对象 |
| qmlcachegen | QML 预编译工具,启动速度 +20~50% |
| QSG_VISUALIZE | Scene Graph 可视化调试环境变量 |
核心哲学:
QML 之所以快,不是因为"解释得快"——
而是它被"编译"为 C++ 对象树,
并由 Scene Graph 用 GPU 批处理渲染。
理解 Scene Graph 的批处理规则 = 等于掌握了 QML 性能的命门。
下一篇:03.QML 语法与类型系统