UE5.8 模块与插件架构:一次把 Public/Private、Build.cs 和加载时机讲清楚

老周是我们客户端组的主程,去年带团队把项目从 UE 5.3 升到 5.8。开放世界动作游戏,代码量不小,光是 Source 目录下就有二十来个模块,Plugins 里还塞了六七个自研插件。升级本身没出啥大乱子,直到第三个月要打 Shipping 包,UBT 突然甩出一排红字:Unable to instantiate module UnrealEd for non-editor targets。那天下午整个编译机房的气氛很压抑。老周盯着报错链看了十分钟,骂了一句:又是模块依赖没拆干净。

这事儿最后花了两天才根绝。根因并不复杂:团队早期把编辑器扩展代码和运行时逻辑放在同一个插件模块里,Runtime 模块通过 PrivateDependency 间接拉到了 UnrealEd。编辑器里跑得好好的,一打包就炸。要我说,这种坑每个做 UE 的客户端程序迟早都会踩一遍。模块和插件的边界看起来简单,真落到代码里全是细节。

模块是编译单元,插件是模块的容器

在 UE 里,Module 是 UBT 能识别的最小编译单位。每个模块至少包含一个 [ModuleName].Build.cs,告诉编译器它叫什么名字、依赖谁、Public 目录在哪、要不要预编译头。模块编译出来通常是一颗 DLL(Modular 链接模式下),或者在打包时被静态链接进可执行文件(Monolithic 模式)。

Plugin 则是更高一层的组织形式。一个插件由一个 .uplugin 描述文件和若干模块文件夹组成。你可以把插件理解为一份打包好的功能合约:我提供这些模块、这些内容、这些依赖,引擎按声明的时机加载。插件可以放在项目 Plugins/ 下,也可以放在引擎 Engine/Plugins/ 下,还能通过 Marketplace 分发。

一个插件里可以放多个模块,而且模块类型可以混搭。常见组合是:一个 Runtime 模块负责实际功能,一个 Editor 模块负责编辑器面板、资产动作或自定义 Details View。老周团队后来把那个出问题的插件拆成了 MyFeatureRuntimeMyFeatureEditor,Shipping 包再也没报过 UnrealEd 的错。

Public 与 Private:谁能看到你的代码

UE 模块文件夹默认分成 Public/Private/。这个区分不是装饰,而是 UBT 的编译契约。

Public 目录下的头文件会被加入模块的 Public Include Paths。其他模块如果依赖了你,就能直接 #include "YourModule/Public/YourHeader.h"。Private 目录下的代码只在本模块内可见,外部模块即便加了依赖也拿不到这些头文件。

依赖关系在 Build.cs 里用两组列表声明:

PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine" });
PrivateDependencyModuleNames.AddRange(new string[] { "Projects", "UnrealEd" });

PublicDependency 具有传染性。A 依赖 B,B Public 依赖 C,那 A 也能直接用 C 的 Public 头文件。PrivateDependency 不会传染,A 只能通过 B 暴露的接口间接使用 C。这个规则决定了你的 API 边界该画在哪。

下面这张图展示了依赖传播的方向。

flowchart TD
    A[ModuleA] -->|PublicDependency| B[ModuleB]
    B -->|PublicDependency| C[CoreUObject]
    A -->|PrivateDependency| D[Projects]
    E[OtherModule] -->|PublicDependency| A
    E -.->|不可见| D
    E -->|可见| B
    E -->|可见| C

老周团队吃过一个哑巴亏:有人在 Public 头文件里包含了 UnrealEd.h,然后把这个依赖写进了 PublicDependencyModuleNames。结果所有依赖这个模块的地方都间接吃到了 UnrealEd,Shipping 包全挂。修复方式很简单:把编辑器相关的头文件和接口挪进 Private 实现,或者拆到独立的 Editor 模块。

Build.cs 与 Target.cs 的分工

很多新人会把 Build.cs 和 Target.cs 搞混。它俩确实都在控制编译,但层级完全不同。

Build.cs 管的是单个模块。它决定模块依赖谁、要编译哪些文件、要不要 RTTI、附加哪些宏定义、Public/Private 包含路径怎么设。每个模块必须有且只有一个 Build.cs。

