Cocos Creator:JSB 2.0 绑定教程

Cocos Creator:JSB 2.0 绑定教程
推荐:将NSDT场景编辑器加入你的3D工具链
3D工具集:NSDT简石数字孪生

JSB 2.0 绑定教程

抽象层

架构

Architecture

宏(Macro)

抽象层必然会比直接使用 JS 引擎 API 的方式多占用一些 CPU 执行时间,如何把抽象层本身的开销降到最低成为设计的第一目标。

JS 绑定的大部分工作其实就是设定 JS 相关操作的 CPP 回调,在回调函数中关联 CPP 对象。其实主要包含如下两种类型:

  • 注册 JS 函数(包含全局函数,类构造函数、类析构函数、类成员函数,类静态成员函数),绑定一个 CPP 回调
  • 注册 JS 对象的属性读写访问器,分别绑定读与写的 CPP 回调

如何做到抽象层开销最小而且暴露统一的 API 供上层使用?

以注册 JS 函数的回调定义为例,JavaScriptCore、SpiderMonkey、V8、ChakraCore 的定义各不相同,具体如下:

JavaScriptCore

  JSValueRef JSB_foo_func(
      JSContextRef _cx,
      JSObjectRef _function,
      JSObjectRef _thisObject,
      size_t argc,
      const JSValueRef _argv[],
      JSValueRef* _exception
  );

SpiderMonkey

  bool JSB_foo_func(
      JSContext* _cx,
      unsigned argc,
      JS::Value* _vp
  );

V8

  void JSB_foo_func(
      const v8::FunctionCallbackInfo<v8::Value>& v8args
  );

ChakraCore

  JsValueRef JSB_foo_func(
      JsValueRef _callee,
      bool _isConstructCall,
      JsValueRef* _argv,
      unsigned short argc,
      void* _callbackState
  );

我们评估了几种方案,最终确定使用 来抹平不同 JS 引擎回调函数定义与参数类型的不同,不管底层是使用什么引擎,开发者统一使用一种回调函数的定义。我们借鉴了 lua 的回调函数定义方式,抽象层所有的 JS 到 CPP 的回调函数的定义为:

bool foo(se::State& s)
{
    ...
    ...
}
SE_BIND_FUNC(foo) // 此处以回调函数的定义为例

开发者编写完回调函数后,记住使用 SE_BIND_XXX 系列的宏对回调函数进行包装。目前提供了如下几个宏:

  • SE_BIND_PROP_GET:包装一个 JS 对象属性读取的回调函数
  • SE_BIND_PROP_SET:包装一个 JS 对象属性写入的回调函数
  • SE_BIND_FUNC_AS_PROP_GET: 普通函数转化为读取属性的回调
  • SE_BIND_FUNC_AS_PROP_SET: 普通函数转化为写入属性的回调
  • SE_BIND_FUNC:包装一个 JS 函数,可用于全局函数、类成员函数、类静态函数
  • SE_BIND_FUNC_FAST: 包装无参的 JS 函数, 相比 SE_BIND_FUNC 更高效
  • SE_DECLARE_FUNC:声明一个 JS 函数,一般在 .h 头文件中使用
  • SE_BIND_CTOR:包装一个 JS 构造函数
  • SE_BIND_SUB_CLS_CTOR:包装一个 JS 子类的构造函数,此子类可以继承
  • SE_BIND_FINALIZE_FUNC:包装一个 JS 对象被 GC 回收后的回调函数
  • SE_DECLARE_FINALIZE_FUNC:声明一个 JS 对象被 GC 回收后的回调函数
  • _SE:包装回调函数的名称,转义为每个 JS 引擎能够识别的回调函数的定义
注意:第一个字符为下划线,类似 Windows 下用的 _T("xxx") 来包装 Unicode 或者 MultiBytes 字符串。

API

CPP 命名空间(namespace)

CPP 抽象层所有的类型都在 se 命名空间下,其为 ScriptEngine 的缩写。

类型

se::ScriptEngine

se::ScriptEngine 为 JS 引擎的管理员,掌管 JS 引擎初始化、销毁、重启、Native 模块注册、加载脚本、强制垃圾回收、JS 异常清理、是否启用调试器。它是一个单例,可通过 se::ScriptEngine::getInstance() 得到对应的实例。

se::Value

se::Value 可以被理解为 JS 变量在 CPP 层的引用。JS 变量有 objectnumberbigint, stringbooleannullundefined 六种类型。因此 se::Value 使用 union 包含 objectnumberstringboolean 5 种 有值类型无值类型 包含 nullundefined,可由 _type 直接表示。

namespace se {
    class Value {
        enum class Type : char
        {
            Undefined = 0,
            Null,
            Number,
            Boolean,
            String,
            Object,
            BigInt, // 多用于存储指针类型
        };
        ...
        ...
    private:
        union {
            bool _boolean;
            double _number;
            std::string* _string;
            Object* _object;
            int64_t _bigint;
        } _u;

        Type _type;
        ...
        ...
    };
}

如果 se::Value 中保存基础数据类型,比如 numberstringboolean,其内部是直接存储一份值副本。
object 的存储比较特殊,是通过 se::Object* 对 JS 对象的弱引用 (weak reference)。

se::Object

se::Object 继承于 se::RefCounter 引用计数管理类。目前抽象层中只有 se::Object 继承于 se::RefCounter

上一小节我们说到,se::Object 是保存了对 JS 对象的弱引用,这里有必要解释一下为什么是弱引用。

原因一:JS 对象控制 CPP 对象的生命周期的需要

当在脚本层中通过 var xhr = new XMLHttpRequest(); 创建了一个 XMLHttpRequest 后,在构造回调函数绑定中我们会创建一个 se::Object 并保留在一个全局的 map (NativePtrToObjectMap) 中,此 map 用于查询 XMLHttpRequest* 指针获取对应的 JS 对象 se::Object*

/// native/cocos/bindings/manual/jsb_xmlhttprequest.cpp
static bool XMLHttpRequest_constructor(se::State& s)
{
    XMLHttpRequest* cobj = JSB_ALLOC(XMLHttpRequest);
    s.thisObject()->setPrivateData(cobj);
    // ...
    return true;
}
SE_BIND_CTOR(XMLHttpRequest_constructor, __jsb_XMLHttpRequest_class, XMLHttpRequest_finalize)

