ShareSDK Cocos2d-x专用组件的一个Bug

近期研究了一下Game App做社交分享,最后选择了ShareSDK来集成,不仅是因为ShareSDK支持国内外主流社交平台,更重要的是ShareSDK提供了专门的 cocos2d-x集成方案,有专门的文档代码Demo供开发者参考。

文档中提到了三种集成方式:纯Java方式、plugin-x方式以及Cocos2d-x专用组件方式,这里选择了ShareSDK Cocos2d-x专用组件(v2.3.7版本)的方式。按照文档中描述的步骤进行的相对顺利,在各个社交平台的appkey生效后,我们对demo app进行了测试,居然发现app经常随机性的崩溃,有时甚至是每次都崩溃,经过深入分析,发现这是ShareSDK Cocos2d-x专用组件的一个严重Bug,下面详细说明一下Bug的产生原因以及Fix方法。

一、App崩溃的场景和代码位置

发生崩溃的场景如下:
    App Demo中有一个"Share"按钮,点击该按钮,App Demo向已经授权的社交平台分享一些Test Content,而App Demo就在收到分享结果应答时发生了崩溃。

代码位置大致如下:

void AppDemo::onShareClick(CCObject* sender)
{
    … …
    C2DXShareSDK::showShareMenu(NULL, content,
                                CCPointMake(100, 100),
                                C2DXMenuArrowDirectionLeft,
                                shareResultHandler);
}

void shareResultHandler(C2DXResponseState state, C2DXPlatType platType,
                        CCDictionary *shareInfo, CCDictionary *error)
{
    switch (state) {
        case C2DXResponseStateSuccess:
            CCLog("Share Ok");
            break;
        case C2DXResponseStateFail:
            CCLog("Share Failed");
            break;
        default:
            break;
    }
}

崩溃的位置大致就在回调shareResultHandler前后的某个位 置,比较随机。

二、现象分析

通过查看Eclipse logcat窗口的调试日志,我们发现一些规律,一些在“Share Ok后的崩溃打印出如下日志:

04-16 01:28:33.890: D/cocos2d-x debug info(1748): Share Ok
04-16 01:28:34.090: D/cocos2d-x debug info(1748): Assert failed: reference count should greater than 0
04-16 01:28:34.090: E/cocos2d-x assert(1748): /home1/tonybai/android-dev/cocos2d-x-2.2.2/samples/Cpp/temp/AppDemo/proj.android/../../../../../cocos2dx/cocoa/CCObject.cpp function:release line:81
04-16 01:28:34.130: A/libc(1748): Fatal signal 11 (SIGSEGV) at 0×00000003 (code=1), thread 1829 (Thread-122)

猜测一下,似乎是某个CCObject在真正Release前已经被释放了,然后后续被引用时触发内存非法访问。Cocos2d-x采用的是内存 计数的内存管理机制,在我的《Cocos2d-x内存管理-绕不过去的坎》一文中有描述。了解Cocos2d-x的内存管理机制是理解这个Bug 的前提条件。

三、原因分析

看来不得不挖掘一下ShareSDK组件的代码了。AppDemo中ShareSDK组件的代码分为两个部分:AppDemo/Classes /C2DXShareSDK和AppDemo/proj.android/src/cn/sharesdk。前者是C++代码,后面则是Java 代码,两者通过jni调用联系在一起。我们重点来找出分享应答返回来时的关键联系。

集成ShareSDK的Cocos2d-x程序会在主Activity的onCreate方法中调用ShareSDKUtils.prepare();

我们来看看prepare方法的实现:

//AppDemo/proj.android/src/cn/sharesdk/ShareSDKUtils.java

public class ShareSDKUtils {
    private static boolean DEBUG = true;
    private static Context context;
    private static PlatformActionListener paListaner;
    private static Hashon hashon;
    … …
   
