Qt内嵌web技术及透明问题解决

1 基本使用

本节先完整梳理下Qt内嵌Web相关模块、类和基本用法,

在这里插入图片描述

模块架构

在这里插入图片描述

1.1 WebEngine Widgets

WebEngine Widgets提供了用于将Web内容嵌入QWidget等窗口控件,

1)组件组成
类名功能
QWebEngineView用于展示web,提供加载url、导航历史、页面缩放等接口
QWebEnginePage用于表示web页面,提供执行js方法、处理表单、截图等接口
QWebEngineProfile用于表示浏览器配置文件,提供设置缓存、cookie和安全策略等接口
QWebEngineSettings用于表示浏览器设置,提供设置web字体、颜色、js和插件等接口
2)组件关系

在这里插入图片描述

Qt WebEngine基于QNetworkAccessManagerQSslSocket处理网络通信、证书验证等。

1.2 QWebEngineView

QWebEngineViewQt内嵌web的关键控件,支持HTML5jsCSS3

1)示例代码
QWebEngineView view;
view.load(QUrl("https://www.baidum.com"));
view.show();

1.3 QWebEnginePage

QWebEnginePage用于表示加载到QWebEngineView中的web页面。

1)示例代码
QWebEngineView view;
QWebEnginePage page;
view.setPage(&page);
page.load(QUrl("https://www.baidu.com"));
view.show();
2)常用方法
名称作用
loadStarted/loadProgress/loadFinished用于监听页面加载状态
urlChanged用于监听页面url变更
runJavaScript用于执行页面中的js代码
3)懒加载

page源码学习下Qt懒加载C++代码写法,

QWebEnginePage* QWebEngineView::page() const
{
    Q_D(const QWebEngineView);
    if (!d->page) {
        QWebEngineView *that = const_cast<QWebEngineView*>(this);
        that->setPage(new QWebEnginePage(that));
        d->m_ownsPage = true;
    }
    return d->page;
}

1.4 QWebEngineSettings

QWebEngineSettings用于配置web设置选项,包括字体、缩放、js支持和插件支持等。

1)常用方法
名称作用
setFontFamily用于设置字体、样式
setFontSize用于设置字体大小
setAttribute用于开启/关闭特性,包括js支持、插件支持(flash和视频解码器等)、本地存储、离线web应用、错误页面等
setUnknownUrlSchemePolicy用于配置跨域访问策略
2)示例代码
QWebEngineView view;
QWebEngineSettings *settings = view.page()->settings();
settings->setFontFamily(QWebEngineSettings::StandardFont, "黑体");
settings->setFontSize(QWebEngineSettings::DefaultFontSize, 20);
settings->setAttribute(QWebEngineSettings::JavascriptEnabled, true);
settings->setAttribute(QWebEngineSettings::PluginsEnabled, true);
view.load(QUrl("https://www.baidu.com"));
view.show();

1.5 QWebEngineScript

QWebEngineScript支持通过注入js脚本到网页来新增/修改现有功能,可以配置脚本执行时机(web加载开始或结束)。

1)示例代码
QWebEngineView view;
QWebEnginePage page;
QWebEngineScript script;
script.setName("testScript");
script.setSourceCode("alert('test script');");
script.setInjectionPoint(QWebEngineScript::DocumentReady);
script.setRunsOnSubFrames(false);
script.setWorldId(QWebEngineScript::ApplicationWorld);
page.scripts().insert(script);
view.setPage(&page);
page.load(QUrl("https://www.baidu.com"));
view.show();

1.6 QWebEngineScriptCollection

用于管理 QWebEnginePage 实例中的所有脚本。

1.7 QWebEngineFullScreenRequest

web触发全屏模式请求时,如HTML5``video元素的全屏按钮,QWebEnginePage发出fullScreenRequested信号,QWebEngineFullScreenRequest对象作为该信号的惨数传递,用于处理web发出的全屏显示请求。

1)常用方法
名称作用
reject用于拒绝全屏请求
accept用于接受全屏请求
toggleOn用于·表示请求是进入全屏模式还是退出全屏模式
origin用于获取发出全屏请求的web元素
2)示例代码
#include <QWebEngineView>
#include <QWebEnginePage>
#include <QWebEngineFullScreenRequest>

class TestWebEngineView : public QWebEngineView
{
    Q_OBJECT

public:
    MyWebEngineView() {
        connect(page(), SIGNAL(fullScreenRequested(QWebEngineFullScreenRequest)),
                this, SLOT(slotFullScreenRequested(QWebEngineFullScreenRequest));
    }

private slots:
    void slotFullScreenRequested(QWebEngineFullScreenRequest request) {
        if (request.toggleOn()) {
            showFullScreen();
        } else {
            showNormal();
            }
        request.accept();
    }
};

1.8 QWebEngineCertificateError

QWebEngineCertificateError用于处理SSL证书错误,当QWebEnginePage加载使用 SSL加密的网页时,遇到证书错误,函数certificateError会被执行,QWebEngineCertificateError对象作为参数传递。

1)常用方法
名称作用
error用于获取错误类型
errorDescription用于获取错误描述
url用于获取错误url
ignoreCertificateError用于忽略错误并继续加载web
2)示例代码
#include <QWebEngineView>
#include <QWebEnginePage>
#include <QWebEngineCertificateError>

class TestWebEnginePage : public QWebEnginePage
{
    Q_OBJECT

protected:
    bool certificateError(const QWebEngineCertificateError &error) override
    {
        if (error.error() == QWebEngineCertificateError::CertificateAuthorityInvalid)
        {
            qDebug() << "Ignoring certificate error:" << error.errorDescription();
            return true;
        }
        return false;
    }
};

1.9 QWebEngineClientCertificateSelection

QWebEngineClientCertificateSelection用于处理客户端证书选择,当服务器要求客户端提供证书进行身份验证时,QWebEnginePage 会发出一个 selectClientCertificate信号,携带参数 QWebEngineClientCertificateSelection对象,可使用该类选择一个合适的客户端证书,返回给服务器。

1)常用方法
名称作用
certificates用于获取客户端可用证书列表
select用于从可用证书列表中选择1个,返回给服务器
host用于获取服务器发请求的url
2)示例代码
#include <QWebEngineView>
#include <QWebEnginePage>
#include <QWebEngineClientCertificateSelection>

