cocos2dx架构一瞥—程序架构、实时更新、主线程

本文接上一篇cocos2dx架构一瞥——Node类、坐标系及UI树。

本文是阅读《我所理解的cocos2d-x》第二章——Cocos2dx架构一瞥的2.4、2.5、2.6小章节后的总结。

(参考书籍《我所理解的cocos2d-x》,秦春林著)

(文中cocos2d-x版本为3.17)

六、应用程序架构

1、游戏生命周期

在cocos2dx中,一个游戏对应的是一个Application对象。但是我们通常不直接创建Application对象,而是通过实现一个Application的子类,来自定义我们游戏生命周期各个阶段的处理,这个子类叫AppDelegate。

int WINAPI _tWinMain(HINSTANCE hInstance,
                       HINSTANCE hPrevInstance,
                       LPTSTR    lpCmdLine,
                       int       nCmdShow)
{
    UNREFERENCED_PARAMETER(hPrevInstance);
    UNREFERENCED_PARAMETER(lpCmdLine);

    // create the application instance
    AppDelegate app;
    return Application::getInstance()->run();
}

AppDelegate类

class  AppDelegate : private cocos2d::Application
{
public:
    AppDelegate();
    virtual ~AppDelegate();
    virtual void initGLContextAttrs();

    virtual bool applicationDidFinishLaunching();
    virtual void applicationDidEnterBackground();
    virtual void applicationWillEnterForeground();
};

Application类中的调用

 int Application::run()
{
 // Initialize instance and cocos2d.
    if (!applicationDidFinishLaunching())//此处调用的就是AppDelegate类中的方法
    {
        return 1;
    }
 }

我们细说一下AppDelegate类中的方法,因为这是我们主要的逻辑。

bool AppDelegate::applicationDidFinishLaunching() {
    // initialize director
    //首先我们初始化并获取Director单例,(单例模式)
    auto director = Director::getInstance();
    //然后我们获取OpenGLES的对象,并判断一下是否存在
    auto glview = director->getOpenGLView();
    if(!glview) {
#if (CC_TARGET_PLATFORM == CC_PLATFORM_WIN32) || (CC_TARGET_PLATFORM == CC_PLATFORM_MAC) || (CC_TARGET_PLATFORM == CC_PLATFORM_LINUX)
        glview = GLViewImpl::createWithRect("MindOfMnemonist", cocos2d::Rect(0, 0, designResolutionSize.width, designResolutionSize.height));
#else
        glview = GLViewImpl::create("MindOfMnemonist");
#endif
        director->setOpenGLView(glview);
		glview->setDesignResolutionSize(800, 480, kResolutionExactFit);
    }
    // turn on display FPS
    director->setDisplayStats(true);

    // set FPS. the default value is 1.0/60 if you don't call this
    director->setAnimationInterval(1.0f / 60);
    
    // create a scene. it's an autorelease object
    auto scene = Scene::scene();

    // run
    //Director对象进行绘制场景
    //在多屏幕设备上,我们可以创建多个Director(去掉单例),每一个Director对应渲染一个屏幕。
    director->runWithScene(scene);
}

//除了以上的设置,我们还可以设置分辨率,帧率,资源搜索目录。

在应用程序启动之后,我们可能会需要相应切换事件(手机切换软件,程序就到了后台),程序前后台切换的时候,切到后台我们应该暂停游戏,切回之后再继续

我们可以在下面两个函数设置

class  AppDelegate : private cocos2d::Application
{
public:
    AppDelegate();
    virtual ~AppDelegate();

    virtual void initGLContextAttrs();
	//程序正常运行
    virtual bool applicationDidFinishLaunching();
	//程序被切到后台
    virtual void applicationDidEnterBackground();
	//切回前台
    virtual void applicationWillEnterForeground();
};
// This function will be called when the app is inactive. Note, when receiving a phone call it is invoked.
void AppDelegate::applicationDidEnterBackground() {
    Director::getInstance()->stopAnimation();
}

void AppDelegate::applicationWillEnterForeground() {
    Director::getInstance()->startAnimation();
}
2、窗口尺寸

在cocos2dx中Director控制着场景的一切,包括OpenGLES的初始化、窗口管理、场景管理以及切换、游戏循环等等。

