[TOC]
导读
上一篇我们把背包的两张权威 Map 和增删逻辑读穿了,但一直把 NotifyInventoryItemChanged 这个"喊一嗓子"的动作当黑盒——改完数据,它负责通知所有关心这件事的人。这一篇就来拆开这个黑盒,外加它的孪生兄弟:角色怎么"隔着接口"读背包,而不用 cast 到具体的 Controller。
读完你应该能回答这几个问题:
- 改一次背包,到底有谁需要被通知?它们各自怎么收到消息?
- 为什么每个事件都要声明 两套 委托(
Xxx+XxxNative)? - 为什么 C++ 订阅者一定要 先于 蓝图 UI 收到通知?顺序反了会怎样?
IRPGInventoryInterface为什么要做成 U 类 + I 类 两个类,还锁死CannotImplementInterfaceInBlueprint?
阅读前提:读过本系列《背包系统 01》,知道背包数据是
InventoryData/SlottedItems两张挂在ARPGPlayerControllerBase上的TMap,所有写操作改完都会调一个Notify*函数。源码范围:
RPGTypes.h(委托声明)、RPGPlayerControllerBase.h/.cpp(广播)、RPGInventoryInterface.h(接口)、RPGCharacterBase.h/.cpp(接口使用方)。引擎版本 4.27.2。
一、先想清楚:改一次背包,谁需要知道?
背包数据一变,至少有两类"观众"想第一时间知道:
- UI(蓝图 UMG Widget)——背包面板、装备栏要重画。它们是蓝图,得用蓝图能绑的通知。
- 角色系统(C++
ARPGCharacterBase)——装备槽一变,角色要重新授予/收回对应的 GAS 技能。它是 C++ 核心系统,要的是高性能、且要"先于 UI"处理完。
这两类观众诉求不同:一个要"蓝图可达",一个要"快且优先"。一套委托满足不了它俩——这就是 ActionRPG 给每个事件都准备两套委托的根本原因。带着这个矛盾往下看。
二、委托声明:每个事件都是"双胞胎"
打开 RPGTypes.h,背包相关的委托是成对声明的(RPGTypes.h:128-138):
/** 物品增减时的委托 */
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnInventoryItemChanged, bool, bAdded, URPGItem*, Item); // 蓝图版
DECLARE_MULTICAST_DELEGATE_TwoParams(FOnInventoryItemChangedNative, bool, URPGItem*); // 原生版
/** 槽位内容改变时的委托 */
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnSlottedItemChanged, FRPGItemSlot, ItemSlot, URPGItem*, Item);
DECLARE_MULTICAST_DELEGATE_TwoParams(FOnSlottedItemChangedNative, FRPGItemSlot, URPGItem*);
/** 整个背包被加载时的委托 */
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnInventoryLoaded);
DECLARE_MULTICAST_DELEGATE(FOnInventoryLoadedNative);
每个事件都是一对:一个 Dynamic(动态)、一个 Native(原生)。到了 ARPGPlayerControllerBase 的头文件里,它俩作为成员变量并排躺着(RPGPlayerControllerBase.h:39-44):
/** 物品增减委托(蓝图可绑) */
UPROPERTY(BlueprintAssignable, Category = Inventory)
FOnInventoryItemChanged OnInventoryItemChanged;
/** 上面那个的原生版,会先于蓝图版被调用 */
FOnInventoryItemChangedNative OnInventoryItemChangedNative;