class TestWebEnginePage : public QWebEnginePage
{
    Q_OBJECT

public:
    TestWebEnginePage()
    {
        connect(this, &QWebEnginePage::selectClientCertificate,
                this, &MyWebEnginePage::handleClientCertificateSelection);
    }

private slots:
    void handleClientCertificateSelection(QWebEngineClientCertificateSelection selection)
    {
        if (!selection.certificates().isEmpty())
        {
            selection.select(selection.certificates().first());
        } else
        {
            selection.select(QSslCertificate());
        }
    }
};

1.10 QWebEngineDownloadItem

QWebEngineDownloadItem用于表示下载项。当用户触发下载请求(点击下载链接),QWebEngineProfile 发出downloadRequested信号,携带参数QWebEngineDownloadItem对象,可用于获取下载项信息、控制下载过程。

1)常用方法
名称作用
state用于获取下载项的当前状态,如下载中、已完成、已取消等
totalBytes用于获取下载项的总字节信息
receivedBytes用于获取提供了下载项的已接收字节信息
url用于获取下载项的源url
setPath用于设置下载项的保存路径

2)示例代码

#include <QApplication>
#include <QWebEngineView>
#include <QWebEngineProfile>
#include <QWebEngineDownloadItem>
#include <QStandardPaths>

class MyWebEngineProfile : public QWebEngineProfile
{
    Q_OBJECT

public:
    MyWebEngineProfile()
    {
        connect(this, &QWebEngineProfile::downloadRequested,
                this, &MyWebEngineProfile::handleDownloadRequested);
    }

private slots:
    void handleDownloadRequested(QWebEngineDownloadItem *download)
    {
        QString downloadPath = QStandardPaths::writableLocation(QStandardPaths::DownloadLocation);
        download->setPath(downloadPath + "/" + download->downloadFileName());
        download->accept();
        connect(download, &QWebEngineDownloadItem::downloadProgress,
                this, &MyWebEngineProfile::handleDownloadProgress);
        connect(download, &QWebEngineDownloadItem::finished,
                this, &MyWebEngineProfile::handleDownloadFinished);
    }

    void handleDownloadProgress(qint64 bytesReceived, qint64 bytesTotal)
    {
        qDebug() << "Download progress:" << bytesReceived << "/" << bytesTotal;
    }

    void handleDownloadFinished()
    {
        qDebug() << "Download finished.";
    }
};

1.11 QWebEngineContextMenuData

QWebEngineContextMenuData用于提供上下文菜单数据,当用户在QWebEngineView中右击,QWebEnginePage 发出contextMenuRequested信号,携带参数QWebEngineContextMenuData对象,可用该对象获取上下文菜单信息,根据需要创建和显示上下文菜单。

1)常用方法
名称作用
position用于获取触发位置对应鼠标坐标
selectedText用于获取触发位置附近的选中文本
linkText用于获取触发位置所包含链接的文本
linkUrl用于获取触发位置所包含链接的url
mediaUrl用于获取触发位置所包含图像的url
mediaType用于获取触发位置所包含媒体元素的类型
2)示例代码
#include <QWebEngineView>
#include <QWebEnginePage>
#include <QWebEngineContextMenuData>
#include <QMenu>

class TestWebEnginePage : public QWebEnginePage
{
    Q_OBJECT

public:
    TestWebEnginePage()
    {
        connect(this, &QWebEnginePage::contextMenuRequested,
                this, &TestWebEnginePage::handleContextMenuRequested);
    }

private slots:
    void handleContextMenuRequested(const QPoint &pos)
    {
        QWebEngineContextMenuData contextData = contextMenuData();
        QMenu menu;
        if (!contextData.linkUrl().isEmpty())
            menu.addAction("Open link");
        if (!contextData.selectedText().isEmpty())
            menu.addAction("Copy selected text");
        menu.addAction("Refresh");
        menu.exec(mapToGlobal(pos));
    }
};

1.12 QChannel

支持jsC++之间的互操作。

web和客户端之间通过signal/slot绑定,用于互相之间方法调用。

1)C++侧

创建一个继承自QObject的类,用于在Qtweb之间传递数据和调用函数。

TestObject.h

#include <QObject>
#include <QString>

class TestObject : public QObject
{
    Q_OBJECT

public:
    TestObject(QObject *parent = nullptr);
    void test();

public slots:
    void slotTest(const QString &message);

signals:
    void signalTest(const QString &message);
};

TestObject.cpp

#include "TestObject.h"

#include <QDebug>

TestObject::MyObject(QObject *parent)
    : QObject{parent}
{

}

void TestObject::test()
{
    emit signalTest("test");
}

void TestObject::slotTest(const QString &message)
{
    qDebug() << message;
}

main.cpp

#include <QApplication>
#include <QWebChannel>
#include <QWebEngineView>
#include "TestObject.h"

int main(int argc, char *argv[])
{
    QApplication app(argc, argv);

    QWebEngineView *webView = new QWebEngineView();
    QWebChannel *channel = new QWebChannel();

    TestObject *myObject = new TestObject();
    channel->registerObject(QStringLiteral("testObject"), myObject);

    webView->page()->setWebChannel(channel);
    webView->load(QUrl("file:///C:/Users/Test/Desktop/test.html")); 
    webView->show();

    return app.exec();
}
2)web侧

HTML中引用qwebchannel.js脚本,即可透明地访问QObject的属性、公共槽和方法。

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <title>bm qt + web test</title>
    <script src="./qwebchannel.js"></script>
    <script type="text/javascript">
        var testObject;
        var webChannel = new QWebChannel(qt.webChannelTransport, function (channel) {
            testObject = channel.objects.testObject;
        });

        function slotTest() {
            testObject.slotTest("Test");
        }

        function signalTest() {
            testObject.signalTest("Test");
        }
    </script>
</head>

<body>
    <button id="slotTest" onclick="slotTest()">slot Test</button>
    <button id="signalTest" onclick="signalTest()">signal Test</button>
</body>

</html>
3)slot未写全

web调用客户端的slot方法,若客户端没写全,会有错误提示;反之,客户端调用websignal方法,若客户端没写全,则不会有错误提示。

4)快速响应

可以通过在url中增加参数来通知web该初始化成什么样子,但是这种加载往往需要重新加载web、比较耗时,一般的变化建议通过signalweb,以实现web快速响应。

