本文同步发布于 个人博客javascript
最近笔者在工做中接触了一些基于 Qt
桌面端混合应用的开发。本文将结合自身在开发中的一些经历,将从前端的角度探讨 QWebChannel
在 client
端实例化的本质,以及如何将 QWebChannel
集成到 Vue.js
等相似前端框架中。html
你首先须要可以充分理解 JS
事件循环模型 和 执行上下文 和 执行上下文栈,后文的 QWebChannel
集成将是以代码执行的实质为切入点来探讨实现 QWebChannel
与前端框架的集成。本文虽以 Vue.js 为示例,但并不限制你使用什么前端框架,在理解其中的原理以后,读者可尝试使用 React 或 Angular 等前端框架来实现 QWebChannel
的集成。前端
在当前 v5.x 版本中,存在下文两种混合应用的实现方式DOC:vue
Qt WebView,该模块已在 v5.5 中被弃用,并被 Qt WebEngine 代替DOC,API。以前主要应用在移动端,且在不包含完整的 web
浏览器栈的状况下,而使用原生 API
(即便用原生端的浏览器引擎)实如今一个 QML 应用中展现网页的方法。笔者在开发 Qt
混合应用时,C++
同事使用的是 v5.6.2
(截至本文发布日,最新版本为 v5.13.1
),故不对此混合应用实现作讨论。java
Qt WebEngine,它自己提供一个 web
引擎,用于在 Qt
应用中嵌入任意的网页内容。这是一种 不依赖 外部 Web
引擎的混合应用的实现方式,也是最简单的一种方式。值得注意的是 Qt WebEngine
是基于 Chromium 项目实现的,因此它并不包含一些 Google
另外在 Google Chrome
上实现的功能,读者可在 Chromium
项目的 上游源码库 中找到 Chromium 和 Google Chrome
的区别。node
对于 client
中的 JS
本质上来讲,Qt WebEngine 主要是提供了一个 JS
的宿主环境(runtime
) —— Chromium 项目下 v8 引擎。另外在 Qt
提供的 web
渲染引擎是 Chromium 项目中的 blink。react
在了解 Qt
为前端提供的集成环境以后。Qt
引入了 Qt WebChannel(后文简称 QWebChannel
) 的概念。这是为了在不能影响各端代码执行的前提下实现 Qt
端于 client
端的无缝 双向 通讯。git
QWebChannel 提供了在 Server
(C++
应用)和 client
端(HTML/JS
)之间点对点的通讯能力。经过向 client
端的 QWebChannel 发布 QObject 的 派生对象,进而实如今 client
端无缝读取来自 Qt
端的 公共插槽
和 QObject 的 属性值
和 方法
。在整个通讯过程当中,无需任何手动序列化传入的参数。全部 Qt
端的 属性
更新,signal
触发,都会 自动且异步 更新到 client
端。github
在 Qt
端实现 QWebChannel 只须要引入对应的 Qt
模块便可。而要实现 client
端的 QWebChannel,必须引入 Qt
官方提供的 qwebchannel.js
github,official 的 JS
库#。该库的目的在于封装了一系列的 通讯接口 和传输信息时的序列化信息的方法。web
对于不一样端的 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 对象指 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 构造函数的回调函数被调用时,即代表 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.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 实例化存在一个 异步回调函数。那么为了 研究 在怎样的一个 时机 来向 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
}
}
}
}
复制代码
打印上文代码中的 send
方法,可见函数体并不是原生 JS
语法代码,而是 v8 templates。经探究发现,在 QtWebEngine
的 开源代码 中,展现了该 transport 对象 是如何注入到全局环境中的。本文为了维持文章主题一致性,不对 C++
代码进行拓展解读,若读者感兴趣,可结合 Qt
中引用的 Chromium
头文件和 v8
的 基本概念 以及 类型文档 来解读。
一句话解读:本质上 QtWebEngine
借助 v8
的单一实例获取到 JS
的全局对象,而后在全局 global
对象上实现挂载 qt
对象,及其下属 webChannelTransport
。
在 这里 读者能够找到官方
Chromium
仓库,并在 Github 上可找到Chromium
镜像仓库。另外前文所述的头文件,主要集中在gin
和third_party/blink
文件夹。
在理解了 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
容器中,若存在响应回调函数,那么首先注册响应的回调函数,实现以下:
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)
}
复制代码
以后发送 init
初始化通讯通道的消息至 Qt
端,实现 第一次握手。消息的 body
为:
{
// QWebChannelMessageTypes 是源码顶部的配置对象
type: QWebChannelMessageTypes.init
}
复制代码
第二次握手: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
中删除刚刚已经完成调用并退出 执行上下文栈 的回调函数。
第三次握手:在深刻 第二次握手 的响应回调,可见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
时期——等待消息推送或消息发送。
这里借助 Vue.js
的 插件机制 实现对 QWebChannel
的优雅集成。向模块外部暴露一个 QWebChannel
实例,并在实例化 QWebChannel 的初始化回调中将 channel.objects
注册到 Vue
原型上,使其成为一个 Vue
的 原型属性。此方法可避免官方示例中将 channel.objects
中全部的发布自 Qt
端的信息对象泄漏到全局。
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
的实例化流程:
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
发布的对象。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
通讯。为何在混合应用中不使用 URL
进行通讯?
尽量下降 C++
端与前端的耦合度,避免手动序列化参数,拼接字符串。当出现嵌套的参数对象时,JSON.stringify
的复杂度明显低于手写序列化函数的复杂度。
URL
长度有限制,在超出 URL
的长度限制后,后续的传参将被丢弃。同时这也是为何不宜在 HTTP GET
请求时携带过多参数的缘由。