Target.cs 管的是最终要生成什么可执行文件。项目 Source 目录下通常有 [ProjectName].Target.cs[ProjectName]Editor.Target.cs,分别对应 Game Target 和 Editor Target。Target.cs 里最重要的字段是 Type

public override void SetupBinaries(TargetInfo Target)
{
    Type = TargetType.Game;
}

UE 的 TargetType 枚举包括 Game、Editor、Client、Server、Program。 Development Editor 配置走 Editor Target,Development 配置走 Game Target。Dedicated Server 会额外有一个 Server Target。Type 不同,参与编译的模块集合就不同,Editor 类型的模块在 Game Target 里会被直接排除。

Target 还有一个属性叫 LinkType。默认行为是:Editor 用 Modular,Game/Server/Client 用 Monolithic。 Modular 模式下每个模块独立成 DLL,重编反馈快;Monolithic 模式下模块被静态链接,启动开销小,更适合 Shipping。这个默认值一般不要去改,除非你清楚自己在换什么。

Target.cs 里还能看到一些全局开关。bBuildEditor 为 true 时编辑器模块才会参与编译;bBuildDeveloperTools 控制 DeveloperTool 类型模块是否加入;bBuildRequiresCookedData 会影响 CookedOnly 和 UncookedOnly 模块的取舍。Platform-specific 的逻辑通常用 Target.Platform == UnrealTargetPlatform.Win64 这类条件写,但建议把平台判断收敛在 Build.cs 或 .uplugin 的 PlatformAllowList 里,别让 Target.cs 变成大杂烩。

老周团队打 Android 包时遇到过一个诡异问题:某个插件在 Win64 下正常,到 Android 就报模块找不到。查了半天发现 Build.cs 里写了 if (Target.Platform == UnrealTargetPlatform.Win64) { PublicDependencyModuleNames.Add("SomeWinOnlyLib"); },结果 Android 编译时这个依赖没了,但 Public 头文件还在引用它。正确做法是用 .uplugin 的 PlatformAllowList 把该模块限定在 Win64,或者在代码里用 #if PLATFORM_WINDOWS 把相关 API 包起来。

Primary Game Module、Game Module 与 Editor Module

一个新项目创建后,Source/[ProjectName]/ 下会自动生成 Primary Game Module。它是整个项目的入口模块,承载着 IMPLEMENT_PRIMARY_GAME_MODULE(FDefaultGameModuleImpl, ProjectName, "ProjectName") 这个宏。游戏启动时,引擎会先找到 Primary Game Module,再沿着它的依赖链把其他模块拉起来。

Game Module 是项目里新增的运行时模块。比如你把战斗系统拆成 CombatModule,把背包系统拆成 InventoryModule,这两个都是 Game Module。它们通过 Project 的 Build.cs 或 Primary Game Module 的依赖列表被引用,最终参与 Game Target 和 Editor Target 的编译。

Editor Module 只应该在编辑器存在。典型用途是自定义资产工具、关卡编辑器扩展、Slate 面板、蓝图节点等。Editor Module 的 Type 要写成 Editor,LoadingPhase 通常用 DefaultPreDefault。最关键的是:Editor Module 只能被 Editor 配置加载,Game Target 根本不会编译它。

老周那个项目的原罪,就是把一个 Editor Module 的代码写进了 Runtime 模块的 Private 目录,然后在 Build.cs 里根据 Target.bBuildEditor 条件性地加了 UnrealEd。这种写法本身没错,但前提是 Public 头文件里绝对不能出现编辑器类型的类。Public 头文件一旦包含,传染性会让所有依赖方都中招。

.uplugin:插件的户口本

.uplugin 是一个 JSON 文件,描述插件的名字、版本、分类、作者、包含的模块、依赖的其他插件。下面是一个典型的自研插件描述:

