Qt优秀开源项目之十五:QCustomPlot

QCustomPlot早已名声在外,使用Qt的几乎无人不知,这篇博客有灌水的嫌疑。
QCustomPlot是一个用于绘图和数据可视化的Qt小部件。它只依赖Qt,并且有详细的开发文档。该绘图库专注于制作外观美、质量高的二维绘图形和图表,并提供高性能的实时可视化。从“Setting up”和“Basic Plotting”开始学习,能达到事半功倍的效果。

QCustomPlot可以导出为各种格式,如矢量化PDF文件和光栅化图像,如PNG、JPG和BMP。因此,如果你的项目中有数据实时可视化或者生成高质量绘图的需求,QCustomPlot将是一个不错的解决方案。
官网地址:https://www.qcustomplot.com/
源码中提供了各种应用场景的demo,参考demo可以很容易实现自己想要的效果。
Qt之基于QCustomPlot绘制直方图(Histogram),叠加正态分布曲线是QCustomPlot一应用案例

一.Demo演示

1.Simple Demo


2.Sinc Scatter Demo


3.Scatter Style Demo


4.Styled Plot Demo


5.Color Map Demo


6.Scatter Pixmap Demo


7.Realtime Data Demo


8.Multiple Axes Demo


9.Logarithmic Axis Demo


10.Line Style Demo


11.Date Axis Demo


12.Parametric Curves Demo


13.Bar Chart Demo


14.Statistical Box Demo


15.Interaction Example


16.Item Demo


17.Advanced Axes Demo


18.Financial Chart Demo


二.QCustomPlot为什么高效

QCustomPlot采用多种技术提升绘图性能,实现海量数据的实时渲染。
1.刷新去重:在调用QCustomPlot的replot函数时,可以指定参数为reQueuedReplot,这样实际的重绘将延迟到下一个事件循环,多个连续的重绘调用仅仅执行一次重绘,避免冗余重绘从而提高性能。
2.自适应采样:在调用QCustomPlot的setAdaptiveSampling函数时指定参数为true,可以开启自适应采样。此时QCustomPlot不会绘制所有的数据点,而是智能的选择并绘制一部分数据点,这些点再视觉上近似于原始的所有点。这大大减少了需要绘制的数据点的数量,从而提高绘图性能。
3.文本对象缓存:坐标轴上的刻度值会缓存,如果刷新时某些刻度值无变化(颜色、位置等),那么下次绘制的时候就可以跳过它们,这样能显著的提高绘图性能。
4.OpenGL硬件加速:在调用QCustomPlot的setOpenGl函数的时候指定第一个参数为true,可以开启OpenGL硬件加速,此时图像的渲染是在GPU中进行的,极大的减轻了CPU的运算压力。相同的时间内就可以绘制更多的数据点,从而提高绘图性能。
开启OpenGL硬件加速需要在pro文件中定义QCUSTOMPLOT_USE_OPENGL,还得添加opengl库,详见:【QCustomPlot】性能提升之修改源码(版本 V2.x.x)

三.性能还能更好

【QCustomPlot】性能提升之修改源码(版本 V2.x.x)中列举了几个提升QCustomPlot性能的小技巧,摘录以备用:

1. 技巧一:启用 GPU 加速

这里选用 FreeGlut 库。

1.1 下载并编译 FreeGlut 库

https://freeglut.sourceforge.net/index.php 下载 freeglut 源码,编译出 freeglut 库,编译过程不做介绍。然后将编译出来的库以及 GL 文件夹下的五个头文件都包含进项目中,我使用的是 MSVC2015 64bit 静态库,因此在 pro/pri 文件中添加以下代码(因人而异):

HEADERS += \
    $$PWD/GL/freeglut.h \
    $$PWD/GL/freeglut_ext.h \
    $$PWD/GL/freeglut_std.h \
    $$PWD/GL/freeglut_ucall.h \
    $$PWD/GL/glut.h

CONFIG(debug, debug | release) {
    LIBS += -L$$PWD/lib64 -lfreeglut_staticd
    LIBS += -L$$PWD/lib64 -lfreeglutd
}

CONFIG(release, debug | release) {
    LIBS += -L$$PWD/lib64 -lfreeglut_static
    LIBS += -L$$PWD/lib64 -lfreeglut
}

1.2 在 qcustomplot.cpp 文件中添加代码

在文件的前面几行(比如 #include "qcustomplot.h" 的后面)添加以下代码:

#define GLUT_DISABLE_ATEXIT_HACK
#include <GL/freeglut.h>

若同一个界面上有多个 QCustimPlot 窗口对象,且都开启了 GPU 加速,则在窗口切换时图形显示可能会出现错乱(被称为上下文异常),为了避免这种现象,需要在 QCPPaintBufferGlFbo::draw 函数里面添加以下代码:

/* inherits documentation from base class */
void QCPPaintBufferGlFbo::draw(QCPPainter *painter) const
{
    if (!painter || !painter->isActive())
    {
        qDebug() << Q_FUNC_INFO << "invalid or inactive painter passed";
        return;
    }
    if (!mGlFrameBuffer)
    {
        qDebug() << Q_FUNC_INFO << "OpenGL frame buffer object doesn't exist, reallocateBuffer was not called?";
        return;
    }
    
    // 这个 if 语句是新添加的
    if(QOpenGLContext::currentContext() != mGlContext.data())
    {
        mGlContext.data()->makeCurrent(mGlContext.data()->surface());
    }
    
    painter->drawImage(0, 0, mGlFrameBuffer->toImage());
}

1.3 在 pro 文件中添加代码

在 pro 文件中,添加以下代码:

QT       += printsupport opengl
DEFINES += QCUSTOMPLOT_USE_OPENGL

1.4 启用 GPU 加速

对 QCustomPlot 对象使用 setOpenGl() 函数设置是否启用 OpenGL,如下所示:

ui->Plot->setOpenGl(true);

可以通过 openGl() 函数的返回值判断是否成功启用了 GPU 加速:

qDebug() << "启用状态" << ui->Plot->openGl();

需要注意的是,当绘制的图形有大块填充区域,尤其是半透明的填充时,GPU 加速的效果才明显,这个时候才能减轻 CPU 压力。如果仅仅绘制一些简单的曲线图还开启 OpenGL,结果往往会适得其反,CPU 压力不减反增,有兴趣的可以进行测试,打开任务管理器观察启用前后 CPU 的占用百分比即可。

1.5 加速效果

绘制实时更新的、含有填充区域的图像,未开启 GPU 加速前的效果:

开启 GPU 加速后的效果:

以上演示例中并没有更改数据刷新频率(都为 10 ms 间隔)及数据量大小(都为 100 个点),两者仅有的差别为是否调用 setOpenGl(true) 开启了 GPU 加速。从结果中可以看到,开启 OpenGL 后,CPU 占用率从 16%~17% 下降到 7%~8%,GPU 占用率从 0% 上升到 41%~43%,并且从视觉效果上看,刷新变得更快了,这可能是因为 CPU 被减轻了压力,单次计算后显示所需时间更短了。

2. 技巧二:添加曲线平滑功能

思路是先计算贝塞尔控制点,然后使用 QPainterPath 绘制平滑曲线

2.1 在 qcustomplot.h 文件中添加代码

在原生的 class QCP_LIB_DECL QCPGraph 类定义中(使用搜索功能找到对应位置)添加以下两个内容,注意 public 与 protected 限定符:

class QCP_LIB_DECL QCPGraph : public QCPAbstractPlottable1D<QCPGraphData>
{
public: 
    ...
    void setSmooth(bool smooth);             // 新增内容
    
protected:
    ...
    bool mSmooth;                            // 新增内容
}

在 qcustomplot.h 文件的末尾(#endif 的上一行)添加 SmoothCurveGenerator 类定义的代码:

class SmoothCurveGenerator
{
protected:
    static QPainterPath generateSmoothCurveImp(const QVector<QPointF> &points) {
        QPainterPath path;
        int len = points.size();
        
        if (len < 2) {
            return path;
        }
        
        QVector<QPointF> firstControlPoints;
        QVector<QPointF> secondControlPoints;
        calculateControlPoints(points, &firstControlPoints, &secondControlPoints);
        
        path.moveTo(points[0].x(), points[0].y());
        
        // Using bezier curve to generate a smooth curve.
        for (int i = 0; i < len - 1; ++i) {
            path.cubicTo(firstControlPoints[i], secondControlPoints[i], points[i+1]);
        }
        
        return path;
    }
public:
    static QPainterPath generateSmoothCurve(const QVector<QPointF> &points) {
        QPainterPath result;
        
        int segmentStart = 0;
        int i = 0;
        int pointSize = points.size();
        while (i < pointSize) {
            if (qIsNaN(points.at(i).y()) || qIsNaN(points.at(i).x()) || qIsInf(points.at(i).y())) {
                QVector<QPointF> lineData(i - segmentStart); std::copy(points.constBegin() + segmentStart, points.constBegin() + i - segmentStart, lineData.begin());
                result.addPath(generateSmoothCurveImp(lineData));
                segmentStart = i + 1;
            }
            ++i;
        }
        QVector<QPointF> lineData(i - segmentStart); std::copy(points.constBegin() + segmentStart, points.constBegin() + i - segmentStart, lineData.begin());
        result.addPath(generateSmoothCurveImp(lineData));
        return result;
    }
    
    static QPainterPath generateSmoothCurve(const QPainterPath &basePath, const QVector<QPointF> &points) {
        if (points.isEmpty()) return basePath;
        
        QPainterPath path = basePath;
        int len = points.size();
        if (len == 1) {
            path.lineTo(points.at(0));
            return path;
        }
        
        QVector<QPointF> firstControlPoints;
        QVector<QPointF> secondControlPoints;
        calculateControlPoints(points, &firstControlPoints, &secondControlPoints);
        
        path.lineTo(points.at(0));
        for (int i = 0; i < len - 1; ++i)
            path.cubicTo(firstControlPoints[i], secondControlPoints[i], points[i+1]);
        
        return path;
    }
    
    static void calculateFirstControlPoints(double *&result, const double *rhs, int n) {
        result = new double[n];
        double *tmp = new double[n];
        double b = 2.0;
        result[0] = rhs[0] / b;
        
        // Decomposition and forward substitution.
        for (int i = 1; i < n; i++) {
            tmp[i] = 1 / b;
            b = (i < n - 1 ? 4.0 : 3.5) - tmp[i];
            result[i] = (rhs[i] - result[i - 1]) / b;
        }
        
        for (int i = 1; i < n; i++) {
            result[n - i - 1] -= tmp[n - i] * result[n - i]; // Backsubstitution.
        }
        
        delete[] tmp;
    }
    
    static void calculateControlPoints(const QVector<QPointF> &knots,
                                       QVector<QPointF> *firstControlPoints,
                                       QVector<QPointF> *secondControlPoints) {
        int n = knots.size() - 1;
        
        firstControlPoints->reserve(n);
        secondControlPoints->reserve(n);
        
        for (int i = 0; i < n; ++i) {
            firstControlPoints->append(QPointF());
            secondControlPoints->append(QPointF());
        }
        
        if (n == 1) {
            // Special case: Bezier curve should be a straight line.
            // P1 = (2P0 + P3) / 3
            (*firstControlPoints)[0].rx() = (2 * knots[0].x() + knots[1].x()) / 3;
            (*firstControlPoints)[0].ry() = (2 * knots[0].y() + knots[1].y()) / 3;
            
            // P2 = 2P1 – P0
            (*secondControlPoints)[0].rx() = 2 * (*firstControlPoints)[0].x() - knots[0].x();
            (*secondControlPoints)[0].ry() = 2 * (*firstControlPoints)[0].y() - knots[0].y();
            
            return;
        }
        
        // Calculate first Bezier control points
        double *xs = nullptr;
        double *ys = nullptr;
        double *rhsx = new double[n]; // Right hand side vector
        double *rhsy = new double[n]; // Right hand side vector
        
        // Set right hand side values
        for (int i = 1; i < n - 1; ++i) {
            rhsx[i] = 4 * knots[i].x() + 2 * knots[i + 1].x();
            rhsy[i] = 4 * knots[i].y() + 2 * knots[i + 1].y();
        }
        rhsx[0] = knots[0].x() + 2 * knots[1].x();
        rhsx[n - 1] = (8 * knots[n - 1].x() + knots[n].x()) / 2.0;
        rhsy[0] = knots[0].y() + 2 * knots[1].y();
        rhsy[n - 1] = (8 * knots[n - 1].y() + knots[n].y()) / 2.0;
        
        // Calculate first control points coordinates
        calculateFirstControlPoints(xs, rhsx, n);
        calculateFirstControlPoints(ys, rhsy, n);
        
        // Fill output control points.
        for (int i = 0; i < n; ++i) {
            (*firstControlPoints)[i].rx() = xs[i];
            (*firstControlPoints)[i].ry() = ys[i];
            
            if (i < n - 1) {
                (*secondControlPoints)[i].rx() = 2 * knots[i + 1].x() - xs[i + 1];
                (*secondControlPoints)[i].ry() = 2 * knots[i + 1].y() - ys[i + 1];
            } else {
                (*secondControlPoints)[i].rx() = (knots[n].x() + xs[n - 1]) / 2;
                (*secondControlPoints)[i].ry() = (knots[n].y() + ys[n - 1]) / 2;
            }
        }
        
        delete xs;
        delete ys;
        delete[] rhsx;
        delete[] rhsy;
    }
};

2.2 在 qcustomplot.cpp 文件中添加代码

在原生的 QCPGraph::QCPGraph(QCPAxis *keyAxis, QCPAxis *valueAxis) 构造函数(使用搜索功能找到对应位置)实现中,添加 mSmooth 成员变量的初始化代码:

QCPGraph::QCPGraph(QCPAxis *keyAxis, QCPAxis *valueAxis) :
  QCPAbstractPlottable1D<QCPGraphData>(keyAxis, valueAxis)
{
    ...
    mSmooth = false;  // 新增内容
}

在对应位置添加 QCPGraph::setSmooth() 成员函数的实现(比如写在 void QCPGraph::setAdaptiveSampling(bool enabled) 的后面):

void QCPGraph::setSmooth(bool smooth)
{
    mSmooth = smooth;
}

将原生的 QCPGraph::drawLinePlot 成员函数(使用搜索功能找到对应位置)修改为如下形式,实质上只添加了个 if 语句:

void QCPGraph::drawLinePlot(QCPPainter *painter, const QVector<QPointF> &lines) const
{
    if (painter->pen().style() != Qt::NoPen && painter->pen().color().alpha() != 0)
    {
        applyDefaultAntialiasingHint(painter);
        if (mSmooth && mLineStyle == lsLine) painter->drawPath(SmoothCurveGenerator::generateSmoothCurve(lines));
        else drawPolyline(painter, lines);
    }
}

2.3 启用曲线平滑

对 QCPGraph 对象使用 setSmooth() 函数设置是否启用曲线平滑,如下所示:

ui->Plot->graph(0)->setSmooth(true);

2.4 平滑效果

绘制 50 个点,未启用曲线平滑时的效果:

启用曲线平滑时的效果:

3. 技巧三:导出一维绘图数据地址

3.1 一维绘图数据的内存结构

一维绘图数据都存储在 QCPDataContainer 这个类里面,绘图数据存储的容器为 QVector<DataType>,详见 qcustomplot.h 文件中 QCPDataContainer 的类定义。不同的一维绘图类型有着不同的底层数据类型:
●对于 QCPGraph 绘图类型,这个 DataType 为 QCPGraphData,查看 QCPGraphData 类定义,它有且仅有两个 double 类型的成员变量 key 和 value。因此 QCPGraph 的绘图数据被存储在一块连续的内存块中(类似于 double 数组),绘图数据在内存中按顺序 x0-y0-x1-y1-x2-y2... 这样依次排列,xi 和 yi 分别表示第 i 个横轴数据和第 i 个纵轴数据。

●对于 QCPCurve 绘图类型,这个 DataType 为 QCPCurveData,查看 QCPCurveData 类定义,它有且仅有三个 double 类型的成员变量 t、key 和 value。因此 QCPCurve 的绘图数据在内存中按顺序 t0-x0-y0-t1-x1-y1-t2-x2-y2... 这样依次排列,这个 t 表示参数曲线对应的参变量。

●对于 QCPBars 绘图类型,这个 DataType 为 QCPBarsData,查看 QCPBarsData 类定义,它有且仅有两个 double 类型的成员变量 key 和 value。因此 QCPBars 绘图数据与 QCPGraph 绘图数据的内存排列方式一样。

●QCPStatisticalBox 与 QCPFinancial 这两个绘图类型就相对复杂些,但不变的是,绘图数据仍被依次存储在一块连续的内存块中,感兴趣的可以看下 QCPStatisticalBoxData 与 QCPFinancialData 的类定义。
更新一维绘图数据时,QCustomPlot 提供了一些接口,分别为:

// QCPGraph 4个接口
void setData(QSharedPointer<QCPGraphDataContainer> data)
void setData(const QVector<double> &keys, const QVector<double> &values, bool alreadySorted=false)
void addData(const QVector<double> &keys, const QVector<double> &values, bool alreadySorted=false)
void addData(double key, double value)
    
// QCPCurve 7个接口
void setData(QSharedPointer<QCPCurveDataContainer> data)
void setData(const QVector<double> &t, const QVector<double> &keys, const QVector<double> &values, bool alreadySorted=false)
void setData(const QVector<double> &keys, const QVector<double> &values)
void addData(const QVector<double> &t, const QVector<double> &keys, const QVector<double> &values, bool alreadySorted=false)
void addData(const QVector<double> &keys, const QVector<double> &values)
void addData(double t, double key, double value)
void addData(double key, double value)
    
// QCPBars 4个接口
void setData(QSharedPointer<QCPBarsDataContainer > data)
void setData(const QVector< double > &keys, const QVector<double> &values, bool alreadySorted=false)
void addData(const QVector< double > &keys, const QVector<double> &values, bool alreadySorted=false)
void addData(double key, double value)
    
// QCPStatisticalBox 4个接口
void setData(QSharedPointer<QCPStatisticalBoxDataContainer> data)
void setData(const QVector<double> &keys, const QVector<double> &minimum, const QVector<double> &lowerQuartile, const QVector<double> &median, const QVector<double> &upperQuartile, const QVector<double> &maximum, bool alreadySorted=false)
void addData(const QVector<double> &keys, const QVector<double> &minimum, const QVector<double> &lowerQuartile, const QVector<double> &median, const QVector<double> &upperQuartile, const QVector<double> &maximum, bool alreadySorted=false)
void addData(double key, double minimum, double lowerQuartile, double median, double upperQuartile, double maximum, const QVector<double> &outliers=QVector<double>())
    
// QCPFinancial 4个接口
void setData(QSharedPointer<QCPFinancialDataContainer> data)
void setData(const QVector<double> &keys, const QVector<double> &open, const QVector<double> &high, const QVector<double> &low, const QVector<double> &close, bool alreadySorted=false)
void addData(const QVector<double> &keys, const QVector<double> &open, const QVector<double> &high, const QVector<double> &low, const QVector<double> &close, bool alreadySorted=false)
void addData(double key, double open, double high, double low, double close)

除第一个接口外,原生的 setData() 与 addData() 接口内部都会调用 QVector 相关的 resize()、size()、std::sort()、std::inplace_merge() 等函数,还存在很多 if 语句。在一些时候,特别是数据点数固定但数值更新速率很高时,频繁的调用 size() 等函数会大大延长刷新时间,此时接口中的很多操作都是不必要的,因此不妨直接将存储绘图数据的 QVector<DataType> 容器地址交给使用者,以获得更佳的性能,缩短更新时间。QCustomPlot 提供了以下几个函数来访问绘图数据的 QVector<DataType> 容器(就是下面的 mData),可通过这些函数的联合使用来修改绘图数据:

// QCPDataContainer
int size() const { return mData.size()-mPreallocSize; }
iterator begin() { return mData.begin()+mPreallocSize; }
iterator end() { return mData.end(); }

// QCPGraph
QSharedPointer<QCPGraphDataContainer> data() const { return mDataContainer; }

// QCPCurve
QSharedPointer<QCPCurveDataContainer> data() const { return mDataContainer; }

// QCPBars
QSharedPointer<QCPBarsDataContainer> data() const { return mDataContainer; }

// QCPStatisticalBox
QSharedPointer<QCPStatisticalBoxDataContainer> data() const { return mDataContainer; }

// QCPFinancial
QSharedPointer<QCPFinancialDataContainer> data() const { return mDataContainer; }

另一种方法是在源码中添加一行代码,直接导出 mData 地址,获得更自由的控制权。

3.2 在 qcustomplot.h 文件中添加代码

在 QCPDataContainer 类定义的 public 区域,添加以下一行代码即可:

template <class DataType>
class QCPDataContainer // no QCP_LIB_DECL, template class ends up in header (cpp included below)
{
public:
    ...
        
    // 新添内容
    QVector<DataType>* coreData() { return &mData; }
}

3.3 使用绘图数据地址来更新数据

对相应的绘图对象使用 coreData() 函数获得绘图数据的地址,如下所示:

QVector<QCPGraphData> *mData = ui->Plot->graph(0)->data()->coreData();

得到这个地址后,就可以用数组访问的方式逐点更新数据,或者使用 memcpy() 做一次更新。后面绘图时会默认数据已经排好了序,不会再进行排序操作,因此若需要重排数据顺序,需人工提前排好。

// 可能需要预分配容器内存,预分配内存仅需一次
mData->reserve(totalSize);
mData->resize(totalSize);

// 逐点更新 xi = 5.0;
(*mData)[i].key = 5.0;

// 逐点更新 yi = sin(5.0);
(*mData)[i].value = sin(5.0);

// 一次更新
memcpy((char*)mData, (char*)pData, sizeof(double)*totalSize*2);

注意:使用 memcpy() 一次更新时,这个 pData 为存储新数据的内存首地址,pData 所指空间中数据的排列方式必须和对应绘图数据的内存排列方式保持一致。

4. 技巧四:导出 QCPColorMap 绘图数据地址

4.1 QCPColorMap 绘图数据的内存结构

QCPColorMap 绘图数据存储在 QCPColorMapData 这个类里面,详见 qcustomplot.h 文件中 QCPColorMapData 的类定义,绘图数据存储的容器为一维 double 数组,按行进行存储,纵坐标小的排在数组前面。纵坐标最小的一行排在数组最前面,纵坐标最大的一行排在数组最后面;存储每行时,横坐标最小的排在数组前面,横坐标最大的排在数组后面。QCustomPlot 提供的数据更新接口有:

// QCPColorMapData
void setData(double key, double value, double z)
void setCell(int keyIndex, int valueIndex, double z)
void fill(double z)
    
// QCPColorMap
void setData(QCPColorMapData *data, bool copy=false)

同样在数据点数固定但数值更新速率很高时,原生接口中的很多操作都是不必要的。对于 QCPColorMap,QCustomPlot 没有提供绘图数据容器迭代器的接口,只能通过在源码中添加代码的方式得到绘图数据容器的地址。

4.2 在 qcustomplot.h 文件中添加代码

在 QCPColorMapData 类定义的 public 区域,添加以下一行代码即可:

class QCP_LIB_DECL QCPColorMapData
{
public:
    ...

    // 新添内容
    double *coreData() { mDataModified = true; return mData; }
}

4.3 使用绘图数据地址来更新数据

对 QCPColorMap 对象使用 coreData() 函数获得绘图数据的地址,如下所示:

double *mData = m_pColorMap->data()->coreData();

得到这个地址后,就可以用数组访问的方式逐点更新数据,或者使用 memcpy() 做一次更新。

// 不要在外部使用 new 来分配内存,而应使用原生接口来做内存预分配
m_pColorMap->data()->setSize(xsize, ysize);

// 逐点更新 m[xi][yj] = 5.0; 其中 xi,yj 为非负整型索引值
mData[(yj-1)*xsize+xi] = 5.0;

// 一次更新
memcpy((char*)mData, (char*)pData, sizeof(double)*xsize*ysize);

注意:使用 memcpy() 一次更新时,这个 pData 为存储新数据的内存首地址,pData 所指空间中数据的排列方式必须和 QCPColorMap 绘图数据的内存排列方式保持一致。

原文链接:https://blog.csdn.net/caoshangpa/article/details/79480979

  • 3
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

草上爬

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值