/// native/cocos/bindings/jswrapper/v8/Object.cpp
void Object::setPrivateObject(PrivateObjectBase *data) {
    // ... 
    if (data != nullptr) {
        _privateData = data->getRaw();
        NativePtrToObjectMap::emplace(_privateData, this);
    } else {
        _privateData = nullptr;
    }
}

设想如果强制要求 se::Object 为 JS 对象的强引用(strong reference),即让 JS 对象不受 GC 控制,由于 se::Object 一直存在于 map 中,finalizer 回调将永远无法被触发,从而导致内存泄露。

原因二:更加灵活,手动调用 root 方法以支持强引用

se::Object 中提供了 root/unroot 方法供开发者调用,root 会把 JS 对象放入到不受 GC 扫描到的区域,调用 root 后,se::Object 就强引用了 JS 对象,只有当 unroot 被调用,或者 se::Object 被释放后,JS 对象才会放回到受 GC 扫描到的区域。

一般情况下,如果对象是非 cc::Ref 的子类,会采用 CPP 对象控制 JS 对象的生命周期的方式去绑定。引擎内 Spine, DragonBones, Box2d 等第三方库的绑定就是采用此方式。当 CPP 对象被释放的时候,需要在 NativePtrToObjectMap 中查找对应的 se::Object,然后手动 unrootdecRef。以 Spine 中 spTrackEntry 的绑定为例:

spTrackEntry_setDisposeCallback([](spTrackEntry* entry) {
    // spTrackEntry 的销毁回调
    se::Object* seObj = nullptr;

    auto iter = se::NativePtrToObjectMap::find(entry);
    if (iter != se::NativePtrToObjectMap::end()) {
        // 保存 se::Object 指针,用于在下面的 cleanup 函数中释放其内存
        seObj = iter->second;
        // Native 对象 entry 的内存已经被释放,因此需要立马解除 Native 对象与 JS 对象的关联。
        // 如果解除引用关系放在下面的 cleanup 函数中处理,有可能触发 se::Object::setPrivateData 中
        // 的断言,因为新生成的 Native 对象的地址可能与当前对象相同,而 cleanup 可能被延迟到帧结束前执行。
        se::NativePtrToObjectMap::erase(iter);
    } else {
        return;
    }

    auto cleanup = [seObj]() {

        auto se = se::ScriptEngine::getInstance();
        if (!se->isValid() || se->isInCleanup())
            return;

        se::AutoHandleScope hs;
        se->clearException();

        // 由于上面逻辑已经把映射关系解除了,这里传入 false 表示不用再次解除映射关系,
        // 因为当前 seObj 的 private data 可能已经是另外一个不同的对象
        seObj->clearPrivateData(false);
        seObj->unroot(); // unroot,使 JS 对象受 GC 管理
        seObj->decRef(); // 释放 se::Object
    };

    // 确保不再垃圾回收中去操作 JS 引擎的 API
    if (!se::ScriptEngine::getInstance()->isGarbageCollecting()) {
        cleanup();
    } else {
        // 如果在垃圾回收,把清理任务放在帧结束中进行
        CleanupTask::pushTaskToAutoReleasePool(cleanup);
    }
});

C++ 对象的生命周期管理

在 3.6 之前, 析构回调 _finalize 会根据对象类型和是否存在于se::NonRefNativePtrCreatedByCtorMap 来决定调用 delete 或者 release 以释放对应的 C++ 对象. 3.6 开始 _finalize 回调被弃用, 暂时为方便调试保留为空函数. se::Object 通过 se::PrivateObjectBase 对象和 C++ 对象建立生命周期的关联. se::PrivateObjectBase 的三个子类对应了不同的释放策略.

  • se::CCSharedPtrPrivateObject<T>

使用 cc::IntrusivePtr 存储 C++ 对象的指针, 要求 C++ 类继承 cc::RefCounted. 其中 cc::IntrusivePtr 为智能指针类型, 会自动增减 cc::RefCounted 的引用计数. 当引用计数为 0 时, 触发析构.

  • se::SharedPrivateObject<T>

使用 std::shared_ptr 存储 C++ 对象指针, 要求 C++ 类不继承 cc::RefCounted. 由于 shared_ptr 本身的特性, 要求所有强引用都 shared_ptr. 所有 shared_ptr 都销毁时触发 C++ 对象的析构.

  • se::RawRefPrivateObject<T>

使用裸指针, 默认为对 C++ 对象的弱引用. 可通过调用 tryAllowDestroyInGC 转为强引用. 作为弱引用时, GC 不触发对象的析构.

关联原生对象

3.6 之后 se::Object::setPrivateData(void *) 扩展成了:

template <typename T>
inline void setPrivateData(T *data);

能自动根据类型信息创建 SharedPrivateObject 或者 CCSharedPtrPrivateObject, 但是不支持 RawRefPrivateObject.

我们可以使用 setPrivateObject 显示指定 PrivateObject 的类型:

// se::SharedPrivateObject<T>
obj->setPrivateObject(se::shared_private_object(v));

// se::CCSharedPtrPrivateObject<T>
obj->setPrivateObject(se::ccshared_private_object(v));

// se::RawRefPrivateObject<T>
obj->setPrivateObject(se::rawref_private_object(v));

对象类型

绑定对象的创建已经被隐藏在对应的 SE_BIND_CTORSE_BIND_SUB_CLS_CTOR 函数中,开发者在绑定回调中如果需要用到当前对象对应的 se::Object,只需要通过 s.thisObject() 即可获取。其中 s 为 se::State 类型,具体会在后续章节中说明。

此外,se::Object 目前支持以下几种对象的手动创建:

  • Plain Object:通过 se::Object::createPlainObject 创建,类似 JS 中的 var a = {};
  • Array Object:通过 se::Object::createArrayObject 创建,类似 JS 中的 var a = [];
  • Uint8 Typed Array Object:通过 se::Object::createTypedArray 创建,类似 JS 中的 var a = new Uint8Array(buffer);
  • Array Buffer Object:通过 se::Object::createArrayBufferObject,类似 JS 中的 var a = new ArrayBuffer(len);

手动创建对象的释放

se::Object::createXXX 方法与 Cocos Creator 中的 create 方法不同,抽象层是完全独立的一个模块,并不依赖与 Cocos Creator 的 autorelease 机制。虽然 se::Object 也是继承引用计数类,但开发者需要处理 手动创建出来的对象 的释放。

se::Object* obj = se::Object::createPlainObject();
...
...
obj->decRef(); // 释放引用,避免内存泄露

