Verse Wiki — 写给蓝图作者的 Verse 手册
拔高 · EXTRA

组合优于继承:为什么 Epic 要换掉 Actor 继承树

「组合优于继承」这句话在软件圈流传了三十年,大部分人只记住了口号。这一页把它拆开:继承在大项目里具体是怎么崩的、组合式架构到底解决了哪一类问题,以及它的代价——这部分教程通常不写,但你迟早会撞上。

一、继承的三种崩法

先说公道话:继承没有错。项目只有 30 个类的时候,继承是最省事的复用手段——写一次基类,子类白拿。问题出在规模需求的形状上。

崩法一:菱形问题。你有 Movable(会动)和 Interactable(可交互)两条能力线,各自是一个基类。现在需要一个「会动又可交互」的东西——它该继承谁?蓝图和绝大多数引擎类型系统都只允许单继承,所以你只能挑一个当爹,另一个的代码复制过来,或者塞进一个 Actor Component 里绕开。名字来源于把类图画出来后那个菱形:两条线从同一个祖先分出去,又想在下面合流,而单继承不允许合流。

关键在于:这不是「引擎功能缺失」。就算允许多继承(C++ 允许),菱形也只是换个形式发作——两条路径都继承了同一个祖先,那个祖先的成员到底存一份还是两份?调用哪一份?C++ 为此发明了虚继承,而虚继承的复杂度本身就是一条劝退线。问题的根不在语法,在于「一个东西只能属于一条分类线」这个前提。

崩法二:上帝类。菱形太麻烦,大部分团队会走另一条路:把所有可能用到的能力都塞进一个足够胖的基类,靠布尔开关按需打开。第一个月它叫 BP_ActorBase,有 6 个函数;第八个月它有 2000 个节点、31 个变量、11 个「这个开关谁在用?」的注释。

上帝类的真正伤害不是文件大,是变更半径。你为了修一个宝箱的 bug 动了基类一行,理论上受影响的是全项目每一个继承者——47 扇门、12 个宝箱、8 个平台、3 个 NPC。你不可能全测,于是你选择不改;于是下一个人只好在旁边再加一个开关。这就是「屎山」的生成机制:不是没人想清理,是清理的成本被继承关系放大了。

崩法三:分类轴不止一根。这条是前两条的根因。继承本质上是一棵树,而树只能表达一根分类轴。你的游戏里有几根?能不能动、能不能交互、会不会掉血、要不要联网同步、会不会被玩家捡起来……五根轴各自二选一,就是 32 种组合。用树表达 32 种组合,要么建 32 个类(爆炸),要么把轴塞进开关(上帝类)。

这也解释了一个反直觉的现象:继承在小项目里显得优雅,恰恰因为小项目的分类轴少。轴一多,树就撑不住——和团队水平没关系。

二、组合式架构解决了什么

组合的思路是把树砍了,换成一张清单:一个对象等于「一个空容器 + 它装了哪些能力」。五根分类轴?那就是五张能力卡,想要哪几样装哪几样,32 种组合不需要 32 个类,只需要 5 张卡。

这类做法在游戏行业有个更学术的名字:ECS(Entity–Component–System,实体-组件-系统)。经典 ECS 把三件事分得很开:entity 只是一个 ID,component 只装数据,system 是遍历数据干活的逻辑。工业界的实现并不都这么纯粹——Unity 的 GameObject/MonoBehaviour、虚幻的 Actor Component、以及 UEFN 的 Scene Graph,都属于「组合式」但把数据与行为放在了同一个 component 里(官方对 component 的定义就是「为 entity 提供数据与行为」)。所以严谨地说:Scene Graph 是组合式架构,借了 ECS 的思路,但不必按经典 ECS 的教条去理解。

换轴之后,前面三种崩法各自的下场:

继承时代的问题组合时代发生了什么
菱形:会动又可交互该继承谁不存在这个问题——装两张卡,谁也不是谁的爹
上帝类:基类越来越胖没有基类可胖。能力分散在各自的 component 里,一张卡只干一件事
变更半径:改一行波及全世界改一张卡,只影响装了这张卡的对象。范围写在清单上,肉眼可查
组合爆炸:32 种组合要 32 个类5 张卡搞定 32 种组合,新组合不需要新建任何类型

