基于 QWebChannel 的前端通信方案

最近笔者在工作中接触了一些基于 Qt 桌面端混合应用的开发。本文将结合自身在开发中的一些经历,将从前端的角度探讨 QWebChannelclient 端实例化的本质,以及如何将 QWebChannel 集成到 Vue.js 等类似前端框架中。

你首先需要能够充分理解 JS 事件循环模型执行上下文执行上下文栈,后文的 QWebChannel 集成将是以代码执行的实质为切入点来探讨实现 QWebChannel 与前端框架的集成。本文虽以 Vue.js 为示例,但并不限制你使用什么前端框架,在理解其中的原理之后,读者可尝试使用 ReactAngular 等前端框架来实现 QWebChannel 的集成。

Qt 嵌入网页技术介绍

在当前 v5.x 版本中,存在下文两种混合应用的实现方式DOC

  1. Qt WebView,该模块已在 v5.5 中被弃用,并被 Qt WebEngine 代替DOCAPI。之前主要应用在移动端,且在不包含完整的 web 浏览器栈的情况下,而使用原生 API (即使用原生端的浏览器引擎)实现在一个 QML 应用中展示网页的方法。笔者在开发 Qt 混合应用时,C++ 同事使用的是 v5.6.2(截至本文发布日,最新版本为 v5.13.1),故不对此混合应用实现做讨论。

  2. Qt WebEngine,它本身提供一个 web 引擎,用于在 Qt 应用中嵌入任意的网页内容。这是一种 不依赖 外部 Web 引擎的混合应用的实现方式,也是最简单的一种方式。值得注意的是 Qt WebEngine 是基于 Chromium 项目实现的,所以它并不包含一些 Google 另外在 Google Chrome 上实现的功能,读者可在 Chromium 项目的 上游源码库 中找到 ChromiumGoogle Chrome 的区别。

对于 client 中的 JS 本质上来说,Qt WebEngine 主要是提供了一个 JS 的宿主环境(runtime) —— Chromium 项目下 v8 引擎。另外在 Qt 提供的 web 渲染引擎是 Chromium 项目中的 blink

Qt v5+ 中与 JS 通信

在了解 Qt 为前端提供的集成环境之后。Qt 引入了 Qt WebChannel(后文简称 QWebChannel) 的概念。这是为了在不能影响各端代码执行的前提下实现 Qt 端于 client 端的无缝 双向 通信。

QWebChannel 提供了在 ServerC++应用)和 client 端(HTML/JS)之间点对点的通信能力。通过向 client 端的 QWebChannel 发布 QObject派生对象,进而实现在 client 端无缝读取来自 Qt 端的 公共插槽QObject属性值方法。在整个通信过程中,无需任何手动序列化传入的参数。所有 Qt 端的 属性 更新,signal 触发,都会 自动且异步 更新到 client 端。

  • QObjectQt 中对象模型的核心。该模型的核心特性是被称为 signalslot 的对象通信机制。

客户端的 QWebChannel

Qt 端实现 QWebChannel 只需要引入对应的 Qt 模块即可。而要实现 client 端的 QWebChannel,必须引入 Qt 官方提供的 qwebchannel.jsgithubofficialJS#。该库的目的在于封装了一系列的 通信接口 和传输信息时的序列化信息的方法。

对于不同端的 Web 站点,而有不同的静态文件引入方式:

  1. QWebEngine 中的本地化站点:通过 qrc:///qtwebchannel/qwebchannel.js 引入。

  2. 远程 web 站点,则必须将官方提供的 qwebchannel.js 复制到目标 web 服务器上。

在实现通信之前,必须实例化一个 QWebChannel 对象并传递一个用于传输功能的对象(称为 transport 对象)和一个回调函数。一旦 QWebChannel 完成实例化并 发布对象 变得可用时,将调用之前实例化时提供的回调函数。在回调函数被调用时,即表明通道建立。

示例代码如下:

import QWebChannel from './qwebchannel.js'

/**
 * @description window.qt.webChannelTransport 可用 WebSocket 实例代替。
 * 经实践发现,Qt 向全局注入的 window.qt 仅有属性 webChannelTransport,并且该对象仅有
 * 两个属性方法:send 和 onmessage
 * send 方法用于 js 端向 Qt 端传输 `JSON` 信息
 * onmessage 用于接受 `Qt` 端发送的 `JSON` 信息
 */
