Qt 鸿蒙沉浸光感实战:让玻璃的每个像素自己发光
材质怎么用一段 GLSL 画出来、弹层怎么在 Qt Quick 里锚定翻转、一个 Qt 应用怎么整个交叉编译跑上鸿蒙——这篇文章用一个 QML 模块加一份 Qt for OpenHarmony 构建记录,把这三个问题一次讲透。
仓库地址:https://atomgit.com/lqjmac/spatial-glass-qt
三方框架版本:https://wiki.qt.io/Qt5.12.12_Open_Source_Release_for_HarmonyOS_zh
组织仓库链接:https://atomgit.com/ohos-qt
0. 环境与实测设备
三方库适配信息:
| 三方库 | 版本 | 形态 | 仓库地址 |
|---|---|---|---|
| spatial-glass-qt | 1.0.0 | Qt Quick / QML 模块(基线 Qt v5.12.12,qmake INSTALLS 安装) | https://atomgit.com/lqjmac/spatial-glass-qt |
| spatial-glass-qt-ohos | 1.0.0 | ohpm 三方库(HAR:QML 插件 so + resfile QML 模块) | 同仓库 ohos/ 目录 |
实测环境:
| 项 | 版本/型号 |
|---|---|
| Qt | v5.12.12(C++17;未用 5.13+ 的 required property,C++ 仅使用 Qt 5.12 API) |
| 渲染 | Qt Quick scene graph(ShaderEffect / OpenGL) |
| 桌面构建 | qmake + make,macOS arm64(Xcode 26.2 SDK) |
| 测试 | C++ QtTest 6 项 + QML TestCase 4 项(QT_QPA_PLATFORM=offscreen qmltestrunner) |
| Qt for OpenHarmony | 交叉编译 qmake(arm64-v8a),DevEco 内置 native SDK |
| ohos 工程基线 | compatibleSdkVersion 6.0.0(20) · targetSdkVersion 26.0.0 |
| spatial-glass-qt | 1.0.0(本仓库) |
| spatial-glass-qt-ohos | 1.0.0(本仓库 ohos/library,HAR) |
| 实测 | macOS 真实 Qt 窗口运行 + 截图(2360×1520);鸿蒙侧 arm64 交叉编译与 HAR/HAP 工程构建 |
1. 玻璃总得有人来画——这个问题是怎么找上我的
起因其实很简单。「沉浸光感」这个系列我做到了第四篇:React Native 里用原生 View 叠 PNG 光斑,Cordova 靠 WebView 合成器采样背景,Electron 干脆把材质整个交给操作系统。轮到 Qt,我卡住了——Qt Quick 什么都不"给"你:没有 CSS 的 backdrop-filter,没有直接可用的系统 vibrancy,场景图是保留模式的,连"每帧重画"这种想法都不太 Qt。
动手之前我的理解跟大多数人一样朴素:玻璃不就是半透明矩形吗?Rectangle 一个、opacity 一调、radius 一加,跑起来一看,还是那块熟悉的"灰塑料片"。第二反应是上 QtGraphicalEffects 的 FastBlur——翻文档才发现它是效果图节点,只模糊 Item 自身的内容,不采样窗口背后;要"玻璃"还得先把背景复制进 ShaderEffectSource,性能和递归风险都更高。
这时候才想明白 Qt 路径的正解:材质不是"采"来的,是"算"来的。Qt Quick 场景图上本来就跑着 GPU,ShaderEffect 允许你往每个像素塞一段 GLSL——那就不采样了,直接在片元着色器里计算一块"被光照亮的玻璃":三团光核、镜面光带、颗粒、圆角,全部来自一个 81 行的 .frag 文件。没有图片、没有模糊、没有额外模块。
所以这个题目的边界其实很清楚。具体拆成三件事:
- 材质怎么在 GLSL 里画(光核、镜面、颗粒、圆角 SDF,这是核心也是最有趣的部分);
- 弹层几何与光源状态(Popup 上的锚定翻转,C++ 控制器怎么把相位和指针喂给 shader);
- Qt 应用怎么上鸿蒙(Qt for OpenHarmony 交叉编译、以 HAR 形态分发、QAbility 宿主桥接)。
下面是干完之后的完整记录,包括我踩的每一个坑。
2. 效果展示
按老规矩,先给效果再讲原理。Demo 是一个深色基调的演示页,背景铺了三团色光——左上偏青、右上偏紫、右下一团暖色微光,给"玻璃"准备好被照亮的环境:

