【UE源码精读-ActionRPG】背包系统02:委托广播与接口解耦

[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。


一、先想清楚:改一次背包,谁需要知道?

背包数据一变,至少有两类"观众"想第一时间知道:

  1. UI(蓝图 UMG Widget)——背包面板、装备栏要重画。它们是蓝图,得用蓝图能绑的通知。
  2. 角色系统(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;

PlayerControllerBase 中并排声明的 Dynamic 与 Native 两套委托

注意两个关键区别:

  • 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 delegateC++AddUObject / AddLambda快(函数指针)角色、GAS 等核心系统
Dynamic delegateC++ + 蓝图BindEvent / UFUNCTION慢(反射查找)UI 蓝图
BlueprintImplementableEvent蓝图子类重写直接重写函数——给 BP 子类一个钩子

为什么 Native 必须排第一? 因为 C++ 侧(典型是角色刷新技能)是"逻辑",蓝图 UI 是"表现"。如果 UI 先收到通知去重画,而角色还没把技能授予/收回处理完,UI 就可能画出一个"装备已变、但技能还没跟上"的中间态。先让逻辑落定,再让表现刷新——这是这套顺序的全部用意。

另外两个 Notify 函数 NotifySlottedItemChangedNotifyInventoryLoaded 是一模一样的三连套路(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 时)完成绑定:

蓝图侧 Bind Event 到 OnInventoryItemChanged

从左到右的链路是:

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,专门给反射系统认领。CastImplements<>()、蓝图识别接口,靠的都是它。
  • 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
}

三个要点:

  1. InventorySource = NewController 是一次"接口赋值"TScriptInterface 在赋值时会去检查这个 UObject 有没有实现 URPGInventoryInterface。玩家的 Controller 实现了 → 赋值成功;AI 的 AAIController 没实现 → 赋值后 InventorySource 无效。所以下面 if (InventorySource) 天然把 AI 排除在外——AI 角色不需要背包驱动的技能。
  2. 角色订阅的是 Native 委托GetSlottedItemChangedDelegate() 返回 FOnSlottedItemChangedNative&),用 AddUObject 直连。这就是它能"先于 UI"被通知的原因。
  3. 存下 FDelegateHandleInventoryUpdateHandle / 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

两套委托解决"性能与顺序",接口解决"解耦",两者合起来,才让"改一次背包,逻辑先行、表现随后、各系统互不写死"成为可能。


七、动手与验收

动手任务

  1. NotifyInventoryItemChanged 的三行 Broadcast 之间各加一行 UE_LOG,再在角色的 OnItemSlotChanged 和 UI 的处理函数里各加日志,PIE 中换装备,观察日志验证"Native 先、Dynamic 后"。
  2. PossessedBy 里加日志打印 InventorySource 是否有效;分别用玩家角色和一个 AI 角色触发,确认 AI 的 InventorySource 为无效。
  3. 在编辑器里打开背包 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 解的必要性。
内容概要:本文介绍了基于改进Retinex算法的视频图像增强技术研究,并提供了相应的Matlab代码实现。Retinex理论源于人类视觉系统对光照变化的适应性,通过分离图像的照度反射分量,有效提升图像的亮度、对比度和色彩保真度。文中所提出的改进算法旨在克服传统Retinex方法中存在的光晕伪影、噪声放大和计算复杂等问题,可能引入了如多尺度分解、颜色校正或自适应滤波等优化策略,从而实现更自然、清晰的图像增强效果。该研究特别适用于低光照、雾霾、水下拍摄等恶劣成像条件下的视频图像处理,提升后续视觉分析的准确性。; 适合人群:具备一定图像处理基础和Matlab编程经验的科研人员、研究生及工程技术人员,尤其是从事计算机视觉、视频监控、遥感影像、医学影像或无人机视觉导航等领域研究的专业人士。; 使用场景及目标:① 解决实际应用中因光照不足或环境干扰导致的图像质量下降问题;② 学习和掌握Retinex算法的核心思想及其改进方法;③ 获取可直接运行和调试的Matlab代码,作为相关课题研究或项目开发的技术参考。; 阅读建议:此资源以Matlab代码实现为核心,建议读者在阅读时结合代码逐行分析,理解算法的每一步实现细节。同时,应尝试使用不同的测试图像进行实验,调整算法参数,观察增强效果的变化,从而深入理解算法的性能特点和优化方向。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

夜猫逐梦

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值