UE插件与工具开发:可扩展性支持

UE Plugin and Tools Development: Extensibility Support

在我们开发插件时,当插件功能逐渐复杂,通常需要提供一些扩展性的支持,实现自定义的扩展功能来丰富插件的功能,也可以方便用户在不修改程序本体的情况下实现项目的定制需求。
在之前的文章(HotPatcher 的模块化改造和开发规划)中,介绍了HotPatcher与扩展模块的支持与如何基于HotPatcher开发一个新的模块,本篇文章主要介绍在UE内的具体实现方式。

简单地说,就是需要在UE本身的插件系统之上,构建自己的扩展系统。本篇文章会介绍我在提高插件扩展性方面的一些思考了技巧,如何方便地让这些扩展功能与插件本体解耦,方便维护和管理。

本体改造

根据我对HotPatcher的经验,扩展Mod通常会实现下面几种需求:

  1. 监听执行流程,做一些事情。比如监听HotPatcher打包完毕之后可以直接把补丁推送到手机上。
  2. 扩展执行流程,在默认执行框架内,做一些额外的数据记录和处理。比如HotPatcher热更流程中,会记录每个本地仓库的当前git commit信息。
  3. 逻辑替换,替换掉插件内的的逻辑,换成自己需要定制的。
  4. 功能封装,这种情况下,不会影响到本体插件的流程和逻辑,但是会把插件本体的功能根据需要做一层特殊的封装,比如GameFeaturePacker这个Mod,就是针对GF的打包对HotPatcher做的封装。

所以,基于这些需求,在进行扩展化改造之前,需要对基础插件本体做一些改造:

  1. 配置化改造:让插件执行任务完全以配置驱动,扩展模块可以自己重写配置逻辑,来实现定制的功能。如我之前实现的GameFeaturePackaer模块,它是对HotPatcher打包能力的封装,但加入了对GF插件的特定的资产和文件进包逻辑。
  2. 执行逻辑拆分阶段:使插件流程拆分为多个不同的阶段,这部分较为关键,拆分后能够使我们可以较为方便地介入到每一个阶段的执行过程,实现扩展性的目的。
  3. 上下文支持:在整个执行流程中,确保有一个统一的上下文(Context),在扩展流程中,很多情况下都是去修改上下文中的数据(比如对HotPatcher差异分析后的结果做进一步处理,就需要从上下文中获取差异结果,并在处理完之后修改上下文)。
  4. 关键逻辑替换:插件中的有些行为,不是直接写死在代码中,而是能够动态或修改配置控制,比如HotPatcher中的资源分析、差异分析等逻辑。
  5. 允许记录额外的数据或文件:当开发了模块后,可能模块会产出一些非本体需要的数据或文件,在本体的流程中需要统一收集起来。
  6. 模块隔离:需要对扩展模块本身的行为最好抽象,避免本体对模块的直接依赖,避免出现UE中插件循环依赖的情况。

当一个插件经过上面的改造之后,本体已经是比较清晰的执行架构,并且能够方便扩展了,接下来会介绍可扩展部分的具体实现。

我们要实现的目标是:

  1. Mod是完全独立的UE插件,安装它无需对本体做任何修改,只需将Mod的插件放入工程即可。
  2. Mod具有可插拔的行为,对本体的影响都是可控的,随时可以通过配置关闭,避免一个Mod出现问题影响整个执行流程。
  3. Mod具有完全独立的配置和执行流程

Mod注册

有时模块会添加完全独立的配置,如HotPatcher的扩展模块:

每一个模块的配置都是独立的,并且都支持Import/Export/Reset的配置机制。
因为主界面是通过HotPatcher本体编写的,而每个模块的配置逻辑和Slate控件是写在自己的模块之内的,并且每个模块也可能具有多种配置情况。
所以需要在本体内对模块定义好抽象,每一个模块行为都被抽象为了一个Action,每个扩展可以具有多个Actions:

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
using FRequestWidgetPtr = TFunction<TSharedRef<SHotPatcherWidgetInterface>(TSharedPtr<FHotPatcherModContextBase>)>;