{
    "FileVersion": 3,
    "Version": 1,
    "VersionName": "1.0.0",
    "FriendlyName": "Combat Toolkit",
    "Description": "战斗系统工具集",
    "Category": "Gameplay",
    "CreatedBy": "老周团队",
    "Modules": [
        {
            "Name": "CombatToolkitRuntime",
            "Type": "Runtime",
            "LoadingPhase": "Default"
        },
        {
            "Name": "CombatToolkitEditor",
            "Type": "Editor",
            "LoadingPhase": "PostEngineInit"
        }
    ],
    "Plugins": [
        {
            "Name": "GameplayAbilities",
            "Enabled": true
        }
    ]
}

Modules 数组里每一项都要指定 Name、Type 和 LoadingPhase。Type 决定模块在什么 Target 下参与编译;LoadingPhase 决定模块在启动序列里的加载时机。两个字段写错一个,轻则编译不过,重则运行时找不到模块。

Type 的常用取值有 Runtime、RuntimeNoCommandlet、Editor、Developer、DeveloperTool 等。Runtime 在任何目标都加载;Editor 只在编辑器启动时加载;Developer 在开发和编辑器下加载,Shipping 不打包;DeveloperTool 受 bBuildDeveloperTools 开关控制。

除了 PublicDependencyModuleNames 和 PrivateDependencyModuleNames,Build.cs 里还有一组不太起眼但很有用的字段:PublicIncludePathModuleNamesPrivateIncludePathModuleNames。它们只引入头文件路径,不参与链接。适合这种情况:你的 Public 头文件里用到了另一个模块的类型声明,但实际实现里并不调用它的函数,也不需要链接它的库。用 IncludePath 代替 Dependency,可以减少不必要的链接传播。

加载时机:LoadingPhase 与依赖顺序

UE 启动时会按固定顺序加载模块。ELoadingPhase 枚举从最早到最晚依次是:EarliestPossible、PostConfigInit、PostSplashScreen、PreEarlyLoadingScreen、PreLoadingScreen、PreDefault、Default、PostDefault、PostEngineInit、None。

大部分游戏模块用 Default 就够了。Editor 扩展模块常用 PostEngineInit,因为这时候 Slate 和编辑器子系统已经就绪,可以安全地注册菜单和面板。底层文件系统插件可能用 EarliestPossible,在 Pak 系统可用之前就位。Patch 或热更相关模块可能用 PreEarlyLoadingScreen,在 CoreUObject 之前拿到控制权。

关键原则是:被依赖的模块要先加载。A 在 Default 阶段加载,B 依赖 A,那 B 至少要用 PostDefault。如果依赖关系反过来,启动时会直接报模块找不到。下面这个序列图展示了编辑器启动时模块加载的简化流程。

LoadingPhase 里还有一个特殊值 None。设置为 None 的模块不会自动加载,需要你在代码里手动调用 FModuleManager::LoadModuleChecked,并传入模块名字符串。这种模块适合按需加载的工具,比如一个只在特定 Commandlet 里使用的离线处理模块,或者一个玩家打开设置界面才需要激活的扩展模块。手动加载的好处是省内存,坏处是你得自己处理加载失败和卸载时机。

卸载模块用 FModuleManager::UnloadModule,但要小心:如果还有其他地方持有该模块的接口指针,卸载后访问会崩溃。老周团队有个日志分析插件用了 None 加手动加载,分析完一批数据就卸掉,内存占用能降几十兆。他们给接口指针加了弱引用检查,避免卸载后误用。

.uplugin 里还能写 PlatformAllowList 和 TargetAllowList。PlatformAllowList 为空时,默认表示所有平台都编译;一旦写了具体平台,就只有列出来的平台会参与。TargetAllowList 同理,可以限制模块只在 Editor 或 Game Target 下出现。老周团队给 Win64 独占的调试工具插件在 .uplugin 里把 TargetAllowList 只填了 Editor,这样打包的人永远不会误把它带进 Shipping。

sequenceDiagram
    participant UBT as UBT
    participant Core as Core
    participant UObject as CoreUObject
    participant Game as Primary Game Module
    participant Plugin as Plugin Modules
    UBT->>Core: PostConfigInit
    Core->>UObject: PreDefault
    UObject->>Game: Default
    Game->>Plugin: Resolve Dependencies
    Plugin->>Plugin: StartupModule
    Note over Plugin: PostEngineInit for Editor modules