在刚刚的源码中我们可以看到,Director控制着一个GLView的对象,它表示一个OpenGLES窗口。GLView会初始化OpenGLES相关的工作,在游戏的每一帧,Director都会通过GLView对象来绘制。

class CC_DLL Director : public Ref
{
GLView *_openGLView;
};

在概念上,WinSize表示实际的画布大小,VisibleSize表示屏幕上可见区域的大小,所以VisibleSize总是小于等于WinSize。

WinSize和VisibleSize都表示设计分辨率,设计分辨率又被称虚拟分辨率,它通常根据美术资源的分辨率来设置。

winSizeInPixels可以获取屏幕的实际分辨率。

3、场景管理
class CC_DLL Director : public Ref
{
	/** Gets current running Scene. Director can only run one Scene at a time. */
    Scene* getRunningScene() { return _runningScene; }
    void runWithScene(Scene *scene);
    void pushScene(Scene *scene);
    void popScene();
    void popToRootScene();
    void popToSceneStackLevel(int level);
    void replaceScene(Scene *scene);
    void drawScene();
    
    
    /* scheduled scenes */
    Vector<Scene*> _scenesStack;
    /* will be the next 'runningScene' in the next frame
     nextScene is a weak reference. */
    Scene *_nextScene;
};
//runWithScene会自动把场景压栈
void Director::runWithScene(Scene *scene)
{
    CCASSERT(scene != nullptr, "This command can only be used to start the Director. There is already a scene present.");
    CCASSERT(_runningScene == nullptr, "_runningScene should be null");

    pushScene(scene);
    startAnimation();
}

Director提供了两种方法切换场景,一种是replaceScene()直接替换掉当前场景,这回释放掉当前场景。

另一种是使用一个栈来保存多个场景(Vector < Scene* > _scenesStack;)

我们可以void pushScene(Scene *scene); 来压入新场景并保证旧场景不会被释放,而且runWithScene会自动压栈。而且压入新场景之后我们会切换到新场景

我们可以通过 void popScene(); 弹出栈顶场景,同时切换场景至最新的栈顶场景。

如果栈空,则程序会进入下一个Loop。

4、游戏循环

整个游戏从我们在main.cpp的Application::getInstance()->run();开始就进入了我们的游戏循环了。

整个游戏循环包括以下过程

(1)检测用户输入。键盘鼠标等

(2)动画计算,执行动画更新。这里只是根据命令计算动画,比如移动等…但是这并不代表最终的效果,后面的逻辑还会对这个进行修改

(3)物理模拟,默认处理物理模拟碰撞。比如碰撞等,

(4)逻辑更新,这部分是一些程序自定义的一些逻辑的更行。程序的大部分算法和逻辑都在这里被处理,这里是修改元素属性的最后机会。

(5)UI树遍历。给每个对象计算坐标变换举证,根据逻辑深度(localZOrder)进行排序,以便生成正确的绘制顺序,每个节点被遍历之后都会发送绘制命令至绘制栈。

(6)绘制。这一步是渲染系统开始根据元素的逻辑绘制顺序(globalZOrder)进行绘制,

(7)自动释放。这一步是PoolManager释放当前帧所有的autorelease对象。

七、实时更新游戏对象

游戏不同于传统应用程序的最大差别就是,游戏是一个实时动态的模拟系统。每一帧中任何对象都可能发生改变,所以游戏系统一般使用循环机制来更新游戏对象的状态。

1、帧率

游戏循环根据一定的时间频率来更新游戏对象的状态,这个时间频率也可以理解为帧率。

帧率是指程序中画面的绘制频率,通常称为每秒帧数(Frame Per Second,FPS)。

在cocos2dx默认情况是60帧/秒,但是我们看代码可以看到一般写作1/60,其实可以理解成1/60秒更新一次游戏状态。(一帧更新一次)

    // set FPS. the default value is 1.0/60 if you don't call this
    director->setAnimationInterval(1.0f / 60);

帧率决定了游戏循环中状态更新的时间间隔(画面刷新的时间间隔),在cocos2dx中,这些都在Application中实现。

原理很简单,执行循环,然后计算时间间隔,距离下一次还有间隔就sleep等待,直至进入下一次循环。

