UE4 Slate 特殊语法

UE Slate 特殊语法分析

简述

UE4 的Slate系统有很多奇怪的语法,创建新的SWidget对象时,语法中会包含SNew,.XXX() , +XXX 和 [XXX]类型的语法 。
在自定义SWidget类的时候,会需要用到SLATE_BEGIN_ARGS , SLATE_END_ARGS等语法,并且需要实现Construct方法,
这些特殊的语法和规则,都是UE4通过宏定义,运算符重载来完成的。UE4希望尽可能简化Slate相关的代码编写难度,
但是对于不熟悉“内幕”的人来说,这些特殊语法可能会造成一些困惑。这里尝试去分析Slate所有的特殊语法,了解这些特殊语法
是怎么起作用的,写Slate相关代码时需要遵守的各种“潜规则”又是怎么来的。

从SNew开始

一个最简单的创建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,不过应该会遵守以下规则(个人总结):

  1. 如果需要执行某个函数,那么必定会先对所有参数求值(如果参数是函数,那么参数对应的函数必定会先执行)
  2. XXX.YYY().ZZZ()类型的多个函数连续调用,必定是先调用左边的函数,再用它的返回值去调用右边的函数
  3. 如果有多个运算符号都可以执行,那么优先级高的运算符先执行

由于C++中 <<= 预算符的优先级相当低,所以上面代码的执行顺序大概可以认为是:

  1. 先执行 <<= 符号左边的表达式 MakeTDecl(…)
  2. 再执行 <<= 符号右边的表达式 STextBlock::FArguments().xxx
  3. 最后,执行 <<= 预算符(这个运算符被重载了),返回最终的SWidget对象(智能指针)

编译器可能选择不严格按照这个顺序执行代码,但是最终结果应该与这个顺序的结果一致 (之后的执行顺序分析不再赘述这一点)

已经可以发现,实际的代码执行和看上去的并不一致,在使用SNew创建SWidget的时候,跟在SNew后面的一系列 .XXX().YYY()函数,
看上去就像是在调用SWidget对象的函数,更改它的属性。然而,实际上这些函数是在另外的对象上调用的,并没有直接修改SWidget对象的数据。
(当然,最终能生效,说明肯定还是间接更改SWidget对象的属性了,只不过更改过程不想看上去那么直接)

更具体的分析一下一上代码的执行顺序:

  1. RequiredArgs::MakeRequiredArgs() 方法被调用,这是一个泛型方法,根据传入参数的不同,会调用到不同的方法,最终生成
    T0RequiredArgs – T5RequiredArgs的对象并返回,这个对象的作用,可以认为是存储SNew中,除了类名之外的其它参数,最多可以有五个
  2. MakeTDecl 方法被调用,生成一个TDecl的对象,这个对象包含真正的SWidget对象。TDecl对象,在构造方法里面,会负责
    对应SWidget对象的创建。这个对象的创建不是直接通过new方法来生成的,而是通过专门的Allocator,UE可能在内部使用了
    内存池的技术,减少不必要的对象生成。

    此时,虽然对应的SWidget对象已经生成了,但是对应的Construct方法并没有调用,所以这个SWidget对象还是没有真正初始化的。

    另外,TDecl的构造方法中,会将SWidget对象的Debug信息(SNew的文件名,行号,SWidget的名称)赋值给这个SWidget对象。
  3. STextBlock::FArguments()被调用,生成STextBlock::FArguments对象,这个对象用来执行接下来的.Text()方法,接收对应的参数
    并且在最后把参数通过Construct方法传递给SWidget对象。
  4. FText::FromString(“Hello World”)被调用,这创建了一个FText的对象,也就是最终需要显示出来的Text
  5. STextBlock::FArguments 对象的 .Text 方法别调用,将需要设置的FText的传入,此时 STextBlock::FArguments 对象中已经包含
    FText对象的数据,但是SWidget对象还没有接收到这个数据
  6. TDecl 的 <<= 方法被调用,这个方法先调用SWidget基类中的方法SWidgetConstruct,设置SWidget的公共属性,比如Enable
    ,Visible等,然后,再通过第一步生成的T0RequiredArgs对象间接调用STextBlock的Construct方法,传入的第一个参数是第三步生成的
    STextBlock::FArguments对象,之后的参数是SNew中传入的其它参数(这个例子中,没有其它参数)。如果想要编译不报错,SNew传入n个额外参数
    ,对应的SWidget类必须要有接收 n + 1 参数的Construct方法 (第一个参数是FArguments,之后的参数与SNew传入的参数对应)。
  7. STextBlock的Construct方法中,有以下代码: BoundText = InArgs._Text; (InArgs是FArgumens类型),这样,之前设置给FArgments对象的数据
    被STextBlock对象接收,开始起作用。
  8. TDecl 的 <<= 方法,最终把之前自己生成的SWidget对象返回。

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_ARGSSLATE_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_ARGSSLATE_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_ARGSSLATE_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 Slots成员变量,
同时,还重载了 + 运算符。每次调用 + ,实际上就是向对应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
	];
}

相关思考

UE4为什么要定义这些特殊的语法?

在设计上,可能处于以下的一些考虑:

  1. 让SWidget类拥有 “命名可变参数的构造方法” (像Python是自带这种语法的),一个SWidget类可能有很多属性,UE4希望允许所有属性都在初始化的时候进行赋值,也允许任意多个属性保持默认值,不需要显式赋值。
    如果按照C++正常的语法的话,要么就得有复杂的构造方法(参数很多,并且都允许默认参数),要么就得为每一个属性声明Set方法。并且,创建SWidget对象的代码也会更加复杂(如果某一个需要设置的参数,在构造方法中参数中很靠后,那么久不得不显示传入前面的所有参数)。
  2. 减少定义SWidget类的代码量,通过一个宏,就能定义对应的参数和设置参数的所有函数,减少手动重复的代码量。
  3. 增加可读性。所有的初始化参数都跟在SNew后面,Slot的添加都使用+运算符,所有设置子SWidget的地方都使用[]运算符。比起显示声明所有SWidget对象和Slot对象,再通过大量的函数调用将它们拼接再一起,可读性还是要好很多的。

也许可以改进?

所有的FArguments类都是继承自同样的基类,所以它们都拥有SWidget基类需要的参数。但是,为什么不是子类的FArguments继承自父类的FArguments呢?目前还没有想清楚为什么。

目前,如果要写一个SWidget类继承自STextBlock,假设这个类名为STextBlockEx。如果想要在SNew STextBlockEx的时候,设置它父类STextBlock的属性,就必须在STextBlockEx的参数声明段声明对应的属性,然后在Construct函数中手动传递对应的参数过去。而大部分时候,肯定是希望子类不用做什么特殊操作,就能在创建子类的时候,指定父类的属性的。

如果让SWidget类的FArgument也保持着继承关系,那么问题就轻松解决了,只需要在子类的Construct方法中调用父类的Construct方法,并且传递对应的参数过去,就可以让父类初始化参数的代码也生效了,SNew子类的时候也就可以设置父类的对应参数了。

另外,如果将Construct方法定义为虚方法,并且每个类都先调用父类的Construct方法,可能会更好。(不过从C++语法上,着没办法直接实现,因为最基类的参数没法确定)。可能可以考虑,让Construct方法接受FArguments最基类的指针,然后每个SWidget子类,再把它强转回自定义的FArgument类(通过宏的方式)。


  1. C++求值顺序 ↩︎

  2. C++预算符优先级 ↩︎

你可能感兴趣的:(UE4)