new QWebChannel(window.qt.webChannelTransport, initCallback)

示例代码中 window.qt.webChannelTransport 即是 transport 对象,而 initCallback 是在 QWebChannel 完成实例化且接受到来自 Qt 端的发布对象后才会被调用的回调函数。在回调函数被调用时,发布对象 一定是可用的,而且包含了所有来自 Qt 端的共享信息,如 属性方法,可被监听的 cpp signal 等信息。

transport 对象

在一般情况下,transport 对象指 window.qt.webChannelTransport(由 Qt 端通过 v8 templates 注入到全局环境中) 或 WebSocket 实例

上文阐述的 transport 对象#实现了一个极简的信息传输接口(interface)。它 始终 都应是一个带有 send 方法的对象,该 send 函数(该函数的功能定位可类比于 WebSocket.send)会传输一个字符串化的 JSON 信息,并将它发送到 Qt 端的 QWebChannelAbstractTransport 对象。此外,当 transport 对象接受完成来自 Qt 端的信息时,应该调用 transport 对象的 onmessage 属性。可选地,你可使用 WebSocket 来实现该接口(即 transport 对象)。

  1. 根据 官方文档第二段 描述,onmessage 函数被调用时,是作为普通 宏任务 被调用,而不是被微任务源的函数包装后调用(如被 Promise.then 包裹的回调函数)。

  2. #Note that all communication between the HTML client and the QML/C++ server is asynchronous. 所有在 clientQML/C++ 服务之间的通信都是 异步 的。

    在官方 qwebchannel.js 中可见 56 行65 行75 行,当 transport 对象接受到来自 Qt 端的信息时,将调用 onmessage 方法,所以此方法本质是一个 消息解析器。通过此方法在 JS分发 不同类型的 Qt 消息,之后将调用在初始化 QWebChannel 回调中定义的回调函数。这也是 Qt 端和 JS异步通信的本质。在每一个信息发送之后,信息发送函数即退出执行上下文栈,并不会为了等待消息响应而阻塞当前任务队列(task queue)。

注意,一旦 transport 对象可用时,JSQWebChannel 对象就应该被实例化。如果是 WebSocket 的实现,这意味着在 socketonopen 回调中就应该创建 QWebChannel 对象。在官方的 QWebChannel 示例中,都是基于 WebSocket 实现的。后文将介绍没有 WebSocket 如何实现 Qt 端和 client 端异步通信。

QWebChannel 实例化回调

一旦传递给 QWebChannel 构造函数的回调函数被调用时,即表明 channel 完成了实例化,并且所有的来自 Qt 发布的 发布对象 都可通过 channel.objects 属性被 JS 客户端访问。注意,所有在 JS 客户端和 QML/C++ 服务之间的通信都是 异步 的。属性可以被 JS 端缓存。此外,记住只有可被转换为 JSONQML/C++ 数据类型才会被正确地序列化或反序列化,从而被 JS 客户端访问。

这里在后文的源码分析中,可得出:QWebChannel 的实例化异步回调的意义在于实现类似于 TCP 协议建立阶段的 三次握手。以用于确保 Qt 端和 client 端的通信通道是正常可用的。

interface Channel {
  objects: {
    [contextKey: string]: any
  }
}

new QWebChannel(window.qt.webChannelTransport, (channel: Channel) => {
  // 所有发布于 Qt 的发布对象都在 channel.objects 下
  // 值得注意的是,必须提供一个上下文名称,将共享信息挂载到 channel.objects[上下文]
})