1.13 Chromium

1)WebContentsAdapter

Qt WebEngineCore模块基于ChromiumQt将对Chromium开源库接口调用逻辑封装在类WebContentsAdapterC:\Qt\Qt5.14.2\5.14.2\Src\qtwebengine\src\core\web_contents_adapter.h)。

在路径C:\Qt\Qt5.14.2\5.14.2\Src\qtwebengine\src\3rdparty\chromium下,可以看到Qt使用了chromium作为三方库。在该chromium目录下,可以看到chromium完整的源代码。

2)Version

Qt官网可以直接查看对应的Chromium版本,

在这里插入图片描述

也可以在官方帮助文档中搜索 Qt WebEngine Overview ,其中的WebEngine Core Module介绍如下,

在这里插入图片描述

使用QWebEngineView加载网址,https://www.w3school.com.cn/js/js_browser.asp
可以直接在线检测版本,

import QtWebEngine 1.4

WebEngineView {
    id: webView;
    anchors.fill : parent;
    url : "https://www.w3school.com.cn/js/js_browser.asp"
    }

1.14 URI解析

Qtparse源码学习下uri解析,

inline void QUrlPrivate::parse(const QString &url, QUrl::ParsingMode parsingMode)
{
    //   URI-reference = URI / relative-ref
    //   URI           = scheme ":" hier-part [ "?" query ] [ "#" fragment ]
    //   relative-ref  = relative-part [ "?" query ] [ "#" fragment ]
    //   hier-part     = "//" authority path-abempty
    //                 / other path types
    //   relative-part = "//" authority path-abempty
    //                 /  other path types here

    sectionIsPresent = 0;
    flags = 0;
    clearError();

    // find the important delimiters
    int colon = -1;
    int question = -1;
    int hash = -1;
    const int len = url.length();
    const QChar *const begin = url.constData();
    const ushort *const data = reinterpret_cast<const ushort *>(begin);

    for (int i = 0; i < len; ++i) {
        uint uc = data[i];
        if (uc == '#' && hash == -1) {
            hash = i;

            // nothing more to be found
            break;
        }

        if (question == -1) {
            if (uc == ':' && colon == -1)
                colon = i;
            else if (uc == '?')
                question = i;
        }
    }

    // check if we have a scheme
    int hierStart;
    if (colon != -1 && setScheme(url, colon, /* don't set error */ false)) {
        hierStart = colon + 1;
    } else {
        // recover from a failed scheme: it might not have been a scheme at all
        scheme.clear();
        sectionIsPresent = 0;
        hierStart = 0;
    }

    int pathStart;
    int hierEnd = qMin<uint>(qMin<uint>(question, hash), len);
    if (hierEnd - hierStart >= 2 && data[hierStart] == '/' && data[hierStart + 1] == '/') {
        // we have an authority, it ends at the first slash after these
        int authorityEnd = hierEnd;
        for (int i = hierStart + 2; i < authorityEnd ; ++i) {
            if (data[i] == '/') {
                authorityEnd = i;
                break;
            }
        }

        setAuthority(url, hierStart + 2, authorityEnd, parsingMode);

        // even if we failed to set the authority properly, let's try to recover
        pathStart = authorityEnd;
        setPath(url, pathStart, hierEnd);
    } else {
        userName.clear();
        password.clear();
        host.clear();
        port = -1;
        pathStart = hierStart;

        if (hierStart < hierEnd)
            setPath(url, hierStart, hierEnd);
        else
            path.clear();
    }

    if (uint(question) < uint(hash))
        setQuery(url, question + 1, qMin<uint>(hash, len));

    if (hash != -1)
        setFragment(url, hash + 1, len);

    if (error || parsingMode == QUrl::TolerantMode)
        return;

    // The parsing so far was partially tolerant of errors, except for the
    // scheme parser (which is always strict) and the authority (which was
    // executed in strict mode).
    // If we haven't found any errors so far, continue the strict-mode parsing
    // from the path component onwards.

    if (!validateComponent(Path, url, pathStart, hierEnd))
        return;
    if (uint(question) < uint(hash) && !validateComponent(Query, url, question + 1, qMin<uint>(hash, len)))
        return;
    if (hash != -1)
        validateComponent(Fragment, url, hash + 1, len);
}

1.15 QtWebEngineProcessd.exe

1)启动

QWebEngineView对象初始化后,就会启动QtWebEngineProcessd.exedebugrelease对应不带后缀d),

在这里插入图片描述

Qt5.14.2为例,上述QtWebEngineProcessd.exe在目录C:\Qt\Qt5.14.2\5.14.2\msvc2017_64\bin下。

QWebEngineView默认的url为"about:blank",

“about:blank” 是一个特殊的网页地址,通常表示一个空白的网页或一个没有加载任何内容的网页。

在大多数浏览器中,当你打开一个新的标签页或窗口,而没有输入任何网址时,浏览器默认会加载 “about:blank”。这个地址实际上并不指向任何实际的网页内容,而只是一个占位符,用于表示一个空的或未加载的页面。

在一些情况下,网页可能会在 “about:blank” 的基础上进行加载或显示一些默认的内容。例如,一些浏览器插件或扩展可能会在 “about:blank” 的页面上显示一些信息或广告。

总之,“about:blank” 是一个特殊的网页地址,用于表示一个空的或未加载的页面。

QtWebEngine采用Chromium提供的多进程模块,该模块要求QtWebEngineProcess.exe随应用一起部署。每个QWebEngineViewWebEngineView实例都会启动一个QtWebEngineProcess.exe进程,用来和内置浏览器QWebEngineView通信。QtWebEngineProcess.exe可以重命名,

QString webEngineProcessPath = QCoreApplication::applicationDirPath() + "/" + "TestWebEngineProcess.exe";
qputenv("QTWEBENGINEPROCESS_PATH", webEngineProcessPath.toLocal8Bit());
2)退出

程序若想正常退出QtWebEngineProcessd.exe,需要在程序退出时调用QApplicationquit()方法,像Visual Studio停止调试或进程强杀等都不会退出QtWebEngineProcessd.exe,造成内存、CPU被无效占用。

3)通信

QtWebEngineProcessd.exe跟主进程之间是如何通信的?

渲染进程和主进程之间的通信机制主要基于Chromium的多进程架构。