左侧排版很克制:一行小字 SPATIAL GLASS / QT QUICK,大标题"空间化:/ 沉浸光感",一段说明文字把技术路径写得很直白——“QML ShaderEffect + GLSL。菜单的背景不是图片,而是一块由片元着色器实时计算的光场,带圆角 SDF、指针视差和 16 秒光流”。下面是触发按钮"打开发光菜单"和状态行 LAST SELECTION —。
点按钮,玻璃菜单从按钮正下方长出来:

顶部小字 SPATIAL LIGHT,标题"空间操作",右上角 × 钮;三个条目——“聚焦光场”(快捷键徽标 F)、“固定到空间层”(P)、“移出空间”(红色 danger 项);footer"ESC 关闭 · 点击外部收起"。选一个条目,菜单收起、LAST SELECTION 更新成条目名。
关键是面板上那层光不是贴图:青色光从左上渗入,紫色从右上压过来,暖色从底部托底——三个光核正随 16 秒的相位缓慢漂移,肉眼几乎察觉不到但一直在动;把指针在面板上滑动,整个光场跟着指针轻微偏移,三个光核的偏移系数还不一样(后面解释为什么)。边缘有一圈 1px 高光,圆角处是干净的透明,面板上有极细的颗粒感——这些全部是 aurora.frag 每帧逐像素算出来的,材质里没有一张图片。
桌面 Demo 还带截图模式:--capture out.png --open-menu 用真实 Qt 窗口的 grabWindow() 出图,本文的截图就是这么来的——不是合成图,是渲染结果。
3. 光感到底从哪来:一段 81 行的片元着色器
写代码之前得先把原理捋好。
这套材质的链路从头到尾是这样:
SpatialLightController (C++)
QTimer 33ms
phase += 33 / 16000 // 16 秒一个周期
pointer = clamp((x / w - .5) * 2) // 归一化 −1..1
│
▼ QML bindings(uniforms)
GlassSurface.qml / ShaderEffect
fragmentShader: aurora.frag
│
▼
aurora.frag(逐像素)
├─ 3 × softGlow(青 / 紫 / 暖) // 光核随 phase 漂移、随 pointer 视差
├─ specular sheet(−16° 镜面光带)
├─ hash grain(确定性颗粒)
├─ 圆角 rect SDF → 透明角 + inside alpha
└─ 1px rim + 顶部收光
│
▼
SpatialPopup(QtQuick.Controls Popup)
├─ modal / dim / Escape / 点击外部关闭
└─ contentItem: GlassSurface
把着色器拆开看,每个像素做五件事:
光场。 三个 softGlow——距离光核越近越亮,(1 - d/r) 的三次幂让衰减干脆不拖泥带水。三个光核的圆心不是死的:各自由 sin/cos 以不同频率(1.0 / 0.91 / 0.72 倍相位)驱动着小幅漂移,所以光流 16 秒不重样;再叠加指针位移乘以不同的系数(1.2 / 0.9 / 0.55)——三团光视差不同步,眼睛会读出"深度",这是整个材质最讨巧的一处。
防灰。 三团光叠加的地方容易发灰,shader 里用一行 color *= 0.94 + 0.06 * smoothstep(...) 把下半部微微压冷,重叠区保持通透。
镜面。 不是一圈平边白框,而是一条绕轴向旋转的 smoothstep 光带(轴向量约 −16°),只在面板中部渐隐——像光源划过玻璃面的反光。
颗粒。 hash21 的确定性噪点,幅度只有 0.018,作用是打散渐变的色带感;噪点种子随相位变化,颗粒是"活"的。
圆角与边缘。 圆角矩形 SDF:圆角外 inside = 0(alpha 直接透明,不需要 OpacityMask 或圆角图片);边缘一条约 1.8px 的 rim 高光,顶部再叠一条收光。注意 SDF 是在像素坐标上算的(p = (uv - 0.5) * uSize),不是归一化坐标——这样圆角过渡带在高分屏上是物理像素级的细腻。
为什么不用 QtGraphicalEffects? FastBlur / GaussianBlur 是效果图节点,不采样窗口背后,只模糊当前 Item 的内容;要做"玻璃"还得把背景复制进 ShaderEffectSource,多一份纹理开销还可能有递归风险。手写 GLSL 一个是零依赖(Qt 5.12 起场景图原生支持),一个是圆角、光核、颗粒、边缘一次全控制。和系列其它路径对照:
| 框架 | 材质路径 | 差异 |
|---|---|---|
| React Native | 原生 View 叠 PNG 光斑 | 图片层 |
| KMP / CMP | Compose Canvas 径向渐变 | Compose 绘制管线 |
| Cordova | CSS backdrop-filter | WebView 合成器采样 |
| Ionic | SVG displacement filter | SVG 滤镜图 |
| Electron | OS vibrancy / acrylic | OS 窗口合成器 |
| Qt(本库) | QML ShaderEffect + GLSL | Qt Quick 场景图片元着色器 |
| ArkWeb | WebGL2 片元着色器 | 页面 WebGL 管线 |
和 ArkWeb 的 WebGL 路径比,本库的 shader 运行在 Qt Quick 的 scene graph 里,由 ShaderEffect 节点提交,不经过浏览器的 canvas/context——共享视觉语言,运行时和 API 完全不同。
也要说清楚这条路径的能力边界:它不采样背景。视觉上是"被光照亮的玻璃片",不是"透过玻璃看背景"——需要真实背景折射时请走系列其它路径(Cordova 的 backdrop-filter、Electron 的 vibrancy)。另外 ShaderEffect 需要可用的场景图渲染后端,纯软件渲染或没有 OpenGL 的环境可能编译不了 shader(Qt Quick 本身仍显示,材质缺失);Qt 6 的 RHI shader 打包方式与 Qt 5 不同,本库基线是 Qt v5.12.12,迁移 Qt 6 要把 .frag 纳入 qt_add_shaders 按新规则编译。
顺手记一笔性能账:shader 每像素只做几次 pow / smoothstep / hash,没有采样、没有模糊 pass、没有 ShaderEffectSource;光场动画每帧只变一个 uniform(uPhase),由场景图批量提交;C++ 侧 33ms 才 tick 一次。真机上的帧预算根本碰不到边——瓶颈不在这里,在你怎么忍住不给光场加更多花样。
4. 工程怎么搭:一个 QML 模块,一份 HAR
先看整体结构。仓库根目录是标准 qmake 子目录工程,ohos/ 里是同一组件的鸿蒙形态——Qt 插件交叉编译成 so,连同 QML 模块文件一起打成 HAR:
spatial-glass-qt/
├─ src/ # C++ 源码
│ ├─ spatialglassplugin.cpp # QQmlExtensionPlugin 注册 SpatialGlass 模块
│ └─ spatiallightcontroller.cpp # 光源状态(phase / pointer / motion)
├─ qml/SpatialGlass/ # QML 模块
│ ├─ qmldir # 模块清单
│ ├─ GlassSurface.qml # ShaderEffect 材质底板
│ ├─ SpatialPopup.qml # Popup + 锚定 + 内容
│ ├─ SpatialMenu.qml # 菜单语义 / 列表 / 条目
│ ├─ SpatialTheme.qml # 主题(颜色 / radius / intensity / motion)
│ └─ shaders/aurora.frag # GLSL 片元材质(81 行)
├─ demo/ # 可运行示例 + 截图模式(--capture --open-menu)
├─ tests/ # C++ QtTest + QML TestCase
├─ ohos/
│ ├─ build-qt-ohos.sh # 交叉编译 + 部署脚本
│ ├─ qt/plugin.pro · demo.pro # 交叉编译用的 qmake 工程
│ ├─ library/ # spatial-glass-qt-ohos(HAR)
│ │ ├─ libs/arm64-v8a/ # libspatialglassplugin.so
│ │ └─ src/main/resources/resfile/qml/SpatialGlass/
│ └─ example/ # Qt for OpenHarmony 宿主 HAP(QAbility 桥接)
└─ spatial-glass.pro # 依次构建插件 → demo → tests

