技术博客

委托系统源码:DECLARE_DELEGATE 到 TBaseDelegate 的实现

第 5 / 100 篇 · 2026-05-17 · ue5 · 委托Delegate源码分析

UE 的委托(Delegate)是引擎里最高频的回调机制:输入绑定、属性变化通知、异步任务完成回调全靠它。但 DECLARE_DELEGATE/DECLARE_MULTICAST_DELEGATE/DECLARE_DYNAMIC_DELEGATE 三兄弟的行为差异很大——绑定开销、能不能被蓝图绑定、能不能序列化,全不一样。这篇文章基于 Runtime/Core/Public/Delegates/ 下的源码把三种委托的实现差异讲透。

一条继承链:从 IDelegateInstance 到具体委托

所有委托的核心是 IDelegateInstance 接口(Delegates/IDelegateInstance.h)——一个类型擦除的调用包装。你 BindUObject(this, &UMyClass::OnDone) 时,委托内部做的事是:new 一个持有"对象指针 + 成员函数指针 + 捕获的参数"的对象,这个对象实现 IDelegateInstance 的 Execute 虚函数,委托本体只存一个 IDelegateInstance*。

// 简化的层次(Delegates/DelegateInstancesImpl.h)
IDelegateInstance                     // 接口:Execute(...)
  └─ TCommonDelegateInstanceState     // 持有绑定目标(TWeakObjectPtr 或裸指针)
       └─ TDelegateInstanceImpl       // 持有函数指针 + payload 元组

DECLARE_DELEGATE_RetVal_OneParam(int32, FOnDamage, float) 展开后定义的 FOnDamage 实际是 TDelegate<int32(float)> 的 typedef,TDelegate 继承 TBaseDelegate(Delegates/DelegateBase.h + Delegate.h)。TBaseDelegate 内部就一个 FDelegateBase 成员,负责分配和持有那个 IDelegateInstance。

绑定的内存开销来自这个 new。BindLambda 捕获三个变量、每次触发都重新 Bind 一次的写法,意味着每次触发都有一次堆分配。高频路径(每帧、每 Tick)的正确姿势是启动时 Bind 一次缓存 FDelegateHandle,结束后 Unbind,而不是反复 Bind/IsBound 检查。

单播委托:一个槽位,覆盖语义

单播委托(DECLARE_DELEGATE 家族)内部就一个槽位,重复 Bind 会静默覆盖上一个绑定——这是和 C# 委托、多播委托最不一样的地方,新手常以为单播也能叠加,结果回调丢了一半。

执行路径很短:Execute(Args...) → 检查 IDelegateInstance* 非空 → 调虚函数。开销是一次虚调用 + payload 解包,和直接函数指针调用基本同量级。单播委托不能序列化、不能暴露给蓝图,因为它是纯 C++ 模板,反射系统不认识它。

多播委托:调用列表 + 句柄管理

DECLARE_MULTICAST_DELEGATE 展开成 TMulticastDelegate<...>,内部是一个 TMulticastDelegateBase(Delegates/MulticastDelegateBase.h),核心结构是:

// 简化示意
class TMulticastDelegateBase {
    typedef TArray<FDelegateBase> InvocationListType;  // 绑定列表
    InvocationListType InvocationList;
    FDelegateHandle NextHandle;                         // 递增的句柄计数器
    // ...
};

每次 Add 往 InvocationList push 一个 FDelegateBase(内含 IDelegateInstance* + 唯一的 FDelegateHandle),Broadcast 遍历整个数组逐个 Execute。两个关键实现细节:

  • 删除是标记清除:Remove(Handle) 不立即从数组擦除,而是把对应项标记为无效(bPendingKill),下次 Broadcast 或 CompactInvocationList 时才真正压缩数组。这是为了允许在 Broadcast 过程中 Remove——如果 Broadcast 遍历时直接擦除数组,迭代器就失效了。代价是多播委托的数组长度可能虚高,Num() 返回值包含已标记未清除的项。
  • 句柄是单调递增的 64 位整数,不复用。FDelegateHandle 判等就是比整数,开销可以忽略;但如果你把句柄存进持久化结构,重启后句柄全失效——句柄是会话级的。

事件(DECLARE_EVENT)是多播委托的受限版:只有声明事件的类能 Broadcast,外部只能 Add/Remove。实现上就是把 Broadcast 设为 private + friend 声明持有者类。这是访问控制语法糖,不是新机制。

动态委托:为序列化和蓝图付出的全部代价

DECLARE_DYNAMIC_DELEGATE / DECLARE_DYNAMIC_MULTICAST_DELEGATE 是另一条完全独立的继承线(Delegates/DelegateSignatureImpl.inl 里的 TBaseDynamicDelegate / TBaseDynamicMulticastDelegate),不走 IDelegateInstance 那套。它的核心结构是:

// 简化示意(TBaseDynamicDelegate)
class TBaseDynamicDelegate {
    TWeakObjectPtr<UObject> Object;    // 绑定的 UObject
    FName FunctionName;                // 函数名 —— 注意是 FName 不是函数指针
};

执行时是按名字查找 + 反射调用:Object->ProcessEvent(Object->FindFunction(FunctionName), Params)。这带来三层开销和三个硬约束:

开销: 1. FindFunction:在类的函数表里按 FName 线性/哈希查找(类函数少时快,多时有可见开销)。 2. ProcessEvent:走完整的蓝图 VM 入口,参数要打包装进帧栈(FFrame),比直接函数指针调用慢一个数量级。 3. 动态多播还要先遍历绑定数组,每个绑定重复上面两步。

硬约束: 1. 目标函数必须是 UFUNCTION()——因为 FindFunction 只查反射函数表,非 UFUNCTION 的成员函数在表里没有。这是"动态委托绑定了但永远不触发"的最常见原因:函数忘了加 UFUNCTION() 宏,Add 不报错(它只存名字),Broadcast 时 FindFunction 返回 nullptr 静默跳过。 2. 函数签名必须是 UHT 能解析的类型——参数只能是 UPROPERTY 支持的类型,不能有 const FMyStruct& 之外的自定义引用语义,不能有模板参数。 3. 动态委托必须命名参数:DECLARE_DYNAMIC_DELEGATE_OneParam(FOnHit, float, Damage) 里的 Damage 参数名不是装饰,UHT 要用它生成参数序列化的 thunk 代码。

换来的是两样东西:能序列化(绑定信息是 UObject 弱引用 + FName,可以存盘、进包)和蓝图可绑定(蓝图的"绑定事件到分发器"节点本质就是往动态多播委托里 Add)。这是纯性能换互操作性的设计——如果回调只在 C++ 内部用、不需要蓝图绑定、不需要存盘,没有任何理由用动态委托。

开销对比的量化直觉

在同一个模块里做百万次调用实测(5.3,Development 配置,数据为数量级参考而非精确基准):

委托类型 单次调用开销 绑定开销 序列化 蓝图可见
单播 TDelegate ~1×(虚调用) 1 次堆分配 否 否
多播 TMulticastDelegate ~1× × 绑定数 每次 Add 1 次堆分配 否 否
动态单播 ~10×(FindFunction+ProcessEvent) 无堆分配(弱引用+名字) 是 是
动态多播 ~10× × 绑定数 无堆分配 是 是

数量级结论:C++ 内部的高频回调(每帧 Tick、碰撞回调)必须用非动态委托;只有跨层到蓝图、或者需要存档的回调才值得付动态委托的十倍开销。

三种委托的绑定数据结构与调用路径 单播 TDelegate IDelegateInstance* (单槽) Bind = new 实例(堆分配) 重复 Bind 静默覆盖 调用:虚函数直转,~1× 多播 TMulticastDelegate TArray<FDelegateBase> 列表 Add = push + 分配实例 Remove = 标记清除(迭代安全) Broadcast:遍历逐个 Execute 动态委托 TWeakObjectPtr + FName 无堆分配,可序列化 目标必须是 UFUNCTION() 调用:FindFunction+VM,~10× 多播 Remove 的标记清除为什么必要 Broadcast 遍历 InvocationList 期间,某个回调里调用 Remove(Handle) 若立即擦除 → TArray 内存重排 → 迭代器失效崩溃。标记清除让删除延迟到 Broadcast 结束后压缩。 选择规则 C++ 内部高频回调 → 非动态委托;需要蓝图绑定 / 存档 → 动态委托,并确认目标是 UFUNCTION 且参数是 UHT 可解析类型。

实战结论

  • 回调"绑了不触发"按这个顺序查:目标是 UObject 且用动态委托 → 查 UFUNCTION() 宏;目标是 C++ 原生类 → 查是不是 Bind 之后对象被销毁了(TWeakObjectPtr 过期)或单播被覆盖。
  • 高频委托(Tick、碰撞、每帧 UI 刷新)一律非动态 + 启动时绑定缓存句柄,不要在 Tick 里 Bind/Unbind。
  • 多播委托的 RemoveAll(UserObject) 会扫整个数组,绑定上千个回调的对象销毁时用它有开销,但比逐个 Remove 安全。
  • DECLARE_EVENT 只是多播委托加访问控制,性能无差别;但它强制了"只有持有者能触发"的封装,公共事件总线该用就用。

委托解决的是"对象间回调",而对象的所有权和生命周期由另一套东西管——智能指针家族。下一篇把 TSharedPtr/TSharedRef/TWeakPtr 和 TObjectPtr 的分工理清。