目标
- 自定义
QWebEngine的URL scheme协议,实现对web页面中发起的request进行拦截和返回自定义的response - 了解自定义
URL scheme协议的具体使用场景(特别是在混合开发场景下,提升页面加载速度、静态和媒体资源的混存等)
本文所有代码都在 github 上:github.com/1111mp/ForQ…。前置代码可通过查看另一篇文章了解:
《Qt中基于QWebEngineView和QWebChannel实现与web的交互》
前置知识
To implement a custom URL scheme for QtWebEngine, you first have to create an instance of QWebEngineUrlScheme and register it using QWebEngineUrlScheme::registerScheme().
QWebEngineUrlScheme
QWebEngine 中自定义 scheme 协议的api是:QWebEngineUrlScheme(官网文档地址:doc.qt.io/qt-6/qweben…
Note: Make sure that you create and register the scheme object before the QGuiApplication or QApplication object is instantiated.
int main(int argc, char **argv)
{
// myscheme协议,web中可通过 myscheme://www.example.com/main.html 发起自定义协议请求
QWebEngineUrlScheme scheme("myscheme");
scheme.setSyntax(QWebEngineUrlScheme::Syntax::HostAndPort);
scheme.setDefaultPort(2345);
scheme.setFlags(QWebEngineUrlScheme::SecureScheme);
QWebEngineUrlScheme::registerScheme(scheme);
// 根据文档,需要在创建QApplication实例之前调用
// ...
QApplication app(argc, argv);
// ...
}
但是 QWebEngineUrlScheme 只是提供了 scheme 的描述,让系统知道有这个协议,要实际使用这个协议,需要通过 QWebEngineUrlSchemeHandler 创建和注册具体协议的实现。
QWebEngineUrlSchemeHandler
官网文档地址:doc.qt.io/qt-6/qweben…
QWebEngineUrlSchemeHandler 是用于处理自定义 URL scheme 方案的基类,所以如果要实现自定义的 scheme,必须创建一个派生自 QWebEngineUrlSchemeHandler 的类,并重新实现 requestStarted()方法。
// 来自官网的demo代码
class MySchemeHandler : public QWebEngineUrlSchemeHandler
{
public:
MySchemeHandler(QObject *parent = nullptr);
void requestStarted(QWebEngineUrlRequestJob *job)
{
// 获取method:GET POST...
const QByteArray method = job->requestMethod();
// url
const QUrl url = job->requestUrl();
// headers
const auto headers = job->requestHeaders();
if (isValidUrl(url)) {
if (method == QByteArrayLiteral("GET")) {
// 返回 response 内容 第一个参数是 Content-Type
job->reply(QByteArrayLiteral("text/html"), makeReply(url));
else // Unsupported method
job->fail(QWebEngineUrlRequestJob::RequestDenied);
} else {
// Invalid URL
job->fail(QWebEngineUrlRequestJob::UrlNotFound);
}
}
bool isValidUrl(const QUrl &url) const // ....
QIODevice *makeReply(const QUrl &url) // ....
};
int main(int argc, char **argv)
{
// 相当于先创建 URL scheme 的描述,后面再创建和注册具体的行为
QWebEngineUrlScheme scheme("myscheme");
scheme.setSyntax(QWebEngineUrlScheme::Syntax::HostAndPort);
scheme.setDefaultPort(2345);
scheme.setFlags(QWebEngineUrlScheme::SecureScheme);
QWebEngineUrlScheme::registerScheme(scheme);
// ...
QApplication app(argc, argv);
// ...
// installUrlSchemeHandler does not take ownership of the handler.
// 创建和注册 URL scheme 的具体行为
MySchemeHandler *handler = new MySchemeHandler(parent);
// 全局注册 所有的 QWebEngine 都支持这个协议
QWebEngineProfile::defaultProfile()->installUrlSchemeHandler("myscheme", handler);
}
URL Scheme 实现
main.cpp:
#include <QApplication>
#include <QWebEngineUrlScheme>
#include <QWebEngineProfile>
#include <QDir>
#include "src/application.h"
#include "src/appschemehandler.h"
int main(int argc, char *argv[])
{
// 协议名为 app
// 页面中通过 app://www.example.com/ 协议的请求都能被拦截到
QWebEngineUrlScheme scheme("app");
scheme.setSyntax(QWebEngineUrlScheme::Syntax::HostAndPort);
scheme.setDefaultPort(2345);
scheme.setFlags(
QWebEngineUrlScheme::SecureScheme |
QWebEngineUrlScheme::LocalAccessAllowed |
QWebEngineUrlScheme::ViewSourceAllowed |
QWebEngineUrlScheme::ContentSecurityPolicyIgnored |
QWebEngineUrlScheme::CorsEnabled |
QWebEngineUrlScheme::FetchApiAllowed); // Qt 6.6 supported
// web 拦截fetch请求需要设置
// 参考地址:https://stackoverflow.com/questions/64892161/qtwebengine-fetch-api-fails-with-custom-scheme
QWebEngineUrlScheme::registerScheme(scheme);
QApplication a(argc, argv);
// QApplication::setQuitOnLastWindowClosed(false);
Application app;
auto handler = QScopedPointer<AppSchemeHandler>(new AppSchemeHandler());
QWebEngineProfile::defaultProfile()->installUrlSchemeHandler("app", handler.data());
return a.exec();
}
具体实现代码 appschemehandler.h & appschemehandler.cpp:
#ifndef APPSCHEMEHANDLER_H
#define APPSCHEMEHANDLER_H
#include <QObject>
#include <QWebEngineUrlSchemeHandler>
#include <QWebEngineUrlRequestJob>
#include <QDebug>
class AppSchemeHandler : public QWebEngineUrlSchemeHandler
{
Q_OBJECT
public:
explicit AppSchemeHandler(QObject *parent = nullptr);
void requestStarted(QWebEngineUrlRequestJob* job) override;
signals:
};
#endif // APPSCHEMEHANDLER_H
#include "appschemehandler.h"
#include <QFile>
#include <QFileInfo>
#include <QMimeDatabase>
#include <QMimeType>
AppSchemeHandler::AppSchemeHandler(QObject *parent)
: QWebEngineUrlSchemeHandler{parent}
{
}
void AppSchemeHandler::requestStarted(QWebEngineUrlRequestJob *job)
{
qInfo() << "method:" << job->requestMethod();
qInfo() << "url:" << job->requestUrl();
QByteArray requestMethod = job->requestMethod();
if (requestMethod != "GET") {
// 拒绝不是GET的请求
job->fail(QWebEngineUrlRequestJob::RequestDenied);
return;
}
QUrl requestUrl = job->requestUrl();
QString requestPath = requestUrl.path();
// 这里写死 加载本地的 main.html 文件
QFile* file(new QFile(":/main.html", job));
if (!file->exists() || file->size() == 0) {
qWarning("QResource '%s' not found or is empty", qUtf8Printable(requestPath));
job->fail(QWebEngineUrlRequestJob::UrlNotFound);
return;
}
QFileInfo fileInfo(*file);
QMimeDatabase mimeDatabase;
QMimeType mimeType = mimeDatabase.mimeTypeForFile(fileInfo);
// 回收 QFile
connect(job, &QObject::destroyed, file, &QObject::deleteLater);
if (mimeType.name() == QStringLiteral("application/x-extension-html"))
// 返回 response
job->reply("text/html", file);
else
job->reply(mimeType.name().toUtf8(), file);
}
web 中使用 login.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>LoginWindow</title>
</head>
<script src="qrc:/qtwebchannel/qwebchannel.js"></script>
<body>
<div>
<h2>Hello QWebChannel</h2>
<p>{ name: "QWebChannel", version: 6.6 }</p>
<button onclick="onLogin()">Login</button>
<br />
<button onclick="onTestScheme()">Test Scheme</button>
</div>
</body>
<script>
console.log(navigator.userAgent);
const data = { name: "QWebChannel", version: 6.6 };
new QWebChannel(qt.webChannelTransport, function (channel) {
window.Context = channel.objects.Context;
});
function onLogin() {
window.Context.onLogin(data);
}
// 测试 是否能被拦截并返回 main.html 文件
function onTestScheme() {
// 自定义 app:// 协议
fetch("app://www.baidu.com", {
method: "GET",
headers: {
"Content-Type": "text/html",
},
})
.then((res) => {
console.log(res);
})
.catch((err) => {
console.log(err);
});
}
</script>
</html>
结果(QWebEngine调试工具):
成功拿到。
但是这里我们只是通过拦截请求之后读取本地文件直接返回,在实际的开发中,应该还有其他的需求场景,比如:web 页面加载一个图片或者视频,第一次从远程服务器加载,加载完之后存到本地,然后下一次直接读取本地的缓存文件(为什么不用http的强缓存,主要因为实际中http缓存会因为一些不可控的因素导致缓存丢失或者失效:杀毒软件/清理垃圾等)。
实现web加载图片,第一次加载之后写入到本地,后续直接从本地读取(这个Demo分别在QWebEngine和Electron中实现一次)
1. QWebEngine中的实现
改造之后的 AppSchemeHandler 代码 appschemehandler.h & appschemehandler.cpp:
#ifndef APPSCHEMEHANDLER_H
#define APPSCHEMEHANDLER_H
#include <QObject>
#include <QWebEngineUrlSchemeHandler>
#include <QWebEngineUrlRequestJob>
#include <QNetworkAccessManager>
#include <QNetworkRequest>
#include <QNetworkReply>
#include <QScopedPointer>
#include <QDebug>
class AppSchemeHandler : public QWebEngineUrlSchemeHandler
{
Q_OBJECT
public:
explicit AppSchemeHandler(QObject *parent = nullptr);
void requestStarted(QWebEngineUrlRequestJob* job) override;
public slots:
void onFinished();
void onDownload();
signals:
private:
QNetworkAccessManager* m_Manager;
QScopedPointer<QNetworkReply> m_Reply;
};
#endif // APPSCHEMEHANDLER_H
#include "appschemehandler.h"
#include <QString>
#include <QFile>
#include <QFileInfo>
#include <QMimeDatabase>
#include <QMimeType>
AppSchemeHandler::AppSchemeHandler(QObject *parent)
: QWebEngineUrlSchemeHandler{parent}
{
m_Manager = new QNetworkAccessManager(this);
}
void AppSchemeHandler::requestStarted(QWebEngineUrlRequestJob *job)
{
qInfo() << "method:" << job->requestMethod();
auto url = job->requestUrl().toString();
qInfo() << "url:" << url;
qInfo() << job->requestHeaders();
// 为了方便 这里就直接写死了 应该根据实际请求的url 进行判断是否是需要下载到本地
if(url == "app://upload-images.jianshu.io/upload_images/5809200-a99419bb94924e6d.jpg")
{
auto file = new QFile("./5809200-a99419bb94924e6d.jpg");
// 判断本地文件是否存在
if(file->exists())
{
// 存在 直接读取 并返回
qInfo() << "exist";
file->setParent(job);
file->open(QIODevice::ReadOnly);
connect(job, &QObject::destroyed, file, &QObject::deleteLater);
job->reply("image/webp", file);
}
else
{
// 不存在 先请求远程服务器 拿到之后写入本地 & 返回
// 获取真实的 url (将 app 替换成 https)
auto realUrl = url.replace(0, 3, "https");
qInfo() << "real url" << realUrl;
QNetworkRequest request;
request.setUrl(realUrl);
m_Reply.reset(m_Manager->get(request));
// 这一步很关键 后面根据 m_Reply->parent() 获取
m_Reply->setParent(job);
connect(m_Reply.data(), &QNetworkReply::finished, this, &AppSchemeHandler::onDownload);
}
file->close();
return;
}
QByteArray requestMethod = job->requestMethod();
if (requestMethod != "GET") {
job->fail(QWebEngineUrlRequestJob::RequestDenied);
return;
}
QUrl requestUrl = job->requestUrl();
QString requestPath = requestUrl.path();
QFile* file(new QFile(":/main.html", job));
if (!file->exists() || file->size() == 0) {
qWarning("QResource '%s' not found or is empty", qUtf8Printable(requestPath));
job->fail(QWebEngineUrlRequestJob::UrlNotFound);
return;
}
QFileInfo fileInfo(*file);
QMimeDatabase mimeDatabase;
QMimeType mimeType = mimeDatabase.mimeTypeForFile(fileInfo);
connect(job, &QObject::destroyed, file, &QObject::deleteLater);
if (mimeType.name() == QStringLiteral("application/x-extension-html"))
job->reply("text/html", file);
else
job->reply(mimeType.name().toUtf8(), file);
}
void AppSchemeHandler::onFinished()
{
qInfo() << "finished";
QWebEngineUrlRequestJob *job = qobject_cast<QWebEngineUrlRequestJob *>(m_Reply->parent());
if (!job) {
return;
}
if (m_Reply->error()) {
job->fail(QWebEngineUrlRequestJob::UrlNotFound);
return;
}
QVariant contentMimeType = m_Reply->header(QNetworkRequest::ContentTypeHeader);
QByteArray mime = contentMimeType.toByteArray();
const int pos = mime.indexOf(';');
if (pos != -1) {
mime = mime.left(pos);
}
// 读取并返回
// 为什么写入和读取分两次 反复测试过 只能这样才能生效 要不然m_Reply->readAll()之后再作为response返回web 会拿不到资源
auto file = new QFile("./5809200-a99419bb94924e6d.jpg");
file->setParent(job);
file->open(QIODeviceBase::ReadOnly);
file->close();
connect(job, &QObject::destroyed, file, &QObject::deleteLater);
job->reply(mime, file);
}
void AppSchemeHandler::onDownload()
{
qInfo() << "onDownload";
// 写入到本地
QFile file("./5809200-a99419bb94924e6d.jpg");
file.open(QIODeviceBase::WriteOnly);
file.write(m_Reply->readAll());
file.close();
onFinished();
}
MainWindow 窗口的main.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MainWindow</title>
<meta
http-equiv="Cache-Control"
content="no-cache, no-store, must-revalidate"
/>
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
<style>
img {
width: 500px;
height: 400px;
}
</style>
<script src="qrc:/qtwebchannel/qwebchannel.js"></script>
</head>
<body>
<div>
<h2>Hello QWebChannel</h2>
<p id="info"></p>
<button onclick="onChangeInfo()">change info</button>
<p id="result"></p>
<button onclick="onTest()">Call native method async & callback</button>
<br />
<!-- 自定义app协议加载图片 -->
<img
src="app://upload-images.jianshu.io/upload_images/5809200-a99419bb94924e6d.jpg"
alt=""
/>
</div>
</body>
<script>
console.log(navigator.userAgent);
const manager = new Map();
let id = 0; // should use uuid
const ele = document.querySelector("#info");
const result = document.querySelector("#result");
new QWebChannel(qt.webChannelTransport, function (channel) {
window.Context = channel.objects.Context;
ele.innerText = JSON.stringify(window.Context.info);
window.Context.onResponse.connect(function (id, data) {
const { resolve = undefined } = manager.get(id);
resolve && resolve(data);
});
window.Context.onInfoChanged.connect(function (info) {
alert(JSON.stringify(info));
ele.innerText = JSON.stringify(info);
});
});
function onChangeInfo() {
window.Context.setInfo(
{ name: "from web", version: 6.6 },
(returnVal) => {
alert(returnVal);
}
);
}
function makePromise(callback) {
id += 1;
return new Promise((resolve, reject) => {
callback(id);
manager.set(id, { resolve, reject });
});
}
function onTest() {
makePromise((id) => {
window.Context.request(id, "from html");
}).then((res) => {
result.innerText = JSON.stringify(res);
});
}
</script>
</html>
看一下结果:
第一次运行,本地没有该图片文件
打开 MainWindow 窗口之后:
重新运行并打开 MainWindow 窗口:
Electron 中的实现
Electron 中实现比较简单,因为官方已经提供了轮子:protocol.registerFileProtocol
protocol.registerFileProtocol(scheme, handler)
-
schemestring -
handler函数-
requestProtocolRequest -
callbackFunctionresponse(string | ProtocolResponse)
-
Returns boolean - 当前协议是否注册成功
注册一个 scheme 协议, 并将该文件作为响应返回。 当 request 是 scheme 内需要的请求时,request 和 callback 将自动调用 handler
要处理 request, 应当使用文件的路径或具有 path 属性的对象来调用 callback。例如:callback(filePath)或 callback({ path: filePath }). filePath 必须是绝对路径
默认情况下, scheme 被当作 http:对待,与遵循"通用URI语法"的协议(如 file:)有不同的解析过程。
// ...
import { protocol } from "electron"
import fs from "fs-extra"
// ...
// ...
// 伪代码 思路是一样的
app.whenReady().then(() => {
// atom 可为任意字符串 比如 Qt 中我们是用的 app://
protocol.registerFileProtocol('atom', (request, callback) => {
const url = request.url.substr(7);
if (fs.existsSync(path)) {
// 存在
callback({ path: path });
} else {
// 不存在
// 使用node api 下载文件到本地 使用axios就行
// 可参考 https://blog.csdn.net/zyh_haha/article/details/89479995
callback({ path: path });
}
callback({ path: path.normalize(`${__dirname}/${url}`) })
})
});
// ...
// ...
然后页面中就可以使用 atom://www.example.com 协议了
就可以实现出跟 Qt 中一样的效果了。
使用场景
在混合开发中,Native 客户端里面需要通过 WebView 加载我们的前端项目(https远程加载),这时候的用户体验就不能做到完全像 Native 的代码一样实现秒开,这时候我们这种这种方案就有用武之地了:可以将web项目的一些静态的css、js资源通过这种方式缓存在本地,下次打开就可以实现类似 Native 的秒开效果了。
-
类似我们这里实现的Demo,第一次远程加载并写入到本地,下次使用下载好的本地资源,提升用户体验。但是解决不了第一次加载延迟的问题。
-
利用客户端闲置的时间,提前下载用户打开频率很高的嵌套的web页面的css、js等静态资源到本地,提升用户体验,整个过程用户无感知。客户端首屏一般都是native代码的,在打开客户端之后,客户端后利用空余时间提前下载。如果首屏都是web的,那么可在登录页面提前下载。怎么提前下载可根据实际客户端的设计来进行。
-
获取web部分css、js代码直接随着客户端打包到本地。
...
其实,还需要设计一个资源的版本管理,就不展开说了。
主要讲的是混合开发场景下怎么加快资源的加载,提升用户体验,其实也有很多其他方面的用途,比如缓存大的视频和音频等...