从原理到实现:如何在 Windows 上“嵌入外部程序窗口”到 QtQML 应用中

目标

  • 从原理到实现,完整讲解:如何在 Windows 上“嵌入外部程序窗口”到我们的 Qt/QML 应用中。
  • 解释我们当前实现的关键组件:WindowEmbedder(C++,启动/查找/封装外部窗口)与测试页 ImprovedWindowEmbedTestPage.qml(QML,触发和承载)。
  • 深入分析 QML 如何混合 QWidget 承载一个外部程序窗口。

一、外部窗口嵌入的核心原理(Windows)

本质是把“另一个进程的窗口”作为我们应用的一个“子窗口”来显示。关键步骤:

  1. 启动外部进程(记事本/画图等),等待主窗口创建
  2. 找到该窗口的原生句柄 HWND(通过类名/标题或更稳健的策略)
  3. 把这个 HWND 封装为 Qt 的 QWindow 对象:QWindow::fromWinId(wid)
  4. 再把 QWindow 封装到一个 QWidget 容器里:QWidget::createWindowContainer(window)
  5. 把这个 QWidget 交给 QML 层的承载器组件去摆放与同步几何

我们当前实现的关键路径如下:

// 31:52:TopMatStudio/src/component/WindowEmbedder.cpp
void WindowEmbedder::embedWindow(const QString &processName, 
                                const QString &windowClass, 
                                const QString &windowTitle)
{
    // 先清理当前窗口
    cleanupCurrentWindow();
    
    setStatusMessage(QString("正在启动: %1").arg(processName));
    
    m_currentProcess = new QProcess(this);
    m_currentProcess->start(processName);
    
    if (!m_currentProcess->waitForStarted(3000)) {
        setStatusMessage(QString("启动失败: %1").arg(processName));
        emit errorOccurred(QString("Failed to start process: %1").arg(processName));
        m_currentProcess->deleteLater();
        m_currentProcess = nullptr;
        return;
    }
    
    // 等待窗口创建 - 使用更短的延迟
    m_currentProcess->waitForFinished(400);
  • 先启动外部进程并等待启动成功,然后短暂延迟等待其窗口出现。
  • 更稳健的做法是使用 WaitForInputIdle 或基于 PID 的窗口枚举(文末“改进建议”展开)。
// 54:77:TopMatStudio/src/component/WindowEmbedder.cpp
#ifdef Q_OS_WIN
// 尝试多次查找窗口,提高成功率
WId wid = 0;
int retryCount = 0;
const int maxRetries = 10;

while (retryCount < maxRetries && wid == 0) {
    // 尝试使用不同的窗口标题组合
    if (!windowTitle.isEmpty()) {
        wid = (WId)FindWindowA(windowClass.isEmpty() ? nullptr : windowClass.toLocal8Bit().constData(), 
                               windowTitle.toLocal8Bit().constData());
    }
    
    // 如果找不到,尝试只用类名
    if (wid == 0 && !windowClass.isEmpty()) {
        wid = (WId)FindWindowA(windowClass.toLocal8Bit().constData(), nullptr);
    }
    
    // 如果还是找不到,等待一下再试
    if (wid == 0) {
        QThread::msleep(200);
        retryCount++;
    }
}
  • 我们实现了“多次重试”的查找策略,避免单次查找失败。
  • 当前策略依赖窗口类名/标题;对于多语言标题或 UWP 外壳窗口不稳定时可改进为“按进程 PID 枚举窗口”(见文末)。
89:103:TopMatStudio/src/component/WindowEmbedder.cpp
// 创建Qt窗口容器
QWindow *window = QWindow::fromWinId(wid);
if (!window) { ... }

// 创建嵌入widget
m_embeddedWidget = QWidget::createWindowContainer(window);
if (!m_embeddedWidget) { ... }
  • fromWinId 将 HWND 包装为 QWindow
  • createWindowContainerQWindow 放进一个 QWidget,这样我们有了一个常规的 QWidget,可以交给 QML 的承载器去显示和布局

生命周期、安全与清理也覆盖到:

// 165:180:TopMatStudio/src/component/WindowEmbedder.cpp
void WindowEmbedder::cleanupCurrentWindow()
{
    if (m_currentProcess) {
        m_currentProcess->kill();
        m_currentProcess->waitForFinished(3000);
        m_currentProcess->deleteLater();
        m_currentProcess = nullptr;
    }
    
    if (m_embeddedWidget) {
        m_embeddedWidget->deleteLater();
        m_embeddedWidget = nullptr;
    }
    
    m_currentWindowTitle.clear();
}

二、类型注册与 QML 接口

在程序入口把 C++ 类型注册到 QML,供测试页直接使用:

// 68:75:TopMatStudio/src/main.cpp
// 注册 MyWidget 类型到 QML
qmlRegisterType<MyWidget>("MyWidgets", 1, 0, "MyWidget");

// 注册 WindowEmbedder 类型到 QML
qmlRegisterType<WindowEmbedder>("WindowEmbedder", 1, 0, "WindowEmbedder");

WindowEmbedder 的 QML API 面向信号/属性设计:

  • 方法:embedWindow(processName, windowClass, windowTitle)clearEmbeddedWindow()getEmbeddedWidget()
  • 属性:statusMessagehasEmbeddedWindow
  • 信号:windowEmbedded(windowTitle)windowCleared()errorOccurred(error)

三、QML 测试页如何组织(触发 + 承载)

测试页 ImprovedWindowEmbedTestPage.qml 负责 UI 与用户交互,调用 WindowEmbedder 完成嵌入,并把返回的 QWidget 交给一个“QML 承载器”显示。

  1. 在 QML 中实例化 WindowEmbedder 并监听其信号:
//47:63:TopMatStudio/res/qml/editions/shared/page/ImprovedWindowEmbedTestPage.qml
// 窗口嵌入器实例
WindowEmbedder {
    id: windowEmbedder
    
    onWindowEmbedded: {
        console.log("窗口已成功嵌入:", windowTitle)
    }
    
    onWindowCleared: {
        console.log("窗口已清空")
        currentProgramIndex = -1
    }
    
    onErrorOccurred: {
        showError(error)
    }
}
  1. 通过按钮触发嵌入(预设程序或自定义路径):
// 150:171:TopMatStudio/res/qml/editions/shared/page/ImprovedWindowEmbedTestPage.qml
Button {
    text: modelData.name
    ...
    onClicked: {
        if (currentProgramIndex === index) {
            windowEmbedder.clearEmbeddedWindow()
            currentProgramIndex = -1
        } else {
            windowEmbedder.embedWindow(
                modelData.processName,
                modelData.windowClass,
                modelData.windowTitle
            )
            currentProgramIndex = index
        }
    }
}
  1. 关键:承载返回的 QWidget
    我们在 QML 中用一个“承载器”ExternalWindowContainer(自定义 QQuickItem)来摆放 QWidget,响应窗口大小与 DPI 缩放变化。这一段把 WindowEmbedder.getEmbeddedWidget() 返回的 QWidget* 赋值给承载器:
// 272:293:TopMatStudio/res/qml/editions/shared/page/ImprovedWindowEmbedTestPage.qml
// 嵌入的窗口容器
ExternalWindowContainer {
    id: embeddedWidget
    anchors.fill: parent
    anchors.margins: 5
    visible: windowEmbedder.hasEmbeddedWindow
    
    // 当窗口嵌入器有新的widget时,设置它
    Connections {
        target: windowEmbedder
        function onWindowEmbedded(windowTitle) {
            var w = windowEmbedder.getEmbeddedWidget()
            if (w) {
                embeddedWidget.widget = w
            }
        }
        
        function onWindowCleared() {
            embeddedWidget.clearWidget()
        }
    }
}

四、QML 混合 QWidget 的关键机制

Qt 提供两种“跨栈”互嵌的桥接方式:

  • QQuickWindow 放进 QWidgetQQuickWidget
  • QWindow 放进 QWidgetQWidget::createWindowContainer

我们这里是第二种:目标是一个“外部窗口的 HWND → QWindow → QWidget”,所以使用了 createWindowContainer

为了把这个 QWidget 出现在 QML 场景里,我们写了一个承载器 ExternalWindowContainerQQuickItem 的子类,暴露 QWidget* widget 属性)。其核心职责:

  • 在 QML 场景几何变化时,更新 QWidget 的几何(考虑 devicePixelRatio
  • 确保 QWidget 的原生窗口句柄和 QQuickWindow 的父子关系正确,避免漂移/遮挡
  • 生命周期:清理/解绑,避免悬空句柄与崩溃

核心几何同步逻辑示意(实际实现位于 ExternalWindowContainer.cpp):

  • 通过 mapToScene + window()->contentItem()->mapFromScene 获取 QML 项目在窗口坐标中的位置
  • 乘以 effectiveDevicePixelRatio() 计算实际像素区域
  • 调用 QWidget::setGeometry(x, y, w, h)QWindow::setGeometry 保持一致

WindowEmbedder 的分工关系:

  • WindowEmbedder 负责“把外部程序窗口转成一个 QWidget
  • ExternalWindowContainer 负责“把这个 QWidget 放进 QML 场景且跟随布局变化”

这种分层让“进程控制/句柄封装”和“场景布局/绘制”解耦,便于测试与维护。

五、常见难点与改进建议

  • 窗口查找的健壮性
    • 目前通过 FindWindowA(class, title) 多次重试;若目标应用是多语言标题或 UWP 外壳窗口,容易失败
    • 更稳健方案:
      • 使用 QProcess::processId() 获取 PID,EnumWindows 遍历顶层窗口配合 GetWindowThreadProcessId 按 PID 匹配
      • WaitForInputIdle(processHandle, timeout) 等待目标进程就绪,用轮询退避查找
  • 父子关系与样式
    • 某些场景可能需要 Win32 原生 SetParent(hwndChild, hwndParent)SetWindowLongPtr 设置 WS_CHILDWS_CLIPSIBLINGS 等样式,再交给 Qt 包装,提升裁剪与输入焦点一致性
  • 几何与 DPI
    • 我们在承载器中按 effectiveDevicePixelRatio() 缩放,减少高分屏错位;复杂布局建议关注窗口缩放/移动时的抖动
  • 焦点/输入法
    • 外部窗口有自己的输入循环,嵌入后焦点切换与快捷键需实测校正(必要时在承载器内转发部分事件)
  • 进程生命周期
    • 当前 clearEmbeddedWindow() 会直接杀进程;如果希望“仅解绑显示,不杀进程”,可再加接口参数或新增“解绑”方法
  • 安全与隔离
    • 嵌入任意第三方进程有稳定性/安全隐患(崩溃/卡顿/权限差异),建议限制白名单或增加提示

六、如何在你的项目中使用

  • 打开 Playground → “窗口嵌入测试”
  • 选择预置程序或输入自定义可执行文件路径(类名/标题可留空)
  • 点击嵌入后,会显示在右侧“承载区域”
  • 支持刷新几何与清空嵌入

七、结语

综上,我们采用“外部 HWND → QWindow → QWidget → QML 承载器”的链路,将外部程序稳定地嵌入到 QML 场景中。WindowEmbedder 专注进程与窗口句柄获取,ExternalWindowContainer 负责 QML 场景内的几何与显示管理,二者配合使得“QML 界面”与“外部原生窗口”顺畅融合。

  • 如需把查找逻辑进一步稳定(按 PID/等待就绪/兼容 UWP),可在 WindowEmbedder 中增补对应实现。
  • 如需更强的父子裁剪/输入一致性,可在 Win32 层增加 SetParent/样式 的控制逻辑,再交给 Qt 封装。

这套机制既适用于简单工具(记事本/画图),也能扩展到更复杂的工程工具,只需针对目标窗口的产生方式与句柄特征调整查找策略即可。