se::HandleObject(推荐的管理手动创建对象的辅助类)

在比较复杂的逻辑中使用手动创建对象,开发者往往会忘记在不同的逻辑中处理 decRef

  bool foo()
  {
      se::Object* obj = se::Object::createPlainObject();
      if (var1)
          return false; // 这里直接返回了,忘记做 decRef 释放操作

      if (var2)
          return false; // 这里直接返回了,忘记做 decRef 释放操作
      ...
      ...
      obj->decRef();
      return true;
  }

就算在不同的返回条件分支中加上了 decRef 也会导致逻辑复杂,难以维护,如果后期加入另外一个返回分支,很容易忘记 decRef。

  • JS 引擎在 se::Object::createXXX 后,如果由于某种原因 JS 引擎做了 GC 操作,导致后续使用的 se::Object 内部引用了一个非法指针,引发程序崩溃

为了解决上述两个问题,抽象层定义了一个辅助管理 手动创建对象 的类型,即 se::HandleObject

se::HandleObject 是一个辅助类,用于更加简单地管理手动创建的 se::Object 对象的释放、root 和 unroot 操作。

以下两种代码写法是等价的,使用 se::HandleObject 的代码量明显少很多,而且更加安全。

{
    se::HandleObject obj(se::Object::createPlainObject());
    obj->setProperty(...);
    otherObject->setProperty("foo", se::Value(obj));
}

等价于:

{
    se::Object* obj = se::Object::createPlainObject();
    obj->root(); // 在手动创建完对象后立马 root,防止对象被 GC

    obj->setProperty(...);
    otherObject->setProperty("foo", se::Value(obj));

    obj->unroot(); // 当对象被使用完后,调用 unroot
    obj->decRef(); // 引用计数减一,避免内存泄露
}
注意
  1. 不要尝试使用 se::HandleObject 创建一个 native 与 JS 的绑定对象,在 JS 控制 CPP 的模式中,绑定对象的释放会被抽象层自动处理,在 CPP 控制 JS 的模式中,前一章节中已经有描述了。
  2. se::HandleObject 对象只能够在栈上被分配,而且栈上构造的时候必须传入一个 se::Object 指针。

se::Class

se::Class 用于暴露 CPP 类到 JS 中,它会在 JS 中创建一个对应名称的 constructor function

它有如下方法:

  • static se::Class* create(className, obj, parentProto, ctor):创建一个 Class,注册成功后,在 JS 层中可以通过var xxx = new SomeClass();的方式创建一个对象
  • bool defineFunction(name, func):定义 Class 中的成员函数
  • bool defineProperty(name, getter, setter):定义 Class 属性读写器
  • bool defineStaticFunction(name, func):定义 Class 的静态成员函数,可通过 SomeClass.foo() 这种非 new 的方式访问,与类实例对象无关
  • bool defineStaticProperty(name, getter, setter):定义 Class 的静态属性读写器,可通过 SomeClass.propertyA 直接读写,与类实例对象无关
  • bool defineFinalizeFunction(func):定义 JS 对象被 GC 后的 CPP 回调
  • bool install():注册此类到 JS 虚拟机中
  • Object* getProto():获取注册到 JS 中的类(其实是 JS 的 constructor)的 prototype 对象,类似 function Foo(){}Foo.prototype
  • const char* getName() const:获取当前 Class 的名称
注意:Class 类型创建后,不需要手动释放内存,它会被封装层自动处理。

更具体 API 说明可以翻看 API 文档或者代码注释。

se::AutoHandleScope

se::AutoHandleScope 对象类型完全是为了解决 V8 的兼容问题而引入的概念。V8 中,当有 CPP 函数中需要触发 JS 相关操作,比如调用 JS 函数,访问 JS 属性等任何调用 v8::Local<> 的操作,V8 强制要求在调用这些操作前必须存在一个 v8::HandleScope 作用域,否则会引发程序崩溃。

因此抽象层中引入了 se::AutoHandleScope 的概念,其只在 V8 上有实现,其他 JS 引擎目前都只是空实现。

开发者需要记住,在任何代码执行中,需要调用 JS 的逻辑前,声明一个 se::AutoHandleScope 即可,比如:

class SomeClass {
    void update(float dt) {
        se::ScriptEngine::getInstance()->clearException();
        se::AutoHandleScope hs;

        se::Object* obj = ...;
        obj->setProperty(...);
        ...
        ...
        obj->call(...);
    }
};

se::State

之前章节我们有提及 State 类型,它是绑定回调中的一个环境,我们通过 se::State 可以取得当前的 CPP 指针、se::Object 对象指针、参数列表、返回值引用。

bool foo(se::State& s)
{
    // 获取 native 对象指针
    SomeClass* cobj = (SomeClass*)s.nativeThisObject();
    // 获取 se::Object 对象指针
    se::Object* thisObject = s.thisObject();
    // 获取参数列表
    const se::ValueArray& args = s.args();
    // 设置返回值
    s.rval().setInt32(100);
    return true;
}
SE_BIND_FUNC(foo)

抽象层依赖 Cocos Creator 引擎么?

不依赖。

ScriptEngine 这层设计之初就将其定义为一个独立模块,完全不依赖 Cocos Creator 引擎。开发者可以通过 copy、paste 把 cocos/bindings/jswrapper 下的所有抽象层源码拷贝到其他项目中直接使用。

手动绑定

回调函数声明

static bool Foo_balabala(se::State& s)
{
    const auto& args = s.args();
    int argc = (int)args.size();

    if (argc >= 2) // 这里约定参数个数必须大于等于 2,否则抛出错误到 JS 层且返回 false
    {
        ...
        ...
        return true;
    }

    SE_REPORT_ERROR("wrong number of arguments: %d, was expecting %d", argc, 2);
    return false;
}

// 如果是绑定函数,则用 SE_BIND_FUNC,构造函数、析构函数、子类构造函数等类似
SE_BIND_FUNC(Foo_balabala)

为 JS 对象设置一个属性值

se::Object* globalObj = se::ScriptEngine::getInstance()->getGlobalObject(); // 这里为了演示方便,获取全局对象
globalObj->setProperty("foo", se::Value(100)); // 给全局对象设置一个 foo 属性,值为 100

在 JS 中就可以直接使用 foo 这个全局变量了

log("foo value: " + foo); // 打印出 foo value: 100

为 JS 对象定义一个属性读写回调

