本文同步發佈於 個人博客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
請求時攜帶過多參數的緣由。