Chromium采用了一种分离式的多进程设计,每个Web页面(或标签页)都在一个独立的渲染进程中运行。这种设计可以提高安全性和稳定性,但同时也要求实现一套有效的进程间通信(IPC)机制。

ChromiumIPCInter-Process Communication)基于消息传递,涉及以下组件,

组件名作用
Channel用于传递消息,采用管道(pipe)或共享内存(shared memory)等底层技术实现
MessageLoop事件循环,监听Channel消息,并将其分发给相应的处理器。
RendererHost/Renderer封装渲染进程和主进程之间通信逻辑,分别位于主进程和渲染进程中,定义了一系列消息类型(加载url、执行js等)和相应的处理函数。

1.16 QWebEngineHistory

QWebEngineHistory用于管理浏览历史记录,其中每个历史记录项都由 QWebEngineHistoryItem 表示。

1)常用方法
名称作用
clear用于清除浏览历史记录
items用于访问和管理浏览历史记录中的每个历史记录项
backforward用于在浏览历史记录中前进和后退
2)示例代码
#include <QApplication>
#include <QWebEngineView>
#include <QWebEngineHistory>
#include <QDebug>

int main(int argc, char *argv[])
{
    QApplication app(argc, argv);
    QWebEngineView view;
    view.load(QUrl("https://www.test.com"));
    view.show();
    view.page()->loadFinished([&](bool success)
    {
        if (!success)
        {
            qDebug() << "Load failed!";
            return;
        }
        QWebEngineHistory *history = view.page()->history();
        if (history->canGoBack())
        {
            history->back();
        }
        if (history->canGoForward()) 
        {
            history->forward();
        }
        history->clear();
    });

    return app.exec();
}

2 问题解决

本节就实际使用Qt内嵌web实现相关效果过程中遇到的问题,进行了一些分析,并给出对应解决或绕过办法。

2.1 鼠标事件

直接用eventFilter捕获QWebEngineView对象的鼠标事件失败,因为实际渲染的并非QWebEngineView这个对象。

在包含QWebEngineViewQWidgetweb区域内,想捕获QEvent::Type::MouseButtonDblClick事件。通过bool event(QEvent* event)bool eventFilter(QObject* watched, QEvent* event)都无法捕获到。

QWebEngineView初始化到show之间的消息类型输出如下,

在这里插入图片描述

QWidget初始化到show之间的消息类型如下,

在这里插入图片描述

发现QWebEngineView存在一个QEvent::ChildAdded事件,难道这个child才能响应鼠标事件?

Qt对事件QEvent::ChildAdded的注释是new child widget,说明QWebEngineView确实新建了一个子窗体,我们可以通过断点看这个子窗体的类型、大小、位置,

在这里插入图片描述

这是一个多继承自QQuickWidgetQWebEngineCore::RenderWidgetHostViewQtDelegate的子类,其中QQuickWidgetQWidget的子类,可以看到这个子窗体的大小、位置,

在这里插入图片描述

可以看到,子窗体实际覆盖了QWebEngineView所在区域。

由此推想得到解决办法,监听这个子窗体的鼠标双击事件。

具体做法,先重写QWebEngineVieweventeventFilter方法,以捕获QWebEngineView中的鼠标双击事件为例,

WebViewWithMouseEvent.h

#pragma once

#include <QWebEngineView>

class WebViewWithMouseEvent  : public QWebEngineView
{
	Q_OBJECT

public:
	WebViewWithMouseEvent(QWidget *parent);
	~WebViewWithMouseEvent() override = default;

private:
	bool event(QEvent* pEvent) override;
	bool eventFilter(QObject* watched, QEvent* event) override;

	QObject* pChildObj_ = nullptr;

signals:
	void signalMouseDblClickEvent();
};

WebViewWithMouseEvent.cpp

#include "WebViewWithMouseEvent.h"
#include <QEvent>

WebViewWithMouseEvent::WebViewWithMouseEvent(QWidget *parent)
	: QWebEngineView(parent)
{}

bool WebViewWithMouseEvent::event(QEvent* pEvent)
{
	if (pEvent->type() == QEvent::ChildPolished)
	{
		QChildEvent* pChildEvent = static_cast<QChildEvent*>(pEvent);
		pChildObj_ = pChildEvent->child();
		if (pChildObj_)
			pChildObj_->installEventFilter(this);
	}
	return QWebEngineView::event(pEvent);
}

bool WebViewWithMouseEvent::eventFilter(QObject* watched, QEvent* event)
{
	if (watched == pChildObj_ && event->type() == QEvent::Type::MouseButtonDblClick)
	{
		emit signalMouseDblClickEvent();
		return true;
	}
	return QWebEngineView::eventFilter(watched, event);
}

另外,QWebEngineView不响应鼠标事件,或许是Qt存在的Bug

链接:[QTBUG-43602] Mouse events are not handled in WebEngineView - Qt Bug Tracker

2.2 设置背景

在加载页面前控件默认背景色是白色,如果加载的url是其他背景颜色,就会出现先闪现零点几秒的白色背景然后再加载url对应的背景色,ui体验不好。要改变背景色使用如下代码,

ui.webEngineView->page()->setBackgroundColor(QColor(44, 50, 61));

并且代码必须在show之前调用,否则仍然会出现同样的问题

2.3 隐藏滚动条

如果不想要页面滚动条,html页面可以如下设置,

<!DOCTYPE html>
<html style="height: 100%">
    <head>
        <meta charset="utf-8">
    </head>
    <body style="height: 100%;">
    </body>
</html>

2.4 视频播放

由于QWebEngineView没有视频解码器,无法播放mp4等视频,需要重新编译QWebEngineView库,具体编译流程参考链接即可,

编译QtWebEngine模块支持音视频播放_qtwebengine 声音-CSDN博客

或者直接下载别人编译好的版本。

经测试,Qt6.6.1依然不支持,

在这里插入图片描述

3 透明失效

本节其实也是“问题解决”的一小节,但是由于篇幅较长,我单拎一节讲述,

3.1 抛出问题

某窗体对象A需要设置为透明,

setAttribute(Qt::WA_TranslucentBackground);

注意,对象A设置了上述属性后,A本身变成了透明,因此对A设置background-color会没效果,可以在A上嵌套一个QWidget对象B,对B设置background-color,或者对A设置border-imageicon得是透明图片,pngjpg等均可。

