# Unreal的C++入门

此文为Introduction to C++ Programming in UE4的原创翻译,本文内容版权归原文所有,仅供学习,如需转载望注本文地址,翻译不易,谢谢理解。

# Unreal的C++是极好的

这篇指南是关于如何在Unreal Engine中写C++代码,不要担心,在引擎中的C++编程是很有意思的,并没有想象的那么难!我们可以将Unreal中的C++认为是"辅助的C++",因为我们添加了很多特性,让每个人更容易上手C++。

在我们开始前,你需要熟悉C++或者其他一门编程语言,这篇文章会假定你有C++经验,但如果你知道C#,Java,或Javascript,你应该会感到亲切。

如果你完全没有编程经验,建议你去看下Blueprints Visual Scripting,你完全可以使用蓝图来创建一个完整的游戏。

在引擎里你是可以写"plain old C++ code",但在你学完这篇指南学习完Unreal编程模型的基础后,你会更成功,我们稍后会再讨论这一点。

# C++和蓝图

引擎提供了两种方法来创建gameplay中的元素,C++和可视化蓝图编程,程序员可以添加基础gameplay系统然后设计者再在这个基础上添加或者定制来创建关卡。在这些情况下,C++程序员可以在他们喜欢的IDE中工作(通常windows下是Visual Studio,苹果下是Xcode),然后设计师在Unreal Editor的蓝图编辑器中工作。

gameplay框架的API和类对于这两个系统都是可用的,可以分开使用,但是将它们结合在一起相互完善时才会展现真正的威力。它意味着程序员在引擎中用C++创建逻辑块,然后设计师在蓝图中很好地使用这些逻辑块。

也就是说,在这个工作流中,我们创建的类稍后会被设计师或程序员通过Blueprints扩展,更进一步,我们需要创建一些属性值,稍后设计者会改这些值,然后我们基于这些属性创建新的值,通过使用我们提供给你的工具和C++宏很容易实现这个过程。

# 类向导

我们在Unreal中要用的第一个东西是类向导,使用它来生成基础的C++类,这些类稍后会被Blueprints扩展,下面的图片展示了我们创建一个新的Actor的第一步。

ChooseParentClass

第二步是告诉向导我们要创建的类名。

NameNewActor

接下来类向导会生成该类的文件然后打开你的开发环境让你开始编辑。下面是一个为你生成好的类定义,如果想了解更多关于类向导的东西,请查看Managing Game Code

#include "GameFramework/Actor.h"
#include "MyActor.generated.h"

UCLASS()
class AMyActor : public AActor
{
    GENERATED_BODY()

public:
    // Sets default values for this actor's properties
    AMyActor();

    // Called every frame
    virtual void Tick( float DeltaSeconds ) override;

protected:
    // Called when the game starts or when spawned
    virtual void BeginPlay() override;
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

类向导生成的类重载了BeginPlay和Tick函数。BeginPlay告诉你Actor在游戏里进入了可玩状态,它是初始化该类游戏逻辑的好地方。Tick每帧都会调用一次,你可以在这放检查或者循环相关的逻辑。但是,如果这些逻辑不是必要的,最好为了性能移除它,如果要移除这个方法,确保将构造器里指示ticking运行的代码也一并移除。这段代码跟下面一样:

AMyActor::AMyActor()

{

    // Set this actor to call Tick() every frame.  You can turn this off to improve performance if you do not need it.

    PrimaryActorTick.bCanEverTick = true;

}
1
2
3
4
5
6
7
8
9

# 创建可以在编辑器中展示的属性

我们已经创建了类,现在我们需要创建一些属性,它们能够暴露在Unreal编辑器中被设计者设置。使用UPROPERTY说明符来将一个属性暴露给编辑器是很简单的,你需要做的所有事情是把UPROPERTY(EditAnywhere)放到你要暴露的属性声明的上面一行,就像下面在类中的那样:

UCLASS()
class AMyActor : public AActor
{
    GENERATED_BODY()
public:

    UPROPERTY(EditAnywhere)
    int32 TotalDamage;

    ...
};
1
2
3
4
5
6
7
8
9
10
11

这就是你需要做的所有事情,还有很多方法控制它应该如何被编辑,这是通过给UPROPERTY()传递更多的信息来完成的,比如,当你想要TotalDamage属性出现在与它相关的属性所属区域的时候,可以使用该指示器的分类特性,下面展示了它的用法:

UPROPERTY(EditAnywhere, Category="Damage")
int32 TotalDamage;
1
2

当用户要编辑这个属性的时候,它现在会出现在Damage类目下,这是一个很好的方法来将一些常用的设置一块给设计者。

现在让我们将同样的属性暴露给蓝图:

UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Damage")
int32 TotalDamage;
1
2

正如你看到的,这个指示器让该属性在蓝图可读写,还有一些单独的指示器比如BlueprintReadOnly,使用它会将该属性在蓝图中被当成常量。还有很多控制属性如何暴露给蓝图的选项,想要了解更多,请看Property Specifiers

在继续下面这个部分之前,让我们来给同样的类增加一对属性,一个属性控制这个actor输出的总伤害,一个属性表明这个伤害持续多久,下面的代码增加了设计者可以设置的属性和只读的属性。

UCLASS()
class AMyActor : public AActor
{
    GENERATED_BODY()
public:

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Damage")
    int32 TotalDamage;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Damage")
    float DamageTimeInSeconds;