值得注意的是,在向 client 端传输一个 Qt 的发布对象时,必须将与 client 端共享的所有信息挂载到一个或多个 channel.objects 的命名空间下,不能直接挂载到 channel.objects 下。即:

  • Qt 端:webchannel.cpp

    // WebBridge 类包含了一些与 JS 共享信息
    class WebBridge: public QOject
    {
      Q_OBJECT
      public slots:
      void invokedByClient()
      {
          QMessageBox::information(NULL,"invokedByClient","I'm called by client JS!");
      }
    };
    
    WebBridge *webBridge = new WebBridge();
    QWebChannel *channel = new QWebChannel(this);
    channel->registerObject('context', webBridge);
    view->page()->setWebChannel(channel);
  • client 端:bridge/init.ts

    interface Channel {
    objects: {
      context: {
        [contextKey: string]: any
      }
      [namespaceKey: any]: {
        [key: string]: any
      }
    }
    }
    
    new QWebChannel(window.qt.webChannelTransport, (channel: Channel) => {
    const qtContext = channel.objects.context
    // 此时 qtContext 包含了 Qt 端 context 命名空间下所有与 client 端共享的信息
    })

探究客户端 QWebChannel 本质

依据前文阐述,QWebChannel 实例化存在一个 异步回调函数。那么为了 研究 在怎样的一个 时机 来向 Vue.js 等框架中集成 QWebChannel发布对象容器,并且避免将 QWebChannel 发布的对象容器 channel.objects(包含所有 published objects ——来自 Qt 端的共享信息) 直接暴露在全局环境中。下文将讨论 QWebChannel初始化化路径(实例化 + 异步回调) 来探究挂载通过 QWebChannel 发布的来自 Cpp发布对象

JS 端初始化 QWebChannel 时,有以下逻辑来触发 QWebChannel 的实例化:

import QWebChannel from './qwebchannel.js'

new QWebChannel(window.qt.webChannelTransport, initCallback)

在以上代码中,全局环境中的 qt.webChannelTransport 对象即是前文所述的 transport 对象。该对象是由 Qt 端通过 C++ 代码注入到 client 端的全局环境中的。经过实践发现,该对象在 Qt v5.6.2 版本中注入时,仅仅包含以下两个方法:

// TS types
interface MessageFromQt {
  data: {
    type: number
    [dataKey: string]: any
  }
}

declare global {
  interface Window {
    qt: {
      webChannelTransport: {
        send: (data: any) => void
        onmessage: (message: MessageFromQt) => void
      }
    }
  }
}

qt.webChannelTransport 注入

打印上文代码中的 send 方法,可见函数体并非原生 JS 语法代码,而是 v8 templates。经探究发现,在 QtWebEngine开源代码 中,展示了该 transport 对象 是如何注入到全局环境中的。本文为了维持文章主题一致性,不对 C++ 代码进行拓展解读,若读者感兴趣,可结合 Qt 中引用的 Chromium 头文件和 v8基本概念 以及 类型文档 来解读。

一句话解读:本质上 QtWebEngine 借助 v8 的单一实例获取到 JS 的全局对象,然后在全局 global 对象上实现挂载 qt 对象,及其下属 webChannelTransport

这里 读者可以找到官方 Chromium 仓库,并在 Github 上可找到 Chromium 镜像仓库。另外前文所述的头文件,主要集中在 ginthird_party/blink 文件夹。

初始化时 onmessage 函数

在理解了 transport 对象的注入实质之后,transport 对象中第二个方法 onmessage 函数可通过查看 qwebchannel.js 源码发现,是我们在实例化 QWebChannel 时才挂载上去的SOURCE

function QWebChannel(transport, initCallback) {
  // some code is here

  var channel = this
  this.transport = transport // qt.webChannelTransport 或 一个 WebSocket 实例

  // 注册 onmessage 函数以用于接受来自 `Qt` 端的 JSON 消息
  this.transport.onmessage = function(message) {
    var data = message.data
    if (typeof data === 'string') {
      data = JSON.parse(data)
    }
    switch (data.type) {
      case QWebChannelMessageTypes.signal:
        channel.handleSignal(data)
        break
      case QWebChannelMessageTypes.response:
        channel.handleResponse(data)
        break
      case QWebChannelMessageTypes.propertyUpdate:
        channel.handlePropertyUpdate(data)
        break
      default:
        console.error('invalid message received:', message.data)
        break
    }
  }
  // some code is here
}

在每一次实例化 QWebChannel 时,都会将全局环境中的 qt.webChannelTransport 挂载到 QWebChannel 实例的 transport 属性下SOURCE。并且将实例的 send 方法与 transport 对象send 方法联系起来。调用实例的 send 方法 本质 上就是调用 transport 对象send 方法来向 Qt 端发送消息。而调用 transport 对象send 方法本质上是调用了之前 Qt 向全局环境中注入的 v8 template,进而实现向 Qt 发送来自 JS 的消息。