    public static void prepare() {
        UIHandler.prepare();
        context = Cocos2dxActivity.getContext().getApplicationContext();
        hashon = new Hashon();
        final Callback cb = new Callback() {
            public boolean handleMessage(Message msg) {
                onJavaCallback((String) msg.obj);
                return false;
            }
        };

        paListaner = new PlatformActionListener() {
            public void onComplete(Platform platform, int action, HashMap<String, Object> res) {
                if (DEBUG) {
                    System.out.println("onComplete");
                    System.out.println(res == null ? "" : res.toString());
                }
                HashMap<String, Object> map = new HashMap<String, Object>();
                map.put("platform", ShareSDK.platformNameToId(platform.getName()));
                map.put("action", action);
                map.put("status", 1); // Success = 1, Fail = 2, Cancel = 3
                map.put("res", res);
                Message msg = new Message();
                msg.obj = hashon.fromHashMap(map);
                UIHandler.sendMessage(msg, cb);
            }

    … …
}

可以看出监听Complete事件的listener将message的处理都交给了cb,而cb调用了onJavaCallback方法。

onJavaCallback方法是jni导出的方法,它的实现在 AppDemo/Classes/C2DXShareSDK/Android/ShareSDKUtils.cpp里面。

JNIEXPORT void JNICALL Java_cn_sharesdk_ShareSDKUtils_onJavaCallback
  (JNIEnv * env, jclass thiz, jstring resp) {
    CCJSONConverter* json = CCJSONConverter::sharedConverter();
    const char* ccResp = env->GetStringUTFChars(resp, JNI_FALSE);
    CCLog("ccResp = %s", ccResp);
    CCDictionary* dic = json->dictionaryFrom(ccResp);
    env->ReleaseStringUTFChars(resp, ccResp);
    CCNumber* status = (CCNumber*) dic->objectForKey("status"); // Success = 1, Fail = 2, Cancel = 3
    CCNumber* action = (CCNumber*) dic->objectForKey("action"); //  1 = ACTION_AUTHORIZING,  8 = ACTION_USER_INFOR,9 = ACTION_SHARE
    CCNumber* platform = (CCNumber*) dic->objectForKey("platform");
    CCDictionary* res = (CCDictionary*) dic->objectForKey("res");
    // TODO add codes here
    if(1 == status->getIntValue()){
        callBackComplete(action->getIntValue(), platform->getIntValue(), res);
    }else if(2 == status->getIntValue()){
        callBackError(action->getIntValue(), platform->getIntValue(), res);
    }else{
        callBackCancel(action->getIntValue(), platform->getIntValue(), res);
    }

    dic->autorelease();
}

这就是两块代码的关键联系。而问题似乎就出在onJavaCallback方 法里,因为我们看到了该方法中使用了Cocos2d-x的数据结构类。

我们来看一下onJavaCallback方法是在哪个线程里执行的。Cocos2d-x App至少有两个线程,一个UI Thread(Activity),一个Render Thread。显然onJavaCallback是在UI Thread中被执行的。但是我们知道Cocos2d-x的AutoreleasePool是在Render Thread中管理的,并在帧切换时进行释放操作的。

我们似乎闻到了问题的味道。Cocos2d-x基本上算是一个"单线程"游戏架构,所有的渲染操作、渲染树节点逻辑管理、绝大多数游戏逻辑都在 Render Thread中进行,UI Thread更多的是接收系统事件,并传递给Render Thread处理。Cocos2d-x的内存管理在这样的“单线程”背景下是没有大问题的,都是串行操作,不存在thread racing的情况。但一旦另外一个线程也调用内存管理接口进行对象内存操作时,问题就出现了,Cocos2d-x的内存池管理不是线程安全的。

我们回到上面代码,重点看一下json转dic的方法,该方法将分享应答字符串转换为内部的dictionary结构:

//AppDemo/Classes/C2DXShareSDK/Android/JSON/CCJSONConverter.cpp

CCDictionary * CCJSONConverter::dictionaryFrom(const char *str)
{
    cJSON * json = cJSON_Parse(str);
    if (!json || json->type!=cJSON_Object) {
        if (json) {
            cJSON_Delete(json);
        }
        return NULL;
    }
    CCAssert(json && json->type==cJSON_Object, "CCJSONConverter:wrong json format");
    CCDictionary * dictionary = CCDictionary::create();
    convertJsonToDictionary(json, dictionary);
    cJSON_Delete(json);
    return dictionary;
}

void CCJSONConverter::convertJsonToDictionary(cJSON *json, CCDictionary *dictionary)
{
    dictionary->removeAllObjects();
    cJSON * j = json->child;
    while (j) {
        CCObject * obj = getJsonObj(j);
        dictionary->setObject(obj, j->string);
        j = j->next;
    }
}

CCObject * CCJSONConverter::getJsonObj(cJSON * json)
{
    switch (json->type) {
        case cJSON_Object:
        {
            CCDictionary * dictionary = CCDictionary::create();           
            convertJsonToDictionary(json, dictionary);
            return dictionary;
        }
        case cJSON_Array:
        {
            CCArray * array = CCArray::create();
            convertJsonToArray(json, array);
            return array;
        }
        case cJSON_String:
        {
            CCString * string = CCString::create(json->valuestring);
            return string;
        }
        case cJSON_Number:
        {
            CCNumber * number = CCNumber::create(json->valuedouble);
            return number;
        }
        case cJSON_True:
        {
            CCNumber * boolean = CCNumber::create(1);
            return boolean;
        }
        case cJSON_False:
       {
            CCNumber * boolean = CCNumber::create(0);
            return boolean;
        }
        case cJSON_NULL:
        {
            CCNull * null = CCNull::create();
            return null;
        }
        default:
        {
            CCLog("CCJSONConverter encountered an unrecognized type");
            return NULL;
        }
    }
}

可以看出整个解析过程,都直接用的是传统的Cocos2d-x对象构造方法:create。在每个对象的create中,代码都会调用该对象的 autorelease方法。而这个方法本身就是线程不安全的,且即便autorelease调用ok,在下一帧切换时,这些对象将都会被release 掉,如果在UI Thread中再引用这些对象的地址,那势必造成内存的非法访问,而引发程序崩溃。

四、Fix方法

可能有朋友会问,create后,我retain一下可否?答案是否。因此create的创建不是线程安全的,create和retain两个调 用之间存在时间差,而在这段时间内,该对象就有可能被render thread释放掉。

Fix方法很简单,就是在UI Thread中不使用Cocos2d-x的内存管理机制,我们用传统的new来替代create,并将 Java_cn_sharesdk_ShareSDKUtils_onJavaCallback最后的autorelease改为release,这样就 不用劳烦Render Thread来帮我们释放内存了。CCDictionary的destructor调用时还会将Dictionarny内部所有Element自动释放 掉。

Cocos2d-x内存管理-绕不过去的坎

Cocos2d-x引擎的核心是用C++编写的,那对于所有使用该引擎的游戏开发人员来说,内存管理是一道绕不过去的坎。

关于Cocos2d-x内存管理,网上已经有了许多参考资料,有些资料写的颇为详实,因为在内存管理这块我不想多费笔墨,只是更多的将思路描述清 楚。

一、对象内存引用计数

Cocos2d-x内存管理的基本原理就是对象内存引用计数,Cocos2d-x将内存引用计数的实现放在了顶层父类CCObject中,这里将涉及引用计数的CCObject的成员和方法摘录出来:

class CC_DLL CCObject : public CCCopying
{
public:
   … …
protected:
    // count of references
    unsigned int        m_uReference;
    // count of autorelease
    unsigned int        m_uAutoReleaseCount;
public:
    void release(void);
    void retain(void);
    CCObject* autorelease(void);
    … ….
}

CCObject::CCObject(void)
: m_nLuaID(0)
, m_uReference(1) // when the object is created, the reference count of it is 1
, m_uAutoReleaseCount(0)
{
  … …
}

void CCObject::release(void)
{
    CCAssert(m_uReference > 0, "reference count should greater than 0");
    –m_uReference;

    if (m_uReference == 0)
    {
        delete this;
    }
}

void CCObject::retain(void)
{
    CCAssert(m_uReference > 0, "reference count should greater than 0");

    ++m_uReference;
}

CCObject* CCObject::autorelease(void)
{
    CCPoolManager::sharedPoolManager()->addObject(this);
    return this;
}

先不考虑autorelease与m_uAutoReleaseCount(后续细说)。计数的核心字段是m_uReference,可以看到:

* 当一个Object初始化(被new出来时),m_uReference = 1;
* 当调用该Object的retain方法时,m_uReference++;
* 当调用该Object的release方法时,m_uReference–,若m_uReference减后为0,则delete该Object。

二、手工对象内存管理

在上述对象内存引用计数的原理下,我们得出以下Cocos2d-x下手工对象内存管理的基本模式:

CCObject *obj = new CCObject();
obj->init();
…. …
obj->release();

在Cocos2d-x中CCDirector就是一个手工内存管理的典型:

CCDirector* CCDirector::sharedDirector(void)
{
    if (!s_SharedDirector)
    {
        s_SharedDirector = new CCDisplayLinkDirector();
        s_SharedDirector->init();

    }

    return s_SharedDirector;
}

void CCDirector::purgeDirector()
{
    … …
    // delete CCDirector
    release();
}

三、自动对象内存管理

所谓的“自动对象内存管理”,指的就是哪些不再需要的object将由Cocos2d-x引擎替你释放掉,而无需你手工再调用Release方法。

自动对象内存管理显然也要遵循内存引用计数规则,只有当object的计数变为0时,才会释放掉对象的内存。

自动对象内存管理的典型模式如下:

CCYourClass *CCYourClass::create()
{
    CCYourClass*pRet = new CCYourClass();
    if (pRet && pRet->init())
    {
        pRet->autorelease();
        return pRet;
    }
    else
    {
        CC_SAFE_DELETE(pRet);
        return NULL;
    }
}

一般我们通过一个单例模式创建对象,与手工模式不同的地方在于init后多了一个autorelease调用。这里再把autorelease调用的实现摘录一遍:

CCObject* CCObject::autorelease(void)
{
    CCPoolManager::sharedPoolManager()->addObject(this);
    return this;
}

追溯addObject方法:

// cocoa/CCAutoreleasePool.cpp

void CCPoolManager::addObject(CCObject* pObject)
{
    getCurReleasePool()->addObject(pObject);
}

void CCAutoreleasePool::addObject(CCObject* pObject)
{
    m_pManagedObjectArray->addObject(pObject);

    CCAssert(pObject->m_uReference > 1, "reference count should be greater than 1");
    ++(pObject->m_uAutoReleaseCount);
    pObject->release(); // no ref count, in this case autorelease pool added.
}

// cocoa/CCArray.cpp
void CCArray::addObject(CCObject* object)                                                                                                   
{                                                                                                                                          
    ccArrayAppendObjectWithResize(data, object);                             
}  

// support/data_support/ccCArray.cpp
void ccArrayAppendObjectWithResize(ccArray *arr, CCObject* object)                                                                          
{                                                                                                                   
    ccArrayEnsureExtraCapacity(arr, 1);                                                              
    ccArrayAppendObject(arr, object);                                         
}

void ccArrayAppendObject(ccArray *arr, CCObject* object)
{
    CCAssert(object != NULL, "Invalid parameter!");
    object->retain();
    arr->arr[arr->num] = object;
    arr->num++;
}

调用层次挺深,涉及的类也众多,这里归纳总结一下。

Cocos2d-x的自动对象内存管理基于对象引用计数以及CCAutoreleasePool(自动释放池)。引用计数前面已经说过了,这里单说自动释放池。Cocos2d-x关于自动对象内存管理的基本类层次结构如下:

    CCPoolManager类 (自动释放池管理器)
        – CCArray*    m_pReleasePoolStack; (自动释放池栈,存放CCAutoreleasePool类实例)
           
    CCAutoreleasePool类
        – CCArray*    m_pManagedObjectArray;
(受管对象数组)

CCObject关于内存计数以及自动管理有两个字段:m_uReference和m_uAutoReleaseCount。前面在手工管理模式下,我只提及了m_uReference,是m_uAutoReleaseCount该亮相的时候了。我们沿着自动释放对象的创建步骤来看看不同阶段,这两个重要字段的值都是啥,代表的是啥含义:

CCYourClass*pRet = new CCYourClass();    m_uReference = 1; m_uAutoReleaseCount = 0;
pRet->init();                           m_uReference = 1; m_uAutoReleaseCount = 0;
pRet->autorelease();                    
    m_pManagedObjectArray->addObject(pObject);
m_uReference = 2; m_uAutoReleaseCount = 0;
    ++(pObject->m_uAutoReleaseCount);          m_uReference = 2; m_uAutoReleaseCount = 1;
    pObject->release();                        m_uReference = 1; m_uAutoReleaseCount = 1;

在调用autorelease之前,两个值与手工模式并无差别,在autorelease后,m_uReference值没有变,但m_uAutoReleaseCount被加1。

m_uAutoReleaseCount这个字段的名字很容易让人误解,以为是个计数器,但实际上绝大多数时刻它是一个标识的角色,以前版本代码中有一个布尔字段m_bManaged,似乎后来被m_uAutoReleaseCount替换掉了,因此m_uAutoReleaseCount兼有m_bManaged的含义, 也就是说该object是否在自动释放池的控制之下,如果在自动释放池的控制下,自动释放池会定期调用该object的release方法,直到该 object内存计数降为0,被真正释放。否则该object不能被自动释放池自动释放内寸,需手工release。这个理解非常重要,再后面我们能用到 这个理解。

四、自动释放时机

通过autorelease我们已经将object放入autoreleasePool中,那究竟何时对象会被释放呢?答案是每帧执行一次自动内存对象释放操作。

在“Hello,Cocos2d-x”一文中,我们讲过整个Cocos2d-x引擎的驱动机制在于GLThread的guardedRun函数,后者会 “死循环”式(实际帧绘制频率受到屏幕vertsym信号的影响)的调用Render的onDrawFrame方法实现,而最终程序会进入 CCDirector::mainLoop方法中,也就是说mainLoop的执行频率是每帧一次。我们再来看看mainLoop的实现:

void CCDisplayLinkDirector::mainLoop(void)
{
    if (m_bPurgeDirecotorInNextLoop)
    {
        m_bPurgeDirecotorInNextLoop = false;
        purgeDirector();
    }
    else if (! m_bInvalid)
     {
         drawScene();

         // release the objects
         CCPoolManager::sharedPoolManager()->pop();
     }
}

这次我们要关注的不是drawScene,而是 CCPoolManager::sharedPoolManager()->pop(),显然在游戏未退出 (m_bPurgeDirecotorInNextLoop决定)的条件下,CCPoolManager的pop方法每帧执行一次,这就是自动释放池执行 的起点。

void CCPoolManager::pop()
{
    if (! m_pCurReleasePool)
    {
        return;
    }

     int nCount = m_pReleasePoolStack->count();

    m_pCurReleasePool->clear();

      if(nCount > 1)
      {
        m_pReleasePoolStack->removeObjectAtIndex(nCount-1);
        m_pCurReleasePool = (CCAutoreleasePool*)m_pReleasePoolStack->objectAtIndex(nCount – 2);
    }
}

真正释放对象的方法是m_pCurReleasePool->clear()

void CCAutoreleasePool::clear()
{
    if(m_pManagedObjectArray->count() > 0)
    {
        CCObject* pObj = NULL;
        CCARRAY_FOREACH_REVERSE(m_pManagedObjectArray, pObj)
        {
            if(!pObj)
                break;

            –(pObj->m_uAutoReleaseCount);
        }

        m_pManagedObjectArray->removeAllObjects();
    }
}

void CCArray::removeAllObjects()     
{   
    ccArrayRemoveAllObjects(data);                    
}

void ccArrayRemoveAllObjects(ccArray *arr)                    
{                       
    while( arr->num > 0 )                      
    {                    
        (arr->arr[--arr->num])->release();               
    }                    
}

不出预料,当前自动释放池遍历每个“受控制”Object,–m_uAutoReleaseCount,并调用该object的release方法。

我们接着按释放流程来看看m_uAutoReleaseCount和m_uReference值的变化:

CCPoolManager::sharedPoolManager()->pop();  m_uReference = 0; m_uAutoReleaseCount = 0;

五、自动释放池的初始化

自动释放池本身是何时出现的呢?回顾一下Cocos2d-x引擎的初始化过程(android版),引擎初始化实在Render的onSurfaceCreated方法中进行的,我们不难追踪到以下代码:

//hellocpp/jni/hellocpp/main.cpp
Java_org_cocos2dx_lib_Cocos2dxRenderer_nativeInit {
   
    //这里CCDirector第一次被创建
    if (!CCDirector::sharedDirector()->getOpenGLView())
    {
        CCEGLView *view = CCEGLView::sharedOpenGLView();
        view->setFrameSize(w, h);

        AppDelegate *pAppDelegate = new AppDelegate();
        CCApplication::sharedApplication()->run();
    }
}

   
CCDirector* CCDirector::sharedDirector(void)
{
    if (!s_SharedDirector)
    {
        s_SharedDirector = new CCDisplayLinkDirector();
        s_SharedDirector->init();  
    }

    return s_SharedDirector;
}

bool CCDirector::init(void)
{
    setDefaultValues();

    … …

    // create autorelease pool
    CCPoolManager::sharedPoolManager()->push();

    return true;
}

六、探寻Cocos2d-x内核对象的自动化内存释放

前面我们基本了解了Cocos2D-x的自动化内存释放原理。如果你之前翻看过一些Cocos2d-x的内核源码,你会发现很多内核对象都是通过单例模式create出来的,也就是说都使用了autorelease将自己放入自动化内存释放池中被管理。

比如我们在HelloCpp中看到过这样的代码:

//HelloWorldScene.cpp
bool HelloWorld::init() {
     …. ….
    // add "HelloWorld" splash screen"
    CCSprite* pSprite = CCSprite::create("HelloWorld.png");

    // position the sprite on the center of the screen
    pSprite->setPosition(ccp(visibleSize.width/2 + origin.x, visibleSize.height/2 + origin.y));

    // add the sprite as a child to this layer
    this->addChild(pSprite, 0);
    … …
}

CCSprite采用自动化内存管理模式create object(cocos2dx/sprite_nodes/CCSprite.cpp),之后将自己加入到HelloWorld这个CCLayer实例 中。按照上面的分析,create结束后,CCSprite object的m_uReference = 1; m_uAutoReleaseCount = 1。一旦如此,那么在下一帧时,该object就会被CCPoolManager释放掉。但我们在屏幕上依旧可以看到该Sprite的存在,这是怎么回事呢?

问题的关键就在this->addChild(pSprite, 0)这行代码中。addChild方法实现在CCLayer的父类CCNode中:

//  cocos2dx/base_nodes/CCNode.cpp
void CCNode::addChild(CCNode *child, int zOrder, int tag)
{
    … …
    if( ! m_pChildren )
    {
        this->childrenAlloc();
    }

    this->insertChild(child, zOrder);

    … …
}

void CCNode::insertChild(CCNode* child, int z)
{
    m_bReorderChildDirty = true;
    ccArrayAppendObjectWithResize(m_pChildren->data, child);
    child->_setZOrder(z);
}

void ccArrayAppendObjectWithResize(ccArray *arr, CCObject* object)
{
    ccArrayEnsureExtraCapacity(arr, 1);
    ccArrayAppendObject(arr, object);
}

void ccArrayAppendObject(ccArray *arr, CCObject* object)
{
    CCAssert(object != NULL, "Invalid parameter!");
    object->retain();
    arr->arr[arr->num] = object;
    arr->num++;
}

又是一系列方法调用,最终我们来到了ccArrayAppendObject方法中,看到了陌生而又眼熟的retain方法调用。

在本文开始我们介绍CCObject时,我们知道retain是CCObject的一个方法,用于增加m_uReference计数。而实际上retain还隐含着“保留”这层意思。

在完成this->addChild(pSprite, 0)调用后,CSprite object的m_uReference = 2; m_uAutoReleaseCount = 1,这很关键。

我们在脑子里再过一下自动释放池释放object的过程:–m_uReference, –m_uAutoReleaseCount。一帧之后,两个值变成了m_uReference = 1; m_uAutoReleaseCount = 0。还记得前面说过的m_uAutoReleaseCount的另外一个非计数含义么,那就是表示该object是否“受控”,现在值为0,显然不再受自动释放池的控制了,后续即便再执行100次内存自动释放,也不会影响到该object的存活。

后续要想释放这个“精灵”,我们还是需要手工调用release,或再调用其autorelease方法。

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! Go语言第一课 Go语言精进之路1 Go语言精进之路2 Go语言编程指南
商务合作请联系bigwhite.cn AT aliyun.com

欢迎使用邮件订阅我的博客

输入邮箱订阅本站,只要有新文章发布,就会第一时间发送邮件通知你哦!

这里是 Tony Bai的个人Blog,欢迎访问、订阅和留言! 订阅Feed请点击上面图片

如果您觉得这里的文章对您有帮助,请扫描上方二维码进行捐赠 ,加油后的Tony Bai将会为您呈现更多精彩的文章,谢谢!

如果您希望通过微信捐赠,请用微信客户端扫描下方赞赏码:

如果您希望通过比特币或以太币捐赠,可以扫描下方二维码:

比特币:

以太币:

如果您喜欢通过微信浏览本站内容,可以扫描下方二维码,订阅本站官方微信订阅号“iamtonybai”;点击二维码,可直达本人官方微博主页^_^:
本站Powered by Digital Ocean VPS。
选择Digital Ocean VPS主机,即可获得10美元现金充值,可 免费使用两个月哟! 著名主机提供商Linode 10$优惠码:linode10,在 这里注册即可免费获 得。阿里云推荐码: 1WFZ0V立享9折!


View Tony Bai's profile on LinkedIn
DigitalOcean Referral Badge

文章

评论

  • 正在加载...

分类

标签

归档



View My Stats