    UPROPERTY(BlueprintReadOnly, VisibleAnywhere, Transient, Category="Damage")
    float DamagePerSecond;

    ...
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

DamageTimeInSeconds是设计者不能改变的属性,它的值是通过设计者设置的值计算出来的,VisibleAnywhere指示器标记这个属性是可视化的,但是不能在Unreal Editor中编辑,Transient指示器意味着它不会从磁盘保存或读取数据,它是推导出的,非持久性的值,所以没有必要来存储它,下面展示了UE中的结果:

DamageProperty

# 在构造器中设置默认值

在构造器中设置某个属性的默认值跟典型的C++类一样,下面是在构造器中设置默认值的两个例子,他们在函数功能性上是一样的:

AMyActor::AMyActor()
{
    TotalDamage = 200;
    DamageTimeInSeconds = 1.0f;
}

AMyActor::AMyActor() : TotalDamage(200),DamageTimeInSeconds(1.0f)
{
}
1
2
3
4
5
6
7
8
9

下面增加了默认值后UE4中的属性面板:

DamagePropertyWithDefault

为了类每个实例都支持设计者设置的属性,值可以从一个给定对象的某个实例加载,这个数据在构造器后被应用,通过挂钩到PostInitProperties()调用链里,你可以创建默认值,它基于设计者设置的值。下面就是这个过程的一个示例,在该例子里TotalDamage和DamageTimeInSeconds是设计者指定的值,即便是设计者指定的值,你也可以将它作为其他属性的默认值:

如果没有提供属性的默认值,引擎会自动地将属性值设置成0,对于指针类型设置成null。

void AMyActor::PostInitProperties()
{
    Super::PostInitProperties();
    DamagePerSecond = TotalDamage / DamageTimeInSeconds;
}
1
2
3
4
5

下面是增加上面的代码后展示的效果:

DamagePropertyWithSet

# 热加载

如果你习惯在其他项目里使用C++编程,你可能会对Unreal的一项酷的特性感到惊讶。你可以不用关掉编辑器而编译改动的C++改动后的代码!下面有两种方法来实现它:

