UE 的 module 是一堆 C 类和代码的集合,类似于 DLL,而 UE 本身也是由一堆 module 构成的。将代码拆分为 module 的目的是:
- 封装和功能分离
- 方便重用代码
- 降低编译链接时间,减小编译结果体积
- 允许控制加载 module 的时机
目录结构 #
假设需要创建一个名为 FooBar
的 module,那么首先需要在工程的 Source 目录下创建一个与 module 同名的目录(此处为 FooBar),然后在该目录下创建一个 FooBar.Build.cs 文件,大致会有如下的目录结构:
ProjectFolder
└── Source
└── FooBar <----------------- module 对应目录
├── FooBar.Build.cs <---- 构建描述文件
├── Private <------------ 私有文件目录
└── Public <------------- 公开文件目录
YourModuleName.Build.cs 文件的功能:
- 声明 module 的构建方式
- 定义 module 的依赖
- ……
UE 基于 C# 实现了名为 UBT(Unreal Build Tool)的工具完成构建流程。UBT 根据 .Target.cs 文件和 .Build.cs 文件对一个工程进行构建。它不依赖于特定平台或特定 IDE 的构建描述(如 Visual Studio 的 .sln 文件),因为 UE 要支持不同平台的编译。
在修改或移动 .Build.cs 文件后,最好重新生成一下 IDE 的 Solution 文件,以便 IDE 能同步到最新构建信息,有几种方法可以生成 IDE 的 Solution 文件:
- 执行 GenerateProject.bat 脚本
- 右键点击 .uproject 文件,然后选择「Generate xxx Project Files」
- 在 UE 编辑器的菜单中选择 File - Refresh xxx Project
最简单的 .Build.cs 文件:
代码语言:javascript复制using UnrealBuildTool;
public class FooBar : ModuleRules {
public FooBar(ReadOnlyTargetRules Target) : base(Target) {
PublicDependencyModuleNames.AddRange(new string[] {
// UE 的 Engine 包含了一些常用的内容,例如 AActor
"Engine" });
PrivateDependencyModuleNames.AddRange(new string[] {
// UE 的 Core module 包含了处理 module 的代码,所以至少要包含它
"Core" });
}
}
这里 PrivateDependencyModuleNames
用于声明只在该 module 内部依赖的 module,而 PublicDependencyModuleNames
则用于声明在该 module 对外暴露的接口中依赖到的 module,这个依赖关系会被依赖该 module 的 module 继承,这个关系和 C 类继承中的 private
与 public
关键字的区别类似。例如本 module 对外暴露了一个继承自 AActor
的类型,由于 AActor
被定义在 Engine
module 中,因此这里需要将 Engine
添加到 PublicDependencyModuleNames
中。
实现 Module #
注意到上面的目录中源码文件被分别放在两个目录下,一个 Public 一个 Private,其中,需要被其他 module include 的头文件放在 Public 目录下,其他文件(包括源码文件和本 module 私有的头文件)都放在 Private 目录下。
代码语言:javascript复制ProjectFolder
└── Source
└── FooBar
├── FooBar.Build.cs
├── Private
│ └── FooBarModule.cpp
└── Public
└── FooBarModule.h
在一个 Unreal 工程新建时会自动创建一个主游戏 module,这个 module 不会被别的 module 依赖,因此一般也不会区分 Public/Private 目录。
每个 module 至少要包含一个 module 对应的 .h 和 .cpp 文件,用于实现对应 module,这里的文件名可以是任意的,但一般会选用 YourModuleName.h/cpp 或 YourModuleNameModule.h/cpp 这样的形式,例如这里的 FooBarModule.h 和 FooBarModule.cpp。
其中 FooBarModule.h 中大致会有如下的内容:
代码语言:javascript复制#include "Modules/ModuleManager.h"
class FFooBarModule : public IModuleInterface {
// 可以通过 override 生命周期回调触发自定义逻辑,如:
// virtual void StartupModule() override;
// virtual void ShutdownModule() override;
// ...
};
这里声明了一个 FFooBarModule
类继承了 IModuleInterface
类。前面提到 Core
module 包含了处理 module 的代码,所以任何 module 的依赖中至少要包含它,这里 include 的 ModuleManager.h 文件在就是 Core
module 中的一部分。
而在 FooBarModule.cpp 中,则需要至少包含一个类似这样的实现:
代码语言:javascript复制#include "FooBarModule.h"
IMPLEMENT_MODULE(FFooBarModule, FooBar)
这里 IMPLEMENT_MODULE
宏的第一个参数是对应的类,第二个参数是 module 名。对于游戏 module 和主游戏 module,这里则改为使用 IMPLEMENT_GAME_MODULE
和 IMPLEMENT_PRIMARY_GAME_MODULE
。
如果其他 module 想使用这个 module 类中的方法,可以使用 FModuleManager
获取对应 module 类对象的引用:
FModuleManager::Get().LoadModuleChecked<FFooBarModule>(TEXT("FooBar")).SomeMethod();
暴露接口 #
类似于 DLL,module 对外暴露的接口都需要进行额外声明,否则默认情况下并不会暴露出去。例如下面是 NicknamedActor.h 中的代码:
代码语言:javascript复制#pragma once
#include "GameFramework/Actor.h"
#include "CoreMinimal.h"
#include "NicknamedActor.generated.h"
UCLASS(Blueprintable)
class ANicknamedActor : public AActor {
GENERATED_BODY()
public:
UPROPERTY(EditAnywhere, BlueprintReadWrite)
FString Nickname;
UFUNCTION(BlueprintCallable)
void SayNickname();
};
它的声明和实现在目录中的位置如下:
代码语言:javascript复制ProjectFolder
└── Source
└── FooBar
├── FooBar.Build.cs
├── Private
│ ├── NicknamedActor.cpp
│ └── FooBarModule.cpp
└── Public
├── NicknamedActor.h
└── FooBarModule.h
此时,如果其他 module 添加了 FooBar
的依赖,那么它能在代码中访问到 ANicknamedActor
。但仍然无法链接到 ANicknamedActor
中的方法。为了将其中的方法暴露给其他 module,需要手动声明。
如果仅仅是想将类型信息暴露出去,那么可以在 UCLASS
声明中加 MinimalAPI
:
UCLASS(Blueprintable, MinimalAPI)
class ANicknamedActor : public AActor { /* ... */ };
这样一来,其他 module 可以:将别的类型转换到该类型;Spawn 该类型的对象;继承该类型;使用该类型中的内联函数。
在此基础上,如果想额外暴露一些方法出去,则需要在对应方法声明前添加 API 声明,形式为 YOURMODULENAME_API
,这个宏是 Unreal 自动为每个 module 生成的。例如这里就是 FOOBAR_API
:
UCLASS(Blueprintable, MinimalAPI)
class ANicknamedActor : public AActor {
// ...
UFUNCTION(BlueprintCallable)
FOOBAR_API void SayNickname();
};
当然,一种更为常见的情况是我们直接将这整个类型的信息都暴露出去,此时可以将 API 声明放到类型名前面,例如:
代码语言:javascript复制UCLASS(Blueprintable)
class FOOBAR_API ANicknamedActor : public AActor {
// ...
};
添加 Module 描述 #
每一个 module 都需要在工程的 meta 文件中进行声明,对于游戏工程而言是在 .uproject 文件中,对于插件而言是在 .uplugin 文件中。这两个 meta 文件都是 JSON 格式的,module 相关的描述在 Module
字段下:
{
// Other meta data...
"Module": [
{
"Name": "FooBar",
"Type": "Runtime",
"LoadingPhase": "Default"
},
// Other modules ...
]
}
显然,这里的 Name
字段对应 module 的名字。这里的 Type
字段对应 module 被加载的环境,最常用的就是 Runtime
和 Editor
分别对应运行时(包括编辑器)和仅编辑器,完整列表可以参考 EHostType::Type 的内容。而 LoadingPhase
则对应于 module 的加载阶段,最常见的是 Default
,如果是写一个包含 shader 的 module,则会用到 PostConfigInit
这个阶段,因为 Unreal 需要在引擎初始化前加载 shader,完整列表可以参考 ELoadingPhase::Type。
简化 Module 操作 #
新建一个 module 的过程其实相当麻烦,一般为了避免出错,会复制一个现成的 module,然后将内容删除再将名字改掉,不仅麻烦还容易出错,这里实现了一个简单的小工具,可以简化这个过程。项目地址在 urem,这是个命令行工具,可以用 go install
命令安装。
先将 urem.exe
所在的位置加入 PATH
,然后就可以执行如下命令新建一个 module 了:
# 新建一个属于工程的 module
urem.exe new mod ModuleName YourPorjectRoot/Source
# 新建一个属于插件的 module
urem.exe new mod ModuleName YourPorjectRoot/Plugins/YourPlugin/Source
这个过程会在 Source 目录下新建一个以 ModuleName 为名的目录,其中包含 ModuleName.Build.cs 文件,以及对应的 ModuleNameModule.h/cpp 文件。另外,为了方便日志打印,还会新建 Log.h/cpp 文件,包含 log category 的声明。在新建文件后,会自动刷新 .sln 工程。
除了新建 module 之外,这个工具还集成了一堆便利的小功能,比如刷新 .sln 工程,刷新 clangd 工程,新增 gitignore 模板、clang-format 模板等。使用 urem.exe --help
查看帮助。
参考资料 #
- UE4 Modules - Ari Arnbjörnsson