int Application::run()
{
    // Initialize instance and cocos2d.
    if (!applicationDidFinishLaunching())
    {
        return 1;
    }

    auto director = Director::getInstance();
    auto glview = director->getOpenGLView();

    // Retain glview to avoid glview being released in the while loop
    glview->retain();

    LONGLONG interval = 0LL;
    LONG waitMS = 0L;

    LARGE_INTEGER freq;
    QueryPerformanceFrequency(&freq);

    while(!glview->windowShouldClose())
    {
        QueryPerformanceCounter(&nNow);
        interval = nNow.QuadPart - nLast.QuadPart;
        if (interval >= _animationInterval.QuadPart)
        {
            nLast.QuadPart = nNow.QuadPart;
            director->mainLoop();
            glview->pollEvents();
        }
        else
        {
            // The precision of timer on Windows is set to highest (1ms) by 'timeBeginPeriod' from above code,
            // but it's still not precise enough. For example, if the precision of timer is 1ms,
            // Sleep(3) may make a sleep of 2ms or 4ms. Therefore, we subtract 1ms here to make Sleep time shorter.
            // If 'waitMS' is equal or less than 1ms, don't sleep and run into next loop to
            // boost CPU to next frame accurately.
            waitMS = (_animationInterval.QuadPart - interval) * 1000LL / freq.QuadPart - 1L;
            if (waitMS > 1L)
                Sleep(waitMS);
        }
    }
	return 0;
}
2、Scheduler循环器

除了游戏自身的各个子系统外,游戏引擎还必须提供某种机制更新我们自定义的游戏对象。

以下是两种游戏对象的更新方式

(1)以游戏对象为单位的更新系统,在这种设计中,游戏引擎会管理所有的游戏对象,并通过调用他们的基类的update函数在每一帧去更新游戏对象(多态调用子类更新函数)。

但是这种方式有一些缺点。

第一、很多游戏对象可能不需要更新,但是依旧会去执行update函数;

第二、因为是使用多态调用update函数,那就要求每个更新对象都必须是统一类型或者都继承某一基类,比如说U3D中的GameObject。这就面临一个问题,如果是别的对象(非GameObject)需要更新怎么办?

第三、以游戏对象为单位的更新系统,在处理多个对象的交叉逻辑关系时的更新优先级上存在着一些麻烦(分清优先级很重要,也很困难)。

综上所述,更新系统设计上更倾向于对逻辑设计更新顺序而不是对游戏对象设计更新顺序。

在cocos2dx中,我们可以通过向Scheduler注册一个回调函数来更新逻辑。Scheduler提供了两种类型的回调更新

第一种方法:类型与游戏循环的帧率保持一致,通过scheduleUpdate()方法注册

class CC_DLL Scheduler : public Ref
{
public:
    template <class T>
    void scheduleUpdate(T *target, int priority, bool paused)
    {
        this->schedulePerFrame([target](float dt){
            target->update(dt);
        }, target, priority, paused);
    }
    
    void Scheduler::schedulePerFrame(const ccSchedulerFunc& callback, void *target, int priority, bool paused)
{
    tHashUpdateEntry *hashElement = nullptr;
    HASH_FIND_PTR(_hashForUpdates, &target, hashElement);
    if (hashElement)
    {
        // change priority: should unschedule it first
        if (hashElement->entry->priority != priority)
        {
            unscheduleUpdate(target);
        }
        else
        {
            // don't add it again
            CCLOG("warning: don't update it again");
            return;
        }
    }

    // most of the updates are going to be 0, that's way there
    // is an special list for updates with priority 0
    if (priority == 0)
    {
        appendIn(&_updates0List, callback, target, paused);
    }
    else if (priority < 0)
    {
        priorityIn(&_updatesNegList, callback, target, priority, paused);
    }
    else
    {
        // priority > 0
        priorityIn(&_updatesPosList, callback, target, priority, paused);
    }
}

};

使用这种方法还可以指定一个更新的优先级,Scheduler会按照priority值从小到大的顺序执行更新回调。

第二种方法:通过schedule()方法注册自定以的更新回调。