  1. 在编辑器还在运行时,就像以前你在Visual Studio或Xcode中那样编译代码,编辑器会检测比较新的DLLs然后立刻重新加载。如果你附上了调试器,你需要首先分离开调试器,然后Visual Studio允许你重新编译。 HotReloadVS
  2. 或者,只是点击编辑器工具栏的Compile按钮。 CompileFromEditor

# 通过蓝图扩展C++类

到目前为止,我们用C++类向导创建了一个简单的gameplay类,然后增加了一些属性给设计者设置,现在让我们看下设计者怎么开始创建一个独特的类。

我们要做的第一件事就是从AMyActor类中创建一个新的蓝图类,注意下面的图片选中的基类名字是MyActor而不是AMyActor,这是编辑器有意对使用该工具的设计者隐藏命名转换,让命名对他们来说更友好。

PickParentClass

一旦你点击了Select,一个新的有默认命名的蓝图类就为你创建好了。在这种情况下,笔者设置该蓝图类名字为CustomActor1,就像你在下面Content Browser的截图中看到的那样。

CustomActor

这是第一个我们要让设计者定制的类,我们需要首先改变我们总伤害属性值,在这里,设计者将TotalDamage设成300,然后让这个伤害持续两秒,下面是它们的现有状态:

SetDamageProperty

注意我们被计算的属性值(DamagePerSecond)没有像预期那样被改变,它本应是150但它还是200的默认值,这可能是因为该属性被加载进程初始化后我们只是计算了该属性值,运行时的改变并没有反应到界面上,要解决这个问题,可能需要引擎通知编辑器中目标对象它的值已经被改变了,下面的这段代码展示了添加一个钩子,将被计算后的改动值反应在编辑器上。

void AMyActor::PostInitProperties()
{
    Super::PostInitProperties();

    CalculateValues();
}

void AMyActor::CalculateValues()
{
    DamagePerSecond = TotalDamage / DamageTimeInSeconds;
}

#ifdef WITH_EDITOR
void AMyActor::PostEditChangeProperty(FPropertyChangedEvent& PropertyChangedEvent)
{
    CalculateValues();

    Super::PostEditChangeProperty(PropertyChangedEvent);
}
#endif
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

需要注意的一件事是PostEditChangeProperty方法是在编辑器指示器#ifdef里面,所以在构建游戏时你只需游戏方面的代码,要移除任何额外的,不必要增加你执行文件大小的代码。当我们将上面的代码编译进来后,DamagePerSecond值就像下面图片是我们期望它的那样。

SetDamagePropertyRight

# 跨C++和蓝图调用函数

到目前为止,我们已经展示了如何将属性暴露给蓝图,但是在深入研究引擎前还有最后一个主题我们需要覆盖到,在gameplay系统的创建过程中,设计者需要有能力调用C++程序员写的函数,gameplay程序员也需要调用在蓝图中完善的C++代码。让我们先做一个CalculateValues()函数,它在蓝图中可以被调用,将一个函数暴露给蓝图和暴露属性一样简单,在函数声明前只需放一个宏!下面是这段代码:

UFUNCTION(BlueprintCallable, Category="Damage")
void CalculateValues();
1
2

UFUNCTION()宏处理了从C++函数到反射系统中的细节。BlueprintCallable选项意味着将该函数暴露给蓝图虚拟机,蓝图对每个要暴露给它的函数要求一个和它相关的分类,所以在蓝图单击右键,可以看到分类如何影响蓝图函数:

ExposeFunction

正如你看到的,这个函数在Damage分类中是可用的,下面的蓝图节点显示了如何在TotalDamage中改变值,并调用函数重新计算得到新值。

BPCalculate

同样地,它使用的是我们之前添加的函数来计算相关依赖的属性,很多引擎的东西通过UFUNCTION()宏暴露给蓝图,所以人们可以不用写C++代码来创建游戏,最好的实践是使用C++来构建基础gameplay系统,而比较吃性能且被蓝图用来定制和配合部分可以用C++代码块来创建。

既然我们设计者可以调用C++代码,让我们来探索更多跨C++/蓝图的方法。这个方法允许C++调用在蓝图中定义的函数,我们经常使用这个方法来让设计者以他们觉得合适的方法来设计事件,通常包含效果的产生或视觉效果,比如隐藏不隐藏一个Actor,下面的代码展示了被蓝图实现的函数:

UFUNCTION(BlueprintImplementableEvent, Category="Damage")
void CalledFromCpp();
1
2

这个函数和其他C++函数一样以同样的方式被调用,在后台,UE4生成一个基础C++函数实现,它能理解如何调用蓝图虚拟机,通常将它认为是C++和蓝图的转换程序,如果蓝图并没有提供该方法的函数体,那这个函数就像一个没有函数体的C++函数,它什么也不做。如果你想提供一个默认的C++实现同时还允许蓝图重写该方法,该怎么做呢?UFUNCTION()宏有一个选项,下面展示了它的用法:

UFUNCTION(BlueprintNativeEvent, Category="Damage")
void CalledFromCpp();
1
2

这个版本依旧生成了调用蓝图虚拟机转换程序,但应该如何提供默认的实现呢?这个工具也生成了一个和<function name>_Implementation()一样的函数声明。你必须提供这个函数否则项目会无法链接,下面是上面声明的实现代码:

void AMyActor::CalledFromCpp_Implementation()
{
    // Do something cool here
}
1
2
3
4

现在当蓝图没有重写该方法时就会调用这个版本的函数体。需要注意的是,在之前的版本的构建工具里,这种_Implementation()声明是自动生成的,但是在4.8或更高的版本里,你需要将这个显式的添加到头文件中。

既然我们已经了解了gameplay程序员常见的工作流程和与设计者合作扩展gameplay特性的方法,现在是时候开始你自己的冒险了,你可以选择是继续阅读来了解我们是在引擎中使用C++的还是到一个实例中来得到更多的实践经验。

# 深入探索

你继续和笔者深入探索这里,这很好!下一个要讨论的主题是关于gameplay框架层级的,在这部分,我们会了解代码块的基础以及它们是如何相互关联的。我们会看到Unreal Engine如何使用继承和组合来定制gameplay特性。

# Gameplay类

Gameplay中主要的类:Objects,Actors和Components,你会从多数gameplay类中继承四个主要类,它们是UObject,AActor,UActorComponent和UStruct,每部分都会在下面被解释。当然你也可以创建并不继承上面任何类的类型,但是它们不会使用任何引擎内置的特性。在UObject体系外面创建的类可以整合第三方库,包裹系统某些特性等等。

# UnrealObjects(UObject)

在Unreal中最基础的构建块被称为UObject,这个类和UClass结合,在引擎中提供了一系列基础的服务:

  • 属性和方法的反射
  • 属性的序列化
  • 垃圾回收机制
  • 通过名字找到UObjects
  • 属性的可配置值
  • 属性和方法的联网支持

每个继承UObject的类都有一个为它创建的单例类UClass,包含这所有类实例的元数据。UObject和UClass一起组成了一个gameplay对象在它生命周期中所有东西的基石。了解UClass和UObject之间差异的最好方法就是UClass描述了一个UObject实例是什么样的,哪些属性是可以序列化的,联网的,等等。大多gameplay开发并不直接继承UObject,而是继承自AActor和UActorComponent。为了写gameplay的代码你不需要知道UClass和UObject的工作细节,知道这些系统的存在即可。

# AActor

一个AActor是一个UObject,这意味着它是gameplay一部分,Actors或者会被设计者放置在一个关卡中或者会被通过gameplay系统创建。所有的对象可以通过继承这个类而被放置在关卡中,比如AStaticMeshActor,ACameraActor和APointLight等。因为AActor继承自UObject,它拥有上面列出的所有UObject标准特性。Actors可通过gameplay代码(C++或蓝图)被显式销毁或者在所属关卡从内存中卸载时被标准垃圾回收机制回收。Actors负责游戏对象在高层级上的行为,AActor也是能在联网时被复制的基本类型,在联网复制中,Actors可以给任何拥有的需要联网支持或同步的UActorComponents分发信息。

Actors有它们自己的行为(通过继承来具体化),但他们也是其他Actor Components(通过组合来具体化)层级的容器。这是通过RootComponent成员来完成的,该成员拥有一个单独的USceneComponent,它可以包含很多其他的。在一个Actor放入关卡之前,它必须至少包含一个SceneComponent,通过该组件Actor可以画它的位移,旋转和缩放。

Actors在它的生命周期会调用一系列的时间,下面是这些事件的简化集合,用来阐明Actors的生命周期。