// 全局对象的 foo 属性的读回调
static bool Global_get_foo(se::State& s)
{
    NativeObj* cobj = (NativeObj*)s.nativeThisObject();
    int32_t ret = cobj->getValue();
    s.rval().setInt32(ret);
    return true;
}
SE_BIND_PROP_GET(Global_get_foo)

// 全局对象的 foo 属性的写回调
static bool Global_set_foo(se::State& s)
{
    const auto& args = s.args();
    int argc = (int)args.size();
    if (argc >= 1)
    {
        NativeObj* cobj = (NativeObj*)s.nativeThisObject();
        int32_t arg1 = args[0].toInt32();
        cobj->setValue(arg1);
        // void 类型的函数,无需设置 s.rval,未设置默认返回 undefined 给 JS 层
        return true;
    }

    SE_REPORT_ERROR("wrong number of arguments: %d, was expecting %d", argc, 1);
    return false;
}
SE_BIND_PROP_SET(Global_set_foo)

void some_func()
{
    se::Object* globalObj = se::ScriptEngine::getInstance()->getGlobalObject(); // 这里为了演示方便,获取全局对象
    globalObj->defineProperty("foo", _SE(Global_get_foo), _SE(Global_set_foo)); // 使用_SE 宏包装一下具体的函数名称
}

为 JS 对象设置一个函数

static bool Foo_function(se::State& s)
{
    ...
    ...
}
SE_BIND_FUNC(Foo_function)

void some_func()
{
    se::Object* globalObj = se::ScriptEngine::getInstance()->getGlobalObject(); // 这里为了演示方便,获取全局对象
    globalObj->defineFunction("foo", _SE(Foo_function)); // 使用_SE 宏包装一下具体的函数名称
}

注册一个 CPP 类到 JS 虚拟机中

static se::Object* __jsb_ns_SomeClass_proto = nullptr;
static se::Class* __jsb_ns_SomeClass_class = nullptr;

namespace ns {
    class SomeClass
    {
    public:
        SomeClass()
        : xxx(0)
        {}

        void foo() {
            printf("SomeClass::foo\n");

            Director::getInstance()->getScheduler()->schedule([this](float dt){
                static int counter = 0;
                ++counter;
                if (_cb != nullptr)
                    _cb(counter);
            }, this, 1.0f, CC_REPEAT_FOREVER, 0.0f, false, "iamkey");
        }

        static void static_func() {
            printf("SomeClass::static_func\n");
        }

        void setCallback(const std::function<void(int)>& cb) {
            _cb = cb;
            if (_cb != nullptr)
            {
                printf("setCallback(cb)\n");
            }
            else
            {
                printf("setCallback(nullptr)\n");
            }
        }

        int xxx;
    private:
        std::function<void(int)> _cb;
    };
} // namespace ns {

static bool js_SomeClass_finalize(se::State& s)
{
    ns::SomeClass* cobj = (ns::SomeClass*)s.nativeThisObject();
    delete cobj;
    return true;
}
SE_BIND_FINALIZE_FUNC(js_SomeClass_finalize)

static bool js_SomeClass_constructor(se::State& s)
{
    ns::SomeClass* cobj = new ns::SomeClass();
    s.thisObject()->setPrivateData(cobj);
    return true;
}
SE_BIND_CTOR(js_SomeClass_constructor, __jsb_ns_SomeClass_class, js_SomeClass_finalize)

static bool js_SomeClass_foo(se::State& s)
{
    ns::SomeClass* cobj = (ns::SomeClass*)s.nativeThisObject();
    cobj->foo();
    return true;
}
SE_BIND_FUNC(js_SomeClass_foo)

static bool js_SomeClass_get_xxx(se::State& s)
{
    ns::SomeClass* cobj = (ns::SomeClass*)s.nativeThisObject();
    s.rval().setInt32(cobj->xxx);
    return true;
}
SE_BIND_PROP_GET(js_SomeClass_get_xxx)

static bool js_SomeClass_set_xxx(se::State& s)
{
    const auto& args = s.args();
    int argc = (int)args.size();
    if (argc > 0)
    {
        ns::SomeClass* cobj = (ns::SomeClass*)s.nativeThisObject();
        cobj->xxx = args[0].toInt32();
        return true;
    }

    SE_REPORT_ERROR("wrong number of arguments: %d, was expecting %d", argc, 1);
    return false;
}
SE_BIND_PROP_SET(js_SomeClass_set_xxx)

static bool js_SomeClass_static_func(se::State& s)
{
    ns::SomeClass::static_func();
    return true;
}
SE_BIND_FUNC(js_SomeClass_static_func)

bool js_register_ns_SomeClass(se::Object* global)
{
    // 保证 namespace 对象存在
    se::Value nsVal;
    if (!global->getProperty("ns", &nsVal))
    {
        // 不存在则创建一个 JS 对象,相当于 var ns = {};
        se::HandleObject jsobj(se::Object::createPlainObject());
        nsVal.setObject(jsobj);

        // 将 ns 对象挂载到 global 对象中,名称为 ns
        global->setProperty("ns", nsVal);
    }
    se::Object* ns = nsVal.toObject();

    // 创建一个 Class 对象,开发者无需考虑 Class 对象的释放,其交由 ScriptEngine 内部自动处理
    auto cls = se::Class::create("SomeClass", ns, nullptr, _SE(js_SomeClass_constructor)); // 如果无构造函数,最后一个参数可传入 nullptr,则这个类在 JS 中无法被 new SomeClass() 出来

    // 为这个 Class 对象定义成员函数、属性、静态函数、析构函数
    cls->defineFunction("foo", _SE(js_SomeClass_foo));
    cls->defineProperty("xxx", _SE(js_SomeClass_get_xxx), _SE(js_SomeClass_set_xxx));

    cls->defineFinalizeFunction(_SE(js_SomeClass_finalize));

    // 注册类型到 JS VirtualMachine 的操作
    cls->install();

    // JSBClassType 为 Cocos Creator 引擎绑定层封装的类型注册的辅助函数,此函数不属于 ScriptEngine 这层
    JSBClassType::registerClass<ns::SomeClass>(cls);

    // 保存注册的结果,便于其他地方使用,比如类继承
    __jsb_ns_SomeClass_proto = cls->getProto();
    __jsb_ns_SomeClass_class = cls;

    // 为每个此 Class 实例化出来的对象附加一个属性
    __jsb_ns_SomeClass_proto->setProperty("yyy", se::Value("helloyyy"));

    // 注册静态成员变量和静态成员函数
    se::Value ctorVal;
    if (ns->getProperty("SomeClass", &ctorVal) && ctorVal.isObject())
    {
        ctorVal.toObject()->setProperty("static_val", se::Value(200));
        ctorVal.toObject()->defineFunction("static_func", _SE(js_SomeClass_static_func));
    }

    // 清空异常
    se::ScriptEngine::getInstance()->clearException();
    return true;
}

