技术博客

插件与模块加载:FModuleManager 的 LoadModule 流程

第 10 / 100 篇 · 2025-12-29 · ue5 · 模块系统插件FModuleManager热重载

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):

  1. 查状态:模块已加载 → 直接返回现有实例;已加载但版本不匹配(热重载场景)→ 走卸载重载分支。
  2. 找 DLL 路径:模块名 → Binaries/<Platform>/UE5Editor-<ModuleName>.dll(编辑器)或 UE5-<ModuleName>.dll(Monolithic 打包时根本没有 DLL,模块静态链接进 exe,LoadModule 变成查表返回静态注册的实例)。
  3. 加载 DLL:平台相关的 LoadLibrary/dlopen。DLL 加载的静态初始化阶段就会执行 IMPLEMENT_MODULE 生成的注册代码——这是关键时序点。
  4. 调 StartupModule:拿到 IModuleInterface 实例后调 StartupModule(),模块在这里做初始化(注册子系统、绑定委托、分配资源)。
  5. 登记状态:模块加入已加载列表,供后续 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++ 代码 → 编译 → 不重启编辑器生效)的流程是:

  1. 编译产生新 DLL(UE5Editor-MyPlugin-1234.dll,带递增编号)。
  2. FModuleManager 对老模块调 ShutdownModule,卸载老 DLL。
  3. 加载新 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,不能复现就是热重载的锅。

FModuleManager::LoadModule 流程与热重载的失败点 LoadModule 的五步 1. 查状态 2. 定位 DLL 3. 加载 DLL 4. StartupModule 5. 登记 步骤 3 的 DLL 静态初始化阶段执行 IMPLEMENT_MODULE 的注册代码; Monolithic 构建时无 DLL,LoadModule 退化为查表 + 调 StartupModule。 热重载失灵的三个根源 UObject 实例持有老类 vtable → 钉住老 DLL 不被卸载 ShutdownModule 清理不干净 → 残留回调指向已释放代码 蓝图父类 CDO 缓存旧版本 → 默认值/属性面板不更新 Live Coding 的边界 补丁式链接:编译改动函数, 新代码挂进现有进程替换老函数 可靠:改函数体、改算法实现 不可靠:改类布局(加成员、改继承) 已有实例按老布局分配,改了会错位 → 结构改动必须重启编辑器

实战结论

  • 插件依赖的启动顺序问题,先查 .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、序列化。