ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Qt窗口混合编程:QWidget::createWindowContainer详解与实战

Qt窗口混合编程:QWidget::createWindowContainer详解与实战 说实话我第一次在Qt源码里看到QWidget::createWindowContainer这个接口时第一反应是这玩意儿到底解决什么问题。当时正在做一台上位机软件客户要求把一个基于 Direct3D 渲染的第三方视频面板嵌进我们自己的 Qt 界面里。对面给的接口很简单一个 HWND你拿去显示吧。但问题来了我们整个界面是 QWidget 体系布局、样式、事件全都走 Qt 那一套HWND 这种东西根本没法直接丢进 QVBoxLayout。后来把 QWindow 和createWindowContainer折腾明白之后才意识到这是 Qt 混合编程里一块非常关键但很少被讲透的拼图它把底层的 QWindow 世界和上层的 QWidget 世界无缝桥接起来。这篇文章打算把这条桥的来龙去脉、实际用法和那些文档里不会细说的坑一次讲清楚。我默认看这篇文章的读者已经写过几个 QWidget 程序但对 QWindow 还停留在听说过的阶段。我会从两种窗口体系为什么并存讲起到最小可用示例再到四类实操中必然踩中的坑最后给几个进阶的嵌法。全程用实际代码和真实工程经验说话。1. 为什么会有 QWidget 和 QWindow 两套体系这决定了你什么时候需要 WindowContainer要先搞清楚WindowContainer是干什么的得先回到 Qt 的历史里看一眼。很多人只用 QWidget从来没碰过 QWindow但 Qt5 之后Qt 的窗口系统实际上已经分裂成两层。1.1 QWidget 是老大哥但它太重了QWidget 是 Qt4 时代就存在的完整控件体系。一个 QWidget 实例不仅代表一个原生窗口还承担了事件循环、布局管理、样式表、绘制系统、焦点管理这一整套上层建筑。好处是写业务界面非常快拖几个控件、调调布局、连个信号槽一个工具软件就出来了。代价也在这QWidget 太重了。它内部有一套完整的 QStyle、QPA 平台适配、双缓冲绘制逻辑哪怕你只想创建一个最普通的空白窗口背后也有大量对象在替你工作。而且 QWidget 跟底层原生窗口句柄winId之间的关系是封装式的你想把一个外部的原生窗口、或者一个纯渲染窗口塞进 QWidget 的布局体系里很难下手。1.2 QWindow 是轻量级接入点但太简陋了Qt5 为了支撑更底层的场景OpenGL、Vulkan、Wayland、QML 场景图引入了 QWindow。QWindow 只负责一件事把窗口这个概念映射到操作系统级的原生窗口上。它没有布局系统、没有控件、没有样式表但它可以直接创建原生窗口、接收底层事件、绑定渲染上下文。拿 QML 举例一个 QQuickWindow 本质上就是一个 QWindow 的子类。它的场景图渲染直接走硬件加速根本不经过 QWidget 那套绘制管线。问题随之而来你在工程里可能会遇到一个QWindow比如第三方库返回的、或者你用 QOpenGLWindow 子类化做的渲染窗口而你的界面主体是 QWidget。它们俩一个是原生窗口接入点一个是完整 UI 框架不能互相addWidget。这时候就需要一个桥。1.3 WindowContainer 就是那座桥QWidget::createWindowContainer(QWindow*)这个静态函数作用是把一个 QWindow 包装成一个 QWidget然后这个 QWidget 就能像普通控件一样放进布局、设置大小、挂到任意父控件上。容器内部的实现会替你把 QWindow 对应的原生窗口设置为该 QWidget 平台窗口的子窗口并且同步坐标、尺寸、焦点等状态。下面这个表基本上概括了两者的核心差异也是我判断该用哪套的依据维度QWidgetQWindow定位完整 UI 框架轻量窗口接入点布局系统有QLayout无样式表有无渲染方式CPU/GPU 混合绘制可直接绑定 OpenGL/Vulkan原生窗口句柄通过 winId() 获取本身就是原生窗口典型场景业务界面、对话框渲染窗口、QML、原生窗口桥接能否直接 addWidget能不能需 createWindowContainer所以如果你的需求是把一个 QWindow 放进 QWidget 界面里不管这个 QWindow 是通过fromWinId包进来的外部窗口还是自己写的QOpenGLWindowContainer 都是官方提供的那座桥。2. WindowContainer 到底是怎么把 QWindow 塞进 QWidget 的核心机制的拆解光知道这是个桥还不够要真正用好它得明白桥墩子架在哪。我翻过 Qt 的源码也写了不少测试代码验证行为下面把createWindowContainer的运行机制拆开讲。2.1 容器的本质一个内部平台窗口的养子从实现上看createWindowContainer返回的 QWidget 并不是一个普通空控件而是一个专门的QWindowContainerWidget。这个内部类做的事情简单描述就是在构造函数里把传入的 QWindow 标记为容器窗口的内嵌窗口。当 Widget 被创建原生平台窗口时会把內嵌 QWindow 的setParent指向容器 QWidget 的平台窗口。QWindow 的几何位置、可见性会跟随 QWidget 的显示、隐藏、移动、缩放同步更新。所以当你调用setWindow(QWindow*)之后QWindow 在系统层面的父窗口句柄就被设置成了容器 Widget 的句柄。换句话说从操作系统眼里看这个 QWindow 是容器 Widget 的一个原生子窗口而不是顶层窗口。这里有个很关键的点WindowContainer不会接管 QWindow 的渲染。子窗口的绘制完全由 QWindow 自己的渲染逻辑负责OpenGL、D3D、场景图随你。Qt 只负责把它的位置和大小安顿好。2.2 create()、winId、show 之间的时序关系很多人第一次用 WindowContainer 失败多半是栽在窗口创建的时序上。QWindow 创建时并不会立刻创建对应的原生平台窗口。只有调用了create()、show()或者某些需要原生窗口的操作比如获取 winId才会真正触发底层创建。WindowContainer 内部会处理这个时序当你调用createWindowContainer(qWindow)时它不会立即创建而是在容器 Widget 获取平台窗口即winId()被调用或 Widget 显示之后才去创建/关联子窗口。所以如果你把一个从未create()过的 QWindow 丢给 Container它也能工作Qt 会在合适的时候替你创建。但如果你想先对 QWindow 做一些设置比如设置标志位、设置渲染上下文就必须在传入 Container之前先create()一次否则有些平台设置不生效。一个典型错误是QWindow *nativeWindow new QWindow(); // 忘了调用 nativeWindow-create(); QWidget *container QWidget::createWindowContainer(nativeWindow);这种情况不是绝对崩溃但我在 Windows 下遇到过若干奇奇怪怪的显示问题子窗口不出来、大小不对、焦点丢失。后面我统一改成先 create再入容器的写法问题基本消失。2.3 QWindow 与 QWidget 的事件、焦点、销毁交互窗口被嵌入之后鼠标点击、键盘输入这些事件怎么走这是很多人的困惑。实际上点击发生在子窗口原生区域时系统事件直接派发给子 QWindowQt 会把这个事件塞回 QWindow 的事件队列QWindow 有独立的事件分发不会变成容器 QWidget 的鼠标事件。这一点很重要意味着你不能在容器 Widget 的mousePressEvent里收到来自子窗口区域的点击。焦点管理同理。QWindow 有自己独立的focusObject概念。如果子窗口内嵌的是一个 QQuickWindow它内部还有自己的焦点链。你需要手动决定何时把焦点给子窗口比如重写容器 Widget 的focusInEvent来调用subWindow-requestActivate()。销毁顺序是我遇到过的第二大坑。Container 文档明确说了它不拥有 QWindow 的所有权。也就是说你 new 一个 QWindow 传给 ContainerContainer 不会负责帮你 delete 它。如果 QWindow 被提前删除容器里会留下一个悬空指针一旦窗口重绘就会崩溃。我建议的清理顺序是先从容器中移除 QWindow调用container-setWindow(nullptr)或者直接删除容器。再删除 QWindow。最后删除容器 Widget。如果反过来先删 QWindowContainer 析构时会访问悬空指针崩溃概率极高。3. 最小可用示例从建一个原生窗口到把它嵌入界面下面给一个完整的例子把创建 QWindow - 包成 QWidget - 放到布局这条链路跑通。我会用两种方式演示第一种自己 new 一个 QWindow 做子窗口第二种把系统级外部窗口句柄包进来。3.1 方式一自己创建 QWindow然后嵌入先写一个最简单的场景界面左边放一个普通 QLabel右边放一个由 QWindow 构成的子窗口区域。// mainwindow.h #include QMainWindow #include QWindow class MainWindow : public QMainWindow { Q_OBJECT public: explicit MainWindow(QWidget *parent nullptr); ~MainWindow(); private: QWindow *m_subWindow nullptr; QWidget *m_container nullptr; };// mainwindow.cpp #include mainwindow.h #include QWindow #include QWidget #include QHBoxLayout #include QLabel MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) { setWindowTitle(WindowContainer 最小示例); resize(800, 400); // 1. 先创建并配置 QWindow m_subWindow new QWindow(); m_subWindow-setFlags(Qt::FramelessWindowHint | Qt::MSWindowsOwnDC); m_subWindow-create(); // 关键先 create再入容器 // 2. 用 WindowContainer 包住它 m_container QWidget::createWindowContainer(m_subWindow); m_container-setMinimumSize(200, 200); // 3. 随便画点东西方便观察子窗口区域 m_subWindow-setTitle(这是一个内嵌的 QWindow); // 4. 布局 auto *layout new QHBoxLayout(); layout-addWidget(new QLabel(左侧内容)); layout-addWidget(m_container, 1); auto *central new QWidget(); central-setLayout(layout); setCentralWidget(central); } MainWindow::~MainWindow() { // 正确销毁顺序先解除容器关联再删 QWindow最后删容器 if (m_container) { m_container-setWindow(nullptr); delete m_container; m_container nullptr; } if (m_subWindow) { delete m_subWindow; m_subWindow nullptr; } }这个例子里嵌入的 QWindow 是一个空白原生窗口背景由系统决定你基本看不清它内部有内容。所以为了让效果更直观一般会用QOpenGLWindow或者QQuickWindow做子窗口这样能看到有颜色的渲染内容。不过最小示例里先跑通结构最重要。一个需要注意的点是Qt::MSWindowsOwnDC这个标志。如果你打算在子 QWindow 上做 GDI/GDI 绘制加上这个标志可以让每个窗口拥有独立的 DC避免切换画笔、画刷时互相污染。不加也能工作但性能和多线程绘制时可能出问题。3.2 方式二用 fromWinId 嵌入外部原生窗口这个才是真正的杀手锏。很多第三方库工业相机 SDK、Halcon、播放器内核只给你一个原生窗口句柄你要做的就是把那个句柄接进你的 Qt 界面。关键代码非常短// 假设 externalHwnd 是某个库返回的 HWNDWindows QWindow *foreignWindow QWindow::fromWinId(reinterpret_castWId(externalHwnd)); foreignWindow-setFlags(Qt::FramelessWindowHint | Qt::WindowDoesNotAcceptFocus); QWidget *container QWidget::createWindowContainer(foreignWindow); container-setMinimumSize(640, 480); ui-videoLayout-addWidget(container);整个过程一句话总结外部窗口句柄包装成 QWindowQWindow 再用 WindowContainer 包成 QWidget。从此这个外部窗口就跟普通控件一样参与布局管理。但我必须提醒一句血泪教训fromWinId这种方式只能用于同一个进程内创建的窗口跨进程是行不通的。道理很简单Windows 的窗口归属与 message loop 绑定你把另一个进程的 HWND 拿来 reparent 到自己的进程窗口下消息派发、输入法、焦点都会乱套。跨进程的画面嵌入需要走 D3D 共享纹理、Desktop Duplication 那种路线不要指望fromWinId能救你。此外fromWinId包装后的 QWindow 并不拥有原窗口的生命周期外部库销毁它时你的 QWindow 就成了一个悬空句柄。稳妥做法是同时捕获外部库的销毁回调在回调里先从界面上移除容器再销毁 QWindow。3.3 关于父子生命周期的归属问题这里要专门展开讲一下所有权createWindowContainer返回的 QWidget 拥有自己的生命周期你把container加入布局后布局接管了它的销毁所以它一般不用手动 delete。但 QWindow 不是这样。官方文档原文大意是The container does not take ownership of the window. 这直接导致了一个常见的反模式QWidget *widget QWidget::createWindowContainer(new QWindow()); widget-show();这段代码看着简洁实际埋了雷。new 出来的 QWindow 没人删除而且因为容器持有的是裸指针当你关闭主窗口、容器销毁后QWindow 并不会自动清理。如果 QWindow 内部还绑定了一个 OpenGL 上下文那么上下文、缓冲、纹理这些 GPU 资源全部泄漏。我见过的 Qt 程序里至少有四五个崩溃现场跟这种匿名 new 一个 QWindow 丢给容器的写法有关。4. 实践中最容易踩的坑焦点、缩放、销毁顺序与平台差异把基础用法跑通之后真正折磨人的是边缘场景。下面按我踩坑频率从高到低排列逐个讲。4.1 焦点问题内嵌窗口抢焦点、不抢焦点当内嵌 QWindow 被点击时系统会把焦点给那个原生子窗口。这在大多数情况下是合理的但在某些场景下会让你的主界面失焦。举个例子如果你的主窗口里有一个搜索框用户正在输入关键词然后不小心点了一下嵌在旁边的视频窗口焦点立刻切到视频窗口主窗口的搜索框光标消失。如果视频窗口本身压根不处理键盘输入这个焦点切换就显得非常多余。解决办法有两种按场景选择如果子窗口需要键盘输入比如 QQuickWindow 里有 TextInput那就在点击时主动requestActivate()这是正常交互。如果子窗口只是纯展示那就禁止它接收焦点。设置标志Qt::WindowDoesNotAcceptFocus是一个办法但实测在个别平台上这个标志对子窗口不完全生效。更可控的做法是重写容器的mousePressEvent在子窗口区域点击时立刻把焦点抢回主窗口void MainWindow::onContainerClicked() { // 强制把焦点还给主窗体的某个控件 ui-searchEdit-setFocus(); }如果你发现怎么也抢不回来可以检查一下子窗口是否设置了Qt::WindowTransparentForInput这个标志能让整个窗口完全忽略鼠标事件但也会导致子窗口内部的交互全部失效。对纯展示没问题如果子窗口需要响应点击就要慎重。4.2 高 DPI 缩放设备和像素坐标错位从 Qt5.6、Qt6 开始高 DPI 是默认开启的。Container 内部会做设备独立像素到物理像素的换算但问题是第三方库的窗口不一定理解 Qt 的 DPI 缩放。典型现象在 150% 缩放的 Windows 显示器上子窗口要么只显示一小块要么溢出容器边界一大截。原因是外部库设置的窗口尺寸按物理像素算而 Qt 布局给它的是逻辑像素尺寸实际 setWindowPos 时可能没有乘上 devicePixelRatio也可能乘了。我在工程里采用的最直接的规避法是设置进程级别的 DPI 感知让 Qt 自己处理缩放第三方窗口那边不用额外的物理像素换算。// main.cpp #include QApplication int main(int argc, char *argv[]) { // Windows 下让系统 DPI 感知交给 Qt QApplication::setHighDpiScaleFactorRoundingPolicy( Qt::HighDpiScaleFactorRoundingPolicy::PassThrough); QApplication app(argc, argv); // ... return app.exec(); }如果你使用 Qt6高 DPI 默认开启PassThrough策略可以让缩放比例不那么四舍五入子窗口尺寸更贴近外部库的真实尺寸。另外当系统缩放比例变化比如把窗口从 100% 的屏幕拖到 150% 的屏幕时QWindow::devicePixelRatioChanged会触发但你没法控制外部库的行为所以实测中偶尔会出现子窗口闪烁、短暂错位。缓解办法是尽量让外部库窗口保持无边框 强制跟随容器大小而不是外部库自己管理尺寸。4.3 销毁顺序与崩溃谁先死的问题我在前面已经提过销毁顺序这里单独列出来是因为它真的太容易爆了而且爆的时机不在现场往往出现在用户关闭窗口后的几秒内或者下一次打开界面时。崩溃的机理是这样的QWindowContainerWidget在析构时会访问它关联的 QWindow 指针如果此时该指针已经指向被释放的内存轻则野指针访问重则整段程序段错误。推荐的安全模式是用一个 QPointer 保存子窗口。窗口关闭或界面销毁时先确认 QWindow 存在调用container-setWindow(nullptr)解除关联。再删除 QWindow。最后删除容器。QPointerQWindow m_subWindow; void closeVideoPanel() { if (m_container) { m_container-setWindow(nullptr); } if (m_subWindow) { delete m_subWindow; // 正常析构 } delete m_container; m_container nullptr; }用 QPointer 还有一个额外好处如果外部库偷偷帮你把 QWindow 删了QPointer 会自动置空后续判断就不会碰悬空指针。虽然不能 100% 防止所有崩溃但能挡住大部分。4.4 不同平台的宽容度差异我分别在 Windows、macOS、Linux 上跑过同一套 Container 代码差异非常明显。Windows兼容性最好。只要进程内窗口基本都能嵌进去子窗口遮挡、层级、焦点也都还算听话。Linux X11可以用但取决于窗口管理器。有些 WM 对 override-redirect 窗口处理不太好出现子窗口不跟随移动或者留残影的情况。Linux Wayland这个平台是最大的坑。Wayland 协议本身上不允许客户端随意重设父子窗口关系或者说子窗口嵌入Container 在 Wayland 下的能力非常有限。如果你的客户环境是纯 Wayland不要承诺窗口嵌入功能老老实实换成共用共享内存纹理或者走 XWayland 兼容层。macOS原生窗口嵌入做得还行但限制也明显子窗口无法做到真正的透明叠加某些 NSView 层级里的特殊绘制也可能出现渲染内容不显示。所以在项目早期做技术选型时一定要先确认目标平台的限制否则后面返工成本很高。5. 进阶玩法嵌入 QQuickWindow、D3D 面板和第三方 App 窗口基础用法稳定之后可以把思路再打开一些。WindowContainer 不是只能嵌一个空白 QWindow很多不太像 QWidget 界面的东西都能通过它融进你的界面。5.1 把 QML 界面嵌进 QWidget 程序QML 的渲染核心是QQuickWindow而QQuickWindow继承自QWindow所以一个自然的推论就是用createWindowContainer把一个QQuickWindow包起来塞进 QWidget 界面。不过实际工程里更推荐用QQuickWidget因为它本身就继承自 QWidget且内部做了不少 Container 场景的优化。但QQuickWidget跟纯渲染场景有一个差异它走的是 Texture 合成所有渲染内容会先被画到纹理再合成到 QWidget 上这跟 Container 的独立原生子窗口模式不一样。什么时候必须用 Container 而不是 QQuickWidget当你的 QML 窗口需要独立的原生窗口做叠加、弹层、多屏显示时。举个例子如果你在 QML 里用了一个Window { flags: Qt.WindowStaysOnTopHint }弹层用 QQuickWidget 在某些平台确实没法触发原生弹层而 Container 包装的 QQuickWindow 则可以完整保留 QML 的窗口管理能力。写法上创建 QQuickWindow 并让其渲染到子窗口然后再包 ContainerQQmlEngine *engine new QQmlEngine(this); QQmlComponent component(engine, QUrl(qrc:/SubPanel.qml)); QQuickWindow *quickWindow qobject_castQQuickWindow*(component.create()); QWidget *container QWidget::createWindowContainer(quickWindow); container-setMinimumSize(320, 240); layout-addWidget(container);这种方式比 QQuickWidget 重但有时候真的更好用。5.2 嵌入第三方 Direct3D / OpenGL 渲染窗口开头场景里我接的第三方视频面板就是这种类型。这类库通常要求你传入一个HWND或者WId作为渲染目标并且这个窗口的渲染由库自己的渲染线程完成。实践中的操作链路非常清晰用new QWindow()创建空白原生子窗口作为第三方库的渲染目标。获取它的winId()传给第三方库初始化。用createWindowContainer把子窗口包成 QWidget 放进布局。第三方库往这个 HWND 上渲染画面。这比fromWinId更安全因为createWindowContainer的子窗口生命周期完全由你管理不会出现外部库先销毁句柄导致悬空指针的问题。一个典型的组态软件场景是界面用 QWidget 搭好框架中间有一块区域显示第三方渲染的三维模型或视频流。这个区域就是一个 Container内嵌一个专门给渲染库用的 QWindow。随着容器尺寸变化Qt 会自动调整子窗口大小渲染库那边只要从事件循环拿出一帧画面画上去即可。5.3 反向混合QML 里嵌入 QWidget既然能把 QWindow 往 QWidget 里塞那反过来能不能在 QML 里放 QWidget答案是不推荐直接用 Container 反向做。QML 程序的主体是 QQuickWindow它是 QWindow 体系而 QWidget 体系里的控件不能直接挂到 QQuickWindow 下。官方提供的QQuickWidget是把 QML 放进 QWidget至于把 QWidget 放进 QML通常是靠QQuickPaintedItem包装某个 QWidget 的图像纹理或者干脆反过来整个界面用 QWidget 搭局部区域用 Container 嵌 QQuickWindow。我自己的经验是除非你真的需要在一个 QML 界面的某个固定位置放一个纯 QWidget 控件比如某个老旧的第三方控件它只能实例化为 QWidget否则别折腾。混合编程的优先级应该是尽量统一到一套体系要么 QWidget要么 QML。实在避不开优先用QQuickWidgetQML 塞 Widget。嵌入原生渲染窗口或外部句柄才考虑 Container。反向把 QWidget 塞 QML是最后手段。5.4 跟Qt 中消息队列常见问题的关系既然你是混合编程新手可能已经在 Qt 的群里看到过qcoreapplication::exec() 之后就无法捕获了这类问题。这其实跟 Container 关系不大但混合编程经常会触发类似困惑。QWindow 的事件派发走的是它自己的QWindowSystemInterface跟 QWidget 的事件系统有一部分重叠但又不完全相同。当你在 QML 或原生子窗口里做一些耗时操作阻塞了消息循环确实会导致父 QWidget 的刷新和鼠标事件卡住。这跟Container 不处理子窗口渲染是一个道理子窗口的消息循环和父窗口的消息循环都跑在同一个 QCoreApplication 事件循环里一旦有一个阻塞所有窗口全部假死。所以做混合编程记住一个铁律不要在 UI 线程里做耗时操作。尤其是第三方渲染库如果提供了独立的渲染线程千万别把它挪到主线程里跑。写在实操之后的小结现在再把所有代码和踩坑经验浓缩成几条我自己都在用的固定套路第一先确认你需要嵌入的东西是不是一个 QWindow。如果不是想办法把它转成 QWindow比如fromWinId如果是直接createWindowContainer即可。第二创建子窗口后优先显式create()一次避免平台窗口在 Container 内部延迟创建导致的各种时序问题。第三生命周期管理上永远把 QWindow 当成你借给 Container 的东西而不是Container 替你保管的东西。销毁时先解除关联再删 QWindow不会错。第四多平台发布前一定要在每个目标平台上跑一遍嵌入、缩放、焦点、销毁这四个场景别只在一台 Windows 上测完就发版。第五如果嵌的是第三方库窗口提前确认它的窗口消息模型、DPI 感知模式和线程模型。这三点决定了你的嵌入方案能不能长期稳定运行。Qt 混合编程这条路分支很多WindowContainer只是其中一道桥。希望这篇把桥的结构、桥墩和能过什么车都讲清楚了你下次再遇到QWidget 和 QWindow 必须共存的需求时能少走几条弯路。
返回列表