这次设计最关键的两个决定:
材质归 shader,状态归 C++,布局归 QML,三层互不越界。 SpatialLightController 刻意不持有任何渲染——它只产生 phase 和 pointer 这两个 uniform(头文件注释原话:“The controller deliberately owns no rendering”);GlassSurface 只管 uniforms、指针追踪和内容列排版;弹出、遮罩、Escape 全交给 Qt Quick Controls 的 Popup。所以 C++ 总共就两个文件、一个 qmlRegisterType。
安装形态是"完整模块"。 qmake INSTALLS 不只装插件 so,qmldir、四个 QML 文件和 shaders/aurora.frag 一起装进 $QT_INSTALL_QML/SpatialGlass/——make install 之后任何工程 import SpatialGlass 1.0 直接用;不想安装就把 qml/ 目录 addImportPath 进去。鸿蒙侧同理:HAR 里 libs/arm64-v8a/ 放插件 so(并入宿主 HAP 的 libs/arm64 后由 QML 导入机制自动发现),resfile/qml/SpatialGlass/ 放 QML 模块文件,两侧内容保持一致。
对使用方来说,最终看到的 API 是这样的:
import QtQuick 2.12
import QtQuick.Controls 2.12
import SpatialGlass 1.0
Rectangle {
id: trigger
width: 200; height: 52; radius: 16
Text { anchors.centerIn: parent; text: "打开光感菜单"; color: "#f4f7ff" }
MouseArea { anchors.fill: parent; onClicked: menu.openAt(trigger) }
}
SpatialMenu {
anchors.fill: parent // 放窗口根节点,坐标系与窗口内容对齐
id: menu
theme: SpatialTheme { accent: "#62e8ff"; accentAlt: "#b6a4ff"; radius: 22 }
items: [
{ id: "focus", label: "聚焦光场", description: "将环境光收敛到当前卡片", shortcut: "F" },
{ id: "pin", label: "固定到空间层", description: "保持浮层在应用最上层", shortcut: "P" },
{ id: "remove", label: "移出空间", description: "此操作不可撤销", tone: "danger" }
]
onItemSelected: console.log(item.id)
onDismissed: console.log("closed")
}
下面把库内部拆开讲:一段 shader、一个 C++ 控制器、一个 Popup、一份菜单,最后是上鸿蒙的完整链路。
5. 把代码讲透
5.1 材质:aurora.frag 的三个光核和一个 SDF
先看光场本体——三个会动的光核:
// qml/SpatialGlass/shaders/aurora.frag(节选)
float softGlow(vec2 p, vec2 center, float radius)
{
float d = length(p - center);
return pow(max(0.0, 1.0 - d / radius), 3.0); // 三次幂:衰减干脆
}
void main()
{
vec2 p = (uv - 0.5) * uSize; // 像素坐标(SDF 用)
vec2 np = p / max(1.0, min(uSize.x, uSize.y)); // 短边归一化(光核用)
float phase = uPhase * 6.2831853;
vec2 pointer = uPointer * 0.026;
// 三个光核:初始位置 + 指针视差(系数各不同)+ 相位漂移(频率各不同)
vec2 c1 = vec2(-0.34, -0.30) + pointer * 1.2
+ vec2(sin(phase) * 0.035, cos(phase * 0.83) * 0.028);
vec2 c2 = vec2(0.38, -0.24) + pointer * 0.9
+ vec2(cos(phase * 0.91) * 0.032, sin(phase * 1.07) * 0.026);
vec2 c3 = vec2(-0.16, 0.42) + pointer * 0.55
+ vec2(sin(phase * 0.72) * 0.025, 0.0);
vec3 color = uSurface;
color += uAccent * g1 * 0.62 * uIntensity; // 青
color += uAccentAlt * g2 * 0.56 * uIntensity; // 紫
color += uWarm * g3 * 0.22 * uIntensity; // 暖
几个我调试时才意识到的地方,写出来给后来人省时间:
视差系数故意不同步。 三个光核对指针的响应系数是 1.2 / 0.9 / 0.55——指针一动,近处的光跟得多、远处的跟得少,二维面板上读出三层深度。全部取 1.0 的话视差就"平"了,等于白做。
漂移频率互质。 相位倍率 1.0 / 0.83 / 0.91 / 1.07 / 0.72 都是拍脑袋选的互质数,保证光流长时间不出现明显循环感。
两套坐标系别混。 光核用短边归一化的 np(光斑大小不随面板宽高变形),SDF 用像素坐标 p(圆角过渡带是物理像素级的)。一开始我把 SDF 也写在归一化坐标上,高分屏圆角全是锯齿——修复就是乘回 uSize。
然后是收尾的 SDF,一个函数同时管透明圆角、1px 光边和顶部收光:
// aurora.frag(节选)
vec2 q = abs(p) - (halfSize - vec2(uRadius));
float dist = length(max(q, vec2(0.0))) + min(max(q.x, q.y), 0.0) - uRadius;
float inside = 1.0 - smoothstep(-0.75, 0.75, dist); // 圆角内 1,圆角外 0(约 1.5px 过渡)
float rim = smoothstep(1.8, 0.0, abs(dist + 0.6)); // 边缘一圈高光
float topRim = smoothstep(1.0, 0.0, abs(p.y + halfSize.y - 1.0))
* smoothstep(0.0, 10.0, halfSize.x - abs(p.x));
color += vec3(1.0) * rim * 0.10;
color += vec3(1.0) * topRim * 0.17;
gl_FragColor = vec4(color, inside * uAlpha) * qt_Opacity;
不需要 OpacityMask、不需要圆角图片、不需要 layer.enabled——圆角"裁剪"就是 alpha 通道本身。
5.2 光源控制器:C++ 只生产两个 uniform
SpatialLightController 是整个库唯一的 C++ 状态,它把自己挂成 QML 属性,shader 通过绑定吃到值:
// src/spatiallightcontroller.cpp(节选)
constexpr int kTickIntervalMs = 33;
constexpr qreal kCycleMs = 16000.0;
SpatialLightController::SpatialLightController(QObject *parent) : QObject(parent)
{
m_timer.setInterval(kTickIntervalMs);
m_timer.setTimerType(Qt::PreciseTimer);
connect(&m_timer, &QTimer::timeout, this, &SpatialLightController::tick);
restartClock();
m_timer.start();
}
void SpatialLightController::updatePointer(qreal x, qreal y, qreal width, qreal height)
{
if (width <= 0.0 || height <= 0.0) {
return; // 非法尺寸直接忽略
}
const qreal nx = std::clamp((x / width - 0.5) * 2.0, -1.0, 1.0);
const qreal ny = std::clamp((y / height - 0.5) * 2.0, -1.0, 1.0);
const QPointF next(nx, ny);
if (qFuzzyCompare(next.x(), m_pointer.x()) && qFuzzyCompare(next.y(), m_pointer.y())) {
return; // 去抖:没变就不发信号
}
m_pointer = next;
emit pointerChanged();
}
void SpatialLightController::tick()
{
if (!m_motionEnabled) {
return;
}
const qreal advanced = m_phase + (kTickIntervalMs / kCycleMs);
m_phase = advanced >= 1.0 ? advanced - 1.0 : advanced; // 0..1 循环
emit phaseChanged();
}
三个设计点:
phase 是自累计的,不是从 QElapsedTimer 现算。 直接 elapsed % 16000 固然省事,但 motionEnabled 关掉再打开时光会"跳"到当前时刻;自累计 + restartClock() 让暂停恢复完全连续。
33ms 而不是 16ms。 光流漂移速度是每秒零点几个面板宽度,30fps 驱动绰绰有余;PreciseTimer 保证不漂。UI 上真正需要满帧的是指针视差——那条路走的是事件驱动的 pointerChanged,不依赖这个 tick。
去抖是省出来的性能。 qFuzzyCompare 挡掉指针微动,pointerChanged 不发,QML 绑定就不重算,shader uniform 就不更新。每秒挡掉的绑定求值比 shader 本身还贵——这种账要算。
5.3 弹层:Popup 上的锚定、翻转与"别抢点击"
SpatialPopup 的根是一个零尺寸 Item(可以随手丢在窗口任何地方),真正的弹层是 Qt Quick Controls 的 Popup——modal、dim、Escape、点击外部关闭这些"礼仪"Qt 全部白送,自己要写的只有定位:
// qml/SpatialGlass/SpatialPopup.qml - updatePosition()
function updatePosition() {
if (!popup.opened || !anchorItem) return;
var win = Window.window;
var viewportWidth = win ? win.width : 800;
var viewportHeight = win ? win.height : 600;
var margin = 12, gap = 10;
var local = anchorItem.mapToItem(root, 0, 0); // 锚点换算到根坐标空间
var menuHeight = Math.min(popup.height, Math.max(120, viewportHeight - margin * 2));
var below = local.y + anchorItem.height + gap; // 下方优先
var above = local.y - gap - menuHeight; // 空间不足翻上方
var useAbove = below + menuHeight > viewportHeight - margin;
var top = useAbove ? above : below;
top = Math.max(margin, Math.min(top, viewportHeight - margin - menuHeight)); // 夹取
var left = local.x + anchorItem.width / 2 - menuWidth / 2; // 水平居中
left = Math.max(margin, Math.min(left, viewportWidth - margin - menuWidth));
popup.x = left;
popup.y = top;
}
和系列前几篇的几何是同一套规则(居中 → 下方优先 → 翻转 → 夹取),差异在 Qt 的两个特性要照顾:
定位必须 Qt.callLater。 onOpened 触发的瞬间 Popup 的布局还没完成,popup.height 是旧值,直接算会定位到错误位置。所以 onOpened: Qt.callLater(root.updatePosition),onHeightChanged 同样推迟一拍;窗口尺寸变化(Connections 挂 Window.window)也触发重定位。
hover 不能抢点击。 GlassSurface 里那个追踪指针的 MouseArea 是这样写的:
// GlassSurface.qml(节选)
// Hover tracking only: acceptedButtons is NoButton so item clicks still
// reach the menu buttons above this item.
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.NoButton
hoverEnabled: true
onPositionChanged: light.updatePointer(mouse.x, mouse.y, width, height)
onExited: light.resetPointer()
}
acceptedButtons: Qt.NoButton 让它只做 hover 不接受任何点击——不然它垫在菜单条目下面,会把按钮的点击全吃掉,菜单"看得见点不动"。这个坑我在第一版踩得结结实实。
5.4 菜单:一个 ListView 和它的 52 像素
SpatialMenu 继承 SpatialPopup,主体是一个 ListView:
// qml/SpatialGlass/SpatialMenu.qml(节选)
ListView {
id: list
width: parent.width
height: Math.min(contentHeight, Math.max(120, menuRoot.maxHeight - 116)) // 超出 maxHeight 内部滚动
model: menuRoot.items
clip: true
boundsBehavior: Flickable.StopAtBounds // 滚到头就是头,不回弹
interactive: contentHeight > height // 不溢出就不吃手势
delegate: Item {
width: list.width
height: 52 + (modelData.description !== undefined && modelData.description !== "" ? 17 : 0)
// 圆点(danger 换红)、label/description、shortcut 徽标、hover 底色,
// 点击 → menuRoot.itemSelected(modelData) → popup.close()
}
}
两个小决定:条目高度 52 + 有描述加 17,和几何估算共用同一套常数,菜单高度可预测;interactive: contentHeight > height——条目不溢出时 ListView 不劫持滚轮和拖拽,把手势留给页面。空列表显示 emptyText(“暂无可执行操作”),不给用户一个白板。
5.5 上鸿蒙:交叉编译、HAR 分发和 QAbility 宿主
这是本篇最"跨端"的部分:Qt 应用整个跑上 HarmonyOS。链路是 Qt for OpenHarmony(ohos-qt 社区对 Qt v5.12.12 的移植)+ 一份交叉编译脚本:
# ohos/build-qt-ohos.sh(节选)
QT_OHOS="$HOME/ohos-frameworks/qt" # Qt for OpenHarmony 工具链
NATIVE_OHOS_SDK="/Applications/DevEco-Studio.app/Contents/sdk/default/openharmony/native"
export OHOS_TARGET_ARCH=arm64-v8a
# 1) qmake 交叉编译:QML 插件 + demo(产出 arm64 的 .so)
(cd "$BUILD/plugin" && "$QT_OHOS/bin/qmake" "$OHOS_DIR/qt/plugin.pro" && make -j8)
(cd "$BUILD/demo" && "$QT_OHOS/bin/qmake" "$OHOS_DIR/qt/demo.pro" && make -j8)
# 2) 部署 HAR(三方库):
# 插件 so → libs/arm64-v8a(并入宿主 HAP 的 libs/arm64 后被 QML 导入机制自动发现)
cp "$BUILD/plugin/libspatialglassplugin.so" "$HAR_LIBS/"
# QML 模块文件 → resfile/qml/SpatialGlass(qmldir + 组件 + shader)
for f in qmldir SpatialTheme.qml GlassSurface.qml SpatialPopup.qml SpatialMenu.qml; do
cp "$ROOT/qml/SpatialGlass/$f" "$HAR_RES/qml/SpatialGlass/"
done
# 3) 部署宿主 HAP:Qt Quick 运行时(Qt5Core/Gui/Qml/Quick/QuickControls2…)
# + QPA 平台插件 libqohos.so + 系统 QML 模块插件 + demo 的 libspatialglassdemo.so
宿主是一个标准的 HarmonyOS 应用,入口 QAbility 把 UIAbility 的生命周期整体转交给 Qt 的 OHOS 平台插件(libqohos.so):
// ohos/example/src/main/ets/qability/QAbility.ets(节选)
import qpa from 'libqohos.so';
export default class QAbility extends UIAbility {
onCreate(want, launchParam) {
QAbilityStage.initQtAppContextIfNeeded(this.context.getApplicationContext(), this.context.resourceDir);
qpa.handleAbilityOnCreate(this, want, launchParam);
}
onWindowStageCreate(windowStage) {
qpa.handleAbilityOnWindowStageCreate(this, windowStage); // 窗口交给 Qt
}
onPrepareToTerminate(): boolean {
return qpa.handleAbilityOnPrepareToTerminate(this); // 退出前问过 Qt
}
}
也就是说:鸿蒙侧看到的仍然是一个 QML 应用——import SpatialGlass 1.0 照写,shader 照跑,材质路径没有任何分叉;变的只是底下那层从 macOS 的 Cocoa 变成 OHOS 的窗口与 OpenGL ES。HAR 的 Index.ets 只导出几个常量(版本号、模块名、resfile 内的模块目录),真正的交付物是 libs/ 里的插件 so 和 resfile/ 里的 QML 模块——鸿蒙三方库不一定是 ArkTS 组件,也可以是"原生库 + 资源"的形态,宿主通过 file:../library 依赖引入。
6. 我踩过的坑(每一条都有真实报错)
这一节可能是全文最值钱的部分。以下坑全部来自这次开发的真实报错和排查记录。
坑一:fragmentShader 传字符串,shader 直接编译失败。 我最初写 fragmentShader: "shaders/aurora.frag",Qt 5.12 的 ShaderEffect 把这个字符串当成 shader 源码去编译,当场失败。正确写法是 Qt.resolvedUrl():
fragmentShader: Qt.resolvedUrl("shaders/aurora.frag")
修复后通过截图模式验证无编译警告。教训:Qt 5.12 的 ShaderEffect 吃的是文件 URL,不是路径字符串。(这条记录在 verification.md 里,是全程第一个报错。)
坑二:SDF 写在归一化坐标上,高分屏圆角全是锯齿。 第一版 SDF 用 uv 算距离,过渡带宽度跟着面板尺寸缩放——retina 屏上圆角边缘毛得没法看。修复:坐标乘回 uSize 用物理像素,过渡带 smoothstep(-0.75, 0.75, dist) 固定约 1.5px,rim 固定 1.8px,任何 DPI 下都是"一根细线"。
坑三:hover 的 MouseArea 把菜单按钮的点击全吃了。 GlassSurface 垫在内容下面、铺满面板,我随手写的 MouseArea 默认接受左键——菜单看得见点不动,hover 高亮倒是正常,非常有迷惑性。修复:acceptedButtons: Qt.NoButton 只留 hover。垫底的交互层,永远先想它会不会挡路。
坑四:onOpened 里直接定位,菜单出现在旧位置。 Popup 打开的瞬间高度还是上一次的值(或 0),updatePosition() 拿旧高度算翻转和夹取,菜单先跳到错误位置再不动了。修复:onOpened / onHeightChanged 都走 Qt.callLater(root.updatePosition),等布局稳定后再定位。
坑五:Qt 5.12 对上新 Xcode SDK,构建警告一片。 Qt 5.12.12 的官方测试矩阵远早于本机的 Xcode 26.2 SDK,构建时满屏 deprecation 警告。实际验证:构建与运行都成功,截图无 shader 警告。教训是这类"工具链代差警告"先跑通再评估,不要为了消警告去动老工具链。
坑六:交叉编译完,QML 引擎在设备上找不到插件。 插件 so 编出来了,直接扔进 HAR 的 resfile 没用——Qt for OpenHarmony 的 QML 导入机制到 libs/arm64 下按模块名找插件 so。修复:插件 so 部署到 HAR 的 libs/arm64-v8a/(并入宿主 HAP 后落位 libs/arm64),QML 模块文件(qmldir/qml/shader)放 resfile/qml/SpatialGlass/——so 和 qmldir 是两条部署路径,少一条都 import 失败。
坑七:HAR 的 resfile 不随包传递给宿主。 以为 QML 模块进了 HAR 就万事大吉,结果宿主侧 QML 引擎解析 import SpatialGlass 时还是找不到 qmldir——HAR 的 resfile 资源并不自动并入宿主。修复:构建脚本把 HAR 里的 qml/SpatialGlass 镜像拷进宿主的 resfile/qml/(脚本注释原话:“HAR resfile 若不随包传递,宿主仍需该目录供 QML 引擎解析 import;两侧内容保持一致”)。同理,Qt Quick Controls 2 这类系统 QML 模块的插件 so 也因为 OHOS 补丁的查找规则,要单独拷进宿主 libs/——插件走 libs、QML 文件走 resfile,这套双轨制是 Qt on OHOS 部署的核心心智。
7. 现在怎么用:从 Demo 到依赖
经过上面的整理,这个工程是双形态交付:QML 模块给桌面 Qt,HAR 给鸿蒙。桌面侧两种接法:
# 形态一:make install 装成正式 QML 模块(qmldir/QML/shader 一起安装)
mkdir build && cd build
qmake ../spatial-glass.pro && make -j4 && make install
# 形态二:不安装,工程里 addImportPath 指到仓库 qml/ 目录
鸿蒙侧一条脚本加一条依赖:
# 交叉编译并部署(产物写入 ohos/library 与 ohos/example)
./ohos/build-qt-ohos.sh
# 宿主 oh-package.json5
# "dependencies": { "spatial-glass-qt-ohos": "file:../library" }
cd ohos && ohpm install
hvigorw --mode module -p module=entry@default -p product=default assembleHap --no-daemon
# 未签名构建:hvigorw assembleHap -p enableSignTask=false
接入代码在"快速开始"那一段已经给过,核心就是 import SpatialGlass 1.0 + SpatialMenu。更完整的 API 表和排错说明在仓库的文档里:
README.md:完整 API(SpatialMenu / SpatialPopup / GlassSurface / SpatialLightController)、安装形态与设计边界;docs/material-path.md:材质路径说明,含与系列其它六条路径的对比;docs/verification.md:构建与测试记录(含安装链验证);demo/:可运行示例,支持--capture --open-menu截图模式;tests/:C++ QtTest + QML TestCase。
当前版本在 Qt v5.12.12(macOS arm64)上验证:C++ QtTest 6 项全过(指针归一化/夹取、非法尺寸、动效启停),QT_QPA_PLATFORM=offscreen 的 qmltestrunner 4 项全过(模块导入、主题默认值与覆写),make install INSTALL_ROOT 安装链产物齐全;鸿蒙侧用 Qt for OpenHarmony(v5.12.12)交叉编译出 arm64 的插件与 demo so,HAR(spatial-glass-qt-ohos 1.0.0)与宿主 HAP 工程在 compatibleSdkVersion 6.0.0(20) / targetSdkVersion 26.0.0 基线上完成构建,import SpatialGlass 1.0 在设备侧由 libs + resfile 双路径解析。
更多推荐




所有评论(0)