UE热更新:Config的重载与应用

UE Hot Update: Config Overloading and Apply

在UE引擎中有大量的配置使用ini来进行设置与控制。对于项目而言,了解其中哪些是能够更新的,能够对制定项目的更新内容规则有帮助。并且,UE很多功能都是通过CVars等方式实现的配置化和动态开关以及对指定平台或设备的Device Profiles设置,同样可以在热更中实现运行中配置的动态下发及应用。
本篇文章从引擎机制分析ini config的加载流程、以及不同的配置模块在热更之后重载、在项目中如何应用等内容,基于Ini配置文件的热更实现运行时引擎或项目中参数的修改、reapply,增强游戏的更新能力。

在研究怎么更新Ini之前,首先要分析引擎的Ini是怎么加载和被应用到游戏中的。之前我写过一篇文章分析了GConfig的加载机制:UE代码分析:GConfig的加载
默认情况下,在引擎启动时就加载了Ini文件,它是第一批被加载的文件之一,后续引擎模块的启动都依赖于这些配置,官方的文档:Configuration Files

引擎对于Module的加载顺序也有加载Config完毕的阶段PostConfigInit用于保证在配置文件加载之后才启动,避免配置文件读取不到的情况:

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
// Phase at which this module should be loaded during startup.
namespace ELoadingPhase
{
enum Type
{
/** As soon as possible - in other words, uplugin files are loadable from a pak file (as well as right after PlatformFile is set up in case pak files aren't used) Used for plugins needed to read files (compression formats, etc) */
EarliestPossible,
/** Loaded before the engine is fully initialized, immediately after the config system has been initialized. Necessary only for very low-level hooks */
PostConfigInit,
/** The first screen to be rendered after system splash screen */
PostSplashScreen,
/** Loaded before coreUObject for setting up manual loading screens, used for our chunk patching system */
PreEarlyLoadingScreen,
/** Loaded before the engine is fully initialized for modules that need to hook into the loading screen before it triggers */
PreLoadingScreen,
/** Right before the default phase */
PreDefault,
/** Loaded at the default loading point during startup (during engine init, after game modules are loaded.) */
Default,
/** Right after the default phase */
PostDefault,
/** After the engine has been initialized */
PostEngineInit,
/** Do not automatically load this module */
None,
// NOTE: If you add a new value, make sure to update the ToString() method below!
Max
};
// ...
};

引擎中启动的代码:

Runtime\Launch\Private\LaunchEngineLoop.cpp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
bool FEngineLoop::AppInit()
{
// ...
{
SCOPED_BOOT_TIMING("FConfigCacheIni::InitializeConfigSystem");
LLM_SCOPE(ELLMTag::ConfigSystem);
// init config system
FConfigCacheIni::InitializeConfigSystem();
}

FDelayedAutoRegisterHelper::RunAndClearDelayedAutoRegisterDelegates(EDelayedRegisterRunPhase::IniSystemReady);

// Load "asap" plugin modules
IPluginManager& PluginManager = IPluginManager::Get();
IProjectManager& ProjectManager = IProjectManager::Get();
if (!ProjectManager.LoadModulesForProject(ELoadingPhase::EarliestPossible) || !PluginManager.LoadModulesForEnabledPlugins(ELoadingPhase::EarliestPossible))
{
return false;
}
// ...
}

FEngineLoop::AppInit函数的末尾,执行了FCoreDelegates::OnInit.Broadcast();这个Delegate在FCoreUObjectModule的StartupModule中,被InitUObject()绑定,用来初始化引擎中的UObject。在这个阶段Config也都已经加载完毕,创建UObject也可以正确地从Config中读取数据。

UE代码分析:GConfig的加载文章中介绍了FConfigCacheIni::InitializeConfigSystem加载ini文件的过程,此处不再赘述,可以理解为执行完毕之后Config就处于可用状态了。

Ini的文件层次与优先级

更新UE的Ini需要注意的以下几个方面:

  1. UE中同一类别的配置,是由多个Ini文件层次结构共同组成的
  2. 不同文件层次的Ini具有不同的优先级,高优先级的会覆盖低优先级的值。

以Engine配置为例,引擎中可以进行配置的文件有28个,其他的类别配置如Game/Input/DeviceProfiles等也相同(UE4.25):
Engine
DeviceProfiles

这些文件(如果存在)都是能被加载的,并且按照优先级升序排列。其效果就是,在PROJECT_DIR/Config/DeaultEngine.ini中就能替换ENGINE_DIR/Config/BaseEngine.ini中的选项。

在一般项目开发中,默认情况下工程内的属性配置一般在DefaultEngine.ini,对某个平台的配置则是在Config/Windows/WindowsEngine.ini中,很少会存在这么多配置文件的情况,也会造成一定程度的混乱。