struct HOTPATCHEREDITOR_API FHotPatcherActionDesc
{
FHotPatcherActionDesc()=default;
FHotPatcherActionDesc(FString InCategory,FString InModName,FString InActionName,FString InToolTip,FRequestWidgetPtr InRequestWidgetPtr,int32 InPriority = 0):
Category(InCategory),ModName(InModName),ActionName(InActionName),ToolTip(InToolTip),RequestWidgetPtr(InRequestWidgetPtr),Priority(InPriority)
{}

FString Category;
FString ModName;
FString ActionName;
FString ToolTip;
FRequestWidgetPtr RequestWidgetPtr;
int32 Priority = 0;
};

struct HOTPATCHEREDITOR_API FHotPatcherModDesc
{
FString ModName;
bool bIsBuiltInMod = false;
float CurrentVersion = 0.f;
FString Description;
FString URL;
FString UpdateURL;
float MinHotPatcherVersion = 0.0f;
TArray<FHotPatcherActionDesc> ModActions;
};

对于每个Mod而言,需要记录它的这些信息:

  1. 名字
  2. 是否是本体内置Mod
  3. 当前的版本号
  4. Mod描述信息
  5. Mod的网址(文档等)
  6. 更新地址(检查Mod的更新)
  7. 本Mod依赖的最小本体版本
  8. Mod支持的Action列表

这样就能清晰地记录每个Mod的信息了。
每个Mod通过注册Action来添加可执行的配置逻辑,允许通过RequestWidgetPtr来动态请求Mod的Widget,它只是一个回调函数。

HotPatcher为例,当在右上角的列表中选择了一个Mod的Action,就会动态地请求该Action的控件,并填充到主界面中:

同时,也需要在Mod插件的Module启动时写一个注册Action的逻辑,这也依赖基础本体插件的加载时机(LoadingPhase)是要早于扩展Mod的。

本体提供注册函数

1
2
3
4
5
6
7
8
9
10
11
struct HOTPATCHEREDITOR_API FHotPatcherActionManager
{
static FHotPatcherActionManager Manager;
static FHotPatcherActionManager& Get() { return Manager; }
// ...
void RegisteHotPatcherAction(const FHotPatcherActionDesc& NewAction);
void UnRegisteHotPatcherAction(const FString& Category, const FString& ActionName);
void RegisteHotPatcherMod(const FHotPatcherModDesc& ModDesc);
void UnRegisteHotPatcherMod(const FHotPatcherModDesc& ModDesc);
// ...
};

Mod需要在插件启动时调用注册:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#define CREATE_ACTION_WIDGET_LAMBDA(WIDGET_CLASS,MODENAME) \  
[](TSharedPtr<FHotPatcherModContextBase> InContext)->TSharedRef<SHotPatcherWidgetInterface>{\
return SNew(WIDGET_CLASS,InContext).Visibility_Lambda([=]()->EVisibility{\
return InContext->GetModeName().IsEqual(MODENAME) ? EVisibility::Visible : EVisibility::Collapsed;\
});}
FHotPatcherModDesc FGameFeaturePackerEditorModule::GetModDesc() const
{
FHotPatcherModDesc TmpModDesc;
TmpModDesc.ModName = MOD_NAME;
TmpModDesc.CurrentVersion = MOD_VERSION;
TmpModDesc.bIsBuiltInMod = IS_INTERNAL_MODE;
TmpModDesc.MinHotPatcherVersion = 80.0f;
TmpModDesc.Description = TEXT("Plugin/GameFeature Packager");
TmpModDesc.URL = TEXT("https://imzlp.com/posts/17658/");
TmpModDesc.UpdateURL = TEXT("https://github.com/hxhb/HotPatcher/Mods/GameFeaturePacker");
TArray<FHotPatcherActionDesc> ActionDescs;
TmpModDesc.ModActions.Emplace(
TEXT("Patcher"),MOD_NAME,TEXT("ByGameFeature"), TEXT("Create an Game Feature Package."),
CREATE_ACTION_WIDGET_LAMBDA(SGameFeaturePackageWidget,TEXT("ByGameFeature")),0
);
return TmpModDesc;
}