实际项目里,模块加载问题往往表现为编辑器启动到 75% 左右卡住,或者某个蓝图函数库节点显示为红色虚线框。排查思路很直接:打开 .uplugin,检查被依赖模块的 LoadingPhase 是否早于依赖方。如果是插件依赖项目模块,那要确保项目模块已经用 Default 或更早阶段加载。

循环依赖:能避开就避开

循环依赖在 C++ 模块层面是个大麻烦。A 的 Public 头文件引用了 B 的类型,B 的 Public 头文件又引用了 A 的类型,UBT 现在默认会直接拒绝这种循环。老版本有个 CircularlyReferencedDependentModules 字段可以绕过,但 Epic 已经把它标记为 legacy,新代码不应该再用。

Blueprint 层面的循环引用更隐蔽。所有蓝图都在同一个全局作用域里,A 蓝图引用 B 蓝图,B 蓝图引用 C 蓝图,C 蓝图又引用 A 蓝图,这种长链不会立刻崩溃,但会导致加载一个普通蓝图时连带加载半个项目。老周团队有次排查发现,打开一个 UI 蓝图居然触发了 1400 多个资产的同步加载。

解决循环依赖的办法就那几条:

  • 用接口拆依赖。A 依赖 B_Interface,B 实现 B_Interface,A 和 B 之间就没有直接引用。
  • 用事件或委托解耦。A 抛事件,B 订阅事件,双方不需要互相 include。
  • 把公共数据下沉到独立模块。A 和 B 都依赖 DataModule,A 和 B 之间不再直接耦合。
  • 必要时把功能合并到一个模块。如果 A 和 B 本来就分不开,硬拆成两个模块只会徒增痛苦。

下面这张图展示了一个常见的循环依赖场景和破局点。

graph LR
    A[CombatSystem] -->|使用道具数据| B[InventorySystem]
    B -->|触发战斗事件| C[GameEventBus]
    C --> A
    D[ItemDataModule] --> B
    A -.->|应改为依赖| D

插件的三种常见形态

按用途划分,UE 插件大致可以分成三类。

第一类是 Content 插件。它只有内容,没有代码,.uplugin 里 Modules 数组为空,bCanContainContent 为 true。美术团队常把角色、关卡、材质打包成 Content 插件,方便多个项目复用。这种插件加载最快,几乎不会引发编译问题。

第二类是 Blueprint Library 插件。它包含蓝图函数库、蓝图组件、数据资产等,没有 C++ 模块。适合策划和脚本做一些跨项目的通用工具。因为没有 C++ 编译,依赖关系相对简单,但和 Content 插件一样,要注意不要形成蓝图循环引用。

第三类是 C++ 插件。这是开发团队最常写的类型,也是最需要谨慎设计的。C++ 插件通常包含 Runtime 和 Editor 两个模块,要处理 Build.cs、uplugin、加载阶段、平台白名单、第三方库链接等一堆事情。UE 5.8 自带的 ModelContextProtocol 插件就是一个典型 C++ 插件,它在编辑器进程里启动一个本地 HTTP 服务,把 Slate、资产、关卡等能力暴露给外部 AI 工具。

C++ 插件里链接第三方库是个高频踩坑点。静态库要区分 VS 版本、运行时库设置、平台架构;动态库要处理打包时的拷贝路径。Build.cs 里通常用 PublicAdditionalLibraries.Add() 加 .lib,用 RuntimeDependencies.Add()PublicDelayLoadDLLs.Add() 处理 .dll。这些路径在编辑器里和打包后往往不一样,建议用 Target.PluginDirectoryModuleDirectory 做相对路径,不要写死绝对路径。

老周团队接过一个语音聊天 SDK,Win64 用一份库,Android 用另一份。他们在 Build.cs 里按平台分支添加库路径,同时在 .uplugin 里给 Android 模块加了 PlatformAllowList。这样换平台编译时不会把错误架构的库链接进去。第三方库的头文件也尽量放在 Private 目录,避免 Public 头文件暴露 SDK 的类型。

老周团队最终的架构

