Qt元对象系统

元对象系统可以说是QT最核心的功能了,就是因为元对象系统的存在,才有了QT的信号槽和动态属性,今天打算复习下这套机制。

以下内容都是自己的理解,有不正确的地方欢迎指正!

Qt元对象系统的实现主要依赖于三点:继承QObject,包含Q_OBJEC宏以及moc编译器。

对于继承了QObject且在头文件声明了Q_OBJECT宏的类classA,moc编译器会扩展生成一个moc_classA.cpp的文件,在里面添加了一些支持信号槽等功能的代码————主要是生成classA对性的metaObject类,以及一些接口的重载。

Q_OBJECT

我们先看Q_OBJECT:

#define Q_OBJECT \
public: \
    QT_WARNING_PUSH \
    Q_OBJECT_NO_OVERRIDE_WARNING \
    static const QMetaObject staticMetaObject; \
    virtual const QMetaObject *metaObject() const; \
    virtual void *qt_metacast(const char *); \
    virtual int qt_metacall(QMetaObject::Call, int, void **); \
    QT_TR_FUNCTIONS \
private: \
    Q_OBJECT_NO_ATTRIBUTES_WARNING \
    Q_DECL_HIDDEN_STATIC_METACALL static void qt_static_metacall(QObject *, QMetaObject::Call, int, void **); \
    QT_WARNING_POP \
    struct QPrivateSignal {}; \
    QT_ANNOTATE_CLASS(qt_qobject, "")

可以看到,对于添加了Q_OBJECT宏的类,会定义一个QMetaObject类型的静态常量成员变量staticMetaObject,以及一个静态函数:

static void qt_static_metacall(QObject *, QMetaObject::Call, int, void **);

然后有几个宏我们再来分析下:

#define QT_DO_PRAGMA(text)                      _Pragma(#text)
#  define QT_WARNING_PUSH                       QT_DO_PRAGMA(GCC diagnostic push)
QT_WARNING_PUSH告诉编译器实际采用了C++11的语法
#  define QT_WARNING_DISABLE_GCC(text)          QT_DO_PRAGMA(GCC diagnostic ignored text)
#  define Q_OBJECT_NO_OVERRIDE_WARNING      QT_WARNING_DISABLE_GCC("-Wsuggest-override")
Q_OBJECT_NO_OVERRIDE_WARNING,让编译器忽略"-Wsuggest-override"这个警告。
#  define QT_TR_FUNCTIONS \
    static inline QString tr(const char *s, const char *c = Q_NULLPTR, int n = -1) \
        { return staticMetaObject.tr(s, c, n); } \
    QT_DEPRECATED static inline QString trUtf8(const char *s, const char *c = Q_NULLPTR, int n = -1) \
        { return staticMetaObject.tr(s, c, n); }
QT_TR_FUNCTIONS宏用于国际化功能,实际是两个内联函数tr和trUtf8,也是我们常用的接口.
Q_OBJECT_NO_ATTRIBUTES_WARNING在我用的5.9版本是个空宏。

我们先看其他三个虚函数,moc_classA.cpp里面会对他们进行重写,先看下大概的含义:

   virtual const QMetaObject *metaObject() const; \    用于获取类拥有的元对象
   virtual void *qt_metacast(const char *); \                   通过元对象获取对象指针
   virtual int qt_metacall(QMetaObject::Call, int, void **); \     用于信号槽机制

等下我们在分析moc_classA.cpp文件的时候再详细看这几个接口。

struct QPrivateSignal {}; 

是一个私有的空结构体,对函数功能来说没啥用,就是在信号被触发时,挂在参数里提醒程序员这是一个私有信号的触发

 

QMetaObject

现在先来看下QMetaObject。代码量和接口比较多,我们挑几个主要的来看:

    struct { // private data
        const QMetaObject *superdata;
        const QByteArrayData *stringdata;
        const uint *data;
        typedef void (*StaticMetacallFunction)(QObject *, QMetaObject::Call, int, void **);
        StaticMetacallFunction static_metacall;
        const QMetaObject * const *relatedMetaObjects;
        void *extradata; //reserved for future use
    } d;

这个结构体定义了元对象的所有数据:

const QMetaObject *superdata;   指向父类的staticMetaObject
const QByteArrayData *stringdata; 保存类名,信号名以及槽函数名的字符串
const uint *data;  只知道保存了一些二进制信息,具体不太理解

接下来我们看一些重要的接口。

inline const QMetaObject *QMetaObject::superClass() const
{ return d.superdata; }

之前也说过,superdata保存的是父类的QMetaObject指针,所以这就是返回父类QMetaObject指针。

 

static inline const QMetaObjectPrivate *priv(const uint* data)
{ return reinterpret_cast<const QMetaObjectPrivate*>(data); }


static inline const char *objectClassName(const QMetaObject *m)
{
    return rawStringData(m, priv(m->d.data)->className);
}

static inline const char *rawStringData(const QMetaObject *mo, int index)
{
    return stringData(mo, index).data();
}


const char *QMetaObject::className() const
{
    return objectClassName(this);
}


static inline const QByteArray stringData(const QMetaObject *mo, int index)
{
    const QByteArrayDataPtr data = { const_cast<QByteArrayData*>(&mo->d.stringdata[index]) };
    return data;
}

priv接口将d.data转换成了QMetaObjectPrivate指针,我们之前说d.data保存了一些二进制数据,现在来看就是其实就是QMetaObjectPrivate的指针。而之前也说了,

stringdata保存了类名以及信号槽名的字符串,所以className()就是根据d.data中记录的索引,从d.stringdata获取类的名字。

简单看下QMetaObjectPrivate的部分定义:

struct QMetaObjectPrivate
{
    enum { OutputRevision = 7 }; // Used by moc, qmetaobjectbuilder and qdbus
 
    int revision;  版本号
    int className;    类名称索引,刚刚我们用过
    int classInfoCount, classInfoData;   classInfo的数量,及索引 ,需要有宏定义才会被MOC生成
    int methodCount, methodData;         函数数量及索引,需要有宏定义才会被MOC生成
    int propertyCount, propertyData;          property数量及索引,需要有宏定义才会被MOC生成
 
    int enumeratorCount, enumeratorData;       枚举成员数量及索引,需要有宏定义才会被MOC生成
    int constructorCount, constructorData; //since revision 2    构造函数数量及索引,需要有宏定义才会被MOC生成
    int flags; //since revision 3                flags的数量
    int signalCount; //since revision 4      信号数量
    ...

}

以后有机会再详细分析这个类,先继续看QMetaObject的接口:

bool QMetaObject::inherits(const QMetaObject *metaObject) const Q_DECL_NOEXCEPT
{
    const QMetaObject *m = this;
    do {
        if (metaObject == m)
            return true;
    } while ((m = m->d.superdata));
    return false;
}

这个接口用来判断是否与目标metaobject有继承关系。

int QMetaObject::methodCount() const
{
    int n = priv(d.data)->methodCount;
    const QMetaObject *m = d.superdata;
    while (m) {
        n += priv(m->d.data)->methodCount;
        m = m->d.superdata;
    }
    return n;
}

返回自身和所有基类的方法总和。

int QMetaObject::methodOffset() const
{
    int offset = 0;
    const QMetaObject *m = d.superdata;
    while (m) {
        offset += priv(m->d.data)->methodCount;
        m = m->d.superdata;
    }
    return offset;
}

这个接口返回方法的偏移量。

int QMetaObject::indexOfMethod(const char *method) const
{
    const QMetaObject *m = this;
    int i;
    QArgumentTypeArray types;
    QByteArray name = QMetaObjectPrivate::decodeMethodSignature(method, types);
    i = indexOfMethodRelative<0>(&m, name, types.size(), types.constData());
    if (i >= 0)
        i += m->methodOffset();
    return i;
}

返回方法的索引。

QMetaMethod QMetaObject::method(int index) const
{
    int i = index;
    i -= methodOffset();
    if (i < 0 && d.superdata)
        return d.superdata->method(index);

    QMetaMethod result;
    if (i >= 0 && i < priv(d.data)->methodCount) {
        result.mobj = this;
        result.handle = priv(d.data)->methodData + 5*i;
    }
    return result;
}

根据索引,获取方法。返回的是一个QMetaMethod类型,Qt提供了QMetaClassInfo,QMetaMethod、QMetaProperty、QMetaEnum来包装这些元信息和对其的一些操作,比如QMetaMethod就提供了name、parameterTypes、invoke等接口。以后有机会再详细记录一下

bool QMetaObject::invokeMethod(QObject *obj,
                               const char *member,
                               Qt::ConnectionType type,
                               QGenericReturnArgument ret,
                               QGenericArgument val0,
                               QGenericArgument val1,
                               QGenericArgument val2,
                               QGenericArgument val3,
                               QGenericArgument val4,
                               QGenericArgument val5,
                               QGenericArgument val6,
                               QGenericArgument val7,
                               QGenericArgument val8,
                               QGenericArgument val9)
{
    if (!obj)
        return false;

    QVarLengthArray<char, 512> sig;
    int len = qstrlen(member);
    if (len <= 0)
        return false;
    sig.append(member, len);
    sig.append('(');

    const char *typeNames[] = {ret.name(), val0.name(), val1.name(), val2.name(), val3.name(),
                               val4.name(), val5.name(), val6.name(), val7.name(), val8.name(),
                               val9.name()};

    int paramCount;
    for (paramCount = 1; paramCount < MaximumParamCount; ++paramCount) {
        len = qstrlen(typeNames[paramCount]);
        if (len <= 0)
            break;
        sig.append(typeNames[paramCount], len);
        sig.append(',');
    }
    if (paramCount == 1)
        sig.append(')'); // no parameters
    else
        sig[sig.size() - 1] = ')';
    sig.append('\0');

    const QMetaObject *meta = obj->metaObject();
    int idx = meta->indexOfMethod(sig.constData());
    if (idx < 0) {
        QByteArray norm = QMetaObject::normalizedSignature(sig.constData());
        idx = meta->indexOfMethod(norm.constData());
    }

    if (idx < 0 || idx >= meta->methodCount()) {
        // This method doesn't belong to us; print out a nice warning with candidates.
        qWarning("QMetaObject::invokeMethod: No such method %s::%s%s",
                 meta->className(), sig.constData(), findMethodCandidates(meta, member).constData());
        return false;
    }
    QMetaMethod method = meta->method(idx);
    return method.invoke(obj, type, ret,
                         val0, val1, val2, val3, val4, val5, val6, val7, val8, val9);
}

通过对比函数名,返回值类型,函数形参来构建标签信息,通过调用QMetaObject::indexOfMethod来获取函数索引,在调用QMetaObject::method来获取对应的QMetaMethod,在调用了invoke实现调用。我们来看一下invoke的实现。

bool QMetaMethod::invoke(QObject *object,
                         Qt::ConnectionType connectionType,
                         QGenericReturnArgument returnValue,
                         QGenericArgument val0,
                         QGenericArgument val1,
                         QGenericArgument val2,
                         QGenericArgument val3,
                         QGenericArgument val4,
                         QGenericArgument val5,
                         QGenericArgument val6,
                         QGenericArgument val7,
                         QGenericArgument val8,
                         QGenericArgument val9) const
{
    if (!object || !mobj)
        return false;

    Q_ASSERT(mobj->cast(object));

    // check return type
    if (returnValue.data()) {
        const char *retType = typeName();
        if (qstrcmp(returnValue.name(), retType) != 0) {
            // normalize the return value as well
            QByteArray normalized = QMetaObject::normalizedType(returnValue.name());
            if (qstrcmp(normalized.constData(), retType) != 0) {
                // String comparison failed, try compare the metatype.
                int t = returnType();
                if (t == QMetaType::UnknownType || t != QMetaType::type(normalized))
                    return false;
            }
        }
    }

    // check argument count (we don't allow invoking a method if given too few arguments)
    const char *typeNames[] = {
        returnValue.name(),
        val0.name(),
        val1.name(),
        val2.name(),
        val3.name(),
        val4.name(),
        val5.name(),
        val6.name(),
        val7.name(),
        val8.name(),
        val9.name()
    };
    int paramCount;
    for (paramCount = 1; paramCount < MaximumParamCount; ++paramCount) {
        if (qstrlen(typeNames[paramCount]) <= 0)
            break;
    }
    if (paramCount <= QMetaMethodPrivate::get(this)->parameterCount())
        return false;

    // check connection type
    QThread *currentThread = QThread::currentThread();
    QThread *objectThread = object->thread();
    if (connectionType == Qt::AutoConnection) {
        connectionType = currentThread == objectThread
                         ? Qt::DirectConnection
                         : Qt::QueuedConnection;
    }

#ifdef QT_NO_THREAD
    if (connectionType == Qt::BlockingQueuedConnection) {
        connectionType = Qt::DirectConnection;
    }
#endif

    // invoke!
    void *param[] = {
        returnValue.data(),
        val0.data(),
        val1.data(),
        val2.data(),
        val3.data(),
        val4.data(),
        val5.data(),
        val6.data(),
        val7.data(),
        val8.data(),
        val9.data()
    };
    int idx_relative = QMetaMethodPrivate::get(this)->ownMethodIndex();
    int idx_offset =  mobj->methodOffset();
    Q_ASSERT(QMetaObjectPrivate::get(mobj)->revision >= 6);
    QObjectPrivate::StaticMetaCallFunction callFunction = mobj->d.static_metacall;

    if (connectionType == Qt::DirectConnection) {
        if (callFunction) {
            callFunction(object, QMetaObject::InvokeMetaMethod, idx_relative, param);
            return true;
        } else {
            return QMetaObject::metacall(object, QMetaObject::InvokeMetaMethod, idx_relative + idx_offset, param) < 0;
        }
    } else if (connectionType == Qt::QueuedConnection) {
        if (returnValue.data()) {
            qWarning("QMetaMethod::invoke: Unable to invoke methods with return values in "
                     "queued connections");
            return false;
        }

        int nargs = 1; // include return type
        void **args = (void **) malloc(paramCount * sizeof(void *));
        Q_CHECK_PTR(args);
        int *types = (int *) malloc(paramCount * sizeof(int));
        Q_CHECK_PTR(types);
        types[0] = 0; // return type
        args[0] = 0;

        for (int i = 1; i < paramCount; ++i) {
            types[i] = QMetaType::type(typeNames[i]);
            if (types[i] != QMetaType::UnknownType) {
                args[i] = QMetaType::create(types[i], param[i]);
                ++nargs;
            } else if (param[i]) {
                // Try to register the type and try again before reporting an error.
                void *argv[] = { &types[i], &i };
                QMetaObject::metacall(object, QMetaObject::RegisterMethodArgumentMetaType,
                                      idx_relative + idx_offset, argv);
                if (types[i] == -1) {
                    qWarning("QMetaMethod::invoke: Unable to handle unregistered datatype '%s'",
                            typeNames[i]);
                    for (int x = 1; x < i; ++x) {
                        if (types[x] && args[x])
                            QMetaType::destroy(types[x], args[x]);
                    }
                    free(types);
                    free(args);
                    return false;
                }
            }
        }

        QCoreApplication::postEvent(object, new QMetaCallEvent(idx_offset, idx_relative, callFunction,
                                                        0, -1, nargs, types, args));
    } else { // blocking queued connection
#ifndef QT_NO_THREAD
        if (currentThread == objectThread) {
            qWarning("QMetaMethod::invoke: Dead lock detected in "
                        "BlockingQueuedConnection: Receiver is %s(%p)",
                        mobj->className(), object);
        }

        QSemaphore semaphore;
        QCoreApplication::postEvent(object, new QMetaCallEvent(idx_offset, idx_relative, callFunction,
                                                        0, -1, 0, 0, param, &semaphore));
        semaphore.acquire();
#endif // QT_NO_THREAD
    }
    return true;
}

先检查了返回值类型,在检查参数个数是否相同,如果在连接的时候使用了默认的Qt::AutoConnection连接,就根据信号和槽的对象的所处的线程来调整,是使用Qt::DirectConnection还是Qt::QueuedConnection。

如果是Qt::DirectConnection,直接调用,如果是Qt::AutoConnection,就使用QCoreApplication::postEvent发送到对方线程的事件循环中,对应线程收到该事件,再调用。

此外,还有Qt::BlockingQueuedConnection,会通过等待信号量QSemaphore再发送。

 

moc_classA.cpp

最后,我们再来看下生成的moc文件。值得注意的是,生成的moc文件应该是在预处理之后,编译之前,然后生成的文件一起编译。

class MyObject : public QObject
{
    Q_OBJECT
public:
    explicit MyObject(QObject *parent = nullptr);

signals:
    void testSiagnal1();

    void testSiagnal2(int);

public:
    void testSlot1();
    void testSlot2(int);
};

这个是.h文件的内容,我们看一下生成的moc_myobject.cpp都有些啥。

QT_BEGIN_MOC_NAMESPACE
QT_WARNING_PUSH
QT_WARNING_DISABLE_DEPRECATED
struct qt_meta_stringdata_MyObject_t {
    QByteArrayData data[6];
    char stringdata0[56];
};
#define QT_MOC_LITERAL(idx, ofs, len) \
    Q_STATIC_BYTE_ARRAY_DATA_HEADER_INITIALIZER_WITH_OFFSET(len, \
    qptrdiff(offsetof(qt_meta_stringdata_MyObject_t, stringdata0) + ofs \
        - idx * sizeof(QByteArrayData)) \
    )
static const qt_meta_stringdata_MyObject_t qt_meta_stringdata_MyObject = {
    {
QT_MOC_LITERAL(0, 0, 8), // "MyObject"
QT_MOC_LITERAL(1, 9, 12), // "testSiagnal1"
QT_MOC_LITERAL(2, 22, 0), // ""
QT_MOC_LITERAL(3, 23, 12), // "testSiagnal2"
QT_MOC_LITERAL(4, 36, 9), // "testSlot1"
QT_MOC_LITERAL(5, 46, 9) // "testSlot2"

    },
    "MyObject\0testSiagnal1\0\0testSiagnal2\0"
    "testSlot1\0testSlot2"
};
#undef QT_MOC_LITERAL

static const uint qt_meta_data_MyObject[] = {

 // content:
       7,       // revision
       0,       // classname
       0,    0, // classinfo
       4,   14, // methods    信号与槽的个数加起来是四个
       0,    0, // properties  没有自定义属性
       0,    0, // enums/sets  没有枚举
       0,    0, // constructors
       0,       // flags
       2,       // signalCount  信号为两个

 // signals: name, argc, parameters, tag, flags
       1,    0,   34,    2, 0x06 /* Public */,
       3,    1,   35,    2, 0x06 /* Public */,

 // slots: name, argc, parameters, tag, flags
       4,    0,   38,    2, 0x0a /* Public */,
       5,    1,   39,    2, 0x0a /* Public */,

 // signals: parameters
    QMetaType::Void,
    QMetaType::Void, QMetaType::Int,    2,

 // slots: parameters
    QMetaType::Void,
    QMetaType::Void, QMetaType::Int,    2,

       0        // eod
};

void MyObject::qt_static_metacall(QObject *_o, QMetaObject::Call _c, int _id, void **_a)
{
    if (_c == QMetaObject::InvokeMetaMethod) {
        MyObject *_t = static_cast<MyObject *>(_o);
        Q_UNUSED(_t)
        switch (_id) {
        case 0: _t->testSiagnal1(); break;
        case 1: _t->testSiagnal2((*reinterpret_cast< int(*)>(_a[1]))); break;
        case 2: _t->testSlot1(); break;
        case 3: _t->testSlot2((*reinterpret_cast< int(*)>(_a[1]))); break;
        default: ;
        }
    } else if (_c == QMetaObject::IndexOfMethod) {
        int *result = reinterpret_cast<int *>(_a[0]);
        void **func = reinterpret_cast<void **>(_a[1]);
        {
            typedef void (MyObject::*_t)();
            if (*reinterpret_cast<_t *>(func) == static_cast<_t>(&MyObject::testSiagnal1)) {
                *result = 0;
                return;
            }
        }
        {
            typedef void (MyObject::*_t)(int );
            if (*reinterpret_cast<_t *>(func) == static_cast<_t>(&MyObject::testSiagnal2)) {
                *result = 1;
                return;
            }
        }
    }
}

const QMetaObject MyObject::staticMetaObject = {
    { &QObject::staticMetaObject, qt_meta_stringdata_MyObject.data,
      qt_meta_data_MyObject,  qt_static_metacall, nullptr, nullptr}
};


const QMetaObject *MyObject::metaObject() const
{
    return QObject::d_ptr->metaObject ? QObject::d_ptr->dynamicMetaObject() : &staticMetaObject;
}