  • BeginPlay:当Actor在gameplay中第一次实例化的时候调用。
  • Tick:在该Actor的每一帧调用一次。
  • EndPlay:在对象离开gameplay空间时被调用。

可以通过Actors来查看更多关于AActor类的详细信息。

# 运行时生命周期

像上面一样,我们讨论了Actor的生命周期的一个子集。对于放置在关卡中的Actors来说,很容易想象它的生命周期:Actors被加载然后被实例化,最后关卡被卸载,然后Actors被摧毁。生成一个actor要比在游戏中创建一个正常的对象要复杂点,为了满足各种运行时系统的需要,Actors需要被它们注册,需要设置Actor的初始位置和旋转,物理系统需要知道它,负责管理每帧运行的系统也需要知道它。因此我们有一个专门的方法来生成一个actor,SpawnActor,UWorld的成员,当Actors成功生成时,引擎会调用它的BeginPlay方法,紧接着下一帧会调用Tick方法。

一旦Actor完成了它的生命周期,你可以调用Destroy来摧毁它,在这个过程中,EndPlay会运行,可以在该Actor被垃圾回收之前让它去执行定制的逻辑。另一个控制Actor存在多久的选项就是使用Lifespan成员,你可以在Actor的构造器中设置时间或者在运行中的其他地方设置,一旦超过设定的时间,Actor会自动调用Destroy方法。

要了解更多关于生成Actor细节,请看Spawning Actors页面。

# UActorComponent

Actor组件(UActorComponent类)有它们自己的行为,常常负责跨很多Actors类型的函数功能,比如提供可视化网格,粒子效果,透视摄像机和物理交互。在Actor常被设定为和游戏中所有角色有关的高级目标时,它常常执行高级目标的单独任务。组件可以依附到其他组件上,或者成为Actor的根组件,一个组件只能依附到一个父组件或Actor上,但是它可以有很多依附到它自身的子组件,想想下一棵组件树,子组件有相对于他们的父组件或Actor的位移,旋转和缩放。

尽管有很多方法去使用Actors和Components,去思考Actor和Component之间的关系的一个方法是Actors可以回答"这是一个什么东西?"的问题,而Components可以回答"这个东西由什么组成?"的问题。

  • RootComponent-它是AActor的成员,用来存放Actor组件树的顶层组件。
  • Ticking-嵌套所属Actor的Tick函数称为其一部分的组件,确保在写你自己的Tick函数时调用Super::Tick。
# 了解第一人称角色控制器

为了详细解释AActor和UActorComponents的关系,我们深究下在你生成一个基于第一人称模板的新项目时所创建的蓝图。下面的图片是FirstPersonCharacter的Actor的组件树,其中RootComponentCapsuleComponent,依附到CapsuleComponent上面的是ArrowComponentMeshFirstPersonCameraComponent组件,其中依附到FirstPersonCameraComponent的叶子组件是Mesh1P组件,意味着第一人称的Mesh是相对于第一人称camera的。

FPCComponents

从外观上来讲,上面的组件树跟下面的图片一样,你可以在3D空间中看到除了Mesh组件的所有其他组件。

VisualComponent

这个组件树被依附到一个Actor类上,正如你在这个例子中看到的,你可以使用继承和组合来创建复杂的gameplay对象。在你想定制现有的AActor或UActorComponent时请使用继承,在你想共享很多不同AActor类型时请使用组合。

# UStruct

要使用一个UStruct,你不得不从任何特定类中扩展,你只需要使用USTRUCT()来标记struct然后我们的构建工具会自动为你做好基础工作。不像一个UObject对象,UStructs不会被垃圾回收。如果你创建它们动态的实例,你必须自己管理好它们的生命周期。UStructs意味着使用基础数据类型,UObject反射支持这些类型在Unreal Editor,Blueprint操作,序列化,联网中等编辑。

现在我们已经讨论了gameplay类构成的基础结构,是时候再一次决定你的路径了,你可以阅读更多关于gameplay类的东西,去实例中看看获取更多消息,或者继续深挖C++构建游戏的特性。

# 再次深入研究

好的,看来你想知道的更多,让我们深入研究下看看引擎是如何工作的

# Unreal反射系统

Blog Post: Unreal Property System (Reflection)

Gameplay类会使用特殊的标记,所以在我们复习它们之前,让我们来回一下Unreal属性系统的基础,UE4使用自己的实现的反射系统,激活了一些动态特性,比如垃圾回收,序列化,联网复制,蓝图和C++的沟通。这些特性是可选的,意味着你得给自己的类型添加一些正确的标记,否则Unreal将会忽略他们且不会为他们生成反射数据,下列是关于基础标记的快速浏览:

