UE4 的Slate系统有很多奇怪的语法,创建新的SWidget对象时,语法中会包含SNew,.XXX() , +XXX 和 [XXX]类型的语法 。
在自定义SWidget类的时候,会需要用到SLATE_BEGIN_ARGS
, SLATE_END_ARGS
等语法,并且需要实现Construct方法,
这些特殊的语法和规则,都是UE4通过宏定义,运算符重载来完成的。UE4希望尽可能简化Slate相关的代码编写难度,
但是对于不熟悉“内幕”的人来说,这些特殊语法可能会造成一些困惑。这里尝试去分析Slate所有的特殊语法,了解这些特殊语法
是怎么起作用的,写Slate相关代码时需要遵守的各种“潜规则”又是怎么来的。
一个最简单的创建SWidget的代码大致如下
SNew(STextBlock).Text(FText::FromString("Hello World"));
几乎所有的Slate相关宏,都可以在 SlateCore\Public\Widgets\DeclarativeSyntaxSupport.h 中找到。
其中,SNew的定义如下:
#define SNew( WidgetType, ... ) \
MakeTDecl( #WidgetType, __FILE__, __LINE__, RequiredArgs::MakeRequiredArgs(__VA_ARGS__) ) <<= TYPENAME_OUTSIDE_TEMPLATE WidgetType::FArguments()
TYPENAME_OUTSIDE_TEMPLATE定义为
#define TYPENAME_OUTSIDE_TEMPLATE typename
把这个东西直接忽略掉似乎也没事
那么,把最开始的代码,宏展开,去掉typename修饰,假定当前的__FILE__是Source.cpp, __LINE__是20(这是SNew代码出现的位置,Debug用的)
大概就成了下面这个样子:
MakeTDecl("STextBlock","Source.cpp",20,RequiredArgs::MakeRequiredArgs())
<<=
STextBlock::FArguments().Text(FText::FromString("Hello World"));
分析一下这行代码的执行顺序,C++代码的执行顺序,具体顺序规则相当复杂1,不过应该会遵守以下规则(个人总结):
由于C++中 <<= 预算符的优先级相当低,所以上面代码的执行顺序大概可以认为是:
编译器可能选择不严格按照这个顺序执行代码,但是最终结果应该与这个顺序的结果一致 (之后的执行顺序分析不再赘述这一点)
已经可以发现,实际的代码执行和看上去的并不一致,在使用SNew创建SWidget的时候,跟在SNew后面的一系列 .XXX().YYY()函数,
看上去就像是在调用SWidget对象的函数,更改它的属性。然而,实际上这些函数是在另外的对象上调用的,并没有直接修改SWidget对象的数据。
(当然,最终能生效,说明肯定还是间接更改SWidget对象的属性了,只不过更改过程不想看上去那么直接)
更具体的分析一下一上代码的执行顺序:
BoundText = InArgs._Text;
(InArgs是FArgumens类型),这样,之前设置给FArgments对象的数据SAssignNew方法的定义如下:
#define SAssignNew( ExposeAs, WidgetType, ... ) \
MakeTDecl( #WidgetType, __FILE__, __LINE__, RequiredArgs::MakeRequiredArgs(__VA_ARGS__) ) . Expose( ExposeAs ) <<= TYPENAME_OUTSIDE_TEMPLATE WidgetType::FArguments()
比起SNew,就是在第2步之后,将生成的SWidget对象额外传递给外部的某个变量,其它流程完全一致。
在之前的分析中,出现了很多的类,T0RequiredArgs,TDecl都是比较简单的辅助类,在DeclarativeSyntaxSupport.h
中都
能看到,这里不做更多的分析。比较奇怪的是SNew中出现的WidgetType::FArguments(),似乎意味着每个SWidget类都必定有一个
与之对应的FArguments内部类,用于参数的接收和传递。但是,似乎并不能直接看到这个类在哪里声明。
一个简单的SWidget类的声明大概是这样的:
class STextBlock : public SLeafWidget
{
public:
SLATE_BEGIN_ARGS(STextBlock)
:_Text()
{
}
SLATE_ATTRIBUTE(FText,Text)
SLATE_END_ARGS
void Construct(const FArguments& InArgs)
{
BoundText = InArgs._Text;
}
private:
TAttribute< FText > BoundText;
};
SLATE_BEGIN_ARGS
, SLATE_END_ARGS
的定义为:
#define SLATE_BEGIN_ARGS( WidgetType ) \
public: \
struct FArguments : public TSlateBaseNamedArgs \
{ \
typedef FArguments WidgetArgsType; \
FORCENOINLINE FArguments()
#define SLATE_END_ARGS() \
};
可以看到,SLATE_BEGIN_ARGS
和 SLATE_END_ARGS
就是用来声明当前SWidget类的内部FArguments类,这也是为什么
每一个自定义的SWidget类,都得加上这样的声明,否则无法通过编译
自定义的FArguments类都继承自对应的基类,所以即使什么都不做,它们也都会有某些公共的字段(IsEnabled,Visibility等),
这也是为什么SNew任何SWidget的时候,都可以设置它们基础SWidget属性的原因。
这些基础属性,不用自己手动设置,在子类的Construct方法被调用前,SWidget最基类中的SWidgetConstruct的方法就会被调用,
(调用时机参考上一小节),从FArguments对象中接收对应的基础属性。
如果想要在自定义的FArguments添加新的属性,则可以通过类似于 SLATE_ATTRIBUTE
的宏来完成,它们的定义大概为:
#define SLATE_ATTRIBUTE( AttrType, AttrName ) \
TAttribute< AttrType > _##AttrName; \
WidgetArgsType& AttrName( const TAttribute< AttrType >& InAttribute ) \
{ \
_##AttrName = InAttribute; \
return this->Me(); \
} \
#define SLATE_ARGUMENT( ArgType, ArgName ) \
ArgType _##ArgName; \
WidgetArgsType& ArgName( ArgType InArg ) \
{ \
_##ArgName = InArg; \
return this->Me(); \
}
#define SLATE_EVENT( DelegateName, EventName ) \
WidgetArgsType& EventName( const DelegateName& InDelegate ) \
{ \
_##EventName = InDelegate; \
return *this; \
} \
可以看到,每一个宏,都声明了某个类型的变量,变量名称统一为 _Name ,Name是使用宏的时候传入的名字
(所以,这些宏都必须在SLATE_BEGIN_ARGS
和 SLATE_END_ARGS
之间,否则变量声明的位置就不对了)。同时,每个宏还会声明
一个或者多个对应的方法,用于给刚才声明的变量赋值。最基础的方法就是 Name(Type xxx)类型的方法,但是一些宏,还会声明很多
扩展方法(由于方法太多,上面的宏定义其实是不完整的,少了很多方法)。
这就是为什么在SNew语句中,可以通过 .Text()
.Text_Lambda()
等方法进行赋值 (赋值给FArgsment对象)
也是为什么在对应的 Construct 方法中,可以通过 InArgs._Text
来取出对应的属性,完成真正的赋值。
某些常用的布局组件 (SVertextBox , SHorizontalBox)在使用时,经常会用到 + ,很明显,这是预算符重载的结果。
但是,具体是“谁”进行了运算符重载?比如如下代码
SNew(SVertextBox) + SVertextBox::Slot();
直觉上,像是先 SNew 创建出了SVertexBox,然后在SVertexBox对象上执行 + 方法,但是,将宏展开之后,这句代码就变成了:
MakeTDecl("SVertextBox","Source.cpp",20,RequiredArgs::MakeRequiredArgs())
<<=
SVertextBox::FArguments() + SVertextBox::Slot();
在C++中,+ 运算符是高于 <<= 的,实际上,基本上每个常见的预算符,优先级都高于<<=2。所以,实际上+预算符是作用于
SVertexBox::FArguments对象上的。
在SVertexBox的定义中, SLATE_BEGIN_ARGS
和 SLATE_END_ARGS
之间,除了正常的属性声明宏之外,还有这样的代码:
SLATE_SUPPORTS_SLOT(SVerticalBox::FSlot)
SLATE_SUPPORT_SLOT
的定义为:
#define SLATE_SUPPORTS_SLOT( SlotType ) \
TArray< SlotType* > Slots; \
WidgetArgsType& operator + (SlotType& SlotToAdd) \
{ \
Slots.Add( &SlotToAdd ); \
return *this; \
}
也就是说,通过这个宏,SVertexBox::FArguments 类中有一个会增加一个 TArray
成员变量,
同时,还重载了 + 运算符。每次调用 + ,实际上就是向对应Array中增加一个新的Slots*成员。
通过这种方式,FArguments不仅能够存储各种参数变量,还能存储外部想传入的多个Slot变量。
最终,在SVertexBox的Construct方法中,通过以下代码,将FArguments中的Slots赋值给了SVertexBox对象,完成了参数的传递:
void SVerticalBox::Construct( const SVerticalBox::FArguments& InArgs )
{
const int32 NumSlots = InArgs.Slots.Num();
for ( int32 SlotIndex = 0; SlotIndex < NumSlots; ++SlotIndex )
{
Children.Add( InArgs.Slots[SlotIndex] );
}
}
需要额外注意的一点是,Slot对象的存储都是存储的指针,并不是直接存储结构体。Slot对象在对应的静态方法调用时被创建,以SVertexBox::Slot()为例:
class SLATECORE_API SVerticalBox : public SBoxPanel
{
public:
class FSlot : public SBoxPanel::FSlot
{
//相关函数重载
}
static FSlot& Slot()
{
return *(new FSlot());
}
}
一般在Slate的相关代码中,[]用于表示“嵌套”子SWidget,比如这样的代码:
SNew(SVertextBox)
+ SVertextBox::Slot()
[
SNew(STextBlock).Text(FText::FromString("hello world"))
];
SNew和+符号的相关内容,之前已经说过,此处,[]显然是向父节点SVertexBox对象中添加一个子节点STextBlock对象。但是,这种添加并不是直接完成的。
由于C++中,[]符号的预算优先级,要高于+符号,所以,这里的[]预算符,是作用在SVertexBox::FSlot对象上的,SVertexBox::FSlot的比较重要的基类定义为:
//TSlotBase
template
class TSlotBase : public FSlotBase
{
public:
SlotType& operator[]( const TSharedRef& InChildWidget )
{
this->AttachWidget(InChildWidget);
return (SlotType&)(*this);
}
};
//FSloatBase
class SLATECORE_API FSlotBase
{
public:
FORCEINLINE_DEBUGGABLE void AttachWidget( const TSharedRef& InWidget )
{
Widget = InWidget;
}
private:
TSharedRef Widget;
};
也就是说,继承自TSlotBase的类,都包含了一个SWidget对象的指针,并且重载了[]运算符,[]中包含的对象,会被赋值给这个指针。
所以,在上面的示例代码中,SVertexBox::FSlot对象在创建出来后,传递给SVertexBox::FArguments对象前,就已经调用了[]函数,设置了这个Slot对象中包含的SWidget对象的指针,最后,在Construct方法调用后,SVertexBox对象会包含这些Slot对象,也就间接包含了STextBlock对象,从而形成了父子关系。
另外,在SCompoundWidget的子类的Construct代码中,经常能看到这样的代码:
ChildSlot
[
SAssignNew(Button, SButton)
];
这个ChildSlot的定义为:
/**
* A CompoundWidget is the base from which most non-primitive widgets should be built.
* CompoundWidgets have a protected member named ChildSlot.
*/
class SLATECORE_API SCompoundWidget : public SWidget
{
/** The slot that contains this widget's descendants.*/
FSimpleSlot ChildSlot;
}
FSimpleSlot也是TSlotBase的子类,所有可以通过[]运算符指定它包含的子SWidget对象,从而让当前SCompoundWidgets对象间接包含子SWidget对象。
Slate代码中,大部分出现[]的地方,都是调用了TSlotBase重载的运算符方法。但是也有例外,比如SBorder支持这样的语法。
SNew(SBorder)
[
SNew(SButton)
];
或者
SNew(SBorder)
.Content()
[
SNew(SButton)
];
很显然,这是SBorder::FArguments本身重载了[]运算符,而且SBorder::FArguments::Content() 方法的返回值也重载了[]运算符。
在SBorder的参数定义中,可以看到:
class SLATE_API SBorder : public SCompoundWidget
{
public:
SLATE_BEGIN_ARGS(SBorder){}
SLATE_DEFAULT_SLOT( FArguments, Content )
SLATE_END_ARGS()
显然是SLATE_DEFAULT_SLOT
宏的功劳,它的定义为:
#define SLATE_DEFAULT_SLOT( DeclarationType, SlotName ) \
SLATE_NAMED_SLOT(DeclarationType, SlotName) ; \
DeclarationType & operator[]( const TSharedRef InChild ) \
{ \
_##SlotName.Widget = InChild; \
return *this; \
}
这里嵌套了一个SLATE_NAMED_SLOT
宏,而SLATE_NAMED_SLOT
的定义为:
#define SLATE_NAMED_SLOT( DeclarationType, SlotName ) \
NamedSlotProperty< DeclarationType > SlotName() \
{ \
return NamedSlotProperty< DeclarationType >( *this, _##SlotName ); \
} \
TAlwaysValidWidget _##SlotName; \
SLATE_NAMED_SLOT
定义了一个TAlwaysValidWidget变量,它的定义很简单
/** A widget reference that is always a valid pointer; defaults to SNullWidget */
struct TAlwaysValidWidget
{
TAlwaysValidWidget()
: Widget(SNullWidget::NullWidget)
{
}
TSharedRef Widget;
};
另外,这个宏还定义了对应参数名的方法 (也就是刚才.Content()
方法),返回一个NamedSlotProperty
对象,而这个对象重载了[]函数,能够对刚才声明的TAlwaysValidWidget变量中的Widget变量赋值。
/**
* We want to be able to do:
* SNew( ContainerWidget )
* .SomeContentArea()
* [
* // Child widgets go here
* ]
*
* NamedSlotProperty is a helper that will be returned by SomeContentArea().
*/
template
struct NamedSlotProperty
{
NamedSlotProperty( DeclarationType& InOwnerDeclaration, TAlwaysValidWidget& ContentToSet )
: OwnerDeclaration( InOwnerDeclaration )
, SlotContent(ContentToSet)
{}
DeclarationType & operator[]( const TSharedRef& InChild )
{
SlotContent.Widget = InChild;
return OwnerDeclaration;
}
DeclarationType & OwnerDeclaration;
TAlwaysValidWidget & SlotContent;
};
这就是为什么SBorder支持.Content()[]
语法的原因:通过宏在SBorder::FArguments类中间接定义了一个SWidget对象指针,并且定义了对应的方法(Content方法),该方法返回的对象重载[]运算符,能给刚才定义的SWidget对象赋值。
SLATE_DEFAULT_SLOT
宏则更进一步,直接重载了SBorder的[]运算符,能够直接给定义的SWidget对象赋值。
最后,理所当然的,SBorder的Construct方法中,会执行相应的操作,讲SBorder::FArguments对象中的 _Content 字段中包含的SWidget对象取出来,让它真正起作用。
void SBorder::Construct( const SBorder::FArguments& InArgs )
{
ChildSlot
.HAlign(InArgs._HAlign)
.VAlign(InArgs._VAlign)
.Padding(InArgs._Padding)
[
InArgs._Content.Widget
];
}
在设计上,可能处于以下的一些考虑:
所有的FArguments类都是继承自同样的基类,所以它们都拥有SWidget基类需要的参数。但是,为什么不是子类的FArguments继承自父类的FArguments呢?目前还没有想清楚为什么。
目前,如果要写一个SWidget类继承自STextBlock,假设这个类名为STextBlockEx。如果想要在SNew STextBlockEx的时候,设置它父类STextBlock的属性,就必须在STextBlockEx的参数声明段声明对应的属性,然后在Construct函数中手动传递对应的参数过去。而大部分时候,肯定是希望子类不用做什么特殊操作,就能在创建子类的时候,指定父类的属性的。
如果让SWidget类的FArgument也保持着继承关系,那么问题就轻松解决了,只需要在子类的Construct方法中调用父类的Construct方法,并且传递对应的参数过去,就可以让父类初始化参数的代码也生效了,SNew子类的时候也就可以设置父类的对应参数了。
另外,如果将Construct方法定义为虚方法,并且每个类都先调用父类的Construct方法,可能会更好。(不过从C++语法上,着没办法直接实现,因为最基类的参数没法确定)。可能可以考虑,让Construct方法接受FArguments最基类的指针,然后每个SWidget子类,再把它强转回自定义的FArgument类(通过宏的方式)。
C++求值顺序 ↩︎
C++预算符优先级 ↩︎