如何绑定 CPP 接口中的回调函数?

static bool js_SomeClass_setCallback(se::State& s)
{
    const auto& args = s.args();
    int argc = (int)args.size();
    if (argc >= 1)
    {
        ns::SomeClass* cobj = (ns::SomeClass*)s.nativeThisObject();

        se::Value jsFunc = args[0];
        se::Value jsTarget = argc > 1 ? args[1] : se::Value::Undefined;

        if (jsFunc.isNullOrUndefined())
        {
            cobj->setCallback(nullptr);
        }
        else
        {
            assert(jsFunc.isObject() && jsFunc.toObject()->isFunction());

            se::Object *jsTargetObj = jsTarget.isObject() ? jsTarget.toObject() : nullptr;

            // 如果当前 SomeClass 是可以被 new 出来的类,我们 使用 se::Object::attachObject 把 jsFunc 和 jsTarget 关联到当前对象中
            s.thisObject()->attachObject(jsFunc.toObject());
            if(jsTargetObj) .thisObject()->attachObject(jsTargetObj);

            // 如果当前 SomeClass 类是一个单例类,或者永远只有一个实例的类,我们不能用 se::Object::attachObject 去关联
            // 必须使用 se::Object::root,开发者无需关系 unroot,unroot 的操作会随着 lambda 的销毁触发 jsFunc 的析构,在 se::Object 的析构函数中进行 unroot 操作。
            // js_audio_AudioEngine_setFinishCallback 的绑定代码就是使用此方式,因为 AudioEngine 始终只有一个实例,
            // 如果使用 s.thisObject->attachObject(jsFunc.toObject);会导致对应的 func 和 target 永远无法被释放,引发内存泄露。

            // jsFunc.toObject()->root();
            // jsTarget.toObject()->root();

            cobj->setCallback([jsFunc, jsTargetObj](int counter){

                // CPP 回调函数中要传递数据给 JS 或者调用 JS 函数,在回调函数开始需要添加如下两行代码。
                se::ScriptEngine::getInstance()->clearException();
                se::AutoHandleScope hs;

                se::ValueArray args;
                args.push_back(se::Value(counter));

                jsFunc.toObject()->call(args, jsTargetObj);
            });
        }

        return true;
    }

    SE_REPORT_ERROR("wrong number of arguments: %d, was expecting %d", argc, 1);
    return false;
}
SE_BIND_FUNC(js_SomeClass_setCallback)

SomeClass 类注册后,就可以在 JS 中这样使用了:

 var myObj = new ns.SomeClass();
 myObj.foo();
 ns.SomeClass.static_func();
 log("ns.SomeClass.static_val: " + ns.SomeClass.static_val);
 log("Old myObj.xxx:" + myObj.xxx);
 myObj.xxx = 1234;
 log("New myObj.xxx:" + myObj.xxx);
 log("myObj.yyy: " + myObj.yyy);

 var delegateObj = {
     onCallback: function(counter) {
         log("Delegate obj, onCallback: " + counter + ", this.myVar: " + this.myVar);
         this.setVar();
     },

     setVar: function() {
         this.myVar++;
     },

     myVar: 100
 };

 myObj.setCallback(delegateObj.onCallback, delegateObj);

 setTimeout(function(){
    myObj.setCallback(null);
 }, 6000); // 6 秒后清空 callback

Console 中会输出:

SomeClass::foo
SomeClass::static_func
ns.SomeClass.static_val: 200
Old myObj.xxx:0
New myObj.xxx:1234
myObj.yyy: helloyyy
setCallback(cb)
Delegate obj, onCallback: 1, this.myVar: 100
Delegate obj, onCallback: 2, this.myVar: 101
Delegate obj, onCallback: 3, this.myVar: 102
Delegate obj, onCallback: 4, this.myVar: 103
Delegate obj, onCallback: 5, this.myVar: 104
Delegate obj, onCallback: 6, this.myVar: 105
setCallback(nullptr)

如何使用 Cocos Creator bindings 这层的类型转换辅助函数?

类型转换辅助函数位于 cocos/bindings/manual/jsb_conversions.h 中,其包含以下内容。

se::Value 转换为 C++ 类型

支持基础类型 int*t/uint*_t/float/double/const char*/bool, std::string,绑定类型, 其容器类型 std::vector, std::array, std::map, std::unordered_map 等.

template<typename T>
bool sevalue_to_native(const se::Value &from, T *to, se::Object *ctx);

template<typename T>
bool sevalue_to_native(const se::Value &from, T *to);

C++ 类型转换为 se::Value

template<typename T>
bool nativevalue_to_se(const T &from, se::Value &to, se::Object *ctx);

template<typename T>
bool nativevalue_to_se(const T &from, se::Value &to);

3.6 之前的 以下这些转换函数已被弃用, 需要改为上面的两组函数

