这里到了一个比较重点的环节,如何将Native 类、方法注入到(绑定)到脚本中,使其能正常使用。
先从简单的说起,当需要注入一个native的方法的时候,可以使用script::Function::newFunction
方法,接受的参数是一个script::FunctionCallback
类型,也就是std::function<void(const Arguments& args)>
,即一个回调函数。当js调用相应方法的时候,ScriptX就会调用这个回调函数,在这个函数里:
- 函数内已经有EngineScope。
- 函数内可以直接通过抛出
script::Exception
向脚本抛异常。 - 函数的参数通过
Local<Value> Arguments::operator[](size_t)
获得。 - 函数的返回值通过
return Local<Value>{xxx}
。
相关例子见上文异常一节。
类基本和方法同理,形式不同罢了。
在Native.h中有一个模板定义:
template<typename T>
struct ClassDefine {
const std::string className{};
const std::string nameSpace{};
const StaticDefine staticDefine{};
const InstanceDefine<T> instanceDefine{};
NativeRegister getNativeRegister() const;
}
使用 ClassDefine<T>
来描述一个类的所有信息。
- 模板参数T:即该脚本class在C++中对应的类,脚本类的实例和C++中T的实例是一一对应的,每创建一个脚本class实例,C++中都会创建一个新的T的实例。当这个class只有静态方法属性(类方法属性)即不能创建实例的,用void代替T。
- className:类的名字
- nameSpace:即这个类在哪个命名空间下。在JS中比如
nameSpace= "game.ui"; className="ImageView";
则该类在脚本中会存在于game.ui.ImageView
,使用时new game.ui.ImageView()
. 为了支持多种语言,命名空间的分隔符统一使用英文句号——.
。 - staticDefine:类的静态方法+属性定义
- instanceDefine:类的实例方法+属性定义
- getNativeRegister: 由于ClassDefine是模板类,在一些需要存储到容器的场景就比较难实现。因此ClassDefine直接实现了一个类型擦除的helper类,
NativeRegister
类是一个普通类,支持拷贝和移动,可以当做指针使用。
通过调用 ScriptEngine::registerNativeDefine
注册一个绑定类,需要注意这个ClassDefine的销毁要比ScriptEngien晚,否则会出现野指针问题。通常的做法是ClassDefine作为全局静态变量。
StaticDefine
中包含方法(方法名+方法回调),属性(属性名+属性getter+属性setter)和绑定方法大同小异,这里不再赘述。
InstanceDefine
template<typename T>
struct InstanceDefine {
struct PropertyDefine { ... };
struct FunctionDefine {
using FunctionCallback = std::function<void(T*, const Arguments& args)>;
...
};
const Constructor constructor{};
const std::vector<PropertyDefine> properties{};
const std::vector<FunctionDefine> functions{};
const size_t instanceSize = sizeof_helper_v<T>;
};
模板参数T必须是是继承了sctipt::ScriptClass
的类型。
InstanceDefine<T>
相比起StaticDefine
则要有些区别,首先这里多了个必选项constructor
,即构造函数的回调。当脚本侧创建class的新实例的时候ScriptX就会回调到这个函数。而这个函数则需要创建,并返回一个 T*
指针。当对应的实例被GC的时候,ScriptX会自动释放并析构T*
。特别的,当构造函数参数非法,导致不能构造出相应C++实例时,可以考虑抛异常,或者return nullptr
(此时ScriptX会帮你抛异常)。
另外一个区别是方法回调多了一个参数T*
用于回传c++的实例指针,该指针确保不为null,且一定是constructor返回的指针。
上述的using FunctionCallback = std::function<void(T*, const Arguments& args)>;
借助std::function
的能力,既可以绑定一个带有两个参数的函数void (*)(T*, const Arguments&)
,也能绑定一个类T的成员函数void (T::*)(const Arguments&)
。
最后上述struct并非public API,只是用来介绍内部逻辑,所有的 ClassDefine<T>
都应该由 ClassDeinfeBuilder<T>::build
来构造出来。
示例如下:
class TestClass : public ScriptClass {
public:
explicit TestClass(const Local<Object>& scriptObj) : ScriptClass(scriptObj) {}
~TestClass() override = default;
virtual Local<Value> greet(const Arguments& args);
Local<Value> age(const Arguments& args);
Local<Value> getSrc();
void setSrc(const Local<Value>& value);
static Local<Value> add(const Arguments& args);
static Local<Value> getVersion();
static void setVersion(const Local<Value>& value);
static TestClass* create(const Arguments& args);
};
const ClassDefine<TestClass> TestClassDefAll =
defineClass<TestClass>("TestClass")
.nameSpace("script.engine.test")
.constructor(&TestClass::create)
.function("add", &TestClass::add)
.property("version", &TestClass::getVersion, &TestClass::setVersion)
.instanceFunction("greet", &TestClass::greet)
.instanceFunction("age", &TestClass::age)
.instanceProperty("src", &TestClass::getSrc, &TestClass::setSrc)
.build();
通过使用 script::defineClass<ClasType>(className)
来创建一个 ClassDefineBuilder
namespace(std::string)
见上文property(std::string,std::function<Local<Value>()> getter, std::function<void(const Local<Value>& value)> setter)
用于添加一个静态属性function(std::string, std::function<Local<Value>(const Arguments& args)>)
用于添加一个静态方法constructor(std::function<T*(const Arguments& args)>)
用于指定一个构造函数,详见上文instanceFunction(std::string, std::function<Local<Value>(T*, const Arguments& args)>)
用于添加一个实例方法instanceProperty(std::string, std::function<Local<Value>(T*)> getter, std::function<void(T*, const Local<Value>& value)> setter)
用于添加一个实例属性build
用于构造ClassDefine
,内部会对数据的合法性做检查,检查不通过会抛异常。
注意,只有当模板参数T
不是void时,也就是该 ClassDeinfe
不是静态类的时候,方法4,5,6才能调用。
如上实例代码所示,ScriptEngine
中和ClassDefine<T>
相关的API共三个。
- newNativeClass:在C++中直接创建一个classDefine的对象
- isInstanceOf:判断一个
Local<Value>
是不是某个classDefine的实例 - getNativeInstance:从classDefine的实例中,获取其C++对象的指针。
其他的包括newNativeClass
的多个重载方法,方便API的使用。
const ClassDefine<TestClass>& def();
EngineScope engineScope(engine);
try {
engine->registerNativeClass<TestClass>(def());
// 1. static property
auto version = engine->eval("script.engine.test.TestClass.version");
ASSERT_TRUE(addRet.isString());
// 2. new instance
Local<Value> ins = engine->newNativeClass<TestClass>();
Local<Value> ret = engine->eval("new script.engine.test.TestClass()");
// 3. 判断一个Script Value,是不是某个ClassDefine的实例
auto instanceof = engine->isInstanceOf<TestClass>(ret);
ASSERT_TRUE(instanceof);
// 4. 通过Script value,获取C++类指针
TestClass* ptr = engine->getNativeInstance<TestClass>(ret);
} catch (const Exception& e) {
...
}
如果要绑定实例类,则必须继承ScriptClass
才行, ScriptClass 提供了一个和脚本对象绑定的C++对象的一些基本操作。
其中需要说明的几个API包括
获取绑定的脚本对象。
使用场景是,有时需要绑定对象需要持有一些其他的脚本对象,比如一个回调函数。
InternalStore是一个被 ScriptObject 所引用的数组,借助这个数组,我们就可以把任何需要存储的对象设置进去。同时也不用担心GC相关的问题。
析构函数,由ScriptX管理GC并销毁对应的C++类。
ScriptX保证这个方法一定在主线程调用。
ScriptX不保证这个实例一定是在 ScriptObject GC的同时销毁(可能延后)。事实上,对于V8和JSCore,因为两个引擎都要求在finalize里不能调用任何引擎相关的API,所以他们的ScriptClass的销毁都是post到MessageQueue里。
所以当你在C++成持有一个ScriptClass的指针时,会在这个时间间隙里发现 ScriptClass::getScriptObject
返回null。
这个是ScriptClass的另一个构造函数,使用场景是某个绑定类构造时需要很多C++依赖,这样再经过一道ScriptX就导致类型转换很多很麻烦。所以提供这个构造函数,直接用C++new一个实例出来,然后通过 getScriptObejct
拿到对应的ScriptObject返回给ScriptX。
请谨慎使用这个能力,并详细阅读头文件中的相关注释,否则你将面临内存问题导致的crash!
class MyImage : public script::ScriptClass {
public:
MyImage(void* canvas) : script::ScriptClass(script::ScriptClass::ConstructFromCpp<MyImage>{});
void drawTo(void* canvas, int x, int y);
};
script::ClassDefine<MyImage> myClassDefine =
script::defineClass<MyImage>("MyImage")
.constructor(nullptr)
.function("newImage", []() -> Local<Value> {
auto img = new MyImage(Render::getInstance()->canvas());
return img.getScriptObject();
})
.function("newImage2", []() -> MyImge* {
// 借助Converter,也可以直接返回指针,ScriptX会转换成和上面等价的代码
return MyImage(Render::getInstance()->canvas());
})
.build();
因为绑定类需要继承 ScriptClass
,对已经有的C++类似乎不太友好?
幸运的是C++可以多继承,只需要写一个Wrapper类继承已有C++类和 ScriptClass
即可。
class BaseClass {
public:
int age = 0;
int num = 1;
const int length = 180;
std::string name() { return "Base"; }
int getNum() { return num; }
void setNum(int n) { num = n; }
};
class BaseClassScriptWrapper : public BaseClass, public ScriptClass {
public:
explicit BaseClassScriptWrapper(const Local<Object>& thiz) : BaseClass(), ScriptClass(thiz) {}
};
const auto baseWrapperDefine =
defineClass<BaseClassScriptWrapper>("BaseWrapper")
.nameSpace("BindBaseClass")
.constructor()
.instanceProperty("age", &BaseClass::age)
.instanceProperty("length", &BaseClass::length) // const property has getter, no setter
.instanceFunction("name", &BaseClass::name)
.instanceProperty("num", &BaseClass::getNum, &BaseClass::setNum)
.build();
详见 NativeTest
尽管上述绑定API已经比较好用了,但是ScriptX还更近了一步,使用现代C++的模板元编程能力,使得ScriptX可以直接绑定普通的C++函数。
如:
class MyImage : public script::ScriptClass {
public:
using script::ScriptClass::ScriptClass;
static const int age = 0;
static const char* name() { return "hello world"; }
std::string src;
int add(int a, int b) {return a+ b;}
};
script::ClassDefine<MyImage> myClassDefine =
script::defineClass<MyImage>("MyImage")
.constructor()
.property("age", &MyImage::age)
.function("name", &MyImage::name)
.instanceFunction("add", &MyImage::add)
.instanceProperty("src", &MyImage::src)
.build();
注意上面,你甚至可以直接对成员变量做绑定。
具体使用原则是,方法参数和返回值是支持转换的类型。
支持转换的类型列表:
- void
- bool
- any float type : float double
- any int type : int8_t uint8_t int16_t uint16_t int32_t uint32_t int64_t uint64_t size_t
- any string type: string string_view char* char8_t* u8string u8string_view
- all kind of Local reference
- any pointer of subclass of ScriptClass
注意7,其实支持的是所有绑定类和类指针的转换。
比如Local<Value>
引用的是TestClass
的绑定对象,那就可以直接转换成 TestClass*
你可以自定义新的类型转换器,只需要特化模板即可:
namespace script::converter {
template <>
struct Converter <T, void> {
// convert custom type T to Local<Value>
static Local<Value> toScript(T&& value) { ...; }
// convert Local<Value> to custom type T
static T toCpp(const Local<Value>& value) { ...; }
};
}
ScriptX给其他常用接口也加上了类型转换的能力。如:
Local<Function> func;
func.call({}, "string", 1, classPtr);
Local<Object> obj;
obj.set("hello", 4.13);
Object::newObject(type, "name", false);
直接绑定C++函数时经常面临C++函数重载的情况,这里提供了一个util函数来选择合适的重载版本。
script::selectOverloadedFunc
int overload(int) { return 0; }
int overload(double) { return 1; }
class X {
public:
int overload(int) { return 0; }
int overload(int&) { return 1; }
int overload(double) { return 2; }
int overload(double) const { return 3; }
};
TEST_F(NativeTest, SelectOverloadedFunction) {
auto o1 = script::selectOverloadedFunc<int(int)>(overload);
auto o2 = script::selectOverloadedFunc<int(double)>(overload);
EXPECT_EQ(0, o1(0));
EXPECT_EQ(1, o2(0.0));
auto x0 = script::selectOverloadedFunc<int (X::*)(int)>(&X::overload);
auto x1 = script::selectOverloadedFunc<int (X::*)(int&)>(&X::overload);
auto x2 = script::selectOverloadedFunc<int (X::*)(double)>(&X::overload);
auto x3 = script::selectOverloadedFunc<int (X::*)(double) const>(&X::overload);
X x;
X* p = &x;
int i = 0;
EXPECT_EQ(0, (p->*x0)(0));
EXPECT_EQ(1, (p->*x1)(i));
EXPECT_EQ(2, (p->*x2)(0.0));
EXPECT_EQ(3, (p->*x3)(0.0));
}
详见 NativeTest
不同的脚本语言能力差异较大,比如 JS 不支持重载操作符,但是Lua、Python等语言是允许的。
ScriptX的设计哲学是:仅提供通用且必要的接口。
比如ClassDefine
只提供了必要的5个接口:构造函数、静态属性、静态方法、实例属性,实例方法。
有了这五个接口,理论上能力就齐全了。
针对上面提出的问题,ScriptX的解决方案是:
每一种语言提供一套语言特定的基础库。
graph code
flowchart BT;
C++核心能力 -- 绑定 --> ScriptX;
ScriptX -- 扩展+包装 --> JS基础库;
ScriptX -- 扩展+包装 --> Lua基础库;
subgraph JS
JS基础库 <-- API --> JS业务脚本;
end
subgraph Lua
Lua基础库 <-- API --> Lua业务脚本;
end
从底层到上层:
- ScriptX提供基础的绑定能力来暴露C++核心能力;
- 使用者在ScriptX之上再写一套语言相关的基础库用来扩展能力(如上述的操作符重载);
- 最后业务脚本会调用基础库和绑定的API实现其功能需求。
这样一来就把语言相关的逻辑放到基础库中了,绑定部分的代码仍然是通用且统一的。