Qt QWebEngine自定义scheme协议实现web页面的请求拦截并返回Response(附带Electron中的实现)

2,710 阅读7分钟

目标

  1. 自定义 QWebEngineURL scheme 协议,实现对web页面中发起的 request 进行拦截和返回自定义的 response
  2. 了解自定义 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调试工具):

image.png

image.png

成功拿到。

但是这里我们只是通过拦截请求之后读取本地文件直接返回,在实际的开发中,应该还有其他的需求场景,比如: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>

看一下结果:

第一次运行,本地没有该图片文件 image.png

打开 MainWindow 窗口之后: image.png

重新运行并打开 MainWindow 窗口: image.png

Electron 中的实现

Electron 中实现比较简单,因为官方已经提供了轮子:protocol.registerFileProtocol

protocol.registerFileProtocol(scheme, handler)

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 的秒开效果了。

  1. 类似我们这里实现的Demo,第一次远程加载并写入到本地,下次使用下载好的本地资源,提升用户体验。但是解决不了第一次加载延迟的问题。

  2. 利用客户端闲置的时间,提前下载用户打开频率很高的嵌套的web页面的css、js等静态资源到本地,提升用户体验,整个过程用户无感知。客户端首屏一般都是native代码的,在打开客户端之后,客户端后利用空余时间提前下载。如果首屏都是web的,那么可在登录页面提前下载。怎么提前下载可根据实际客户端的设计来进行。

  3. 获取web部分css、js代码直接随着客户端打包到本地。

    ...

其实,还需要设计一个资源的版本管理,就不展开说了。

主要讲的是混合开发场景下怎么加快资源的加载,提升用户体验,其实也有很多其他方面的用途,比如缓存大的视频和音频等...