  • UCLASS()-用来告诉Unreal为类生成反射数据,这个类必须继承自UObject。
  • USTRUCT()-用来告诉Unreal为一个结构体生成反射数据。
  • GENERATED_BODY()-UE4将这句话替换成为这个类型生成所必要的样板代码。
  • UPROPERTY()-使一个UCLASS或USTRUCT的成员变量被标记为UPROPERTY,它有很多用法,可以让变量被复制,序列化,被蓝图获取,也可以被垃圾回收机制用来记录有多少指向一个UObject的引用。
  • UFUNCTION()-让一个UCLASS或USTRUCT的成员方法被标记为UFUNCTION,一个UFUNCTION允许类的方法被蓝图调用,作为RPCs使用或作为其他用途使用。

下面是一个UCLASS类声明的实例:

#include "MyObject.generated.h"

UCLASS(Blueprintable)
class UMyObject : public UObject
{
    GENERATED_BODY()

public:
    MyUObject();

    UPROPERTY(BlueprintReadOnly, EditAnywhere)
    float ExampleProperty;

    UFUNCTION(BlueprintCallable)
    void ExampleFunction();
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

你会首先注意到"MyClass.generated.h"的包含,Unreal会生成所有的反射数据然后放入这个文件中,在声明类型的头文件中你应该把这个包含作为最后一个包含。

这个例子中UCLASS,UPROPERTY和UFUNCTION标记包含很多其他指示器,他们不是必要的,下面是一些常见的指示器用于示范目的,他们允许我们指定某些行为或属性。

  • Blueprintable-这个类可被蓝图扩展。
  • BlueprintReadOnly-这个属性可被蓝图读取,但是不能写。
  • EditAnywhere-这个属性可被合适窗口编辑,在原型和实例上都可以。
  • Category-定义这个属性在编辑器的Details面板应该出现的区域,这有助于按组织意图来安排属性。
  • BlueprintCallable-这个函数可以被蓝图调用。

下面还列出很多其他指示器,可以查看相应的连接:

# Object/Actor迭代器

对象迭代器是一个很有用的工具,用来迭代某个特定UObject类型和它子类的所有实例。

// Will find ALL current UObject instances
for (TObjectIterator<UObject> It; It; ++It)
{
    UObject* CurrentObject = *It;
    UE_LOG(LogTemp, Log, TEXT("Found UObject named: %s"), *CurrentObject->GetName());
}
1
2
3
4
5
6

你可以通过给迭代器提供一个具体类型来限制搜索的范围。假定你有一个继承UObject命名为UMyClass的类,你可以像下面这样找到该类(或继承该类的所有子类)的所有实例:

for (TObjectIterator<UMyClass> It; It; ++It)
{
    // ...
}
1
2
3
4

在PIE(Play In Editor)中使用对象迭代器会导致意料之外的结果,因为编辑器被加载了,对象迭代器除了会返回所有为游戏世界实例创建的UObjects外,还有那些只被编辑器使用的UObject。

Actor迭代器和Object迭代器一样,但只应用于继承自AActor的对象。Actor没有上面提到的问题,只会返回被当前游戏世界实例使用的对象。

当创建一个Actor迭代器时,你需要给它一个指向UWorld实例的指针,很多UObject类,例如APlayerController,提供GetWorld()来帮助你做到这点。如果你不确定,你可以检查UObject的ImplementsGetWorld方法来看它是否实现了GetWorld方法。

APlayerController* MyPC = GetMyPlayerControllerFromSomewhere();
UWorld* World = MyPC->GetWorld();

// Like object iterators, you can provide a specific class to get only objects that are
// or derive from that class
for (TActorIterator<AEnemy> It(World); It; ++It)
{
    // ...
}
1
2
3
4
5
6
7
8
9

因为AActor继承UObject,你可以使用TObjectIterator来找到AActors的实例,只是在PIE中小心!

# 内存管理和垃圾回收

在这部分我们会复习UE4中基本的内存管理和垃圾回收系统。

Wiki: Garbage Collection & Dynamic Memory Allocation

# UObjects和垃圾回收

UE4使用反射系统来完成垃圾回收系统,在垃圾回收系统管理下,你不用非得手动删除你的UObjects,你只需要维护他们的有效引用,你的类为了使用垃圾回收需要继承自UObject,下面是我们使用的简单例子:

UCLASS()
class MyGCType : public UObject
{
    GENERATED_BODY()
};
1
2
3
4
5

在垃圾回收机制里,一个概念称为root set,它是回收器知道不会被垃圾回收的一系列对象,一个对象只要它有到root set中一个对象的引用路径就不会被垃圾回收,如果没有这样的路径,那它被称作不可达,然后在下次垃圾回收运行的时候被回收,引擎会以某个固定的间隔来运行垃圾回收。

什么才算一个"引用"?任何存储在UPROPERTY中的UObject指针,让我们看下这个例子:

void CreateDoomedObject()
{
    MyGCType* DoomedObject = NewObject<MyGCType>();
}
1
2
3
4

当我们调用上面的函数时,我们会创建一个新的UObject,但是我们不能在UPROPERTY中存储任何指向该物体的指针,它并不是root set的一部分,最终垃圾回收会检测到这个object是不可达的然后销毁它。

# Actors和垃圾回收

Actors常常不会被垃圾回收,除了在关卡的关闭期间,一旦生成了,你必须手动调用他们的Destroy或在关卡没有结束时将它们从关卡移除,它们不会被直接删除,而是在下一次垃圾回收时清除。

这是一个比较常见的场景,在这里你拥有的Actors有UObject属性:

UCLASS()
class AMyActor : public AActor
{
    GENERATED_BODY()

public:
    UPROPERTY()
    MyGCType* SafeObject;