创建QWidget名为TestWindow,添加控件、布局如下,

在这里插入图片描述

TestWindow.h.cpp如下,

.h

#pragma once

#include <QWidget>
#include "ui_TestWindow.h"

class TestWindow : public QWidget
{
	Q_OBJECT

public:
	TestWindow(QWidget *parent = nullptr);
	~TestWindow();

private:
	Ui::TestWindow ui;

private slots:
	void slotBtnClicked();
};

.cpp

#include "TestWindow.h"

TestWindow::TestWindow(QWidget *parent)
	: QWidget(parent)
{
	ui.setupUi(this);
	setAttribute(Qt::WA_TranslucentBackground);
	connect(ui.pushButton, SIGNAL(clicked()), this, SLOT(slotBtnClicked()));
}

TestWindow::~TestWindow()
{}

void TestWindow::slotBtnClicked()
{
	ui.testWidget->winId();
}

main.cpp

#include <QtWidgets/QApplication>
#include "TestWindow.h"

int main(int argc, char *argv[])
{
    QApplication a(argc, argv);
    TestWindow w;
    w.show();
    return a.exec();
}

上述代码中,ui.testWidget对象类型是QOpenGLWidget

为了有背景色,我在TestWindowtestWidget之间放了widget,并为widget设置了背景色background-color: rgba(100, 100, 100, 150)

另外,为ui.pushButton绑定了槽函数slotBtnClicked(),所执行操作仅为执行了ui.testWidgetwinId(),该槽函数执行前,效果如下,

在这里插入图片描述

该槽函数执行后,效果如下,

在这里插入图片描述

可以看到,TestWindow原本设置的透明,在testWidget执行了winId后就失效了。

然后,把testWidget的类型改为QWidget,为了让人能看出testWidget所在区域,给testWidget设置背景色background-color: rgba(0, 0, 0, 150)

在这里插入图片描述

结果,TestWindow透明没生效。

TestWindow加上无边框属性,

setWindowFlags(Qt::FramelessWindowHint | windowFlags());

透明恢复正常(另一个坑,后续讨论),

在这里插入图片描述

不过,当testWidget改为QWidget再执行winId()TestWindow同样顺带被设置了Qt::WA_NativeWindow,但透明效果依然正常。

那么,testWidget改为QWidget,就一点没问题了吗?实际使用这时候testWidget返回的winId去渲染视频,发现视频根本没渲染出来,又是个坑。

3.2 Qt6.6.1

为了排除本问题是旧Qt已修复的Bug,下载了最新稳定版本进行验证,存在同样的问题,

在这里插入图片描述

3.3 源码分析

先回到testWidgetQOpenGLWidget类型的情况,虽然透明效果异常,但对于视频类业务,起码这时候返回的winId用于渲染视频是正常的。

winId源码如下,

/*!
    \fn WId QWidget::internalWinId() const
    \internal
    Returns the window system identifier of the widget, or 0 if the widget is not created yet.

*/

/*!
    \fn WId QWidget::winId() const

    Returns the window system identifier of the widget.

    Portable in principle, but if you use it you are probably about to
    do something non-portable. Be careful.

    If a widget is non-native (alien) and winId() is invoked on it, that widget
    will be provided a native handle.

    This value may change at run-time. An event with type QEvent::WinIdChange
    will be sent to the widget following a change in window system identifier.

    \sa find()
*/
WId QWidget::winId() const
{
    if (!data->in_destructor
        && (!testAttribute(Qt::WA_WState_Created) || !internalWinId()))
    {
#ifdef ALIEN_DEBUG
        qDebug() << "QWidget::winId: creating native window for" << this;
#endif
        QWidget *that = const_cast<QWidget*>(this);
        that->setAttribute(Qt::WA_NativeWindow);
        that->d_func()->createWinId();
        return that->data->winid;
    }
    return data->winid;
}

可以看到,winId会执行setAttribute(Qt::WA_NativeWindow)setAttribute源码中Qtattribute保存的源码如下,

 /*!
  \internal

  This just sets the corresponding attribute bit to 1 or 0
 */
static void setAttribute_internal(Qt::WidgetAttribute attribute, bool on, QWidgetData *data,
                                  QWidgetPrivate *d)
{
    if (attribute < int(8*sizeof(uint))) {
        if (on)
            data->widget_attributes |= (1<<attribute);
        else
            data->widget_attributes &= ~(1<<attribute);
    } else {
        const int x = attribute - 8*sizeof(uint);
        const int int_off = x / (8*sizeof(uint));
        if (on)
            d->high_attributes[int_off] |= (1<<(x-(int_off*8*sizeof(uint))));
        else
            d->high_attributes[int_off] &= ~(1<<(x-(int_off*8*sizeof(uint))));
    }
}

从上述代码中可以看到,attribute以位图方式被保存起来,因此多个attribute可以共同作用于一个QWidget

为了确认ui.testWidgetTestWindowattribute变化,我分别创建了临时变量记录如下,

在这里插入图片描述

可以看到,虽然是ui.testWidget执行的winId,但是TestWindow也被设置了Qt::WA_NativeWindow属性,不过其Qt::WA_TranslucentBackground属性依然存在。查看Qt助手的介绍,

Qt::WA_NativeWindow
100
Indicates that a native window is created for the widget. Enabling this flag will also force a native window for the widget’s ancestors unless Qt::WA_DontCreateNativeAncestors is set.

函数winId()确实会对作用的QWidget及其祖父QWidget均影响。

其中,我用到的testAttribute函数源码如下,

inline bool QWidget::testAttribute(Qt::WidgetAttribute attribute) const
{
    if (attribute < int(8*sizeof(uint)))
        return data->widget_attributes & (1<<attribute);
    return testAttribute_helper(attribute);
}

另外,TestWindow的透明失效,并非是在winId执行完后立即发生,而是在Qt事件循环完后发生,可以通过调用QCoreApplication::processEvents()强制更新。

直接给TestWindow设置Qt::WA_TranslucentBackgroundQt::WA_NativeWindow,经testAttribute验证TestWindow也确实拥有了Qt::WA_NativeWindow属性,但是TestWindow的透明依然正常。

那么,Qt::WA_NativeWindow属性,是如何设置到TestWindow上的呢?

void QWidget::setAttribute(Qt::WidgetAttribute attribute, bool on)的源码,由于该函数代码量较多,直接看case Qt::WA_NativeWindow