function QWebChannel(transport, initCallback) {
  // some code is here
  var channel = this
  this.transport = transport

  this.send = function(data) {
    if (typeof data !== 'string') {
      data = JSON.stringify(data)
    }
    // 即是调用 qt.webChannelTransport 或 WebSocket 实例的 send 方法
    channel.transport.send(data)
  }
  // some code is here
}

三次握手

qwebchannel.js 中存在以下实例函数 exec 来包装 transport 对象send 方法,作为向 Qt 端发送消息的途径 之一。在消息发送之后,存储对应的回调函数,这些回调函数都会存储在实例的 execCallback 属性中。

this.execCallbacks = {} // 所有的回调函数容器
this.execId = 0
this.exec = // ... 后文将对此做必要分析

若读者感兴趣,可深入源码发现,不论是监听 C++ 的属性还是 signal 都需要通过此函数通知 Qt 端。

// Qt signal 处理函数
this.handleSignal = //...

// Qt 消息处理函数,如通信初始化时的三次握手就是该函数来处理的。
this.handleResponse = // ...

// Qt 属性更新的处理函数
this.handlePropertyUpdate = // ...

然后在注册实例的 exec 方法后,后续相继注册了实例的 3 个用于处理来自 Qt 消息的回调函数。

QWebChannel 实例化的最后一步是实现 Qt 通信通道的 初始化SOURCE,类似于 TCP 协议的 三次握手wiki。这一步的目的就在于确保通信通道的可用性。

// 1. 调用前文所述的 exec 实例方法,通知 Qt 端初始化通信通道
// 2. 设定一个回调用于接受 Qt 端的初始化通道响应
channel.exec({ type: QWebChannelMessageTypes.init }, function(data) {
  for (var objectName in data) {
    // 创建信息载体 —— client 端的 QObject
    var object = new QObject(objectName, data[objectName], channel)
  }
  // now unwrap properties, which might reference other registered objects
  for (var objectName in channel.objects) {
    channel.objects[objectName].unwrapProperties()
  }
  if (initCallback) {
    // 调用初始化的回调函数
    initCallback(channel)
  }
  // 3. 发送第三次握手信息
  channel.exec({ type: QWebChannelMessageTypes.idle })
})
  1. 第一次握手:在 client 端创建了一个 init 消息,并发送给 Qt 端,用于通知 Qt 端开始初始化通信通道,并返回发布对象(如有)。

    1. client 端的 execCallbacks 容器中,若存在响应回调函数,那么首先注册响应的回调函数,实现如下:
    this.exec = function(data, callback) {
      if (!callback) {
        // if no callback is given, send directly
        channel.send(data)
        return
      }
      if (channel.execId === Number.MAX_VALUE) {
        // wrap
        channel.execId = Number.MIN_VALUE
      }
      if (data.hasOwnProperty('id')) {
        console.error(
          'Cannot exec message with property id: ' + JSON.stringify(data)
        )
        return
      }
      data.id = channel.execId++
      // 在 execCallbacks 容器中注册响应回调函数
      channel.execCallbacks[data.id] = callback
      // 根据前文分析,本质调用的是 qt.webChannelTransport.send 方法 来向 Qt 通信
      channel.send(data)
    }
    1. 之后发送 init 初始化通信通道的消息至 Qt 端,实现 第一次握手。消息的 body 为:
    {
      // QWebChannelMessageTypes 是源码顶部的配置对象
      type: QWebChannelMessageTypes.init
    }
  2. 第二次握手Qt 端应响应该 init 消息,若 client 端可正常接受到 Qt 端的响应消息,将执行前文所述的注册在实例属性 execCallbacks 容器中对应的回调函数。

    首先触发 onmessage 函数(据 前文,所有响应均由 onmessage 处理并分发任务),之后将根据响应的类型由对应的 channel.handleResponse 处理函数来处理响应。

    this.handleResponse = function(message) {
     if (!message.hasOwnProperty('id')) {
       console.error(
         'Invalid response message received: ',
         JSON.stringify(message)
       )
       return
     }
     channel.execCallbacks[message.id](message.data)
     delete channel.execCallbacks[message.id]
    }

    这里我们可以看到之前在 init 消息发送之前,已在 execCallbacks 中注册了之前的 init 消息响应的回调。在实例方法 handleResponse 中,将剥离响应中的有效载荷并传入响应回调中完成 第二次握手。并在调用响应回调之后在容器 execCallbacks 中删除刚刚已经完成调用并退出 执行上下文栈 的回调函数。

  3. 第三次握手:在深入 第二次握手 的响应回调,可见SOURCE

    function(data) {
     for (const objectName in data) {
       var object = new QObject(objectName, data[objectName], channel)
     }
     // now unwrap properties, which might reference other registered objects
     for (const objectName in channel.objects) {
       channel.objects[objectName].unwrapProperties()
     }
     if (initCallback) {
       // 调用 new QWebChannel 时传入的回调函数
       initCallback(channel)
     }
     // 第三次握手发送
     channel.exec({type: QWebChannelMessageTypes.idle})
    }

    该函数执行时,首先接受来自 Qt 端的响应信息,创建 client 端的 QObject 以实现对 Cpp 端的 QObject 的追踪。在实例化 QObject 时,进行了一系列的 method 映射,signal 监听,property 监听的设定。

    在存在 initCallback 时,调用 initCallback 函数。值得注意地是,这里的 initCallback 函数即是在实例化 QWebChannel 时,传入的第二个回调函数。此时调用 initCallback 时,Qt 端的 QObject 已经与 client 端通过 第二次握手 实现同步。

    最后,client 端向 Qt 端发出 第三次握手 请求,以用于告知 Qt 端,所有发布对象都已经在 client 端完成同步,并此时的 client 端的通信通道进入 idle 时期——等待消息推送或消息发送。

