UE 的插件系统不是"把 DLL 放进目录就行"——它有一层描述符驱动的加载编排:.uplugin 文件声明加载阶段和依赖,FModuleManager(Runtime/Core/Public/Modules/ModuleManager.h)负责按阶段顺序加载 DLL、调 StartupModule、维护模块状态。热重载的灵异问题、插件依赖的启动顺序问题,根因都在这套机制里。这篇把从 .uplugin 到 StartupModule 的链路走完。
.uplugin 描述符:加载的编排数据
每个插件根目录的 .uplugin 是 JSON 描述符,关键字段:
{
"FileVersion": 3,
"Modules": [
{
"Name": "MyPlugin",
"Type": "Runtime", // Runtime / Editor / Developer / Program
"LoadingPhase": "Default" // EarliestPossible / PreDefault / Default / PostDefault / PostEngineInit
},
{
"Name": "MyPluginEditor",
"Type": "Editor",
"LoadingPhase": "Default"
}
],
"Plugins": [
{ "Name": "OtherPlugin", "Enabled": true } // 插件级依赖
]
}
Type 决定模块在什么目标下加载:Editor 类型只在编辑器目标加载,Cook 打包时被剥离——插件模块类型写错是 Cook 失败的常见原因:Runtime 模块里 include 了 Editor 模块的头文件,开发环境(编辑器目标)编译没问题,Cook Game 目标时 Editor 模块不存在,直接链接错误。
LoadingPhase 控制加载时机,是模块启动顺序的主要手段。EarliestPossible 的模块在引擎核心系统初始化前加载(适合替换底层服务),PostEngineInit 在引擎完全就绪后加载(适合依赖完整引擎功能的工具)。默认的 Default 够大多数插件用;只有当"我的 StartupModule 里用了某个还没初始化的系统"时,才需要往后调。
FModuleManager 的加载流程
FModuleManager::LoadModule 的完整路径(ModuleManager.cpp):
- 查状态:模块已加载 → 直接返回现有实例;已加载但版本不匹配(热重载场景)→ 走卸载重载分支。
- 找 DLL 路径:模块名 →
Binaries/<Platform>/UE5Editor-<ModuleName>.dll(编辑器)或UE5-<ModuleName>.dll(Monolithic 打包时根本没有 DLL,模块静态链接进 exe,LoadModule 变成查表返回静态注册的实例)。 - 加载 DLL:平台相关的
LoadLibrary/dlopen。DLL 加载的静态初始化阶段就会执行IMPLEMENT_MODULE生成的注册代码——这是关键时序点。 - 调 StartupModule:拿到
IModuleInterface实例后调StartupModule(),模块在这里做初始化(注册子系统、绑定委托、分配资源)。 - 登记状态:模块加入已加载列表,供后续
GetModule查询。
IMPLEMENT_MODULE(FMyPluginModule, MyPlugin) 展开后定义的是模块的入口函数:DLL 被加载时,UE 的全局模块注册表记下"模块名 → 创建 IModuleInterface 实例的工厂函数"。FModuleManager 在步骤 4 里调这个工厂拿到实例。
静态链接 vs DLL 的分叉在步骤 2-3:Monolithic 构建(Shipping 单 exe)时所有模块静态链接,IMPLEMENT_MODULE 退化成"在静态初始化时注册工厂",LoadModule 只是查表 + 调 StartupModule,没有 DLL 操作。这解释了为什么有些插件在编辑器下正常、Shipping 下行为不同——DLL 边界的存在与否影响符号可见性和全局变量的实例数(DLL 模式下每个模块一份全局变量,Monolithic 下全程序共享一份)。
StartupModule/ShutdownModule 的正确用法
IModuleInterface 的两个虚函数是模块的生命周期钩子:
class FMyPluginModule : public IModuleInterface
{
virtual void StartupModule() override
{
// 引擎核心已就绪(Default 阶段之后),可以:
// - 注册资产类型动作(IAssetTools)
// - 绑定编辑器扩展(FLevelEditorModule 的菜单扩展)
// - 初始化子系统
}
virtual void ShutdownModule() override
{
// 必须对称清理 StartupModule 里做的一切
// 漏了清理是热重载崩溃的主要来源
}
};
StartupModule 里能做的和不能做的,取决于 LoadingPhase:Default 阶段时引擎核心(Core、CoreUObject、Engine)已就绪,但编辑器模块(UnrealEd、LevelEditor)只在编辑器目标下存在——Runtime 模块的 StartupModule 里直接引用编辑器模块会在 Game 目标下链接失败。正确姿势是判断 GIsEditor 或用 FModuleManager::Get().LoadModuleChecked<ILevelEditorModule>("LevelEditor") 的延迟加载,且包在 #if WITH_EDITOR 里。
ShutdownModule 的清理必须是 StartupModule 的严格逆序:注册的委托要 Unbind,注册的资产动作要 Unregister,分配的 TSharedPtr 要 Reset。漏清理的后果在热重载时才暴露:老模块的 DLL 卸载了,但它注册的回调还被引擎其他地方持有,下次触发时跳到已释放的内存——崩溃栈里全是乱码符号,极难定位。
热重载为什么会失灵
热重载(编辑器里改 C++ 代码 → 编译 → 不重启编辑器生效)的流程是:
- 编译产生新 DLL(
UE5Editor-MyPlugin-1234.dll,带递增编号)。 FModuleManager对老模块调ShutdownModule,卸载老 DLL。- 加载新 DLL,调
StartupModule。
失灵的根源在步骤 2 的"卸载"不是真正的卸载——Windows 上 FreeLibrary 只在引用计数归零时真正卸载 DLL,而以下情况会让老 DLL 残留在内存里:
- UObject 还引用着老类的代码:老模块定义的
UCLASS的 vtable 在老 DLL 的代码段里,任何存活的老类实例都钉着老 DLL。热重载后这些实例调虚函数会跳到老代码,行为就是"改了代码没生效"或随机崩溃。 - 全局函数指针/委托残留:
StartupModule里注册的回调在ShutdownModule里没清干净,老 DLL 被新加载后回调还指向老地址。 - CDO(Class Default Object)缓存:蓝图的父类 CDO 在热重载后可能还是老版本的,表现为"蓝图里的默认值没更新"。
Epic 的应对是 Live Coding(LiveCoding 模块,默认在编辑器设置里可切换):不走 DLL 卸载重载,而是用补丁式链接——编译改动的函数,把新代码挂到现有进程里,老函数被新函数替换。Live Coding 对"改函数体"很可靠,对"改类布局(加成员变量、改继承)"无能为力——因为已有实例的内存布局是按老类分配的,改了布局老实例就错位了。
实战结论:改函数实现用 Live Coding 或热重载都行;改类结构(加 UPROPERTY、改继承链、改构造函数参数)必须重启编辑器,否则行为不可预期。热重载后行为异常先重启编辑器验证——能复现才是真 bug,不能复现就是热重载的锅。
实战结论
- 插件依赖的启动顺序问题,先查
.uplugin的LoadingPhase和Plugins依赖声明,别在StartupModule里硬编码LoadModule的顺序——描述符声明的依赖会被FModuleManager自动拓扑排序,硬编码会绕过这个机制。 StartupModule/ShutdownModule必须严格对称,每注册一个东西就对应一个清理。热重载崩溃先审计ShutdownModule。- Cook 失败先查模块
Type:Runtime 模块 include 了 Editor 模块的头文件是最高频原因,.Build.cs里把 Editor 依赖包在if (Target.bBuildEditor)里。 - 热重载/Live Coding 之后行为异常,第一步永远是重启编辑器复现——不能复现就不是你的 bug。改类结构必须重启,这是机制决定的,不是经验猜测。
到这里第一章"源码模块结构与构建系统"走完。从拿到源码的目录地图,到 UBT/UHT 两个生成器,再到宏体系、委托、智能指针、容器、并发、日志、模块加载——这套地基覆盖了后续所有章节要反复回到的概念。第二章进 UObject 内存管理的核心:生命周期、CDO、序列化。