材质怎么用一段 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-qt1.0.0Qt Quick / QML 模块(基线 Qt v5.12.12,qmake INSTALLS 安装)https://atomgit.com/lqjmac/spatial-glass-qt
spatial-glass-qt-ohos1.0.0ohpm 三方库(HAR:QML 插件 so + resfile QML 模块)同仓库 ohos/ 目录

实测环境:

项版本/型号
Qtv5.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-qt1.0.0(本仓库)
spatial-glass-qt-ohos1.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 / CMPCompose Canvas 径向渐变Compose 绘制管线
CordovaCSS backdrop-filterWebView 合成器采样
IonicSVG displacement filterSVG 滤镜图
ElectronOS vibrancy / acrylicOS 窗口合成器
Qt(本库)QML ShaderEffect + GLSLQt Quick 场景图片元着色器
ArkWebWebGL2 片元着色器页面 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 双路径解析。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