d->createTLExtra();
        if (on)
            d->createTLSysExtra();
#ifndef QT_NO_IM
        QWidget *focusWidget = d->effectiveFocusWidget();
        if (on && !internalWinId() && this == QGuiApplication::focusObject()
            && focusWidget->testAttribute(Qt::WA_InputMethodEnabled)) {
            QGuiApplication::inputMethod()->commit();
            QGuiApplication::inputMethod()->update(Qt::ImEnabled);
        }
        if (!QCoreApplication::testAttribute(Qt::AA_DontCreateNativeWidgetSiblings) && parentWidget())
            parentWidget()->d_func()->enforceNativeChildren();
        if (on && !internalWinId() && testAttribute(Qt::WA_WState_Created))
            d->createWinId();
        if (isEnabled() && focusWidget->isEnabled() && this == QGuiApplication::focusObject()
            && focusWidget->testAttribute(Qt::WA_InputMethodEnabled)) {
            QGuiApplication::inputMethod()->update(Qt::ImEnabled);
        }
#endif //QT_NO_IM

首先可以看到,有个enforceNativeChildren

    inline void enforceNativeChildren()
    {
        if (!extra)
            createExtra();

        if (extra->nativeChildrenForced)
            return;
        extra->nativeChildrenForced = 1;

        for (int i = 0; i < children.size(); ++i) {
            if (QWidget *child = qobject_cast<QWidget *>(children.at(i)))
                child->setAttribute(Qt::WA_NativeWindow);
        }
    }

从源码可以看出,enforceNativeChildren将所有testWidget同级及子孙QWidget都设置成了Qt::WA_NativeWindow

关键在于,当为testWidget同级及子孙QWidget执行setAttribute(Qt::WA_NativeWindow)时,会进入setAttribute的如下逻辑分支,

        if (on && !internalWinId() && testAttribute(Qt::WA_WState_Created))
            d->createWinId();

进一步看createWinId的源码,

void QWidgetPrivate::createWinId()
{
    Q_Q(QWidget);

    const bool forceNativeWindow = q->testAttribute(Qt::WA_NativeWindow);
    if (!q->testAttribute(Qt::WA_WState_Created) || (forceNativeWindow && !q->internalWinId())) {
        if (!q->isWindow()) {
            QWidget *parent = q->parentWidget();
            QWidgetPrivate *pd = parent->d_func();
            if (forceNativeWindow && !q->testAttribute(Qt::WA_DontCreateNativeAncestors))
                parent->setAttribute(Qt::WA_NativeWindow);
            if (!parent->internalWinId()) {
                pd->createWinId();
            }

            for (int i = 0; i < pd->children.size(); ++i) {
                QWidget *w = qobject_cast<QWidget *>(pd->children.at(i));
                if (w && !w->isWindow() && (!w->testAttribute(Qt::WA_WState_Created)
                                            || (!w->internalWinId() && w->testAttribute(Qt::WA_NativeWindow)))) {
                    w->create();
                }
            }
        } else {
            q->create();
        }
    }
}

会进入上述代码中的逻辑分支,

            if (forceNativeWindow && !q->testAttribute(Qt::WA_DontCreateNativeAncestors))
                parent->setAttribute(Qt::WA_NativeWindow);

然后就给TestWindow也执行了setAttribute(Qt::WA_NativeWindow)

那么,是不是可以将上述逻辑分支中的testAttribute(Qt::WA_DontCreateNativeAncestors)设置成true,就能解决问题呢?

ui.testWidget->winId()前,我先加了

ui.testWidget->setAttribute(Qt::WA_DontCreateNativeAncestors);

经过testAttribute(Qt::WA_NativeWindow)TestWindow仍然被设置了Qt::WA_NativeWindow

想到还有个widget对象在TestWindowtestWidget之间,再加上

ui.widget->setAttribute(Qt::WA_DontCreateNativeAncestors);

断点查看,

在这里插入图片描述

TestWindow不再Qt::WA_NativeWindow,但是透明失效的影响仍在。

3.4 冲突介绍

本节所说的冲突,存在于以Qt作为UI框架、用winId作为视频渲染句柄的业务中,如果不用winId渲染视频,使用Qt提供的QSurfaceQGraphicView相关模块,那就不存在这个问题。

但是,目前大部分Windows视频渲染业务,还是基于WinId,特别是要求硬件渲染的场景。

这冲突,深入点看,是QWidget属性Qt::WA_TranslucentBackgroundQt::WA_NativeWindow之间的冲突,是Qt渲染和Windows原生窗体之间的不兼容。

Qt官方也有建议,

Is it possible to have transparent Qt widgets on top of a QOpenGLWidget?
Qt provides a way to paint to an OpenGL context, but this is in the end painted separately from Qt and the two painting systems are not synchronized, so Qt cannot compose (semi-)transparent images on top of a QOpenGLWidget. At best, you can have opaque widgets on top of the QOpenGLWidget, but even then you may experience some flicker due to the painting being asynchronous.

可以完全用Qt渲染或转成QPixmap再绘制,

OpenGL and translucent background do not work together due to a limitation
Due to a limitation in window managers, it is not possible to combine OpenGL with a translucent background window (set with WA_TranslucentBackground). The reason for this is because OpenGL is rendered directly onto the screen and the compositor used to enable the translucent background cannot ensure that the OpenGL is rendered correctly.
There is nothing that can be done in Qt to fix this as it needs to be done on the side of the window manager.
To work around this issue you can either just use Qt to render everything and not OpenGL, or you can render the OpenGL into a pixmap and draw that onto your widget instead.

QMediaPlayerQVideoWidget来实现透明窗体上的视频渲染,在这不再多赘述。

3.5 填充法

关于上述TestWindow透明失效的问题,若我执意要用winId来处理视频渲染业务,有没有绕过办法呢?这里给出一种自创的“填充法”。

从透明失效的现象可以看到,失效的是testWidget(类型是QOpenGLWidget)的parentWidget,那么如果我在TestWindow上填充非testWidgetparentWidgetQWidget,使得失效区域都都被这些填充的QWidget覆盖,是不是就能“恢复”透明?

还有,上述填充QWidget还需要保证不影响正常布局设计。

填充法.ui布局如下,

在这里插入图片描述