class CC_DLL Scheduler : public Ref
{
void Scheduler::schedule(SEL_SCHEDULE selector, Ref *target, float interval, unsigned int repeat, float delay, bool paused)
{
    CCASSERT(target, "Argument target must be non-nullptr");
    
    tHashTimerEntry *element = nullptr;
    HASH_FIND_PTR(_hashForTimers, &target, element);
    
    if (! element)
    {
        element = (tHashTimerEntry *)calloc(sizeof(*element), 1);
        element->target = target;
        
        HASH_ADD_PTR(_hashForTimers, target, element);
        
        // Is this the 1st element ? Then set the pause level to all the selectors of this target
        element->paused = paused;
    }
    else
    {
        CCASSERT(element->paused == paused, "element's paused should be paused.");
    }
    
    if (element->timers == nullptr)
    {
        element->timers = ccArrayNew(10);
    }
    else
    {
        for (int i = 0; i < element->timers->num; ++i)
        {
            TimerTargetSelector *timer = dynamic_cast<TimerTargetSelector*>(element->timers->arr[i]);
            
            if (timer && !timer->isExhausted() && selector == timer->getSelector())
            {
                CCLOG("CCScheduler#schedule. Reiniting timer with interval %.4f, repeat %u, delay %.4f", interval, repeat, delay);
                timer->setupTimerWithInterval(interval, repeat, delay);
                return;
            }
        }
        ccArrayEnsureExtraCapacity(element->timers, 1);
    }
    
    TimerTargetSelector *timer = new (std::nothrow) TimerTargetSelector();
    timer->initWithSelector(this, selector, target, interval, repeat, delay);
    ccArrayAppendObject(element->timers, timer);
    timer->release();
}
};

其中,target指定需要更新的游戏对象;callback指定回调方法;interval设置自定义的更新间隔(默认与游戏循环保持一致);repeat指定有限次数的更新,而不是永久更新;paused设置更新将被暂停,直至重新将其设置为“true”;key则可以为一个更新注册指定一个检索的标签。

在实现上,因为可以自定以时间间隔(interval),所以每个自定以的更新回调都需要一个Timer类( TimerTargetSelector *timer)来计时,而这也会浪费更多的内存和时间,而且自定以回调还不能够指定更新优先级。

所以,尽量选择第一种,效率高而且可以指定优先级。

3、时间线

通常情况下我们是使用正常的时间线,即真实时间线,和真实世界相同的时间线。

但是某些时候我们需要使用一种相对的时间线来实现快进、慢进甚至回退。

我们可以通过对Scheduler设置timerScale来设置其相对时间线。默认情况下,timerScale的值为1.0,表示与游戏循环的帧率保持一致。

小于1.0就是减慢,大于1.0就是加快。

// main loop
void Scheduler::update(float dt)
{
    _updateHashLocked = true;

    if (_timeScale != 1.0f)
    {
        dt *= _timeScale;  
    }
        //
    // Selector callbacks
    //

    // Iterate over all the Updates' selectors
    tListEntry *entry, *tmp;

    // updates with priority < 0
    DL_FOREACH_SAFE(_updatesNegList, entry, tmp)
    {
        if ((! entry->paused) && (! entry->markedForDeletion))
        {
            entry->callback(dt);
        }
    }
}
//dt是我们的每一帧的时间间隔,
//dt *= _timeScale;一开始我看到这里有些疑惑,dt是时间间隔,_timeScale大于1.0的话,dt不是变大了吗?时间间隔变得更大,怎么会变快?
//但是我们看下面优先级<0的回调函数调用(其他优先级是一样的)
//我们依旧按照一开始设置的帧率进行同步调用回调函数更新对象(假设之前是每秒60帧,现在依旧是每秒60帧,一帧调用一次)
//!!!
//但是我们在进行动画更新的时候要通过间隔时间来进行位移!!!
//!!!
//实验代码如下
bool TestScene::init()
{

	if (!Scene::init()) return false;
	greenBlock = LayerColor::create(Color4B(0, 255, 0, 255), 100, 100);
	greenBlock->setIgnoreAnchorPointForPosition(false);
	greenBlock->setAnchorPoint(Vec2(0, 0));
	greenBlock->setPosition(100, 100);
	this->addChild(greenBlock);
	auto schedule = Director::getInstance()->getScheduler();
	schedule->setTimeScale(20.0);
	this->scheduleUpdate();
	return true; 
}
void TestScene::update(float dt)   //注意参数类型
{
	int posx = greenBlock->getPositionX();
	CCLOG("posx = %d", posx);
    //不根据dt来执行动画的话,setTimeScale()根本就不起作用
	posx += 1;
    posx += 60.0 * dt;//这个才有用
	greenBlock->setPositionX(posx);
}