把这些信息传递进去,就实现了Mod的注册,在本体中动态接收的能力。

通过这样的实现机制,就做到了,只需要把Mod的插件放入工程中,就能够被本体识别,并可以选择Mod进行配置了。

流程扩展

在前面本体改造小结,我介绍了本体需要拆分执行阶段,并且需要具有一个统一的上下文(Context),这是进行流程扩展的关键。

Delegates

当我们把执行阶段拆分完毕后,我们可以为每个阶段的前后都添加流程控制的口子,常规的做法是可以添加的Delegate代理,用于通知执行到了哪个阶段。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
DECLARE_MULTICAST_DELEGATE_TwoParams(FNotificationEvent, FText, const FString&)  
DECLARE_MULTICAST_DELEGATE_OneParam(FHotPatcherPatcherEvent,FHotPatcherContextBase*)

class HOTPATCHERCORE_API FHotPatcherDelegates
{
public:
/** Return a single FGameDelegates object */
static FHotPatcherDelegates& Get();
static void OnExecDelegate(EPatcherCustomFlowType FlowType,FHotPatcherContextBase* Context);
// for patcher
DEFINE_GAME_DELEGATE_TYPED(OnPatcherInited,FHotPatcherPatcherEvent);
DEFINE_GAME_DELEGATE_TYPED(OnPatcherShutdown,FHotPatcherPatcherEvent);
DEFINE_GAME_DELEGATE_TYPED(OnPatcherContextInited,FHotPatcherPatcherEvent);
DEFINE_GAME_DELEGATE_TYPED(OnPatcherBaseVerImported,FHotPatcherPatcherEvent);
DEFINE_GAME_DELEGATE_TYPED(OnPatcherCurrentVerAnalysised,FHotPatcherPatcherEvent);
DEFINE_GAME_DELEGATE_TYPED(OnPatcherVerDiffFinished,FHotPatcherPatcherEvent);
DEFINE_GAME_DELEGATE_TYPED(OnPatcherPostMakeChunks,FHotPatcherPatcherEvent);
DEFINE_GAME_DELEGATE_TYPED(OnPatcherPreCook,FHotPatcherPatcherEvent);
DEFINE_GAME_DELEGATE_TYPED(OnPatcherPostCook,FHotPatcherPatcherEvent);
DEFINE_GAME_DELEGATE_TYPED(OnPatcherPakGenerated,FHotPatcherPatcherEvent);
DEFINE_GAME_DELEGATE_TYPED(OnPatcherReleaseGenerated,FHotPatcherPatcherEvent);
DEFINE_GAME_DELEGATE_TYPED(OnPatcherFinished,FHotPatcherPatcherEvent);
};

通过监听这个全局代理,就能够监听执行到了什么阶段,并且从参数中接收上下文,进行需要的处理。

其实引擎内也有大量的流程是基于Delegate做的,比如FGameDelegatesFCoreDelegates等等,是类似的做法,都是在引擎执行的特定时机触发,用于做需要的事情。

UClass

通常全局代理在执行过程中是一定会调用的,只要有绑定就会接收到回调。它并不是可以动态控制插拔的逻辑,所以一般只会用在一些通用的逻辑中,比如监听整个任务执行完毕了,做一些后续的处理等。

那么如何实现动态配置可插拔呢?

可以通过UClass来实现,把各个阶段的回调函数原型也定义出一个基类,每个Mod可以继承这个基类重写虚函数:

然后可以在配置中指定该Class,在运行时构造一个它的实例,来执行我们重写的逻辑。
比如HotPatcher中,支持创建二进制补丁的Mod:

以及自定义资源分析过程的Mod(HotChunker接入热更流程):