为了让你看到leftWidgettopWidgetrightWidgetbottomWidget具体所在位置,我给这几个QWidget设置border标记出来,

在这里插入图片描述

3.6 冲突表现

上述Qt::WA_NativeWindow导致的透明失效,其实不只影响QWidgetQOpenGLWidget,也影响本文主要介绍的QOpenGLWidget,本节将进一步聊聊这种冲突的具体表现。

除了抛出问题的表现外,还是以TestWindow为例,当给TestWindow增加Qt::Window使它成为独立窗体,

TestWindow::TestWindow(QWidget *parent)
	: QWidget(parent)
{
	ui.setupUi(this);
	setAttribute(Qt::WA_TranslucentBackground);
	setWindowFlags(Qt::FramelessWindowHint | Qt::Window | windowFlags());
	connect(ui.pushButton, SIGNAL(clicked()), this, SLOT(slotBtnClicked()));
}

此时TestWindow若存在parentWidget,也存在透明失效影响。目前测试下来,TestWindowparentWidget并非直接导致透明失效,比如parentWidget只是简单的QWidget,透明就正常。

基于上述经过填充法改造的TestWindow,新增TestParentWidget类,

TestParentWidget.ui

在这里插入图片描述

其中,widget对象设置背景色background-color: rgba(0, 0, 150, 100)方便看效果。

TestParentWidget.h

#pragma once

#include <QWidget>
#include "ui_TestParentWidget.h"

class TestParentWidget : public QWidget
{
	Q_OBJECT

public:
	TestParentWidget(QWidget *parent = nullptr);
	~TestParentWidget();

private:
	Ui::TestParentWidgetClass ui;
};

TestParentWidget.cpp

#include "TestParentWidget.h"
#include "TestWindow.h"

TestParentWidget::TestParentWidget(QWidget *parent)
	: QWidget(parent)
{
	ui.setupUi(this);
	setAttribute(Qt::WA_TranslucentBackground);
	setWindowFlags(Qt::FramelessWindowHint | windowFlags());
    auto* pWidget = new TestWindow(this);
	pWidget->show();
}

TestParentWidget::~TestParentWidget()
{}

/* void TestParentWidget::slotBtnClicked()
{
新的坑:如果主窗体是QWidget,且内含QWebEngineWidget或QOpenGLWidget对象,在QApplication::exec()之后弹出TestWindow,然后执行winId(),会触发Qt渲染模块的崩溃。但是,如果主窗体不是TestParentWidget,另有不含QWebEngineWidget或QOpenGLWidget对象的QWidget,则能正常执行winId()。
	auto* pWidget = new TestWindow(this);
	pWidget->show();
} */

main.cpp修改如下,

#include <QtWidgets/QApplication>
#include "TestParentWidget.h"

int main(int argc, char *argv[])
{
    QApplication a(argc, argv);
    TestParentWidget w;
    w.show();
    return a.exec();
}

TestParentWidget透明效果如下,

在这里插入图片描述

TestParentWidget添加一个QWebEngineViewQOpenGLWidget对象后,情况就不同了,

在这里插入图片描述

执行完ui.testWidget->winId()TestParentWidget的透明就失效了,

在这里插入图片描述

3.7 背景法

上一小节已经基本给出了问题的出现条件,

既然parentWidget是因为子控件执行了winId,也被强行设置了Qt::WA_NativeWindow,那么如果把该属性设置falsesetAttribute(Qt::WA_NativeWindow, false)),能否恢复parentWidget的透明效果呢?

经过测试,即使对parentWidget执行了setAttribute(Qt::WA_NativeWindow, false),通过testAttribute也验证生效,但透明效果依然无法恢复。

经过对比测试,像TestWindow影响带QWebEngineViewQOpenGLWidgetTestParentWidget,只影响一层parentWidget,

TestParentWidget::TestParentWidget(QWidget *parent)
	: QWidget(parent)
{
	ui.setupUi(this);
	setAttribute(Qt::WA_TranslucentBackground);
	setWindowFlags(Qt::FramelessWindowHint | windowFlags());

	auto* pMiddleWidegt = new QWidget(this);
	pMiddleWidegt->setWindowFlags(Qt::Window | windowFlags());
	pMiddleWidegt->show();

	auto* pWidget = new TestWindow(pMiddleWidegt);
	pWidget->show();
}

如上述代码所示,在TestWindowTestParentWidget之间夹一个QWidget,透明失效就不会影响到TestParentWidget

这也算是种绕过办法,我称之为“背景法”,不过由于加的pMiddleWidget非透明,实际上可以给它设置成透明无边框,并且甚至不需要是visible


TestParentWidget::TestParentWidget(QWidget *parent)
	: QWidget(parent)
{
	ui.setupUi(this);
	setAttribute(Qt::WA_TranslucentBackground);
	setWindowFlags(Qt::FramelessWindowHint | windowFlags());

	pMiddleWidget = new QWidget(this);
	pMiddleWidget->setAttribute(Qt::WA_TranslucentBackground);
	pMiddleWidget->setWindowFlags(Qt::Window | windowFlags());
	pMiddleWidget->hide();

	auto* pWidget = new TestWindow(pMiddleWidget);
	connect(pWidget, SIGNAL(signalTest()), this, SLOT(slotTest()));
	pWidget->show();
}

TestWindow加个signal用于testAttribute一下TestParentWidget执行完winId()前后的attribute,执行前,

在这里插入图片描述

执行后,

在这里插入图片描述

可以看到,TestParentWidget以及夹在中间的pMiddleWidget都没有被带上Qt::WA_NativeWindow,不过结合前边源码,Qt::WA_NativeWindow的属性本身就只影响到了TestWindow这个底层QWidget而已。

3.8 以毒攻毒

除了加个pMiddleWidget,还有种情况TestParentWidget的透明也能恢复正常,那就是以毒攻毒,TestParentWidget也加上QOpenGLWidget执行winId()并用填充法解决自身的透明问题。

经测试,“以毒攻毒”效果良好~

不过,这种绕过办法使用场景相对更局限些,适用于TestParentWidget自身也需要视频渲染的场景。