void *MyObject::qt_metacast(const char *_clname)
{
    if (!_clname) return nullptr;
    if (!strcmp(_clname, qt_meta_stringdata_MyObject.stringdata0))
        return static_cast<void*>(this);
    return QObject::qt_metacast(_clname);
}

int MyObject::qt_metacall(QMetaObject::Call _c, int _id, void **_a)
{
    _id = QObject::qt_metacall(_c, _id, _a);
    if (_id < 0)
        return _id;
    if (_c == QMetaObject::InvokeMetaMethod) {
        if (_id < 4)
            qt_static_metacall(this, _c, _id, _a);
        _id -= 4;
    } else if (_c == QMetaObject::RegisterMethodArgumentMetaType) {
        if (_id < 4)
            *reinterpret_cast<int*>(_a[0]) = -1;
        _id -= 4;
    }
    return _id;
}

// SIGNAL 0
void MyObject::testSiagnal1()
{
    QMetaObject::activate(this, &staticMetaObject, 0, nullptr);
}

// SIGNAL 1
void MyObject::testSiagnal2(int _t1)
{
    void *_a[] = { nullptr, const_cast<void*>(reinterpret_cast<const void*>(&_t1)) };
    QMetaObject::activate(this, &staticMetaObject, 1, _a);
}
QT_WARNING_POP
QT_END_MOC_NAMESPACE

我们看之前说到的三个虚方法:

const QMetaObject *MyObject::metaObject() const
{
    return QObject::d_ptr->metaObject ? QObject::d_ptr->dynamicMetaObject() : &staticMetaObject;
}

返回了QMetaObject指针,这个是个静电常量指针。

void *MyObject::qt_metacast(const char *_clname)
{
    if (!_clname) return nullptr;
    if (!strcmp(_clname, qt_meta_stringdata_MyObject.stringdata0))
        return static_cast<void*>(this);
    return QObject::qt_metacast(_clname);
}

其实就是返回了实现了到void*的转换。

int MyObject::qt_metacall(QMetaObject::Call _c, int _id, void **_a)
{
    _id = QObject::qt_metacall(_c, _id, _a);
    if (_id < 0)
        return _id;
    if (_c == QMetaObject::InvokeMetaMethod) {
        if (_id < 2)
            qt_static_metacall(this, _c, _id, _a);
        _id -= 2;
    } else if (_c == QMetaObject::RegisterMethodArgumentMetaType) {
        if (_id < 2)
            *reinterpret_cast<int*>(_a[0]) = -1;
        _id -= 2;
    }
    return _id;
}

利用槽函数在qt_static_metacall 函数的索引位置来调用槽函数。

void MyObject::qt_static_metacall(QObject *_o, QMetaObject::Call _c, int _id, void **_a)
{
    if (_c == QMetaObject::InvokeMetaMethod) {
        MyObject *_t = static_cast<MyObject *>(_o);
        Q_UNUSED(_t)
        switch (_id) {
        case 0: _t->testSiagnal1(); break;
        case 1: _t->testSiagnal2((*reinterpret_cast< int(*)>(_a[1]))); break;
        case 2: _t->testSlot1(); break;
        case 3: _t->testSlot2((*reinterpret_cast< int(*)>(_a[1]))); break;
        default: ;
        }
    } else if (_c == QMetaObject::IndexOfMethod) {
        int *result = reinterpret_cast<int *>(_a[0]);
        void **func = reinterpret_cast<void **>(_a[1]);
        {
            typedef void (MyObject::*_t)();
            if (*reinterpret_cast<_t *>(func) == static_cast<_t>(&MyObject::testSiagnal1)) {
                *result = 0;
                return;
            }
        }
        {
            typedef void (MyObject::*_t)(int );
            if (*reinterpret_cast<_t *>(func) == static_cast<_t>(&MyObject::testSiagnal2)) {
                *result = 1;
                return;
            }
        }
    }
}
void MyObject::testSiagnal2(int _t1)
{
    void *_a[] = { nullptr, const_cast<void*>(reinterpret_cast<const void*>(&_t1)) };
    QMetaObject::activate(this, &staticMetaObject, 1, _a);
}

_a为一个指向参数的指针的数组,并将指针数组传给QMetaObject::activate函数。数组的第一个元素是返回值。active的第三个参数为信号的参数个数

其实有些流程我也还不是很清楚,以后进一步补充。

 

 

 

 

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值