而且还可以利用PropertyCustomization机制(详见文章虚幻引擎中的属性面板定制化),给每个指定的Class添加覆写的参数,这样能够更灵活地控制行为:

并且可以根据参数的类型自动创建属性控件,底层存储的是字符串:

这样就能够根据配置文件来决定启用哪些扩展能力了。

扩展案例

除了前面介绍的之外,我们在热更流程中,还基于ResScanner添加了对热更资产的检查流程,在每次热更时都会检查所有参与热更的资产的规范,避免把有问题的资源带入现网。

HotPatcher中,就可以指定ResScanner的适配器来做到这一点:

实现逻辑就是当热更补丁创建完毕后,获取补丁内的所有资产,然后检查所有的规则:

并且会将结果输出到本次补丁的目录中。

基于这样的扩展模式,可以非常方便地把各种插件组合起来,实现1+1>2的效果,并且无需编写额外的控制流程,都在同一个执行过程就能实现了。

逻辑替换

除了上一小节提到的流程扩展之外,还会有替换某些内置逻辑的需要。
比如HotPatcher中选择什么样的二进制补丁算法、修改默认的差异分析流程等,都是想要替换掉默认的流程,指定一个新的。

ModularFeature

在很早之前,我写过一篇文章,在UE内集成新的压缩算法:ModularFeature:为 UE4 集成 ZSTD 压缩算法

它就是利用了引擎中的ModularFeature机制,可以提供一个基础的接口类,然后分别实现,并且注册到引擎中。

在需要对应的能力时,可以从引擎中获取到对应的ModularFeature实例,来执行逻辑。

HotPatcher提供的用来创建二进制补丁的MF基础接口,需要集成自IModularFeature

1
2
3
4
5
6
7
struct IBinariesDiffPatchFeature: public IModularFeature  
{
virtual ~IBinariesDiffPatchFeature(){};
virtual bool CreateDiff(const TArray<uint8>& NewData, const TArray<uint8>& OldData, TArray<uint8>& OutPatch) = 0;
virtual bool PatchDiff(const TArray<uint8>& OldData, const TArray<uint8>& PatchData, TArray<uint8>& OutNewData) = 0;
virtual FString GetFeatureName()const = 0;
};

然后在Mod中扩展了一个HDiffPatch的算法:

1
2
3
4
5
6
7
8
9
10
11
12
class FHDiffPatchModule : public IBinariesDiffPatchFeature,public IModuleInterface
{
public:

/** IModuleInterface implementation */
virtual void StartupModule() override;
virtual void ShutdownModule() override;
public:
virtual bool CreateDiff(const TArray<uint8>& NewData, const TArray<uint8>& OldData, TArray<uint8>& OutPatch) override;
virtual bool PatchDiff(const TArray<uint8>& OldData, const TArray<uint8>& PatchData, TArray<uint8>& OutNewData) override;
virtual FString GetFeatureName()const { return TEXT("HDIFFPATCH"); }
};

需要在UE的Module启动时,把它注册到引擎中:

1
2
3
4
5
6
7
8
9
void FHDiffPatchModule::StartupModule()
{
IModularFeatures::Get().RegisterModularFeature(BINARIES_DIFF_PATCH_FEATURE_NAME,this);
}

void FHDiffPatchModule::ShutdownModule()
{
IModularFeatures::Get().UnregisterModularFeature(BINARIES_DIFF_PATCH_FEATURE_NAME,this);
}

当运行时,需要访问到ModularFeature时,可以从引擎中获取所有已注册的Features:

1
TArray<IBinariesDiffPatchFeature*> ModularFeatures = IModularFeatures::Get().GetModularFeatureImplementations<IBinariesDiffPatchFeature>(BINARIES_DIFF_PATCH_FEATURE_NAME);

然后可以遍历这个数组,通过GetFeatureName找到需要使用的Feature了。

这样就能脱离插件本体,单独扩展任意的算法了。

它比较适合用于指定那些业务逻辑无关的,比较通用的机制,比如引擎内支持新的压缩算法也是通过ModularFeature实现的。