QWebChannel 与 Vue.js 集成

以 Vue.js 插件形式集成

这里借助 Vue.js插件机制 实现对 QWebChannel 的优雅集成。向模块外部暴露一个 QWebChannel 实例,并在实例化 QWebChannel 的初始化回调中将 channel.objects 注册到 Vue 原型上,使其成为一个 Vue原型属性。此方法可避免官方示例中将 channel.objects 中所有的发布自 Qt 端的信息对象泄漏到全局。

  • _utils/index.ts
import Vue from 'vue'

export const isQtClient = (function() {
  return navigator.userAgent.includes('QtWebEngine')
})()

export const bus = new Vue({})

export function assert(condition: any, msg: string) {
  // falsy is not only 'false' value.
  if (!condition)
    throw new Error(msg || `[ASSERT]: ${condition} is a falsy value.`)
}

const __DEV__ = process.env.NODE_ENV !== 'development'

以上代码是在 _utils.js 中的三个工具函数。

function 描述
isQtClient 用于探测是否是 QtQWebEngine 环境。若在浏览器开发环境将模拟一个 qt.webChannelTransport 对象用于防止报错。
bus 一个 Vue 实例,将用于在 Vue 原型上实现 异步挂载
assert 断言函数

接下来在 bridge/init.ts 中建立 QWebChannel 的实例化流程:

  • bridge/init.ts
import Vue from 'vue'
import QWebChannel from './qwebchannel' // 另有 qwebchannel.d.ts 声明文件
import { assert, isQtClient, bus, __DEV__ } from './_utils'
import dispatch from './index'

declare global {
  interface Window {
    qt: {
      webChannelTransport: {
        send: (payload: any) => void
        onmessage: (payload: any) => void
      }
    }
  }
}

export default {
  install(Vue: Vue) {
    if (!__DEV__) {
      assert(
        window && window.qt && window.qt.webChannelTransport,
        "'qt' or 'qt.webChannelTransport' should be initialized(injected) by QtWebEngine"
      )
    }

    // 用于在浏览器端开发时,模拟 `Qt` 的注入行为
    if (__DEV__ && !isQtClient) {
      window.qt = {
        webChannelTransport: {
          send() {
            console.log(`
              QWebChannel simulator activated !
            `)
          }
        }
      }
    }

    new QWebChannel(window.qt.webChannelTransport, function init(channel) {
      const qtContext = channel.objects.context

      // 官方示例直接在此,将整个 channel.objects 对象注册到全局对象上,这里并不推荐这样做。

      /**
       * @description 这里笔者采用的方法是注册到 Vue 的原型对象上,实现在任意子组件中都可访问 `Qt` 的所有发布在 context 下的发布对象。
       */
      Vue.prototype.$_bridge = qtContext

      /**
       * @description 此处时调用了 Cpp 的同名方法 onPageLoaded
       * @destination 用于通知 Qt 端 client 的 Vue.js 应用已经初始化完成
       * @analysis 后文将会分析为什么此处回调可表示 Vue.js 应用已经完成初始化
       */
      qtContext.onPageLoaded('', function(payload: string) {
        dispatch(payload)
        console.info(`
          Bridge load !
        `)
      })

      // 若有需求,可继续在此注册 C++ signal 的监听回调函数
      // qtContext.onSignalFromCpp.connect(() => {})
      // 以上注册了一个回调函数用于监听名为 onSignalFromCpp 的 signal
    })
  }
}

在以上示例代码中,主要做的事情就是:

  1. 在当前的 Qt 浏览器环境中实例化一个 client 端的 QWebChannel 实例用于与 Qt 端进行 异步 通信。
  2. QWebChannel 的实例化回调中,将来自于 Qt 端所有的发布对象注册到 Vue 实例上,使得可在任意 Vue 实例组件中访问 Qt 发布的对象。

Vue.js 项目的入口文件分析

import Vue from 'vue'
import App from './App.vue'
import router from './router'

import '@/plugins/bridge' // 其中包含 bridge 异步挂载

Vue.config.productionTip = process.env.NODE_ENV === 'development'

new Vue({
  router,
  render: h => h(App)
}).$mount('#app')

结合事件循环分析

术语 含义
事件循环模型 HTML living standard
宏任务 HTML living standard, ECMA

通过 Vue 源码(或任意一个 Vue 应用的 火焰图(flame chart))可见,在初始实例化 Vue 时(不含数据更新 —— Vue.js 的数据更新 异步 更新的),是 同步 实例化。那么结合 JS 事件循环模型 仅当 src/main.ts 文件(截图 1 处)完全执行完毕,并退出 执行上下文栈 时,才会执行下一个 宏任务HTML living standard, ECMA。此时,onmessage 回调才有可能成为下一个待进入 执行上下文栈宏任务

以上通俗点来说,就是基于 Vue 的实例化(不含数据更新)是 同步宏任务 这一本质,QWebChannel 实例化回调函数 initCallback 一定 是在 Vue 实例化之后才会被执行的。下面火焰图的 10 处可清晰可见 Vue同步 初始化流程。

那么因为 ./src/main.ts 入口文件本身是一个 模块,那么在执行该模块是,Webpack 将其包装为一个 函数,那么就会创建一个执行上下文。基于 Execution context 模型ECMA,也就等价于在 ./src/main.ts 中代码没有执行完全,并退出 执行上下文栈 时,后续的 宏任务(task) 始终都只会处于 宏任务队列 (task queue) 中,而不会被推入执行上下文栈中。以上即解释了为什么实例化 QWebChannel 时传入的回调函数 一定 是在 Vue 初始化 之后 被调用。

bridge-flame-chart

在结合以上的所有分析后,不难得出:

  1. initCallback 始终是在 new Vue 之后被调用。

  2. 基于 JS事件循环模型,在 initCallback 被调用时,routervue 功能据前文阐述一定是可用的。

  3. 结合 1,至少不能早于 Vue 实例化完成,并且 initCallback 被调用前(即三次握手 的第二次握手完成前),触发 signalQt 通信。

FAQ

  • 为什么在混合应用中不使用 URL 进行通信?

    1. 尽可能降低 C++ 端与前端的耦合度,避免手动序列化参数,拼接字符串。当出现嵌套的参数对象时,JSON.stringify 的复杂度明显低于手写序列化函数的复杂度。

    2. URL 长度有限制,在超出 URL 的长度限制后,后续的传参将被丢弃。同时这也是为什么不宜在 HTTP GET 请求时携带过多参数的原因。

References

Qt mirrors