综上所述,对Scheduler设置timerScale其实没有改变帧率,设置的只是每一帧后,对象经历的时间(dt),原本1/60秒,设置为两倍就是1/30秒,总时间变长,移动的路径也就变长了。

最后,timeScale的值会影响所有的使用向Scheduler注册的更新回调函数。

4、逻辑更新优先级

Scheduler提供了一种简单、灵活的机制,使得游戏中的对象可以注册一个更新回调,并且指定处理的优先级。

但是这种按照游戏对象来划分逻辑更新优先级的方式并不是一种合理的方法,尤其是多个对象之间有交叉的状态读取时,各个对象的优先级往往都很难排列。这就导致一个非常严重的后果,就是一个对象中会包含大量的逻辑(因为这样做能控制各个子逻辑的优先级,并且程序员需要记住大量无意义的游戏对象的优先级)

因此,在游戏设计中,对逻辑而不是游戏对象设计优先级往往更有意义,我们只需要关注逻辑的优先级即可。这样,一个逻辑可以成为一个子系统或者组件。

在cocos2dx中就有这样的例子——ActionManager和PhysicalWorld。二者都像Scheduler注册了一个更新回调。

ActionManager负责所有Node对象的动画更新。PhysicalWorld负责所有与物理模拟相关的计算。

这样ActionManager和PhysicalWorld可以作为一个逻辑子系统,我们只需要关心子系统的逻辑优先级就可以了。

实际上,ActionManager的更新优先级为Scheduler::PRIORITY_NON_SYSTEM_MIN,它是整个游戏循环中优先级最高的,保证了ActionManager始终被最先执行。

PhysicalWorld的默认优先级是0;

ActionManager和PhysicalWorld给了我们一种启示:我们应该对一个逻辑注册一个更新回调,而不是使用该逻辑的某个对象来注册更新回调,这样子我们就只需要关心逻辑的优先级,而不需要考虑对象的优先级。

5、性能问题

游戏逻辑更新对游戏性能特别敏感,因此需要小心地处理,这里总结一些日常小原则!

(1)避免每帧查找。很多算法都需要从数组或者字典中查找数据再进行计算,每一帧都查找会严重的影响程序性能。我们应该尽量缓存查找地结果,对于字典,使用std::unordered_map,使用整型作为索引。

(2)对一些非频繁更新地状态进行缓存,确保只有更新的时候才会进行重新计算。例如,在UI树遍历的时候,如果元素位置没有变化,模型变换矩阵也不会重新计算。

(3)对于一些与UI元素绘制无关、即时性不强的算法,应该减少其update()方法调用的频率。

总之,要严格控制算法复杂度,尤其是迭代、查找、递归等等,在某些地方使用内存换时间也是可以的

八、Cocos2dx的主线程

1、在主线程中执行异步处理

cocos2dx目前仍然是一个单线程地游戏引擎,这使我们几乎不考虑游戏对象更新地线程安全性。然而,我们仍然需要关注一些情形,如网络请求、异步加载文件或者异步处理一些逻辑算法。

向Scheduler注册一个方法指针。Scheduler中储存一个需要在主线程中执行的方法指针的数组,在当前帧所有的系统或者和自定义的scheduler执行完成后,Scheduler就会检查该数组并执行其中的方法,示例如下;

// main loop
void Scheduler::update(float dt)
{
	//
    // Functions allocated from another thread
    //

    // Testing size is faster than locking / unlocking.
    // And almost never there will be functions scheduled to be called.
    if( !_functionsToPerform.empty() ) {
        _performMutex.lock();
        // fixed #4123: Save the callback functions, they must be invoked after '_performMutex.unlock()', otherwise if new functions are added in callback, it will cause thread deadlock.
        auto temp = std::move(_functionsToPerform);
        _performMutex.unlock();
        
        for (const auto &function : temp) {
            function();
        }
    }
}

通过这样的机制我们就可以将一个方法转移到主线程中执行。这里需要注意的是:这些方法在主线程中被执行的时机是所有系统或自定义的scheduler之后,即在UI树遍历之前。

2、纹理的异步加载

在上面的机制中,所有的算法逻辑以及Scheduler注册的方法都会在该帧结束的时候被全部执行。

