Qt與Web混合開發(二)--建立連接


轉載聲明:文章采用知識共享署名-非商業性使用-相同方式共享 4.0 國際許可協議進行許可, 轉載請注明出處 © 武威的濤哥

前言

《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進行通信。

這里用我自己做的例子來說明吧。

先看看效果:

左邊是Qt實現的一個簡易小車,可以前進和轉向。右邊是Html5實現的控制端,控制左邊的小車。

源碼在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的方案。參考: https://www.freedesktop.org/wiki/Software/dbus/)

改進小車

我做了一些修改,主要如下:

  • 去掉了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。

大致使用流程:

  1. Qt程序中,要暴露的QObject全部注冊到WebChannel。

  2. Qt程序中,啟動一個WebSocketServer,等待Html的連接。

  3. Html加載好qwebchannel.js文件, 然后去連接WebSocket。

  4. 連接建立以后,Qt程序中,由WebChannel接手這個WebSocket,按協議將QObject的各種“元數據”傳輸給遠端Html。

  5. 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參數

1
2
3
4
5
6
//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。

所以有了下面的代碼:

1
2
3
4
5
6
7
8
...
QString psCmd = QString("powershell -noprofile -command \"[void][System.Diagnostics.Process]::Start('%1', '%2')\"").arg(browser).arg(url.toString());
bool ok = QProcess::startDetached(psCmd);
qWarning() << psCmd;
if (!ok) {
qWarning() << "failed";
}
...

結果完美運行。

Web控制端

目錄結構

Web端就按照Web常規流程開發。

Web部分的源碼也在前文提到的github倉庫,子路徑是QtWeb\WebChannelCar\Web

如下是Web部分的目錄結構:

腳本用typescript,包管理用npm,打包用webpack,編輯器用vs code, 都中規中矩。

內容比較簡單,暫時不需要前端框架,手(復)寫(制)的html和css。

Html

html部分比較簡單

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
//index.html
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; chartset=utf-8" />
<link rel="stylesheet" type="text/css" href="../style/style.css" />
<link rel="stylesheet" type="text/css" href="../style/layout.css" />
</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">0</label>
</div>
<div>
<label>角度: </label>
<label id="angle">0</label>
</div>
</div>
</body>
<script src="../out/main.js">

</script>

</html>

樣式和布局全靠css,這里就不貼了。

TypeScript

腳本部分需要細說了。

src文件夾為全部腳本,目錄結構如下:

TypeScript中的QObject

從main開始, 加點注釋:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
//main.ts
import 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";
}
//異步更新 speed
async function onSpeedChanged() {
let speedLabel = (window as any).document.getElementById("speed");
let car = WebChannelCore.SDK.car;
//獲取speed,異步等待。
//注意這里改造過qwebchannel.js,才能使用await。
speedLabel.textContent = await car.getSpeed();
}
//異步更新 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();
}

可以看到我們從WebChannelCore.SDK 中獲取了一個car對象,之后就當作QObject來用了,包括調用它的函數、連接change信號、訪問屬性等。

這一切都得益於WebSocket/WebChannel.

TypeScript中連接websocket

接下來看一下WebChannelCore的實現

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
//WebChannelCore.ts
import { 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的頭文件)

內容如下:

1
2
//qwebchannel.d.ts
export declare function QWebChannel(transport: any, initCallback: Function): void;

只是導出了一個函數。

這個函數的實現在‘qwebchannel.js’中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
//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)
{
...
}

這個代碼比較長,就不全部貼出來了。主要實現了兩個類,QWebChannel和QObject。

QWebChannel就是用來接管websocket的,而QObject是用js Object模擬的 Qt的 QObject。

這一塊不細說了,感興趣的同學可以自己去研究源碼。

改進qwebchannel.js以支持await

Qt默認的qwebchannel.js在實際使用過程中,有些不好的地方,就是函數的返回值不是直接返回,而是要在回調函數中獲取。

比如car.getAngle要這樣用:

1
2
3
4
let angle = 0;
car.getAngle((value:number)=> {
angle = value;
});

我們的實際項目中,有大量帶返回值的api,這樣的用法每次都嵌套一個回調函數,很不友好,容易造成回調地獄。

我們同事的解決方案是,在typescript中把這些api再用Promise封裝一層,外面用await調用。

例如這樣封裝一層:

1
2
3
4
5
6
7
function getAngle () {
return new Promise((resolve)=>{
car.getAngle((value:number)=> {
resolve(value);
});
});
}

使用和前面的代碼一樣:

1
2
3
4
5
6
7
8
//異步更新 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 to Typescript

我們在Qt 程序中寫了QObject,然后暴露給了ts。

在ts這邊,一般也需要提供一個聲明文件,明確有哪些api可用。

例如我們的car聲明:

1
2
3
4
5
6
7
8
9
10
11
12
13
//CarObject.ts
declare class Car {
get speed():number;
set speed(value:number);

get angle():number;
set angle(vlaue:number);

public accelerate():void;
public decelerate():void;
public turnLeft():void;
public turnRight():void;
}

這里濤哥寫了一個小工具,能夠解析Qt中的QObject,並生成對應的ts文件。

當然還是實驗階段,有興趣的也可以關注一下

https://github.com/jaredtao/QObject2TypeScript

 

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM