UE Subsystem详解

  • UEngine* GEngine;
  • UEditorEngine* GEditor;
  • UGameInstance* GameInstance;
  • UWorld* World;
  • ULocalPlayer* LocalPlayer;
  • UEngineSubsystem
  • UEditorSubsystem
  • UGameInstanceSubsystem
  • UWorldSubsystem
  • ULocalPlayerSubsystem

Subsystems是一套可以定义自动实例化和释放的类的框架。这个框架允许你从5类里选择一个来定义子类(只能在C++)中。

定义后可享受的福利:

  • 无需手动创建与释放。
  • 无需显式定义变量。
  • 有Subsystems已定义好的方便友好的访问接口。
  • 5个父类即为五个不同的生命周期。
  • Initialize()和Deinitialize()会根据父类不同,自动在合适的时机被调用。
  • 无需操心当一个Subsystem类型创建的的多个实例时的繁琐逻辑。

在谈到为什么需要Subsystems以及如何使用好Subsystems之前,我们先来了解一些Subsystems的基础实用知识。

首先,Subsystems只允许在C++端使用。

第一步,定义C++子类:

定义C++子类(点击展开/折叠代码块)
// 声明定义
UCLASS()
class ISS_API UMyEditorSubsystem : public UEditorSubsystem
UCLASS()
class ISS_API UMyEngineSubsystem : public UEngineSubsystem
UCLASS()
class ISS_API UMyGameInstanceSubsystem : public UGameInstanceSubsystem
UCLASS()
class ISS_API UMyWorldSubsystem : public UWorldSubsystem
UCLASS()
class ISS_API UMyLocalPlayerSubsystem : public ULocalPlayerSubsystem

// 注:使用EditorSubsystem需要在项目的Build.cs里面添加对于EditorSubsystem模块的引用
if (Target.bBuildEditor)
{
    PublicDependencyModuleNames.AddRange(new string[] { "EditorSubsystem" })
}

第二步,像普通的UObject类一样,可以在里面自定义属性和函数:

在自创建的Subsystem中自定义属性和函数(点击展开/折叠代码块)

第三步,可以在C++和蓝图里访问这些类和调用函数了。

在C++中访问(点击展开/折叠代码块)

在蓝图中访问(点击展开/折叠图片)

可以说Subsystems机制的核心之处就在于引擎帮助托管了对象的生命周期。

Subsystem对象的生命周期简短的归纳有以下几点:

  • 取决于其依存的Outer对象的生命周期,即随着Outer对象的创建而创建,随着Outer对象的销毁而销毁。
  • 选择依存哪种Outer对象,就是选择哪种Subsystem生命周期,靠的就是选择继承于哪个Subsystem基类。
从数据角度分析,一图概之(点击展开/折叠图片)

从源码中分析,以5类对象中的GEngine而言,里面有一个 FSubsystemCollection<TBaseType> SubsystemCollection 对象,而其基类 FSubsystemCollectionBase 里存储了对Subsystem对象的引用。

GEngine中关于Subsystem的部分源码(点击展开/折叠代码块)

五类Outer对象中都有一个 FSubsystemCollection<TBaseType> SubsystemCollection 的成员变量,用来存储其关联的Subsystem对象。

以常用的UGameInstance来举例,假如用户自定了UScoreSubsystem(计分系统)和UTaskSubsystem(任务系统) 两个系统,全都继承于UGameInstanceSubsystem后,对象的布局应该是如下图所示:

对象的布局(点击展开/折叠图片)

可以看到在GameInstance里面的FSubsystemCollection对象存储了生成的UScoreSubsystem和UTaskSubsystem对象的引用,且这两者其Outer都是指向GameInstance对象。

数据内存结构比较简单,但是也有一些要点:

  • FSubsystemCollectionBase继承于FGCObject,这说明虽然FSubsystemCollection是个结构,且在GameInstance里面,但其内部的对象引用也是受到GC管理的。FGCObject是一个让F结构也可以被GC管理内部U对象引用的机制。
  • FSubsystemCollectionBase里的UObject* Outer,指向外部的UGameInstance对象。这个Outer可以用来在USubsystem::ShouldCreateSubsystem(UObject* Outer) 或 USubsystem::Initialize(FSubsystemCollectionBase& Collection)操作USubsystem对象的创建的时候,在USubsystem对象创建之前获取到外部Outer,从而继续获取到其他的兄弟Subsystem对象,从而做出一些判断逻辑。当然创建完之后,因为USubsystem对象的Outer其实也为UGameIntance,所以直接GetOuter() 就可以。

从TMap的Key为TSubclassOf可以看出,一种特定类型的USubsystem子类只能创建出一个USubsystem对象。所以UScoreSubsystem和UTaskSubsystem可以同时存在,但一种也只能有一个,类似单例模式。如查看UGameInstanceSubsystem的源码(其他同理):