总结下,

  1. 如果TestParentWidget布局简单,可以基于填充法调整布局解决,只不过必须得在底层QWidget上填充,而前边填充法并无底层QWidget要求。
  2. 如果TestParentWidget主体是QStackedWidget,而且其中某个page包含了QWebEngineViewQOpenGLWidget对象。这种情况下,QStackedWidget本身及全部page透明都会失效,那么只能用“背景法“了。
  3. 如果刚好,TestParentWidget也有视频渲染需求,那么可以使用QOpenGLWidget预先执行setAttribute(Qt::WA_NativeWindow)结合填充法绕过。

3.9 属性被冲掉

修改parentWidget窗导致QWidget的窗体属性被冲掉,如下代码,

	auto* pWidget = new TestWindow(this);

	pMiddleWidget = new QWidget(this);
	pMiddleWidget->setAttribute(Qt::WA_TranslucentBackground);
	pMiddleWidget->setWindowFlags(Qt::Window | windowFlags());
	pMiddleWidget->hide();

	pWidget->setParent(pMiddleWidget);
	connect(pWidget, SIGNAL(signalTest()), this, SLOT(slotTest()));
	pWidget->show();

原本TestWindowparentWidgetTestParentWidget,由于解决透明问题的需要,中途修改了parentWidget,结果你会发现TestWindow构造函数中执行的setWindowFlags(Qt::FramelessWindowHint | Qt::Window | windowFlags())失去效果,恢复成非独立窗体。

遇到上述问题,在修改parentWidget,重新执行一遍setWindowFlags(Qt::FramelessWindowHint | Qt::Window | windowFlags())即可解决。

3.10 effectiveWinId

既然winId()会强制给QWidget设置Qt::WA_NativeWindow,那么是否有直接拿到winId的办法呢?

有,QWidget还有个接口名叫effectiveWinId,该接口不会改变QWidgetattribute,源码如下,

/*!
    \since 4.4

    Returns the effective window system identifier of the widget, i.e. the
    native parent's window system identifier.

    If the widget is native, this function returns the native widget ID.
    Otherwise, the window ID of the first native parent widget, i.e., the
    top-level widget that contains this widget, is returned.

    \note We recommend that you do not store this value as it is likely to
    change at run-time.

    \sa nativeParentWidget()
*/
WId QWidget::effectiveWinId() const
{
    const WId id = internalWinId();
    if (id || !testAttribute(Qt::WA_WState_Created))
        return id;
    if (const QWidget *realParent = nativeParentWidget())
        return realParent->internalWinId();
    return 0;
}

不过,effectiveWinId()会尝试查找nativeParentWidget()然后返回有效winIdnativeParentWidget()源码如下,

/*!
    \since 4.4

    Returns the native parent for this widget, i.e. the next ancestor widget
    that has a system identifier, or \nullptr if it does not have any native
    parent.

    \sa effectiveWinId()
*/
QWidget *QWidget::nativeParentWidget() const
{
    QWidget *parent = parentWidget();
    while (parent && !parent->internalWinId())
        parent = parent->parentWidget();
    return parent;
}

注意,上述源码并未要求这个parentWidgetQt::WA_NativeWindow,而只判断是否拥有有效的winId

回顾最开始抛出问题的TestWindowtestWidget例子,看看testWidgeteffectiveWinId最终会从哪里获取到,

在这里插入图片描述

可以看到,虽然TestWindow最初并未设置成Qt::WA_NativeWindow,但拥有有效的winId

这给我们一个启发,其实所有独立窗体都应该拥有有效的winId,虽然我们直接将渲染视频逻辑绑定到内部某个QWidgetQOpenGLWidget对象,存在透明失效问题,那是否可以直接绑定到有有效winId的窗体上?如果这个窗体是底层QWidget,其区域并非我们预期,我们可以给我们的渲染业务指定winId对应窗体实际渲染的区域(起始位置xy,区域wh)。

不过,当遇到QStackedWidegt或其它绘制区域,需要随所在QWidget隐藏的情况,也需要扩展我们的渲染业务,使得能够及时响应不显示。

4 未完待续

还有一些点,限于篇幅、时间,先记录如下,后续博文更新。

4.1 对象复用

由于每个QWebEngineView对象都会启动一个QtWebEngineProcessed.exe,而且还需要重新加载web,不管是资源占用,还是加载耗时都感觉“浪费”。

能否多页面复用QWebEngineView对象?比如,直接从一个QWidget拖走QWebEngineView对象,嵌入另一个QWidget,结果已加载的网页无法正常显示。

举个例子,当在stackWidget上某个页面上工作时,假设QWebEngineView在第1页,而当前stackWidget在其他页面,这时候如果使用第1页的QWebEngineView去加载本地html和js文件,会发现js脚本部分完全无法运行,但是其他静态的内容都能解析成功。
目前我的解决方法是只有在当前页面是QWebEngineView时才加载html和js文件,这样即使之后切换到别的页面,也不会导致js无法执行。但这样一来用户体验会变差,并且总是要规避第一次加载页面时不在QWebEngineView页面的问题,非常麻烦。不过暂时也没别的更好的解决方法。

4.2 像素级缩放

倘若web未适配缩放,如何直接操作QWebEngineView控件,对所显示的web进行缩放?

比如通过QGraphicView画布。

通常如果web本身支持缩放,可以重写QWebEngineViewsizeHint来操作,

QSize sizeHint() const override;

4.3 自动登录

Qt容易实现跳转浏览器web,也容易实现web嵌入,但是对于已经通过HTTP协议完成账号密码登录,并获取到token的情况,希望不需要再在浏览器或内嵌web再次输入账号、密码即可登录,实现流程上的统一、简化。

浏览器上登录,通常会把tokensession等相关内容存储到缓存中,Qt也支持setCookie等,但是比如chrome浏览器上的session storageQtWebEngine模块中就没找到方法可以设置。

除了直接设置浏览器缓存,还有个思路就是直接调用web上的js方法后台执行登录操作。

当然,如果服务端本身支持url中加参数的方式获取对应页面,也可以实现自动登录web

4.4 WebAssembly

基于Qt6.6.1,聊下WebAssebly

4.5 主窗体透明不生效

前面已经说过,主窗体(QApplication最底层QWidget)若只设置了Qt::WA_TranslucentBackground,透明并未生效,但若拥有QOpenGLWidegt对象作为childWidget或设置Qt::FramelessWindowHint,就能恢复主窗体的透明。

另外,即使这时候给testWidget设置不透明的背景色也不能生效。

5 参考链接

  • 21
    点赞
  • 28
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值