前言 这次讨论Qt与Web混合开发相关技术。
这类技术存在适用场景,例如:Qt项目使用Web大量现成的组件/方案做功能扩展,
Qt项目中性能无关/频繁更新迭代的页面用html单独实现,Qt项目提供Web形式的SDK给
用户做二次开发等等,或者是Web开发人员齐全而Qt/C++人手不足,此类非技术问题,
都可以使用Qt + Web混合开发。
(不适用的请忽略本文)
简介 上次的文章《Qt与Web混合开发》,讨论了Qt与Web混合开发相关技术。
这次通过一个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。
大致使用流程:
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参数
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 import WebChannelCore from "./webchannelCore" ;window .onload = () => { WebChannelCore.initialize(onInit, onUninit); } function onInit ( ) { (window as any ).document.getElementById("img" ).src = "../img/connected.svg" ; let car = WebChannelCore.SDK.car; 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 = () => { car.accelerate(); } downBtn.onclick = () => { car.decelerate(); } leftBtn.onclick = () => { car.turnLeft(); } rightBtn.onclick = () => { car.turnRight(); } car.speedChanged.connect(onSpeedChanged); car.angleChanged.connect(onAngleChanged); } function onUninit ( ) { (window as any ).document.getElementById("img" ).src = "../img/disconnected.svg" ; } async function onSpeedChanged ( ) { let speedLabel = (window as any ).document.getElementById("speed" ); let car = WebChannelCore.SDK.car; speedLabel.textContent = await car.getSpeed(); } async function onAngleChanged ( ) { let angleLabel = (window as any ).document.getElementById("angle" ); let car = WebChannelCore.SDK.car; 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 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 { WebChannelCore.link( (socket) => { QWebChannel(socket, (channel: any ) => { WebChannelCore.SDK = channel.objects; WebChannelCore.connectedCb(); }); } , (error ) => { 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 ) { 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); let socket = new WebSocket(baseUrl); WebChannelCore.socket = socket; 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 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 "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 async function onAngleChanged ( ) { let angleLabel = (window as any ).document.getElementById("angle" ); let car = WebChannelCore.SDK.car; 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 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