基于 QWebChannel 的前端通信方案
最近笔者在工作中接触了一些基于 Qt 桌面端混合应用的开发。本文将结合自身在开发中的一些经历,将从前端的角度探讨 QWebChannel 在 client 端实例化的本质,以及如何将 QWebChannel 集成到 Vue.js 等类似前端框架中。
你首先需要能够充分理解 JS 事件循环模型 和 执行上下文 和 执行上下文栈,后文的 QWebChannel 集成将是以代码执行的实质为切入点来探讨实现 QWebChannel 与前端框架的集成。本文虽以 Vue.js 为示例,但并不限制你使用什么前端框架,在理解其中的原理之后,读者可尝试使用 React 或 Angular 等前端框架来实现 QWebChannel 的集成。
Qt 嵌入网页技术介绍
在当前 v5.x 版本中,存在下文两种混合应用的实现方式DOC:
-
Qt WebView,该模块已在 v5.5 中被弃用,并被 Qt WebEngine 代替DOC,API。之前主要应用在移动端,且在不包含完整的
web浏览器栈的情况下,而使用原生API(即使用原生端的浏览器引擎)实现在一个 QML 应用中展示网页的方法。笔者在开发Qt混合应用时,C++同事使用的是v5.6.2(截至本文发布日,最新版本为v5.13.1),故不对此混合应用实现做讨论。 -
Qt WebEngine,它本身提供一个
web引擎,用于在Qt应用中嵌入任意的网页内容。这是一种 不依赖 外部Web引擎的混合应用的实现方式,也是最简单的一种方式。值得注意的是Qt WebEngine是基于 Chromium 项目实现的,所以它并不包含一些Google另外在Google Chrome上实现的功能,读者可在Chromium项目的 上游源码库 中找到 Chromium 和Google Chrome的区别。
对于 client 中的 JS 本质上来说,Qt WebEngine 主要是提供了一个 JS 的宿主环境(runtime) —— Chromium 项目下 v8 引擎。另外在 Qt 提供的 web 渲染引擎是 Chromium 项目中的 blink。
Qt v5+ 中与 JS 通信
在了解 Qt 为前端提供的集成环境之后。Qt 引入了 Qt WebChannel(后文简称 QWebChannel) 的概念。这是为了在不能影响各端代码执行的前提下实现 Qt 端于 client 端的无缝 双向 通信。
QWebChannel 提供了在 Server(C++应用)和 client 端(HTML/JS)之间点对点的通信能力。通过向 client 端的 QWebChannel 发布 QObject 的 派生对象,进而实现在 client 端无缝读取来自 Qt 端的 公共插槽 和 QObject 的 属性值 和 方法。在整个通信过程中,无需任何手动序列化传入的参数。所有 Qt 端的 属性 更新,signal 触发,都会 自动且异步 更新到 client 端。
客户端的 QWebChannel
在 Qt 端实现 QWebChannel 只需要引入对应的 Qt 模块即可。而要实现 client 端的 QWebChannel,必须引入 Qt 官方提供的 qwebchannel.jsgithub,official 的 JS 库#。该库的目的在于封装了一系列的 通信接口 和传输信息时的序列化信息的方法。
对于不同端的 Web 站点,而有不同的静态文件引入方式:
-
QWebEngine 中的本地化站点:通过 qrc:///qtwebchannel/qwebchannel.js 引入。
-
远程
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 对象)。
-
根据 官方文档第二段 描述,onmessage 函数被调用时,是作为普通 宏任务 被调用,而不是被微任务源的函数包装后调用(如被
Promise.then包裹的回调函数)。 -
#Note that all communication between the HTML client and the QML/C++ server is asynchronous. 所有在
client和QML/C++服务之间的通信都是 异步 的。在官方 qwebchannel.js 中可见 56 行 和 65 行 和 75 行,当
transport对象接受到来自Qt端的信息时,将调用 onmessage 方法,所以此方法本质是一个 消息解析器。通过此方法在JS端 分发 不同类型的Qt消息,之后将调用在初始化QWebChannel回调中定义的回调函数。这也是Qt端和JS端 异步通信的本质。在每一个信息发送之后,信息发送函数即退出执行上下文栈,并不会为了等待消息响应而阻塞当前任务队列(task queue)。
注意,一旦 transport 对象可用时,JS 的 QWebChannel 对象就应该被实例化。如果是 WebSocket 的实现,这意味着在 socket 的 onopen 回调中就应该创建 QWebChannel 对象。在官方的 QWebChannel 示例中,都是基于 WebSocket 实现的。后文将介绍没有 WebSocket 如何实现 Qt 端和 client 端异步通信。
QWebChannel 实例化回调
一旦传递给 QWebChannel 构造函数的回调函数被调用时,即表明 channel 完成了实例化,并且所有的来自 Qt 发布的 发布对象 都可通过 channel.objects 属性被 JS 客户端访问。注意,所有在 JS 客户端和 QML/C++ 服务之间的通信都是 异步 的。属性可以被 JS 端缓存。此外,记住只有可被转换为 JSON 的 QML/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.cppc++// 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.tstsinterface 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镜像仓库。另外前文所述的头文件,主要集中在gin和third_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 })
})
-
第一次握手:在
client端创建了一个init消息,并发送给Qt端,用于通知Qt端开始初始化通信通道,并返回发布对象(如有)。-
在
client端的execCallbacks容器中,若存在响应回调函数,那么首先注册响应的回调函数,实现如下:jsthis.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) } -
之后发送
init初始化通信通道的消息至Qt端,实现 第一次握手。消息的body为:js{ // QWebChannelMessageTypes 是源码顶部的配置对象 type: QWebChannelMessageTypes.init }
-
-
第二次握手:
Qt端应响应该init消息,若client端可正常接受到Qt端的响应消息,将执行前文所述的注册在实例属性execCallbacks容器中对应的回调函数。首先触发 onmessage 函数(据 前文,所有响应均由 onmessage 处理并分发任务),之后将根据响应的类型由对应的
channel.handleResponse处理函数来处理响应。jsthis.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中删除刚刚已经完成调用并退出 执行上下文栈 的回调函数。 -
第三次握手:在深入 第二次握手 的响应回调,可见SOURCE:
jsfunction(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 | 用于探测是否是 Qt 的 QWebEngine 环境。若在浏览器开发环境将模拟一个 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
})
}
}
在以上示例代码中,主要做的事情就是:
- 在当前的
Qt浏览器环境中实例化一个client端的QWebChannel实例用于与Qt端进行 异步 通信。 - 在
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 初始化 之后 被调用。

在结合以上的所有分析后,不难得出:
initCallback始终是在new Vue之后被调用。
- 基于
JS的 事件循环模型,在initCallback被调用时,router等vue功能据前文阐述一定是可用的。
- 结合
1,至少不能早于Vue实例化完成,并且initCallback被调用前(即三次握手 的第二次握手完成前),触发signal等Qt通信。
FAQ
-
为什么在混合应用中不使用
URL进行通信?-
尽可能降低
C++端与前端的耦合度,避免手动序列化参数,拼接字符串。当出现嵌套的参数对象时,JSON.stringify的复杂度明显低于手写序列化函数的复杂度。 -
URL长度有限制,在超出URL的长度限制后,后续的传参将被丢弃。同时这也是为什么不宜在HTTP GET请求时携带过多参数的原因。
-