普通的算法或操作没有问题,但是如果是一些很耗时的算法或者操作就会严重影响程序的性能,我们需要把这些耗时的操作分布在每一帧中去执行。

将纹理上传至GL内存的操作非常费时(glTexImage2D);

因此,Cocos2dx的纹理异步加载回调使用了一个自定义的Schedule。在该Schedule内部,检查已经完成加载的纹理,每一帧处理一个纹理,直至所有的纹理处理完毕,则注销该Schedule。最后,纹理在主线程中的执行情况代码如下

void TextureCache::addImageAsyncCallBack(float /*dt*/)
{
    Texture2D *texture = nullptr;
    AsyncStruct *asyncStruct = nullptr;
    while (true)
    {
        // pop an AsyncStruct from response queue
        //加锁
        _responseMutex.lock();
        //检测队列是否为空
        if (_responseQueue.empty())
        {
            asyncStruct = nullptr;
        }
        else
        {
            //去除头部元素
            asyncStruct = _responseQueue.front();
            _responseQueue.pop_front();

            // the asyncStruct's sequence order in _asyncStructQueue must equal to the order in _responseQueue
            CC_ASSERT(asyncStruct == _asyncStructQueue.front());
            _asyncStructQueue.pop_front();
        }
        
        //解锁
        _responseMutex.unlock();

        if (nullptr == asyncStruct) {
            break;
        }

        // check the image has been convert to texture or not
        auto it = _textures.find(asyncStruct->filename);
        if (it != _textures.end())
        {
            texture = it->second;
        }
        else
        {
            // convert image to texture
            if (asyncStruct->loadSuccess)
            {
                Image* image = &(asyncStruct->image);
                // generate texture in render thread
                texture = new (std::nothrow) Texture2D();

                texture->initWithImage(image, asyncStruct->pixelFormat);
                //parse 9-patch info
                this->parseNinePatchImage(image, texture, asyncStruct->filename);
#if CC_ENABLE_CACHE_TEXTURE_DATA
                // cache the texture file name
                VolatileTextureMgr::addImageTexture(texture, asyncStruct->filename);
#endif
                // cache the texture. retain it, since it is added in the map
                _textures.emplace(asyncStruct->filename, texture);
                texture->retain();

                texture->autorelease();
                // ETC1 ALPHA supports.
                if (asyncStruct->imageAlpha.getFileType() == Image::Format::ETC) {
                    auto alphaTexture = new(std::nothrow) Texture2D();
                    if(alphaTexture != nullptr && alphaTexture->initWithImage(&asyncStruct->imageAlpha, asyncStruct->pixelFormat)) {
                        texture->setAlphaTexture(alphaTexture);
                    }
                    CC_SAFE_RELEASE(alphaTexture);
                }
            }
            else {
                texture = nullptr;
                CCLOG("cocos2d: failed to call TextureCache::addImageAsync(%s)", asyncStruct->filename.c_str());
            }
        }

        // call callback function
        if (asyncStruct->callback)
        {
            (asyncStruct->callback)(texture);
        }

        // release the asyncStruct
        delete asyncStruct;
        --_asyncRefCount;
    }

    if (0 == _asyncRefCount)
    {
        Director::getInstance()->getScheduler()->unschedule(CC_SCHEDULE_SELECTOR(TextureCache::addImageAsyncCallBack), this);
    }
}

在向TextureCache发起一个异步文件加载请求时,TextureCache会向Scheduler注册一个更新回调addImageAsyncCallBack。

(1)文件加载完毕时,将其纹理储存在_imageInfoQueue中

(2)主线程每帧被更新回调时检测其是否有数据,如果有就缓存到TextureCache中,并上传至GL内存中,

(3)删除_imageInfoQueue中的数据

(4)当所有文件都加载完毕,则注销更新回调

3、异步处理的单元测试

在主线程执行逻辑算法可以使程序复杂度大大降低,并且可以自由地在某些地方使用多线程,但是这种回调机制使得单元测试变得困难,因为它依赖于Cocos2dx的主循环。

单元测试通常用来测试一个同步的方法,只要执行该方法,就能知道其运行结果。单元测试甚至不依赖太多的上下文,实际上,太多的上下文会使单元测试变得困难。

在本书的最后将给出一个解决方案,使其能够测试cocos2dx中的异步回调。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值