前言
《Qt與Web混合開發》系列文章,主要讨論Qt與Web混合開發相關技術。
這類技術存在适用場景,例如:Qt項目使用Web大量現成的元件/方案做功能擴充,
Qt項目中性能無關/頻繁更新疊代的頁面用html單獨實作,Qt項目提供Web形式的SDK給
使用者做二次開發等等,或者是Web開發人員齊全而Qt/C++人手不足,此類非技術問題,
都可以使用Qt + Web混合開發。
(不适用的請忽略本文)
簡介
第二篇文章,先介紹Qt與Web嵌套使用,再介紹Qt與Web分開使用,之後着重讨論分開使用的一些實作細節,特别是WebChannel通信、WebChannel在Web/typescript中的使用。
Qt與Web嵌套
MiniBrowser
這裡以Qt官方的例子MiniBrowser來說明吧。
打開方式如下:

運作效果如下:
這個例子是在Qml中嵌套了WebView。
半透明測試
濤哥做了一個簡單的半透明測試。
增加了兩個半透明的小方塊,藍色的在WebView上面,紅色的在WebView下面。
運作效果也是正确的:
代碼是這樣的:
紅色框中是我增加的代碼。
為什麼要做半透明測試呢?根據以往的經驗,不同渲染方式的兩種視窗/元件嵌套在一起,總會出現透明失效之類的問題,例如 qml與Widget嵌套。
渲染原理
濤哥翻了一下Qt源碼,了解到渲染的實作方式,Windows平台大緻如下:
chromium在單獨的程序處理html渲染,并将渲染結果存儲在共享記憶體中;主視窗在需要重繪的時候,從共享記憶體中擷取内容并渲染。
小結
這裡的WebView内部封裝好了WebEngine,其本身也是一個Item,就和普通的Qml一樣,屬性綁定、js function都可以正常使用,暫時不深入讨論了。
Qt與Web分離
Qt與Web分離,就是字面意思,Web在單獨的浏覽器或者App中運作,不和Qt堆在一起。兩者通過socket進行通信。
這裡用我自己做的例子來說明吧。
(...微信公衆号把gif圖檔吃掉了...)
源碼在github上: https://github.com/jaredtao/QtWeb
Qt小車
原版小車
小車來自Qt的D-Bus Remote Controller 例子
原版的例子,實作了通過QDBus 跨程序 控制小車。
(吐槽:這是一個古老的例子,使用了GraphicsView 和QDBus)
(知識拓展1:DBus是unix系統特有的一種程序間通信機制,使用有些複雜。
Qt對DBus機制進行了封裝/簡化,即QDBus子產品,
通過xml檔案的配置後,把DBus的使用轉換成了信号-槽的形式。
類似于現在的Qt Remote Objects)
(知識拓展2:Windows本身不支援DBus,網上有socket模拟DBus的方案。
參考: freedesktop.org/wiki/So)
改進小車
我做了一些修改,主要如下:
- 去掉了DBus
- 增加控制按鈕
- 增加WebChannel
- 修改Car的實作,導出一些屬性和函數。
- 注冊Car到WebChannel
這裡貼一些關鍵代碼
Car的頭檔案:
其中要說明的是:
speed和angle屬性具備 讀、寫、change信号。
還有加速、減速、左轉、右轉四個公開的槽函數。
必要的知識
WebSocket和 QWebSocket
WebSocket 是 HTML5 開始提供的一種在單個 TCP 連接配接上進行全雙工通訊的協定。
WebSocket 使得用戶端和伺服器之間的資料交換變得更加簡單,允許服務端主動向用戶端推送資料。在 WebSocket API 中,浏覽器和伺服器隻需要完成一次握手,兩者之間就直接可以建立持久性的連接配接,并進行雙向資料傳輸。
Qt為我們封裝好了WebSocket,即QWebSocket和QWebSocketServer,簡單易用。
如果你了解socket程式設計,就看作TCP好了;如果不了解,請先去補充一下知識吧。
WebChannel
按濤哥的了解,WebChannel是在socket上建立的一種通信協定,這個協定的作用是把QObject暴露給遠端的HTML。
大緻使用流程:
- Qt程式中,要暴露的QObject全部注冊到WebChannel。
- Qt程式中,啟動一個WebSocketServer,等待Html的連接配接。
- Html加載好qwebchannel.js檔案, 然後去連接配接WebSocket。
- 連接配接建立以後,Qt程式中,由WebChannel接手這個WebSocket,按協定将QObject的各種“中繼資料”傳輸給遠端Html。
-
Html端,qwebchannel.js處理WebSocket收到的各種“中繼資料”,用js的Object 動态建立出對應的QObject。
到這裡兩邊算是做好了準備,可以互相調用了。
Qt端QObject資料變化隻要發出信号,就會由WebChannel自動通知Web端;
Web端可以主動調用QObject的public的 invok函數、槽函數,以及讀、寫屬性。
Qt啟動系統浏覽器
在使用WebChannel的時候,Qt端建立了WebSocketServer,之後要把server的路徑(例如:ws://127.0.0.1:12345)告訴Html。
一般就是在打開Html的時候帶上Query參數,例如:F:\QtWeb\index.html?webChannelBaseUrl=ws://127.0.0.1:12345
Qt的OpenUrl
Qml中有 Qt.openUrlExternally, C++ 中有 QDesktopServices::openUrl,本質一樣, 都可以打開一個本地的html網頁。
其在Windows平台的底層實作是Win32 API。這裡有個Win32 API的缺陷,傳Query參數會被丢掉。
C# .net的 Process::Start
濤哥找到了替代的方案:
.net framework / .net core有個啟動程序的函數:System.Diagnostics.Process::Start, 可以調用浏覽器并傳query參數
//C# 啟動chrome System.Diagnostics.Process.Start('chrome', 'F:\QtWeb\index.html?webChannelBaseUrl=ws://127.0.0.1:12345'); //C# 啟動firefox System.Diagnostics.Process.Start('firefox', 'F:\QtWeb\index.html?webChannelBaseUrl=ws://127.0.0.1:12345'); //C# 啟動IE System.Diagnostics.Process.Start('IExplore', 'F:\QtWeb\index.html?webChannelBaseUrl=ws://127.0.0.1:12345');
Qt中直接寫C#當然不太好,不過呢,Win7/Win10 系統都帶有Powershell,而powershell依賴于.net framework, 我們可以調用powershell來間接使用.net framework。
是以有了下面的代碼:
結果完美運作。
Web控制端
目錄結構
Web端就按照Web正常流程開發。
Web部分的源碼也在前文提到的github倉庫,子路徑是QtWeb\WebChannelCar\Web
如下是Web部分的目錄結構:
腳本用typescript,包管理用npm,打包用webpack,編輯器用vs code, 都中規中矩。
内容比較簡單,暫時不需要前端架構,手(複)寫(制)的html和css。
Html
html部分比較簡單
//index.html
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; chartset=utf-8" />
<link rel="stylesheet" type="text/css" href="../style/style.css" target="_blank" rel="external nofollow" />
<link rel="stylesheet" type="text/css" href="../style/layout.css" target="_blank" rel="external nofollow" />head>
<body>
<button id="up" class="green button">加速button>
<button id="down" class="red button">減速button>
<button id="left" class="blue button">左轉button>
<button id="right" class="blue button">右轉button>
<img id="img" src="../img/disconnected.svg" />
<div>
<div>
<label>速度: label>
<label id="speed">0label>div>
<div>
<label>角度: label>
<label id="angle">0label>div>div>body>
<script src="../out/main.js">script>html>
樣式和布局全靠css,這裡就不貼了。
TypeScript
腳本部分需要細說了。
src檔案夾為全部腳本,目錄結構如下:
TypeScript中的QObject
從main開始, 加點注釋:
//main.tsimport WebChannelCore from "./webchannelCore";
//window加載時回調,入口window.onload = () => {
//初始化WebChannel,傳參為兩個回調,分别對應WebChannel建立連接配接和連接配接斷開。 WebChannelCore.initialize(onInit, onUninit);
}
//WebChannel建立連接配接的處理function onInit() {
//換圖示 (window as any).document.getElementById("img").src = "../img/connected.svg";
//擷取QObject對象 let car = WebChannelCore.SDK.car;
//取dom樹上的元件
let upBtn = (window as any).document.getElementById("up");
let downBtn = (window as any).document.getElementById("down");
let leftBtn = (window as any).document.getElementById("left");
let rightBtn = (window as any).document.getElementById("right");
let speedLabel = (window as any).document.getElementById("speed");
let angleLabel = (window as any).document.getElementById("angle");
//綁定按鈕點選事件 upBtn.onclick = () => {
//調用QObject的接口 car.accelerate();
}
downBtn.onclick = () => {
car.decelerate();
}
leftBtn.onclick = () => {
car.turnLeft();
}
rightBtn.onclick = () => {
car.turnRight();
}
//QObject的信号連接配接到js 回調 car.speedChanged.connect(onSpeedChanged);
car.angleChanged.connect(onAngleChanged);
}
//WebChannel斷開連接配接的處理function onUninit() {
//換圖示 (window as any).document.getElementById("img").src = "../img/disconnected.svg";
}
//異步更新 speedasync function onSpeedChanged() {
let speedLabel = (window as any).document.getElementById("speed");
let car = WebChannelCore.SDK.car;
//擷取speed,異步等待。 //注意這裡改造過qwebchannel.js,才能使用await。 speedLabel.textContent = await car.getSpeed();
}
//異步更新 angleasync function onAngleChanged() {
let angleLabel = (window as any).document.getElementById("angle");
let car = WebChannelCore.SDK.car;
//擷取angle,異步等待。 //注意這裡改造過qwebchannel.js,才能使用await。 angleLabel.textContent = await car.getAngle();
}
可以看到我們從WebChannelCore.SDK 中擷取了一個car對象,之後就當作QObject來用了,包括調用它的函數、連接配接change信号、通路屬性等。
這一切都得益于WebSocket/WebChannel.
TypeScript中連接配接websocket
接下來看一下WebChannelCore的實作
//WebChannelCore.tsimport { QWebChannel } from './qwebchannel';
type callback = () => void;
export default class WebChannelCore {
public static SDK: any = undefined;
private static connectedCb: callback;
private static disconnectedCb: callback;
private static socket: WebSocket;
//初始化函數 public static initialize(connectedCb: callback = () => { }, disconnectedCb: callback = () => { }) {
if (WebChannelCore.SDK != undefined) {
return;
}
//儲存兩個回調 WebChannelCore.connectedCb = connectedCb;
WebChannelCore.disconnectedCb = disconnectedCb;
try {
//調用link,并傳入兩個回調參數 WebChannelCore.link(
(socket) => {
//socket連接配接成功時,建立QWebChannel QWebChannel(socket, (channel: any) => {
WebChannelCore.SDK = channel.objects;
WebChannelCore.connectedCb();
});
}
, (error) => {
//socket出錯 console.log("socket error", error);
WebChannelCore.disconnectedCb();
});
} catch (error) {
console.log("socket exception:", error);
WebChannelCore.disconnectedCb();
WebChannelCore.SDK = undefined;
}
}
private static link(resolve: (socket: WebSocket) => void, reject: (error: Event | CloseEvent) => void) {
//擷取Query參數中的websocket位址 let baseUrl = "ws://localhost:12345";
if (window.location.search != "") {
baseUrl = (/[?&]webChannelBaseUrl=([A-Za-z0-9\-:/\.]+)/.exec(window.location.search)![1]);
}
console.log("Connectiong to WebSocket server at: ", baseUrl);
//建立WebSocket let socket = new WebSocket(baseUrl);
WebChannelCore.socket = socket;
//WebSocket的事件處理 socket.onopen = () => {
resolve(socket);
};
socket.onerror = (error) => {
reject(error);
};
socket.onclose = (error) => {
reject(error);
};
}
}
(window as any).SDK = WebChannelCore.SDK;
這部分代碼不複雜,主要是連接配接WebSocket,連接配接好之後建立一個QWebChannel。
TypeScript中的QWebChannel
觀察仔細的同學會發現,src檔案夾下面,沒有叫‘qwebchannel.ts’的檔案,而是‘qwebchannel.js’,和一個‘qwebchannel.d.ts’
這涉及到另一個話題:
TypeScript中使用javaScript
‘qwebchannel.js’是Qt官方提供的,在js中用足夠了。
而我們這裡是用TypeScript,按照TypeScript的規則,直接引入js是不行的,需要一個聲明檔案 xxx.d.ts
是以我們增加了一個qwebchannel.d.ts檔案。
(熟悉C/C++的同學,可以把d.ts看作typescript的頭檔案)
内容如下:
//qwebchannel.d.ts
export declare function QWebChannel(transport: any, initCallback: Function): void;
隻是導出了一個函數。
這個函數的實作在‘qwebchannel.js’中:
//qwebchannel.js
"use strict";
var QWebChannelMessageTypes = {
signal: 1,
propertyUpdate: 2,
init: 3,
idle: 4,
debug: 5,
invokeMethod: 6,
connectToSignal: 7,
disconnectFromSignal: 8,
setProperty: 9,
response: 10,
};
var QWebChannel = function(transport, initCallback)
{
if (typeof transport !== "object" || typeof transport.send !== "function") {
console.error("The QWebChannel expects a transport object with a send function and onmessage callback property." +
" Given is: transport: " + typeof(transport) + ", transport.send: " + typeof(transport.send));
return;
}
...
}
function QObject(name, data, webChannel)
{
...
}
這個代碼比較長,就不全部貼出來了。主要實作了兩個類(js中的類就是”帶原型方法“的函數),QWebChannel和QObject。
QWebChannel就是用來接管websocket的,而QObject是用js Object模拟的 Qt的 QObject。
這一塊不細說了,感興趣的同學可以自己去研究源碼。
改進qwebchannel.js以支援await
Qt預設的qwebchannel.js在實際使用過程中,有些不好的地方,就是函數的傳回值不是直接傳回,而是要在回調函數中擷取。
比如car.getAngle要這樣用:
let angle = 0;
car.getAngle((value:number)=> {
angle = value;
});
我們的實際項目中,有大量帶傳回值的api,這樣的用法每次都嵌套一個回調函數,很不友好,容易造成回調地獄。
我們同僚的解決方案是,在typescript中把這些api再用Promise封裝一層,外面用await調用。
例如這樣封裝一層:
function getAngle () {
return new Promise((resolve)=>{
car.getAngle((value:number)=> {
resolve(value);
});
});
}
使用和前面的代碼一樣:
//異步更新 angle
async function onAngleChanged() {
let angleLabel = (window as any).document.getElementById("angle");
let car = WebChannelCore.SDK.car;
//擷取angle,異步等待。
//注意這裡改造過qwebchannel.js,才能使用await。
angleLabel.textContent = await car.getAngle();
}
這種解決方案規避了回調地獄,但是工作量增加了。
濤哥思考良久,稍微改造一下qwebchannel.js,自動把Promise加進去,也不需要再額外封裝了。
改動如下:
在QObject的addMethod中,增加了Promise。
QObject to Typescript
我們在Qt 程式中寫了QObject,然後暴露給了ts。
在ts這邊,一般也需要提供一個聲明檔案,明确有哪些api可用。
例如我們的car聲明:
這裡濤哥寫了一個小工具,能夠解析Qt中的QObject,并生成對應的ts檔案。
當然還是實驗階段,有興趣的也可以關注一下
github.com/jaredtao/QObject2TypeScript
編輯于 03-13