bool seval_to_int32(const se::Value &v, int32_t *ret);
bool seval_to_uint32(const se::Value &v, uint32_t *ret);
bool seval_to_int8(const se::Value &v, int8_t *ret);
bool seval_to_uint8(const se::Value &v, uint8_t *ret);
bool seval_to_int16(const se::Value &v, int16_t *ret);
bool seval_to_uint16(const se::Value &v, uint16_t *ret);
bool seval_to_boolean(const se::Value &v, bool *ret);
bool seval_to_float(const se::Value &v, float *ret);
bool seval_to_double(const se::Value &v, double *ret);
bool seval_to_size(const se::Value &v, size_t *ret);
bool seval_to_std_string(const se::Value &v, std::string *ret);
bool seval_to_Vec2(const se::Value &v, cc::Vec2 *pt);
bool seval_to_Vec3(const se::Value &v, cc::Vec3 *pt);
bool seval_to_Vec4(const se::Value &v, cc::Vec4 *pt);
bool seval_to_Mat4(const se::Value &v, cc::Mat4 *mat);
bool seval_to_Size(const se::Value &v, cc::Size *size);
bool seval_to_ccvalue(const se::Value &v, cc::Value *ret);
bool seval_to_ccvaluemap(const se::Value &v, cc::ValueMap *ret);
bool seval_to_ccvaluemapintkey(const se::Value &v, cc::ValueMapIntKey *ret);
bool seval_to_ccvaluevector(const se::Value &v, cc::ValueVector *ret);
bool sevals_variadic_to_ccvaluevector(const se::ValueArray &args, cc::ValueVector *ret);
bool seval_to_std_vector_string(const se::Value &v, std::vector<std::string> *ret);
bool seval_to_std_vector_int(const se::Value &v, std::vector<int> *ret);
bool seval_to_std_vector_uint16(const se::Value &v, std::vector<uint16_t> *ret);
bool seval_to_std_vector_float(const se::Value &v, std::vector<float> *ret);
bool seval_to_std_vector_Vec2(const se::Value &v, std::vector<cc::Vec2> *ret);
bool seval_to_Uint8Array(const se::Value &v, uint8_t *ret);
bool seval_to_uintptr_t(const se::Value &v, uintptr_t *ret);
bool seval_to_std_map_string_string(const se::Value &v, std::map<std::string, std::string> *ret);
bool seval_to_Data(const se::Value &v, cc::Data *ret);
bool seval_to_DownloaderHints(const se::Value &v, cc::network::DownloaderHints *ret);
template<typename T>
bool seval_to_native_ptr(const se::Value& v, T* ret);
template <typename T>
typename std::enable_if<std::is_class<T>::value && !std::is_same<T, std::string>::value, T>::type
seval_to_type(const se::Value &v, bool &ok);
template <typename T>
typename std::enable_if<std::is_integral<T>::value, T>::type
seval_to_type(const se::Value &v, bool &ok);
template <typename T>
typename std::enable_if<std::is_enum<T>::value, T>::type
seval_to_type(const se::Value &v, bool &ok);
template <typename T>
typename std::enable_if<std::is_floating_point<T>::value, T>::type
seval_to_type(const se::Value &v, bool &ok);

template <typename T>
typename std::enable_if<std::is_same<T, std::string>::value, T>::type
seval_to_type(const se::Value &v, bool &ok);
template <typename T>
typename std::enable_if<std::is_pointer<T>::value && std::is_class<typename std::remove_pointer<T>::type>::value, bool>::type
seval_to_std_vector(const se::Value &v, std::vector<T> *ret);

template <typename T>
typename std::enable_if<!std::is_pointer<T>::value, bool>::type
seval_to_std_vector(const se::Value &v, std::vector<T> *ret);
template<typename T>
bool seval_to_Map_string_key(const se::Value& v, cc::Map<std::string, T>* ret)

改用 sevalue_to_native

bool int8_to_seval(int8_t v, se::Value *ret);
bool uint8_to_seval(uint8_t v, se::Value *ret);
bool int32_to_seval(int32_t v, se::Value *ret);
bool uint32_to_seval(uint32_t v, se::Value *ret);
bool int16_to_seval(uint16_t v, se::Value *ret);
bool uint16_to_seval(uint16_t v, se::Value *ret);
bool boolean_to_seval(bool v, se::Value *ret);
bool float_to_seval(float v, se::Value *ret);
bool double_to_seval(double v, se::Value *ret);
bool long_to_seval(long v, se::Value *ret);
bool ulong_to_seval(unsigned long v, se::Value *ret);
bool longlong_to_seval(long long v, se::Value *ret);
bool uintptr_t_to_seval(uintptr_t v, se::Value *ret);
bool size_to_seval(size_t v, se::Value *ret);
bool std_string_to_seval(const std::string &v, se::Value *ret); 
bool Vec2_to_seval(const cc::Vec2 &v, se::Value *ret);
bool Vec3_to_seval(const cc::Vec3 &v, se::Value *ret);
bool Vec4_to_seval(const cc::Vec4 &v, se::Value *ret);
bool Mat4_to_seval(const cc::Mat4 &v, se::Value *ret);
bool Size_to_seval(const cc::Size &v, se::Value *ret);
bool Rect_to_seval(const cc::Rect &v, se::Value *ret);
bool ccvalue_to_seval(const cc::Value &v, se::Value *ret);
bool ccvaluemap_to_seval(const cc::ValueMap &v, se::Value *ret);
bool ccvaluemapintkey_to_seval(const cc::ValueMapIntKey &v, se::Value *ret);
bool ccvaluevector_to_seval(const cc::ValueVector &v, se::Value *ret);
bool std_vector_string_to_seval(const std::vector<std::string> &v, se::Value *ret);
bool std_vector_int_to_seval(const std::vector<int> &v, se::Value *ret);
bool std_vector_uint16_to_seval(const std::vector<uint16_t> &v, se::Value *ret);
bool std_vector_float_to_seval(const std::vector<float> &v, se::Value *ret);
bool std_map_string_string_to_seval(const std::map<std::string, std::string> &v, se::Value *ret); 
bool ManifestAsset_to_seval(const cc::extension::ManifestAsset &v, se::Value *ret); 
bool Data_to_seval(const cc::Data &v, se::Value *ret);
bool DownloadTask_to_seval(const cc::network::DownloadTask &v, se::Value *ret);
template <typename T>
typename std::enable_if<!std::is_base_of<cc::Ref, T>::value, bool>::type
native_ptr_to_seval(T *v_c, se::Value *ret, bool *isReturnCachedValue = nullptr);
template <typename T>
typename std::enable_if<!std::is_base_of<cc::Ref, T>::value && !std::is_pointer<T>::value, bool>::type
native_ptr_to_seval(T &v_ref, se::Value *ret, bool *isReturnCachedValue = nullptr);
template <typename T>
bool native_ptr_to_rooted_seval(
    typename std::enable_if<!std::is_base_of<cc::Ref, T>::value, T>::type *v,
    se::Value *ret, bool *isReturnCachedValue = nullptr);
template <typename T>
typename std::enable_if<!std::is_base_of<cc::Ref, T>::value, bool>::type
native_ptr_to_seval(T *vp, se::Class *cls, se::Value *ret, bool *isReturnCachedValue = nullptr);
template <typename T>
typename std::enable_if<!std::is_base_of<cc::Ref, T>::value, bool>::type
native_ptr_to_seval(T &v_ref, se::Class *cls, se::Value *ret, bool *isReturnCachedValue = nullptr);
template <typename T>
bool native_ptr_to_rooted_seval(
    typename std::enable_if<!std::is_base_of<cc::Ref, T>::value, T>::type *v,
    se::Class *cls, se::Value *ret, bool *isReturnCachedValue = nullptr);