注意两个关键区别:
OnInventoryItemChanged(Dynamic)带UPROPERTY(BlueprintAssignable)——它会在蓝图 Details 面板里露出一个可绑事件的入口,蓝图能Bind Event。OnInventoryItemChangedNative(Native)是个裸成员,没有UPROPERTY,蓝图根本看不见它,只有 C++ 能AddUObject/AddLambda绑上去。
这就是"双胞胎"的分工:Dynamic 那个给蓝图,Native 那个给 C++。
UE 基础补给:
DECLARE_DYNAMIC_MULTICAST_DELEGATE_*—— 走反射的多播委托。蓝图能绑、能在 Details 面板点"+",代价是每次广播要做反射查找,慢一些;参数必须是 UPROPERTY 友好类型。DECLARE_MULTICAST_DELEGATE_*—— 纯 C++ 多播委托。用函数指针直连,快;但蓝图完全碰不到。- “多播(Multicast)”= 可以挂多个订阅者,广播时挨个调用。
三、NotifyInventoryItemChanged:三连广播
数据改完,写操作会调 Notify* 函数统一对外通知。看它的实现(RPGPlayerControllerBase.cpp:372-380):
void ARPGPlayerControllerBase::NotifyInventoryItemChanged(bool bAdded, URPGItem* Item)
{
// ① 先通知 Native(C++ 订阅者)
OnInventoryItemChangedNative.Broadcast(bAdded, Item);
// ② 再通知 Dynamic(蓝图订阅者)
OnInventoryItemChanged.Broadcast(bAdded, Item);
// ③ 最后调 BlueprintImplementableEvent 钩子(给 BP 子类一个直接重写点)
InventoryItemChanged(bAdded, Item);
}

一次通知,连发三道,顺序是设计出来的,不是随手写的:
| 顺序 | 机制 | 谁能绑 | 绑定方式 | 性能 | 典型用途 |
|---|---|---|---|---|---|
| ① | Native delegate | C++ | AddUObject / AddLambda | 快(函数指针) | 角色、GAS 等核心系统 |
| ② | Dynamic delegate | C++ + 蓝图 | BindEvent / UFUNCTION | 慢(反射查找) | UI 蓝图 |
| ③ | BlueprintImplementableEvent | 蓝图子类重写 | 直接重写函数 | —— | 给 BP 子类一个钩子 |
为什么 Native 必须排第一? 因为 C++ 侧(典型是角色刷新技能)是"逻辑",蓝图 UI 是"表现"。如果 UI 先收到通知去重画,而角色还没把技能授予/收回处理完,UI 就可能画出一个"装备已变、但技能还没跟上"的中间态。先让逻辑落定,再让表现刷新——这是这套顺序的全部用意。
另外两个 Notify 函数 NotifySlottedItemChanged、NotifyInventoryLoaded 是一模一样的三连套路(cpp:382-397),不再赘述。
第 ③ 步那个
InventoryItemChanged是什么? 它是头文件里声明的UFUNCTION(BlueprintImplementableEvent)(RPGPlayerControllerBase.h:51-52):C++ 只声明、不实现,留给蓝图子类重写。它和第 ② 步的 Dynamic 委托区别在于:委托是"任意对象都能来订阅",而 BlueprintImplementableEvent 是"PlayerController 蓝图子类自己重写自己的钩子"。两者都给蓝图用,但一个对外、一个对内。
那蓝图这头是怎么"接"上第 ② 步的?下面单独说清楚。
3.1 蓝图绑定的前提:BlueprintAssignable
蓝图能绑,唯一的前提就是委托带了这个标记(RPGPlayerControllerBase.h:40-41):
UPROPERTY(BlueprintAssignable, Category = Inventory)
FOnInventoryItemChanged OnInventoryItemChanged; // Dynamic 委托 + BlueprintAssignable
BlueprintAssignable 会让这个委托在蓝图里以一个可绑节点的形式出现。只有 Dynamic 委托能加这个标记——那个 OnInventoryItemChangedNative(纯 C++ 多播)蓝图里根本看不到。
3.2 三种绑法(底层等价)
对一个 BlueprintAssignable 委托,蓝图有三种方式挂事件:
| 方式 | 操作 | 说明 |
|---|---|---|
| Bind Event to XXX | 拖出 Bind Event to On Inventory Item Changed 节点 | 最显式,本项目用的就是这个 |
| Assign XXX | 在变量上右键 → Assign | 自动帮你建一个红色 Custom Event 并连好 |
| 直接 Add/Call | 较少用 | —— |
它们底层都是同一件事:把一个 Custom Event(自定义事件) 注册进委托。
3.3 实际连线:在 On Possess 里绑
本项目在 Event On Possess(控制器接管 Pawn 时)完成绑定:

从左到右的链路是:
Event On Possess
→ Cast to BP_PlayerCharacter → SET Player Character
→ Create HUD (建背包/血条 UI)
→ Bind Event to On Inventory Item Changed ← 关键节点
├─ Target = 拥有委托的对象(PlayerController 自己)
└─ Event = Create Event →「Handle Inventory Item Changed」自定义事件
Bind Event 节点的两个输入针是理解的关键:
- Target:谁的委托。这里是 PlayerController 自身——它就是声明
OnInventoryItemChanged的那个类。 - Event:绑哪个函数。通过
Create Event下拉,选中一个签名匹配的 Custom Event,项目里叫Handle Inventory Item Changed。
绑定一次之后,C++ 每 OnInventoryItemChanged.Broadcast(...) 一次,这个自定义事件就被回调一次,里面再去刷新 UI:

3.4 签名必须对得上
Create Event 能选中某个 Custom Event,前提是参数签名和委托完全一致。委托声明(RPGTypes.h:129):
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnInventoryItemChanged, bool, bAdded, URPGItem*, Item);
→ 蓝图那个 Handle Inventory Item Changed 自定义事件就必须是两个参数:bAdded (Boolean) + Item (RPGItem 对象引用)。Create Event 节点下方标注的 Signature (BoolProperty, RPGItem Object Reference) 就是在做这个匹配——对不上,下拉里根本选不到它。
3.5 别和 BlueprintImplementableEvent 搞混
第三节开头三连广播的第 ③ 步那个 InventoryItemChanged 也给蓝图用,但和这里的 Dynamic 委托是两回事:
- Dynamic 委托(本小节):任何蓝图对象都能
Bind Event来订阅,多对一、对外。UI Widget、其它 Actor 都能各绑各的。 BlueprintImplementableEvent:只有 PlayerController 的蓝图子类重写自己的那个钩子,一对一、对内。
一句话收束蓝图侧:C++ 用 BlueprintAssignable 暴露 Dynamic 委托 → 蓝图在 On Possess 时用 Bind Event 挂一个签名匹配的 Custom Event → 之后 C++ Broadcast,蓝图事件被回调,刷新 UI。 全程事件驱动,没有谁在轮询。
四、IRPGInventoryInterface:角色凭什么能读背包?
通知机制解决了"背包变了怎么吼一声"。但还有个反方向的问题:角色(ARPGCharacterBase)要主动去读背包数据、要订阅那个 Native 委托,它怎么拿到 Controller?
最笨的办法是 Cast<ARPGPlayerControllerBase>(GetController())。但这样角色就和"具体某个 Controller 类"死死绑住了。ActionRPG 用一个接口把这层依赖解开——RPGInventoryInterface.h:
// ① U 类:给反射系统的占位 UObject,你几乎不直接碰它
UINTERFACE(MinimalAPI, meta = (CannotImplementInterfaceInBlueprint))
class URPGInventoryInterface : public UInterface
{
GENERATED_BODY()
};
// ② I 类:真正的抽象接口,写纯虚函数、被继承
class ACTIONRPG_API IRPGInventoryInterface
{
GENERATED_BODY()
public:
virtual const TMap<URPGItem*, FRPGItemData>& GetInventoryDataMap() const = 0;
virtual const TMap<FRPGItemSlot, URPGItem*>& GetSlottedItemMap() const = 0;
virtual FOnInventoryItemChangedNative& GetInventoryItemChangedDelegate() = 0;
virtual FOnSlottedItemChangedNative& GetSlottedItemChangedDelegate() = 0;
virtual FOnInventoryLoadedNative& GetInventoryLoadedDelegate() = 0;
};
这里有两个对 C++ 程序员很费解的点,逐个说清。
4.1 为什么是 U 类 + I 类两个类?
普通 C++ 里,一个抽象基类(一堆纯虚函数)就够了。但 UE 的接口要拆成两个类:
URPGInventoryInterface(U 前缀,继承UInterface):一个空壳 UObject,专门给反射系统认领。Cast、Implements<>()、蓝图识别接口,靠的都是它。IRPGInventoryInterface(I 前缀):才是你写虚函数、别人来实现和调用的真正接口。
为什么非要这么别扭?因为 UE 的反射系统要求"接口类型"也得在 RTTI 里有个 UObject 实例来登记,而真正承载虚函数的抽象类(I 类)又不能是 UObject。于是拆成两个:U 类负责"在反射系统里挂个名",I 类负责"干活"。记忆口诀:U 类报户口,I 类干实事。
4.2 为什么锁死 CannotImplementInterfaceInBlueprint?
meta = (CannotImplementInterfaceInBlueprint) 明确禁止蓝图实现这个接口。原因看接口的返回类型就懂了:
virtual const TMap<URPGItem*, FRPGItemData>& GetInventoryDataMap() const = 0; // 返回 TMap 的【引用】
virtual FOnInventoryItemChangedNative& GetInventoryItemChangedDelegate() = 0; // 返回 Native 委托的【引用】
这些返回类型——TMap<>&、FOnInventoryItemChangedNative&——蓝图根本表达不了。蓝图没法持有一个原生委托的引用,也没法返回容器引用。所以这个接口从设计上就只给 C++ 用,干脆锁死,不留给蓝图实现的口子。
还有个细节:接口只暴露 *Native 版委托(GetInventoryItemChangedDelegate 返回的是 FOnInventoryItemChangedNative&)。因为接口的使用者是角色这种 C++ 核心系统,它要的就是第 ① 顺位、先于 UI 的那套 Native 委托——正好呼应第三节的广播顺序。
4.3 谁实现了这个接口?
回到上一篇的类声明:
class ACTIONRPG_API ARPGPlayerControllerBase : public APlayerController, public IRPGInventoryInterface
Controller 实现接口的方式特别朴素——直接在头文件里 inline 把内部成员的引用透出去(RPGPlayerControllerBase.h:112-132):
virtual const TMap<URPGItem*, FRPGItemData>& GetInventoryDataMap() const override
{
return InventoryData; // 把上一篇那张权威 Map 的引用直接交出去
}
virtual FOnInventoryItemChangedNative& GetInventoryItemChangedDelegate() override
{
return OnInventoryItemChangedNative; // 把 Native 委托的引用交出去,供 C++ 订阅
}
// ... 其余三个同理
五、角色如何用接口订阅:PossessedBy / UnPossessed
接口的真正消费者是 ARPGCharacterBase。它持有的不是某个具体 Controller,而是一个接口引用(RPGCharacterBase.h:123-125):
/** 缓存的背包来源,可能为 null(AI 没有背包) */
UPROPERTY()
TScriptInterface<IRPGInventoryInterface> InventorySource;
TScriptInterface<I> 是 UE 的"接口智能引用":内部同时持有一个 UObject* 和一个 I*,既能被 GC 追踪,又能直接调接口方法。
绑定时机在 PossessedBy(控制器接管角色时,RPGCharacterBase.cpp:211-230):
void ARPGCharacterBase::PossessedBy(AController* NewController)
{
Super::PossessedBy(NewController);
// 直接把 Controller 赋给接口引用——对 AI 控制器会赋值失败(它没实现接口),InventorySource 保持无效
InventorySource = NewController;
if (InventorySource)
{
// 通过接口拿到 Native 委托,订阅它(注意:订的是 Native 版,第 ① 顺位)
InventoryUpdateHandle = InventorySource->GetSlottedItemChangedDelegate()
.AddUObject(this, &ARPGCharacterBase::OnItemSlotChanged);
InventoryLoadedHandle = InventorySource->GetInventoryLoadedDelegate()
.AddUObject(this, &ARPGCharacterBase::RefreshSlottedGameplayAbilities);
}
// ... 初始化 GAS
}
三个要点:
InventorySource = NewController是一次"接口赋值"。TScriptInterface在赋值时会去检查这个 UObject 有没有实现URPGInventoryInterface。玩家的 Controller 实现了 → 赋值成功;AI 的AAIController没实现 → 赋值后InventorySource无效。所以下面if (InventorySource)天然把 AI 排除在外——AI 角色不需要背包驱动的技能。- 角色订阅的是 Native 委托(
GetSlottedItemChangedDelegate()返回FOnSlottedItemChangedNative&),用AddUObject直连。这就是它能"先于 UI"被通知的原因。 - 存下
FDelegateHandle:InventoryUpdateHandle/InventoryLoadedHandle,为的是之后能精确解绑。
解绑时机在 UnPossessed(控制器离开角色时,cpp:232-245):
void ARPGCharacterBase::UnPossessed()
{
if (InventorySource && InventoryUpdateHandle.IsValid())
{
InventorySource->GetSlottedItemChangedDelegate().Remove(InventoryUpdateHandle);
InventoryUpdateHandle.Reset();
InventorySource->GetInventoryLoadedDelegate().Remove(InventoryLoadedHandle);
InventoryLoadedHandle.Reset();
}
InventorySource = nullptr;
}
绑了必须解。否则角色被换下、Controller 还活着时,那个已经失效的角色仍挂在 Controller 的委托列表上,下次广播就会调到悬空对象。PossessedBy 绑、UnPossessed 解,成对出现——这是 UE 委托使用的铁律。
六、把整条通知链串起来
用一个场景收束全篇:玩家在背包里把一把新剑拖进武器槽,触发 SetSlottedItem:
SetSlottedItem(武器槽0, 剑)
└─ 改完 SlottedItems
└─ NotifySlottedItemChanged(武器槽0, 剑)
├─ ① OnSlottedItemChangedNative.Broadcast(...) ← C++ 先收到
│ └─ 角色 ARPGCharacterBase::OnItemSlotChanged
│ └─ 刷新 GAS 技能(旧技能收回 / 新技能授予) ← 逻辑先落定
├─ ② OnSlottedItemChanged.Broadcast(...) ← 蓝图后收到
│ └─ UI Widget 重画装备栏 ← 表现后刷新
└─ ③ SlottedItemChanged(...)(BP 子类钩子)
而角色之所以能出现在第 ① 步,是因为它在 PossessedBy 时,通过 IRPGInventoryInterface 接口拿到了 Controller 的 Native 委托并订阅了它——全程没有一次 Cast 到具体的 ARPGPlayerControllerBase。
两套委托解决"性能与顺序",接口解决"解耦",两者合起来,才让"改一次背包,逻辑先行、表现随后、各系统互不写死"成为可能。
七、动手与验收
动手任务
- 在
NotifyInventoryItemChanged的三行Broadcast之间各加一行UE_LOG,再在角色的OnItemSlotChanged和 UI 的处理函数里各加日志,PIE 中换装备,观察日志验证"Native 先、Dynamic 后"。 - 在
PossessedBy里加日志打印InventorySource是否有效;分别用玩家角色和一个 AI 角色触发,确认 AI 的InventorySource为无效。 - 在编辑器里打开背包 UI 的 Widget 蓝图,找到
Bind Event to On Inventory Item Changed节点,确认它绑的正是 Controller 的 Dynamic 委托。
验收清单
- 能说清"为什么每个事件要 Native + Dynamic 两套委托"(蓝图可达 vs 性能/优先级)。
- 能复述三连广播的顺序,并解释"为什么 Native 必须排第一"。
- 能区分 Dynamic 委托和
BlueprintImplementableEvent两个"给蓝图用"的机制有何不同。 - 能解释 UE 接口为什么要 U 类 + I 类两个类,以及
CannotImplementInterfaceInBlueprint的原因。 - 能说出
TScriptInterface<IRPGInventoryInterface>如何让角色"不 cast 也能读背包",以及PossessedBy绑、UnPossessed解的必要性。
1248

被折叠的 条评论
为什么被折叠?