还有一个不太被提起、但对 Epic 很重要的动机:可迁移性。装配式的对象是「一份清单」,天然容易被序列化、被跨项目搬运、被工具读写。当你的目标是一个可以互相搬运资产的元宇宙时,「这个物件是 47 层继承链上的一个节点」是个很难跨项目搬的东西,而「这个物件是一个 entity 加五张卡」就好搬得多。

三、代价:老实说三笔账

如果组合全是好处,继承早就绝迹了。它没绝迹,因为账要两边算。

代价一:间接层变多,「这东西到底会干什么」不再一眼可见。继承时代,你打开 BP_GlowingDoor,顺着父类链往上翻,行为链条虽然长但是线性的,而且 IDE 能带你跳。组合时代,一个 entity 的行为等于它身上所有 component 的行为之和,再加上父 entity 和子 entity 的影响——你得把 Details 面板上的卡片一张张看过来,而且卡片之间可能有隐式的先后依赖。信息没有变多,但变散了。

代价二:调试更难。这是代价一的直接后果。「盖子没翻开」这个 bug,在继承时代你打开那个类,断点一打,顺着执行线走;在组合时代你得先回答:是动画那张卡没触发,还是互动那张卡没发事件,还是事件发了但发给了错的 entity?官方的最佳实践里有一条很能说明问题——不要在游戏运行中反复做「大范围层级扫描」,而应该在开始模拟时一次性找到需要的子组件、把引用存下来或订阅它们的事件,之后靠事件反应,而不是每次重新在图里搜。这条建议之所以要写进文档,正因为「谁找谁」在组合式架构里是个真问题。

代价三:通信成本与心智负担。继承时代,子类调父类的函数就是一行;组合时代,A 卡想让 B 卡干活,得先找到 B 卡(在同一个 entity 上?在子 entity 上?),再调用或发事件。这就是为什么 Scene Graph 要专门提供沿层级向上/向下发送场景事件的机制,也是为什么官方最佳实践特意提醒:事件要打得准,能局部解决就别向整张图广播。方便是有代价的,代价就是你得自己维护「谁认识谁」这张关系网。

最后一笔不是代价而是提醒:组合不是免死金牌。拆得太碎,你会得到 40 张互相依赖的卡片和一张没人看得懂的关系网——那是另一种形式的屎山,只是换了个形状。怎么拆才刚好,见技巧页 把一个 Actor 蓝图拆成 components 的思维练习

四、那我现在该怎么办

务实一点:Actor 与 Blueprint 在 UE6 Early Access(目标 2027 年底)及早期版本中完整支持,弃用要等 Scene Graph 足够成熟、时间未定;Epic 已承诺提供转换工具,但尚未发布。所以本页不是让你今天回去重构项目。

真正能立刻做、而且就算 Scene Graph 一天不来也不亏的事只有一件:在现有蓝图项目里多用 Actor Component,少加继承层。下次策划要「会发光的门」,别再新建 BP_GlowingDoor——做一个发光 Component,谁要谁挂。这在 Actor 体系里本来就是更好的写法,而且它把你的项目提前掰成了「一堆能力卡」的形状——将来无论是手工迁移还是等官方转换工具,一份已经拆好的清单都比一棵盘根错节的继承树好搬。

五、小测验

下面哪一项是组合式架构相对继承的代价,而不是好处?

来源

本文的框架事实与最佳实践引自 Epic 官方文档:Scene Graph in Unreal Editor for Fortnite(官方文档)↗ · Getting Started in Scene Graph in Fortnite(官方文档)↗ · Scene Graph Best Practices in Fortnite(官方文档)↗ · The Road to Unreal Engine 6(Epic 官方公告)↗

关于菱形问题、上帝类与 ECS 的论述属于通用软件工程知识,不是 Epic 的官方表述;Scene Graph 内部实现是否遵循经典 ECS,以官方文档为准