template <typename T>
typename std::enable_if<std::is_base_of<cc::Ref, T>::value, bool>::type
native_ptr_to_seval(T *vp, se::Value *ret, bool *isReturnCachedValue = nullptr);
template <typename T>
typename std::enable_if<std::is_base_of<cc::Ref, T>::value, bool>::type
native_ptr_to_seval(T *vp, se::Class *cls, se::Value *ret, bool *isReturnCachedValue = nullptr);
template <typename T>
bool std_vector_to_seval(const std::vector<T> &v, se::Value *ret);
template <typename T>
bool seval_to_reference(const se::Value &v, T **ret);

改用 nativelue_to_se

辅助转换函数不属于 Script Engine Wrapper 抽象层,属于 Cocos Creator 绑定层,封装这些函数是为了在绑定代码中更加方便的转换。每个转换函数都返回 bool 类型,表示转换是否成功,开发者如果调用这些接口,需要去判断这个返回值。

以上接口,直接根据接口名称即可知道具体的用法,接口中第一个参数为输入,第二个参数为输出参数。用法如下:

se::Value v;
bool ok = nativevalue_to_se(100, v); // 第二个参数为输出参数,传入输出参数的地址
int32_t v;
bool ok = sevalue_to_native(args[0], &v); // 第二个参数为输出参数,传入输出参数的地址

更多关于手动绑定的内容可参考 使用 JSB 手动绑定。

自动绑定

配置模块 ini 文件

具体可以参考引擎目录下的 tools/tojs/cocos.iniini 配置。

理解 ini 文件中每个字段的意义

# 模块名称
[cocos]

# 绑定回调函数的前缀,也是生成的自动绑定文件的前缀
prefix = engine

# 绑定的类挂载在 JS 中的哪个对象中,类似命名空间
target_namespace = jsb

# 自动绑定工具基于 Android 编译环境,此处配置 Android 头文件搜索路径
android_headers = 

# 配置 Android 编译参数
android_flags = -target armv7-none-linux-androideabi -D_LIBCPP_DISABLE_VISIBILITY_ANNOTATIONS -DANDROID -D__ANDROID_API__=14 -gcc-toolchain %(gcc_toolchain_dir)s --sysroot=%(androidndkdir)s/platforms/android-14/arch-arm  -idirafter %(androidndkdir)s/sources/android/support/include -idirafter %(androidndkdir)s/sysroot/usr/include -idirafter %(androidndkdir)s/sysroot/usr/include/arm-linux-androideabi -idirafter %(clangllvmdir)s/lib64/clang/5.0/include -I%(androidndkdir)s/sources/cxx-stl/llvm-libc++/include

# 配置 clang 头文件搜索路径
clang_headers = 

# 配置 clang 编译参数
clang_flags = -nostdinc -x c++ -std=c++17 -fsigned-char -mfloat-abi=soft -U__SSE__

# 配置引擎的头文件搜索路径
cocos_headers = -I%(cocosdir)s/cocos -I%(cocosdir)s/cocos/platform/android -I%(cocosdir)s/external/sources

# 配置引擎编译参数
cocos_flags = -DANDROID -DCC_PLATFORM=3 -DCC_PLATFORM_MAC_IOS=1 -DCC_PLATFORM_MAC_OSX=4 -DCC_PLATFORM_WINDOWS=2 -DCC_PLATFORM_ANDROID=3

# 配置额外的编译参数
extra_arguments = %(android_headers)s %(clang_headers)s %(cxxgenerator_headers)s %(cocos_headers)s %(android_flags)s %(clang_flags)s %(cocos_flags)s %(extra_flags)s

# 需要自动绑定工具解析哪些头文件
headers = %(cocosdir)s/cocos/platform/FileUtils.h %(cocosdir)s/cocos/platform/CanvasRenderingContext2D.h %(cocosdir)s/cocos/platform/Device.h %(cocosdir)s/cocos/platform/SAXParser.h

# 在生成的绑定代码中,重命名头文件
replace_headers=

# 需要绑定哪些类,可以使用正则表达式,以空格为间隔
classes = FileUtils$ SAXParser CanvasRenderingContext2D CanvasGradient Device DownloaderHints

# 哪些类需要在 JS 层扩展,以空格为间隔
classes_need_extend = 

# 需要为哪些类绑定属性,以逗号为间隔
field =

# 需要忽略绑定哪些类,以逗号为间隔
skip = FileUtils::[getFileData setFilenameLookupDictionary destroyInstance getFullPathCache getContents listFilesRecursively],
        SAXParser::[(?!(init))],
        Device::[getDeviceMotionValue],
        CanvasRenderingContext2D::[setCanvasBufferUpdatedCallback set_.+ fillText strokeText fillRect measureText],
        Data::[takeBuffer getBytes fastSet copy],
        Value::[asValueVector asValueMap asIntKeyMap]