所以,在更新时,可以直接使用Config/Default*.iniConfig/PLATFORM/PLATFORM*.ini这种方式更新。也可以使用原先不存在的文件进行更新,如原本项目中不存在Config/UserEngine.ini,在更新时可以将需要修改的配置写入这个文件,在运行时读取追加到引擎中,在自动挂载的方式中也支持,因为Config/User*.ini的优先级是最高的。

所以,为了实现Ini的更新,需要实现:

  1. 在更新时打包指定的ini文件
  2. 能够根据更新信息获取到引擎中已加载的FConfigFile实例,将新的配置追加其中。
  3. 更新使用新配置的UObject对象中的数据

UObject加载Config

在处理更新之前,需要先了解UObject加载Config的过程(只有标记了Config的类的CDO才会执行加载),其栈如下:

UObject从Ini加载数据

大致流程为:

  1. 检查UClass是否具有CLASS_Config的Flag
  2. 检查Property是否具有CPF_Config的Flag
  3. 从字符串序列化CDO的属性

如:

1
2
3
4
5
6
7
8
UCLASS(config=GameUserSettings)
class ENGINE_API UGameConfig : public UObject
{
GENERATED_BODY()
public:
UPROPERTY(Config)
FString GCloudGameID;
};

这个类的UCLASS中加了Config,会通过UHT给它的UClass生成CLASS_Config的Flag,并且这个类的ival这个数据成员,也在UPROPERTY中添加了Config,会给当前属性的FProperty增加CPF_Config的Flag,这样实现代码与加载时的统一。

具体的加载实现详见CoreUObject\Private\UObject\Obj.cpp中的LoadConfig函数。

在创建非CDO的对象时,会从CDO中拷贝所有的属性(在构造函数执行之后):

UObjectGlobals.cpp
1
2
// Binary initialize object properties to zero or defaults.
void FObjectInitializer::InitProperties(UObject* Obj, UClass* DefaultsClass, UObject* DefaultData, bool bCopyTransientsFromClassDefaults);

所以,UObject的对象使用的Config标记对游戏使用的影响分了两个步骤:

  1. 引擎启动时加载ini,创建出CDO并从ini中读取配置
  2. 在创建Non-CDO对象时,从CDO拷贝数据

如果想要覆盖ini中的配置值,不能直接写到构造函数里,因为LoadConfig的时机晚于构造函数。但可以重载PostInitProperties函数,它会在LoadConfig之后调用:

1
virtual void PostInitProperties()override;

更新UObject中的配置值

对于热更新的需求而言,对于UObject使用的Config,我们可以在创建对象之前介入这个过程,修改CDO,在CDO创建之后也可以对Object的对象调用ReloadConfig实现从文件重新加载。

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
bool UFlibUGConfigReloadHelper::ReloadIniFile(const FString& StrippedName,const FString& File)
{
FConfigFile* ConfigFile = GetConfigFile(StrippedName);
if(!ConfigFile)
{
return false;
}
if (!ConfigFile->Combine(File))
{
return false;
}
TArray<UObject*> Classes;
GetObjectsOfClass(UClass::StaticClass(), Classes, true, RF_NoFlags);
for (UObject* ClassObject : Classes)
{
if (UClass* const Class = Cast<UClass>(ClassObject))
{
if (Class->HasAnyClassFlags(CLASS_Config) &&
Class->ClassConfigName.IsEqual(*StrippedName))
{
TArray<UObject*> Objects;
GetObjectsOfClass(Class, Objects, true, RF_NoFlags);
for (UObject* Object : Objects)
{
if (!Object->IsPendingKill())
{
// Force a reload of the config vars
Object->ReloadConfig();
}
}
}
}
}
return true;
}

如代码所示,首先获取引擎中所有具有Config标记的UClass,并且需要Class所绑定的ini名字与需要重新加载的ini匹配,再遍历它们每个UClass的UObject实例,对其调用ReloadConfig。

注意,上述代码中先修改了对应的FConfigFile实例,因为在引擎启动时已经加载了旧的ini文件,存储在FConfigFile中,在调用ReloadConfig前,需要先对他们进行更新,这样在ReloadConfig中就能从GConfig读取新的数据。

ConsoleVariables

在UE中可以通过使用ConsoleVariables作为模块间的数据传递方式,我之前的笔记中有在运行时获取和修改Console Variables的方式:设置 ConsoleVariables 值的几种方式

Console Variables在引擎启动时的自动应用的值有以下两种应用方法:

  1. 可以在Engine/Config/ConsoleVariables.ini中的[Startup]中设置:
1
2
3
[Startup]
r.ShaderPipelineCache.Enabled=0
r.ShaderPipelineCache.LogPSO=0
  1. 在Engine类别Ini层次中指定(如DefaultEngine.ini),[ConsoleVariables]:
1
2
3
[ConsoleVariables]
r.ShaderPipelineCache.Enabled=0
r.ShaderPipelineCache.LogPSO=0

当我们对Engine类别的FConfigCache更新之后,调用以下函数:

1
::ApplyCVarSettingsFromIni(TEXT("ConsoleVariables"), *GEngineIni, ECVF_SetBySystemSettingsIni);

应用[ConsoleVariables]中最新的CVars值。如果想要同时更新ConsoleVariable.ini中的[Startup][ConsoleVariables]中的设置,可以调用:

1
FConfigCacheIni::LoadConsoleVariablesFromINI()

它会先Apply[Startup]中的,再Apply[ConsoleVariables]中的。

更新的流程步骤:

  1. 重新加载Engine类别的Ini文件
  2. 调用ApplyCVarSettingsFromIni函数。

Device Profiles

Device Profiles是UE为特定平台或设备设定参数的一种方法,官方文档:Setting Device Profiles。可以通过它来实现不同平台、设备间的性能、参数适配等需求。

引擎中的默认提供的平台、设备名与Ini平台名的配置:BaseDeviceProfiles.ini#L6,存储在*DeviceProfiles.ini[DeviceProfiles]DeviceProfileNameAndTypes的值。在BaseDeviceProfiles.ini中具Profile的依赖配置、设备指定、IOS设备映射、指定设备的配置等等,自己项目中的设置可以参考。

并且,UE的Device Profiles是支持继承的,如iPhoneX->IOS_High->IOS->Mobile,能够方便地复用配置。

UDeviceProfile类也是标记为Config的,并且是perObjectConfig,每个对象的配置都会单独保存和加载。简而言之,Deviece Profiles的加载,其实也就是创建了很多个的对象,每个对象会保存自己平台或设备的配置信息,NewObject<UDeviceProfile>传递的Name就是平台名。

在创建UDeviceProfile时会自动从Ini中加载(NewObject<UDeviceProfile>的栈):

既然Device Profiles的配置加载也是走的默认的LoadConfig流程,那么我们对UDeviceProfile的更新就可以按照更新UObject中所使用的配置值该节的步骤,获取所有的UDeviceProfile实例,对其调用ReloadConfig,并且需要对其的Parent Profile也调用(Parent Profile也是UDeviceProfile对象):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void UFlibUGConfigReloadHelper::RecursiveReloadDeviceProfile(UDeviceProfile* Profile)
{
if(!Profile->IsPendingKill())
{
Profile->LoadConfig();
}
if(Profile->Parent && !Profile->IsPendingKill())
{
RecursiveReloadDeviceProfile(Cast<UDeviceProfile>(Profile->Parent));
}
};
void UFlibUGConfigReloadHelper::ReloadDeviceProfiles()
{
for (UObject* Profile : UDeviceProfileManager::Get().Profiles)
{
UDeviceProfile* const DeviceProfile = Cast<UDeviceProfile>(Profile);
if(!DeviceProfile->IsPendingKill())
{
RecursiveReloadDeviceProfile(DeviceProfile);
DeviceProfile->ValidateProfile();
}
}
// UDeviceProfileManager::Get().OnManagerUpdated().Broadcast();
}

之后可以调用UDeviceProfileManagerReapplyDeviceProfile

1
UDeviceProfileManager::Get().ReapplyDeviceProfile();

GameUserSettings

在重载GameUserSettings的配置之后,可以调用ApplySettings进行应用:

1
2
3
4
5
void UFlibUGConfigReloadHelper::ReapplyGameUserSettings()
{
GEngine->GetGameUserSettings()->LoadSettings(true);
GEngine->GetGameUserSettings()->ApplySettings(true);
}

ApplySettings的代码为:

GameUserSettings.cpp
1
2
3
4
5
6
7
8
void UGameUserSettings::ApplySettings(bool bCheckForCommandLineOverrides)
{
ApplyResolutionSettings(bCheckForCommandLineOverrides);
ApplyNonResolutionSettings();
RequestUIUpdate();

SaveSettings();
}

结语

UE中对于各种配置文件的加载其实都是基于UE的Ini层次的更新,首先需要更新GConfig中的值,再将其应用到不同的模块,文章中列举了数种不同配置的重载和应用,其他未列举的情况也可参照相同的方式进行处理。

全文完,若有不足之处请评论指正。

微信扫描二维码,关注我的公众号。

本文标题:UE热更新:Config的重载与应用
文章作者:查利鹏
发布时间:2021年10月18日 14时57分
本文字数:本文一共有4k字
原始链接:https://imzlp.com/posts/9028/
许可协议: CC BY-NC-SA 4.0
文章禁止全文转载,摘要转发请保留原文链接及作者信息,谢谢!
您的捐赠将鼓励我继续创作!