UGameInstanceSubsystem的定义(点击展开/折叠代码块)

解释一下定义中两个重要的宏标记:

  • Abstract:指明UGameInstanceSubsystem是抽象基类,是不能被直接创建出来的。
  • Within = GameInstance:Within指明其对象Outer必须是某个类型,另Within的标记是会被继承到子类的。所以综合的意思是继承于UGameInstanceSubsystem的之类的Subsystem对象的Outer必须是GameInstance保证了其对象的依存合法性。所以是不能自己随便NewObject()出来,避免了误操作。

总结,在Subsystems之前我们其实也可以自己用C++来实现类似的“单例模式”,也可以达成类似的效果。但Subsystems带来的远不止这些。

不用手动实现,且更正确

实现一个分数Manager(点击展开/折叠代码块)

虽然我们可以自己手动实现一个与Subsystems类似的Manager类型,但手动实现的Manager可能会导致以下问题:

  • 学习负担重,但凡是想要用单例模式实现的人,必须要先理解学习单例模式的编写套路。
  • AddRoot()可能会被遗忘,导致对象被释放。这样在调用时就会产生崩溃。
  • 虽然可使用GetMutableDefault和CDO来实现类似于Subsystems的功能,但是并不是通识。
  • 单例模式的续存期在Editor模式下也会存在,故在Play和Stop后其值依然会存在,表现为脏。
  • 需要考虑重复创建和销毁的时机。需要为每个单例类都写一遍Initialize()和Deinitialize()等函数,且不能写错
  • 维护成本高。每当我们手动创建一个Manager类型,需要记得先定义静态变量,然后在Init里面加上创建代码,在ShutDown里加上销毁代码。
  • 虽然可以使用Engine提供的GameInstanceClass实现,但缺点是只支持单类型,而且生命周期是整个引擎,不是整个游戏。

更模块化

在传统实现一些全局的游戏系统的时候,习惯上是从GameInstance上继承下来,然后在里面添加一系列的代码,但这就会带来很多问题:

  • 一个类里面挤占着太多的逻辑。
  • 不利于模块复用。需要先手动定位到逻辑的位置,然后手动复制粘贴对应的代码块。
  • 手动划分Manager类也不够机制。手动实现Manager类型的弊端前面已经简述过了。
使用Subsystem实现不同的Manager(点击展开/折叠代码块)

使用Subsystem实现后有以下好处:

  • 代码更优雅。
  • 无需维护创建与释放逻辑。
  • 更容易的迁移与复用。
  • 更好的封装粒度,以及避免系统之间的数据污染。

理解的一致性

不同的开发者在编程的时候都有自己的方式,互相之前缺少共识。在有了Subsystems后,在经过学习和使用之后,开发者之间会有对于复用的理解一致性。

在UE中,对复用的理解一致性:

  • 通用功能的复用:从各个ActorComponent里面看,代表的往往是和“游戏逻辑”无关的可复用功能。
  • 业务逻辑的复用:从Subsystem来查找,代表的是游戏逻辑相关的可复用部分。

避免重载引擎类

特别是在实现插件模块时,不需要继承GameInstance或之类的。只需要针对不同的需求创建不同的Subsystem,结构上更优雅,用户使用体验上也更直接。

生命周期控制粒度更细

如果只是想要自己的Manager类依存于UGameInstance的生命周期还好实现,因为UGameInstance里提供了Init和Shutdown的重载,但是如果想实现依存Engine,Editor,World,LocalPlayer的不同生命周期,就很困难。需要手动注册这些对象的创建于销毁事件,如果没有提供的话,就需要去修改源码了。对比Subsystems来看,这些功能引擎开发人员已经实现好了,不需要我们自己手动处理。

更友好的访问接口

UE自身提供了Subsystem的Python以及蓝图访问的接口,避免了自己去实现。

而且在获取Subsystem时有一个“上下文”的概念,相比于使用全局函数获取自定义的Manager类,Subsystem的访问的接口中会判断当前所属对象的ContextObject是否能够获得对应的Subsystem对象。比如想要获取一个UWorldSubsystem,就必须要根据当前对象是否能够获取到World对象来判断是否可以访问。如果当前的World为null,虽然可以在蓝图中调用到UWorldSubsystem但是返回值会是null。其他的也是同理。