UClass

除了ModularFeature之外,前面流程扩展小节的部分也已经提到了可以通过UClass类来扩展。其实流程替换也是一样的,也是需要先抽象出一个独立的接口类,然后在Mod中继承和覆写。

HotPatcher的差异流程为例,我创建了一个抽象接口类:

1
2
3
4
5
6
7
8
UCLASS(Abstract)  
class HOTPATCHERRUNTIME_API UPatcherCustomDiffGenerator : public UPatcherCustomOperatorBase
{
GENERATED_BODY()
public:
// generate patch
virtual bool DiffVersionByContext(FHotPatcherContextBase* Context){ return false; }
};

然后基于默认的分析过程,写了一个Default的类:

1
2
3
4
5
6
7
UCLASS()  
class HOTPATCHERCORE_API UDefaultPatchDiffGenerator:public UPatcherCustomDiffGenerator
{
GENERATED_BODY()
public:
bool DiffVersionByContext(FHotPatcherContextBase* Context) override;
};

并且把它在配置中变成可以指定的:

1
2
UPROPERTY(EditAnywhere)  
TSubclassOf<UPatcherCustomDiffGenerator> DiffGenerator;

访问指定的Class,

1
2
3
4
5
6
// 获取是否指定有效的UClass,否则使用默认的
TSubclassOf<UPatcherCustomDiffGenerator> CustomDiffGeneratorClass = Context.GetSettingObject()->GetDiffOptions().DiffGenerator;
if(!IsValid(CustomDiffGeneratorClass))
{
CustomDiffGeneratorClass = UDefaultPatchDiffGenerator::StaticClass();
}

拿到Class后创建实例,覆写实例参数,并调用预设接口:

1
2
3
4
5
6
7
UPatcherCustomDiffGenerator* CustomDiffGenerator = Cast<UPatcherCustomDiffGenerator>(NewObject<UObject>(Context.GetProxy(),CustomDiffGeneratorClass.Get()));  
UFlibReflectionHelper::OverrideArgs(CustomDiffGenerator,Context.GetSettingObject()->GetDiffOptions().Args);
if(IsValid(CustomDiffGenerator))
{
CustomDiffGenerator->DiffVersionByContext(&Context);
// ...
}

这样就在流程中实现了可替换逻辑。

修改逻辑,也只是需要在配置中选择对应的UClass:

模块管理

基于上面几个小结的介绍,我们已经可以对插件本体做较为丰富的扩展了。那么如何方便地管理Mod也是需要考虑的。

我目前是基于git submodule来进行管理的(详见Mods),较为方便:

  1. 每个submodule是一个独立的git仓库,可以独立维护与扩展
  2. 单独的权限管理,因为每个仓库是独立的,所以可以精确地控制每个Mod的访问权限
  3. 插件本体中可以记录每个mod的git hash,可以用于指定默认submodule版本,方便迭代

git submodule相关的内容,可以看我之前写的一篇文章:Git快速上手指南

结语

本篇文章介绍了我在开发HotPatcher过程中,一些对插件进行模块化开发的经验和思路,避免所有的功能都堆积在一个插件下,实现灵活的扩展性。
可以比较方便地进行功能扩展、不同插件的功能组合、模块化的管理和迭代方式,尽可能地解耦实现,可以作为在UE的插件系统上实现一个Mod扩展系统的实现参考。

HotPatcher经过长期地开发与扩展,它的功能早已不局限于资源打包和热更新,而是包含资源分包热更新包体优化资源审计自动化资源处理的一整套资源管理方案。

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

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

本文标题:UE插件与工具开发:可扩展性支持
文章作者:查利鹏
发布时间:2025/07/12 09:54
本文字数:4.1k 字
原始链接:https://imzlp.com/posts/27510/
许可协议: CC BY-NC-SA 4.0
文章禁止全文转载,摘要转发请保留原文链接及作者信息,谢谢!
您的捐赠将鼓励我继续创作!