# 需要为哪些类绑定访问属性,以逗号为间隔
getter_setter = CanvasRenderingContext2D::[width//setWidth height//setHeight fillStyle//setFillStyle font//setFont globalCompositeOperation//setGlobalCompositeOperation lineCap//setLineCap lineJoin//setLineJoin lineWidth//setLineWidth strokeStyle//setStrokeStyle textAlign//setTextAlign textBaseline//setTextBaseline]

# 重命名函数,以逗号为间隔
rename_functions = FileUtils::[loadFilenameLookupDictionaryFromFile=loadFilenameLookup]

# 重命名类,以逗号为间隔
rename_classes = SAXParser::PlistParser

# 配置哪些类不需要搜索其父类
classes_have_no_parents = SAXParser

# 配置哪些父类需要被忽略
base_classes_to_skip = Ref Clonable

# 配置哪些类是抽象类,抽象类没有构造函数,即在 js 层无法通过 var a = new SomeClass();的方式构造 JS 对象
abstract_classes = SAXParser Device

更多关于自动绑定的内容可参考 使用 JSB 自动绑定。

远程调试与 Profile

默认远程调试和 Profile 是在 debug 模式中生效的,如果需要在 release 模式下也启用,需要手动修改构建原生平台后生成的文件 native/engine/common/CMakeLists.txt 中的宏开关。

# ...
if(NOT RES_DIR)
    message(FATAL_ERROR "RES_DIR is not set!")
endif()

include(${RES_DIR}/proj/cfg.cmake)

if(NOT COCOS_X_PATH)
    message(FATAL_ERROR "COCOS_X_PATH is not set!")
endif()
# ...

改为:

if(NOT RES_DIR)
    message(FATAL_ERROR "RES_DIR is not set!")
endif()

include(${RES_DIR}/proj/cfg.cmake)
set(USE_V8_DEBUGGER_FORCE ON) ## 覆盖 USE_V8_DEBUGGER_FORCE 的值

if(NOT COCOS_X_PATH)
    message(FATAL_ERROR "COCOS_X_PATH is not set!")
endif()
# ...

再修改 native/engine/common/Classes/Game.cpp

#if CC_DEBUG
  _debuggerInfo.enabled = true;
#else
  _debuggerInfo.enabled = false;
#endif
  // 覆盖配置
  _debuggerInfo.enabled = true;

Chrome 远程调试 V8

Windows/Mac

  • 编译、运行游戏(或在 Creator 中直接使用模拟器运行)
  • 用 Chrome 浏览器打开 devtools://devtools/bundled/js_app.html?v8only=true&ws=127.0.0.1:5086/00010002-0003-4004-8005-000600070008。(若使用的是旧版 Chrome,则需要将地址开头的 devtools 改成 chrome-devtools

断点调试:

抓取 JS Heap:

Profile:

Android/iOS

  • 保证 Android/iOS 设备与 PC 或者 Mac 在同一个局域网中
  • 编译,运行游戏
  • 用 Chrome 浏览器打开 devtools://devtools/bundled/js_app.html?v8only=true&ws=xxx.xxx.xxx.xxx:6086/00010002-0003-4004-8005-000600070008,其中 xxx.xxx.xxx.xxx 为局域网中 Android/iOS 设备的 IP 地址。(若使用的是旧版 Chrome,则需要将地址开头的 devtools 改成 chrome-devtools
  • 调试界面与 Windows 相同

Q & A

se::ScriptEngine 与 ScriptingCore 的区别,为什么还要保留 ScriptingCore?

在 1.7 中,抽象层被设计为一个与引擎没有关系的独立模块,对 JS 引擎的管理从 ScriptingCore 被移动到了 se::ScriptEngine 类中,ScriptingCore 被保留下来是希望通过它把引擎的一些事件传递给封装层,充当适配器的角色。

ScriptingCore 只需要在 AppDelegate 中被使用一次即可,之后的所有操作都只需要用到 se::ScriptEngine

bool AppDelegate::applicationDidFinishLaunching()
{
    ...
    ...
    director->setAnimationInterval(1.0 / 60);

    // 这两行把 ScriptingCore 这个适配器设置给引擎,用于传递引擎的一些事件,
    // 比如 Node 的 onEnter, onExit, Action 的 update,JS 对象的持有与解除持有
    ScriptingCore* sc = ScriptingCore::getInstance();
    ScriptEngineManager::getInstance()->setScriptEngine(sc);

    se::ScriptEngine* se = se::ScriptEngine::getInstance();
    ...
    ...
}

se::Object::root/unrootse::Object::incRef/decRef 的区别?

root/unroot 用于控制 JS 对象是否受 GC 控制,root 表示不受 GC 控制,unroot 则相反,表示交由 GC 控制,对一个 se::Object 来说,rootunroot 可以被调用多次,se::Object 内部有 _rootCount 变量用于表示 root 的次数。当 unroot 被调用,且 _rootCount 为 0 时,se::Object 关联的 JS 对象将交由 GC 管理。还有一种情况,即如果 se::Object 的析构被触发了,如果 _rootCount > 0,则强制把 JS 对象交由 GC 控制。

incRef/decRef 用于控制 se::Object 这个 cpp 对象的生命周期,前面章节已经提及,建议用户使用 se::HandleObject 来控制 手动创建非绑定对象 的方式控制 se::Object 的生命周期。因此,一般情况下,开发者不需要接触到 incRef/decRef

对象生命周期的关联与解除关联

使用 se::Object::attachObject 关联对象的生命周期
使用 se::Object::dettachObject 解除对象的生命周期。

objA->attachObject(objB); 类似于 JS 中执行 objA.__nativeRefs[index] = objB,只有当 objA 被 GC 后,objB 才有可能被 GC。
objA->dettachObject(objB); 类似于 JS 中执行 delete objA.__nativeRefs[index];,这样 objB 的生命周期就不受 objA 控制了。

请不要在栈(Stack)上分配 cc::RefCounted 的子类对象

Ref 的子类必须在堆(Heap)上分配,即通过 new,然后通过 release 来释放。当 JS 对象的 finalize 回调函数中统一使用 release 来释放。如果是在栈上的对象,reference count 很有可能为 0,而这时调用 release,其内部会调用 delete,从而导致程序崩溃。所以为了防止这个行为的出现,开发者可以在继承于 cc::RefCounted 的绑定类中,标识析构函数为 protected 或者 private,保证在编译阶段就能发现这个问题。

例如:

class CC_EX_DLL EventAssetsManagerEx : public EventCustom
{
public:
    ...
    ...
private:
    virtual ~EventAssetsManagerEx() {}
    ...
    ...
};

EventAssetsManagerEx event(...); // 编译阶段报错
dispatcher->dispatchEvent(&event);

// 必须改为

EventAssetsManagerEx* event = new EventAssetsManagerEx(...);
dispatcher->dispatchEvent(event);
event->release();

如何监听脚本错误

在 AppDelegate.cpp 中通过 se::ScriptEngine::getInstance()->setExceptionCallback(...) 设置 JS 层异常回调。

bool AppDelegate::applicationDidFinishLaunching()
{
    ...
    ...
    se::ScriptEngine* se = se::ScriptEngine::getInstance();

    se->setExceptionCallback([](const char* location, const char* message, const char* stack){
        // Send exception information to server like Tencent Bugly.
        // ...
        // ...
    });

    jsb_register_all_modules();
    ...
    ...
    return true;
}

3D建模学习工作室 翻译整理,转载请注明出处

上一篇:Cocos Creator:WebXR 项目构建与发布 (mvrlink.com)

下一篇:Cocos Creator:简化使用 JavaScript 调用 Java 方法 (mvrlink.com)

NSDT场景编辑器 | NSDT 数字孪生 | GLTF在线编辑器 | 3D模型在线转换 | UnrealSynth虚幻合成数据生成器 | 3D模型自动纹理化工具
2023 power by nsdt©鄂ICP备2023000829号