深刻理解5类Outer对象的生命周期

  • UEngine* GEngine:代表引擎,数量1。Editor或Runtime模式都是全局唯一,从进程启动开始创建,进程退出时销毁。
  • UEditorEngine* GEditor:代表编辑器,数量1。只在编辑器下存在且全局唯一,从编辑器启动时开始创建,到编辑器退出时销毁。
  • UGameInstance* GameInstance:代表一场游戏,数量1。从一场游戏(Runtime / PIE)启动开始创建,游戏退出时销毁。一场游戏中会创建多个World切换。
  • UWorld* world:代表一个世界,数量可能 > 1。World和GameMode是关联的,可以包含多个Level,默认情况下OpenLevel常常会切换World。因此生命周期是和GameMode一起的。注意:编辑器模式下视口里的场景其实也是World,因此EworldType有多个类型:Game,Editor,PIE,EditorPreview,GamePreview。
  • ULocalPlayer* LocalPlayer:代表本地玩家,数量可能 > 1。当本地分屏多玩家时会有多个。但LocalPlayer往往是跟随PlayerController一起访问的,但是其生命周期其实是跟UGameInstance一起的(默认游戏开始就创建好需求数量的本地玩家,但也可以通过运行时调用AddLocalPlayer添加玩家)。

理解Subsystem对象反射创建销毁流程

Subsystem对象的创建

5类Outer对象在被创建时,会调用 FSubsystemCollectionBase::Initialize(UObject* NewOuter) 并把自己作为Outer传递进去。因此Subsystem对象的创建流程其实就在这个函数中

Initialize函数的实现(点击展开/折叠代码块)

关于UDynamicSubsystem,稍后再分析。线分析直接从USubsystem直接继承下来的子类对象的创建,可以看到第一步是由反射获取到继承自BaseType(即生命周期5类)的所有Subsystem子类。然后一一执行AddAndInitializeSubsystem函数。

AddAndInitializeSubsystem函数的部分实现(点击展开/折叠代码块)

这段核心代码说明了ShouldCreateSubsystem和Initlize的作用!

Subsystem对象的销毁

当玩家退出游戏或者对应的生命周期,Outer对象需要被销毁时,会调用 SubsystemCollection.Deinitialize()

Deinitialize函数的部分实现(点击展开/折叠代码块)

逻辑非常简单,遍历,然后DeInitialize。

关于UDynamicSubsystem

UDynamicSubsystem详解(点击展开/折叠文章)

Subsystem是如何被GC的呢?

我们在Deinitialize中并不会看到手动的调用Destroy,因为USubsystem对象是个UObject对象,其依然是受GC管理的。Subsystem对象和Outer对象之前隔了一个FSubsystemCollection结构,为了让F结构依然可以追溯到对象引用,因此FSubsystemCollectionBase继承于FGCObject,所以我们也能找到如下代码:

AddReferencedObjects的实现(点击展开/折叠代码块)

FSubsystemCollectionBase::Deinitialize() 里进行 SubsystemMap.Empty() 后,USubsystem对象就没有被持有引用了。所以在下一帧的GC的时候,就会被判定为PendingKill的对象,从而得到Destroy。

其直接利用了UObject对象之间引用所带来的生命周期绑定机制,来直接把USubsystem对象的生命周期和其Outer对象关联起来,而不用写重复的代码。

Subsystem支持网络复制么?

不支持。我们知道UE里的网络复制是基于Actor的ActorChannel的,而USubsystem是普通的UObject对象,因此并不支持。

在这一点上,可能定义一个AManagerActor类来作为通信通道,这也许是一个好主意,但也得仔细的评估。

因为常常很多时候,GameState和PlayerState等一些内建的GamePlay通信已经能够满足你的需求,我比较建议尽量把职责划分清楚。

如果实在有需求,那就用一个Actor来作为通信通道吧。

Subsystem对象的个体生命周期

理解一个Subsystem对象的ShouldCreateSubsystem(),Initialize()和Deinitialize()的调用时机才知道应该怎么重载。

USubsystem是一个UObjet对象,所以具有CDO

Subsystem生命周期流程图(点击展开/折叠图片)

总结一下生命周期的不同时机

生命周期的关键点就在于什么时候触发SystemCollection的Initiaze和DeInitialize,根据Outer对象自身运行机制生命周期的不同,由此搭配出不同的使用方式。在理解了这些不同Subsystem对象的不同之后,可以由此组织实现符合自己需求的加载创建策略。

Outer对象的创建时机(点击展开/折叠图片)

Subsystem为什么单挑这五类Outer?

是否可以自己参照着新建USubsystem基类?

蓝图中是怎么访问到Subsystem全局变量的?

Subsystems其实算是Gameplay的一个DLC,仔细分析源码并吸收了这些架构知识营养后,可以在自己的游戏结构里更加灵活的应用上对象的反射和事件注册等知识,来让程序架构愈发清晰。

《InsideUE4》GamePlay架构(十一)Subsystems:https://zhuanlan.zhihu.com/p/158717151

Programming Subsystems:https://dev.epicgames.com/documentation/en-us/unreal-engine/programming-subsystems-in-unreal-engine

发表回复