    MyGCType* DoomedObject;

    AMyActor(const FObjectInitializer& ObjectInitializer)
        : Super(ObjectInitializer)
    {
        SafeObject = NewObject<MyGCType>();
        DoomedObject = NewObject<MyGCType>();
    }
};

void SpawnMyActor(UWorld* World, FVector Location, FRotator Rotation)
{
    World->SpawnActor<AMyActor>(Location, Rotation);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

当我们调用上面的函数时,我们会生成一个Actor进入世界,这个Actor的构造器创建了两个对象,一个被分配为UPROPERTY,另一个是空的指针。因为Actors自动是root set的一部分,SafeObject不会被垃圾回收,因为它能从root set中的object可达。但是对于DoomedObject就不会这样了,我们没有将它标记为UPROPERTY,所以回收器并不知道它被引用了,因此最终会销毁它,留下一个悬空指针。

当一个UObject被垃圾回收了,所有的UPROPERTY引用最后会被设成null,这让你更安全地检查一个object是否已经被垃圾回收。

if (MyActor->SafeObject != nullptr)
{
    // Use SafeObject
}
1
2
3
4

这是很重要的,因为就像之前提出来的,Actors已经调用了Destroy因此会在下次垃圾回收运行时清除,你可以检查IsPendingKill方法来查看一个UObject是否等待被删除,如果这个方法返回true,你可以认为这个对象已经被回收就不要再使用它了。

# UStructs

就像上面说的,UStructs是一个简化版的UObject,比如UStructs不能被回收,如果你必须要使用UStructs动态实例,你可能需要使用更聪明的指针,稍后我们会讲到。

# Non-UObject引用

正常来讲,non-UObjects可以通过增加一个对象的引用然后阻止垃圾回收,为了做这点,你的对象必须继承FGCObject然后重写它的AddReferencedObjects类。

class FMyNormalClass : public FGCObject
{
public:
    UObject* SafeObject;

    FMyNormalClass(UObject* Object)
        : SafeObject(Object)
    {
    }

    void AddReferencedObjects(FReferenceCollector& Collector) override
    {
        Collector.AddReferencedObject(SafeObject);
    }
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

我们使用FReferenceCollector来手动地为我们不希望被垃圾回收的UObject对象增加一个引用,当目标对象被删除然后它的析构函数运行时,这个对象会自动清除它添加的所有引用。

# 类的命名前缀

Unreal引擎提供了在构建过程中自动为你生成代码的工具,这些工具有一些类命名约定,如果命名不符合预期的约定,就会触发警告或错误。下面描述了这些工具对于类前缀的约定:

  • 继承Actor的以A为前缀,比如AController。
  • 继承Object的以U为前缀,比如UComponent。
  • 枚举以E为前缀,比如EFortificationType。
  • 接口类以I为前缀,比如IAbilitySystemInterface。
  • 模板类以T为前缀,比如TArray。
  • 继承SWidget(Slate UI)的类以S为前缀,比如SButton。
  • 其他的类都以F为前缀,比如FVector。

# 数字类型

因为不同的平台上不同的基础类型(比如short,int和long)有不一样的大小,UE4提供了下面的类型,你可以作为备用方案使用:

  • int8/uint8:8位符号/无符号整数
  • int16/uint16:16位符号/无符号整数
  • int32/uint32:32位符号/无符号整数
  • int64/uint64:64位符号/无符号整数

浮点数被标准float(32位)和double(64位)类型支持。

Unreal有一个模板,TNumericLimits<t>,这个模板用来找到一个类型能拥有的最小最大值,如果想了解更多,请看这里

# Strings

UE4为处理strings提供了几种不用的类,使用哪个取决于你的需求。

Full Topic: String Handling

# FString

FString是可变字符串,类似于std::string,FString有很多方法,让处理字符串更简单,为了创建一个新的FString,请使用TEXT()宏:

FString MyStr = TEXT("Hello, Unreal 4!");
1

Full Topic: FString API

# FText

FText和FString相似,但它是为了本地化的文本,为了创建一个新的FText,使用NSLOCTEXT宏,这个宏使用默认的命名空间,键和默认语言的键值。

FText MyText = NSLOCTEXT("Game UI", "Health Warning Message", "Low Health!")
1

你可以使用LOCTEXT宏,需要在每个文件定义一个命名空间,确保在你文件底部取消定义它。

// In GameUI.cpp
#define LOCTEXT_NAMESPACE "Game UI"

//...
FText MyText = LOCTEXT("Health Warning Message", "Low Health!")
//...

#undef LOCTEXT_NAMESPACE
// End of file
1
2
3
4
5
6
7
8
9

Full Topic: FText API

# FName

一个FName存储了常用反复出现的字符串作为标识符,用以在比较它们时节省内存和CPU时间,面对每个引用它的对象,不再是多次存储完整的字符串,一个FName使用一个更小的存储指纹在map中标识给定的字符串,这样便只会存储一次该字符串的内容,当该字符串被很多objects引用时便节省了内存,通过检查NameA.Index和NameB.Index是否相等来快速比较两个字符串,不用再一个个检查string中的每个字符是否相等。

Full Topic: FName API

# TCHAR

TCHARs是一种独立于字符集存储字符的方法,每个平台都不太一样,实质上,UE4使用TCHAR数组以UTF-16的编码方式来存储数据,你可以通过重载废弃的返回TCHAR的操作符来获取这些原数据。

Full Topic: Character Encoding

这对一些函数是必要的,比如FString::Printf,这它里面'%s'字符串格式指示器就期望是一个TCHAR类型的字符而不是FString类型的字符。

FString Str1 = TEXT("World");
int32 Val1 = 123;
FString Str2 = FString::Printf(TEXT("Hello, %s! You have %i points."), *Str1, Val1);
1
2
3

FChar类型提供一系列静态工具函数来处理单独的TCHARs。

TCHAR Upper('A');
TCHAR Lower = FChar::ToLower(Upper); // 'a'
1
2

注意FChar类型被定义成TChar<TCHAR>,它在这个API中列出来了

Full Topic: TChar API

# 容器

容器是一堆存储数据集合的类,最常见的容器类是TArray,TMap和TSet,每个都是动态大小的,他们可以变成你想要的任意大小。

Full Topic: Containers API

# TArray

在这三个容器中你会首选使用的就是UE4中的TArray,它就跟std::vector类一样,但是提供了更多了函数,下面是一些常见的操作:

TArray<AActor*> ActorArray = GetActorArrayFromSomewhere();

// Tells how many elements (AActors) are currently stored in ActorArray.
int32 ArraySize = ActorArray.Num();

// TArrays are 0-based (the first element will be at index 0)
int32 Index = 0;
// Attempts to retrieve an element at the given index
AActor* FirstActor = ActorArray[Index];

// Adds a new element to the end of the array
AActor* NewActor = GetNewActor();
ActorArray.Add(NewActor);

// Adds an element to the end of the array only if it is not already in the array
ActorArray.AddUnique(NewActor); // Won't change the array because NewActor was already added

// Removes all instances of 'NewActor' from the array
ActorArray.Remove(NewActor);

// Removes the element at the specified index
// Elements above the index will be shifted down by one to fill the empty space
ActorArray.RemoveAt(Index);

// More efficient version of 'RemoveAt', but does not maintain order of the elements
ActorArray.RemoveAtSwap(Index);

// Removes all elements in the array
ActorArray.Empty();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

TArrays增加被垃圾回收机制回收的功能,但是这假定TArray是被标记为UPROPERTY,它会存储UObject派生的指针。

UCLASS()
class UMyClass : UObject
{
    GENERATED_BODY();

    // ...

    UPROPERTY()
    TArray<AActor*> GarbageCollectedArray;
};
1
2
3
4
5
6
7
8
9
10

我们会在下一部分更深入讨论垃圾回收功能:

Full Topic: TArrays

Full Topic: TArray API

# TMap

TMap是一系列键值对的集合,和std::map相似,TMap有比较快的方法,用于查找,基于键增加,移除元素,你可以使用任何类型的键,只要它有一个定义好的GetTypeHash函数,我们稍后讨论这个函数。

让我们假定你正在创建一个基于棋盘格的桌游,你需要存储和检索哪个格子上有什么,要做到这点一个TMap会给你提供一个简单的方法,如果你的棋盘格比较小而且总是同样的尺寸,很显然有更有效的方法来做到这点,但现在让我们单纯只为使用TMap来实现它吧。

enum class EPieceType
{
    King,
    Queen,
    Rook,
    Bishop,
    Knight,
    Pawn
};

struct FPiece
{
    int32 PlayerId;
    EPieceType Type;
    FIntPoint Position;

    FPiece(int32 InPlayerId, EPieceType InType, FIntVector InPosition) :
        PlayerId(InPlayerId),
        Type(InType),
        Position(InPosition)
    {
    }
};

class FBoard
{
private:

    // Using a TMap, we can refer to each piece by its position
    TMap<FIntPoint, FPiece> Data;

public:
    bool HasPieceAtPosition(FIntPoint Position)
    {
        return Data.Contains(Position);
    }
    FPiece GetPieceAtPosition(FIntPoint Position)
    {
        return Data[Position];
    }

    void AddNewPiece(int32 PlayerId, EPieceType Type, FIntPoint Position)
    {
        FPiece NewPiece(PlayerId, Type, Position);
        Data.Add(Position, NewPiece);
    }

    void MovePiece(FIntPoint OldPosition, FIntPoint NewPosition)
    {
        FPiece Piece = Data[OldPosition];
        Piece.Position = NewPosition;
        Data.Remove(OldPosition);
        Data.Add(NewPosition, Piece);
    }

    void RemovePieceAtPosition(FIntPoint Position)
    {
        Data.Remove(Position);
    }

    void ClearBoard()
    {
        Data.Empty();
    }
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65

Full Topic: TMaps

Full Topic: TMap API

# TSet

一个TSet是一堆不重复值的集合,就像std::set,尽管TArray也有AddUnique和Contains这样支持像集合的方法,TSet能在这些操作上实现更快而且可防止添加重复的值。

TSet<AActor*> ActorSet = GetActorSetFromSomewhere();

int32 Size = ActorSet.Num();

// Adds an element to the set, if the set does not already contain it
AActor* NewActor = GetNewActor();
ActorSet.Add(NewActor);

// Check if an element is already contained by the set
if (ActorSet.Contains(NewActor))
{
    // ...
}

// Remove an element from the set
ActorSet.Remove(NewActor);

// Removes all elements from the set
ActorSet.Empty();

// Creates a TArray that contains the elements of your TSet
TArray<AActor*> ActorArrayFromSet = ActorSet.Array();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

Full Topic: TSet API

# 容器迭代器

使用容器迭代器,你可以快速迭代容器中的每个元素,下面是使用TSet容器时一个迭代器语法的示例:

void RemoveDeadEnemies(TSet<AEnemy*>& EnemySet)
{
    // Start at the beginning of the set, and iterate to the end of the set
    for (auto EnemyIterator = EnemySet.CreateIterator(); EnemyIterator; ++EnemyIterator)
    {
        // The * operator gets the current element
        AEnemy* Enemy = *EnemyIterator;
        if (Enemy.Health == 0)
        {
            // 'RemoveCurrent' is supported by TSets and TMaps
            EnemyIterator.RemoveCurrent();
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

你也可以使用迭代器所支持的其他操作:

// Moves the iterator back one element
--EnemyIterator;

// Moves the iterator forward/backward by some offset, where Offset is an integer
EnemyIterator += Offset;
EnemyIterator -= Offset;

// Gets the index of the current element
int32 Index = EnemyIterator.GetIndex();

// Resets the iterator to the first element
EnemyIterator.Reset();
1
2
3
4
5
6
7
8
9
10
11
12

# For-each循环

迭代器是不错的,但假如你只想迭代一次每个元素,可能会显的稍微有些笨拙,因此每个容器类都支持一个for each风格的循环来迭代元素。TArray和TSet返回每个元素,而TMap返回一个键值对。

// TArray
TArray<AActor*> ActorArray = GetArrayFromSomewhere();
for (AActor* OneActor : ActorArray)
{
    // ...
}

// TSet - Same as TArray
TSet<AActor*> ActorSet = GetSetFromSomewhere();
for (AActor* UniqueActor : ActorSet)
{
    // ...
}

// TMap - Iterator returns a key-value pair
TMap<FName, AActor*> NameToActorMap = GetMapFromSomewhere();
for (auto& KVP : NameToActorMap)
{
    FName Name = KVP.Key;
    AActor* Actor = KVP.Value;

    // ...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

记住auto关键字并不会自动指定指针或引用,在前面的例子中,你需要在使用auto时添加合适的符号。

# 对TSet/TMap使用自定义类

TSet和TMap内部都使用hash函数,大部分你存储在一个TSet和TMap的键中UE4类型都已经定义了他们自己的hash函数。如果你创建自己的类,想要在TSet中或者在TMap的键中使用的话,你需要提供一个hash函数,这个函数会使用一个你自定义类型的常量指针或引用,然后返回一个uint32,这个返回值就是这个对象的hash值,是识别该object的唯一标识,这意味着你自定义类的两个对象是被认为相等的,并总是返回一样的hash值。

class FMyClass
{
    uint32 ExampleProperty1;
    uint32 ExampleProperty2;

    // Hash Function
    friend uint32 GetTypeHash(const FMyClass& MyClass)
    {
        // HashCombine is a utility function for combining two hash values.
        uint32 HashCode = HashCombine(MyClass.ExampleProperty1, MyClass.ExampleProperty2);
        return HashCode;
    }

    // For demonstration purposes, two objects that are equal
    // should always return the same hash code.
    bool operator==(const FMyClass& LHS, const FMyClass& RHS)
    {
        return LHS.ExampleProperty1 == RHS.ExampleProperty1
            && LHS.ExampleProperty2 == RHS.ExampleProperty2;
    }
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

现在,TSet<FMyClass>和TMap<FMyClass, ...>在计算哈希值时将使用合适hash函数,如果你使用自定义类指针(比如TSet<FMyClass*>)作为键时也是得实现uint32 GetTypeHash(const FMyClass* MyClass)方法。

Blog Post: UE4 Libraries You Should Know About