在我们开发插件时,当插件功能逐渐复杂,通常需要提供一些扩展性的支持,实现自定义的扩展功能来丰富插件的功能,也可以方便用户在不修改程序本体的情况下实现项目的定制需求。
在之前的文章(HotPatcher 的模块化改造和开发规划)中,介绍了HotPatcher与扩展模块的支持与如何基于HotPatcher开发一个新的模块,本篇文章主要介绍在UE内的具体实现方式。
简单地说,就是需要在UE本身的插件系统之上,构建自己的扩展系统。本篇文章会介绍我在提高插件扩展性方面的一些思考了技巧,如何方便地让这些扩展功能与插件本体解耦,方便维护和管理。
本体改造
根据我对HotPatcher的经验,扩展Mod通常会实现下面几种需求:
- 监听执行流程,做一些事情。比如监听HotPatcher打包完毕之后可以直接把补丁推送到手机上。
- 扩展执行流程,在默认执行框架内,做一些额外的数据记录和处理。比如HotPatcher热更流程中,会记录每个本地仓库的当前git commit信息。
- 逻辑替换,替换掉插件内的的逻辑,换成自己需要定制的。
- 功能封装,这种情况下,不会影响到本体插件的流程和逻辑,但是会把插件本体的功能根据需要做一层特殊的封装,比如GameFeaturePacker这个Mod,就是针对GF的打包对HotPatcher做的封装。
所以,基于这些需求,在进行扩展化改造之前,需要对基础插件本体做一些改造:
- 配置化改造:让插件执行任务完全以配置驱动,扩展模块可以自己重写配置逻辑,来实现定制的功能。如我之前实现的GameFeaturePackaer模块,它是对HotPatcher打包能力的封装,但加入了对GF插件的特定的资产和文件进包逻辑。
- 执行逻辑拆分阶段:使插件流程拆分为多个不同的阶段,这部分较为关键,拆分后能够使我们可以较为方便地介入到每一个阶段的执行过程,实现扩展性的目的。
- 上下文支持:在整个执行流程中,确保有一个统一的上下文(
Context
),在扩展流程中,很多情况下都是去修改上下文中的数据(比如对HotPatcher差异分析后的结果做进一步处理,就需要从上下文中获取差异结果,并在处理完之后修改上下文)。
- 关键逻辑替换:插件中的有些行为,不是直接写死在代码中,而是能够动态或修改配置控制,比如HotPatcher中的资源分析、差异分析等逻辑。
- 允许记录额外的数据或文件:当开发了模块后,可能模块会产出一些非本体需要的数据或文件,在本体的流程中需要统一收集起来。
- 模块隔离:需要对扩展模块本身的行为最好抽象,避免本体对模块的直接依赖,避免出现UE中插件循环依赖的情况。
当一个插件经过上面的改造之后,本体已经是比较清晰的执行架构,并且能够方便扩展了,接下来会介绍可扩展部分的具体实现。
我们要实现的目标是:
- Mod是完全独立的UE插件,安装它无需对本体做任何修改,只需将Mod的插件放入工程即可。
- Mod具有可插拔的行为,对本体的影响都是可控的,随时可以通过配置关闭,避免一个Mod出现问题影响整个执行流程。
- 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而言,需要记录它的这些信息:
- 名字
- 是否是本体内置Mod
- 当前的版本号
- Mod描述信息
- Mod的网址(文档等)
- 更新地址(检查Mod的更新)
- 本Mod依赖的最小本体版本
- 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: static FHotPatcherDelegates& Get(); static void OnExecDelegate(EPatcherCustomFlowType FlowType,FHotPatcherContextBase* Context); 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做的,比如FGameDelegates
、FCoreDelegates
等等,是类似的做法,都是在引擎执行的特定时机触发,用于做需要的事情。
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: 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: 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
| 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),较为方便:
- 每个submodule是一个独立的git仓库,可以独立维护与扩展
- 单独的权限管理,因为每个仓库是独立的,所以可以精确地控制每个Mod的访问权限
- 插件本体中可以记录每个mod的git hash,可以用于指定默认submodule版本,方便迭代
git submodule相关的内容,可以看我之前写的一篇文章:Git快速上手指南。
结语
本篇文章介绍了我在开发HotPatcher过程中,一些对插件进行模块化开发的经验和思路,避免所有的功能都堆积在一个插件下,实现灵活的扩展性。
可以比较方便地进行功能扩展、不同插件的功能组合、模块化的管理和迭代方式,尽可能地解耦实现,可以作为在UE的插件系统上实现一个Mod扩展系统的实现参考。
而HotPatcher经过长期地开发与扩展,它的功能早已不局限于资源打包和热更新,而是包含资源分包、热更新、包体优化、资源审计、自动化资源处理的一整套资源管理方案。
![]()