修完那轮坑之后,老周团队给项目定了一套模块/插件规范:

  • 任何新插件必须同时考虑 Runtime 和 Editor 两个模块,先写 Runtime,确实需要编辑器功能再补 Editor。
  • Public 头文件不允许出现 UnrealEdSlateEditorSubsystem 等编辑器命名空间。
  • 模块依赖只允许单向,出现循环依赖必须在一周内重构。
  • 每个 .uplugin 必须写清楚 LoadingPhase,默认 Default,Editor 模块用 PostEngineInit。
  • Shipping 包每次提测前先在本地打一遍,不是为了测功能,是为了让 UBT 帮你看依赖有没有漏。

下面这张图是他们项目模块与插件的整体架构示意。

architecture-beta
    group project(Cloud)["MyGame Project"]
    group engine(Cloud)["UE5.8 Engine"]
    group plugin1(Server)["Combat Toolkit Plugin"]
    group plugin2(Server)["Inventory Plugin"]
    
    service primary(server)["Primary Game Module"] in project
    service combat_runtime(database)["CombatToolkitRuntime"] in plugin1
    service combat_editor(disk)["CombatToolkitEditor"] in plugin1
    service inv_runtime(database)["InventoryRuntime"] in plugin2
    service inv_editor(disk)["InventoryEditor"] in plugin2
    service core(database)["Core CoreUObject"] in engine
    
    primary:L --> R:combat_runtime
    primary:L --> R:inv_runtime
    combat_runtime:B --> T:core
    inv_runtime:B --> T:core
    combat_editor:L --> R:combat_runtime
    inv_editor:L --> R:inv_runtime

这套规范执行了两个月后,项目 Shipping 编译失败的次数从平均每周两次降到了两个月一次。省下来的时间,老周让组里两个小伙去研究 UE 5.8 的 MCP 插件了。按他的话说:模块拆清楚了,才有精力玩新东西。

一点个人建议

模块和插件架构没有银弹。小项目把所有代码塞进 Primary Game Module 完全没问题,硬拆反而拖慢开发。项目规模上了十几个系统之后,Public/Private 边界、Runtime/Editor 分离、依赖方向这些事儿才会真正值钱。

判断模块拆分是否合理的标准,我通常看三点:增量编译速度、Shipping 包稳定性、新人上手难度。如果改一行 Public 头文件要触发半项目重编,说明 Public 目录过大。如果打包时总冒出编辑器模块错误,说明 Runtime/Editor 没拆干净。如果新人看 Source 目录三天还找不到入口,说明模块命名和依赖关系太乱。

最后给出一个简单的估算式,用来判断一次模块拆分的收益。假设项目有 nn 个模块,每次修改平均触发 kk 个模块重编,单个模块平均编译时间为 tt 秒,则一次改动的等待时间约为:

Twait=kt+LlinkT_{wait} = k \cdot t + L_{link}

其中 LlinkL_{link} 是链接开销。拆模块的目标不是让 nn 越多越好,而是让 kk 尽可能小。Public/Private 切得干净,PrivateDependency 用得恰当,kk 自然会降下来。这笔账,老周团队现在算得很熟。

写在最后

UE5.8 是 UE5 周期的收官版本, Epic 把重心放在了工具链、移动端和虚拟制作上。模块与插件的底层规则并没有大改,但新项目越来越多、第三方插件环境越来越复杂,这些基础规则反而更容易被忽视。希望老周踩过的这些坑,能让你少熬几个晚上。

每次项目升级到新的 UE 版本,模块和插件都是第一个会爆雷的地方。UBT 的报错链通常很长,但绝大多数问题都能归到三类:依赖方向画反了、编辑器代码混进运行时、LoadingPhase 写错。养成每次改完 Build.cs 就打一遍 Game Target 的习惯,能把问题消灭在萌芽阶段。代码结构干净了,团队才有底气去尝试 UE 5.8 里那些更酷的新工具。

如果你现在正在重构项目里的模块,我的建议是:先别急着一次性拆成十几个小模块。先把最明显的 Runtime 和 Editor 分开,再把跨系统引用的 Public 头文件收敛一下,最后处理循环依赖。这三步走完了,项目基本就能喘口气。剩下的细化可以随着需求慢慢做,不必追求一步到位。