响应式编程入门教程第二节:构建 ObservableProperty<T> — 封装 ReactiveProperty 的高级用法

响应式编程入门教程第一节:揭秘 UniRx 核心 - ReactiveProperty - 让你的数据动起来!-CSDN博客

响应式编程入门教程第二节:构建 ObservableProperty<T> --- 封装 ReactiveProperty 的高级用法-CSDN博客

在上一篇中,我们详细探讨了 UniRx 的核心组件 ReactiveProperty<T> ,了解了它如何让数据变化自动通知订阅者,从而简化了数据绑定和状态管理。ReactiveProperty<T> 能够告诉我们"值变了,新值是什么",这在很多场景下都非常有用。

然而,在实际的游戏开发中,我们经常会遇到这样的需求:不仅想知道"值变了",还想知道"值是从什么变成了什么" ------也就是说,我们希望在收到通知时,能够同时获取到旧值 (Old Value)新值 (New Value)

例如,当玩家血量从 50 变为 30 时,我们可能需要播放一个"受伤"的音效;当从 10 变为 0 时,则需要触发"死亡"动画。如果只有新值,我们通常需要额外记录旧值,并进行比较。这虽然不难,但如果每个需要新旧值的地方都重复这些逻辑,代码就会变得冗余。

这就是我的框架中 ObservableProperty<T> 类诞生的原因。它正是为了优雅地解决这个痛点,在 ReactiveProperty<T> 的基础上,提供一个功能更强大的"可观察属性"。那么现在我们来聊聊ObservableProperty<T>类是如何设计的、以及如何使用的。

ObservableProperty 的设计思想

ObservableProperty<T> 的核心目标是:ReactiveProperty<T> 强大的数据通知能力之上,额外提供对旧值的追踪,并将旧值和新值同时传递给订阅者。

为了实现这一点,它的设计思想可以概括为以下几点:

  1. 内部封装: ObservableProperty<T> 会在内部私有地持有一个 ReactiveProperty<T> 实例。所有的值设置和变化通知,仍然由这个内部的 ReactiveProperty<T> 来驱动。

  2. 旧值记录: 引入一个私有变量 _oldValue,专门用于保存属性上一次的值。

  3. 定制订阅行为: 在提供给外部的 Subscribe 方法中,我们会对内部 ReactiveProperty<T> 的通知流进行一些巧妙的操作:

    • 过滤初始值: 使用 UniRx 的操作符 Skip(1),跳过 ReactiveProperty<T> 在订阅时立即发射的那个初始值通知,确保我们只关心真正的"变化"。

    • 注入旧值:ReactiveProperty<T> 实际发生变化时,将我们记录的 _oldValue 和当前变化的 newVal 一同传递给订阅者。

  4. 自动更新旧值: 在每次值变化并成功通知订阅者后,立即将 _oldValue 更新为当前的 newVal,为下一次变化做好准备。

ObservableProperty<T> 的实现与解析

现在,让我们来详细剖析这个类的代码实现,理解它是如何将上述设计思想变为现实的:

cs 复制代码
using UniRx; // 引入 UniRx 库,提供 ReactiveProperty 和各种操作符
using System; // 引入 System 命名空间,提供 Action 和 IDisposable

/// <summary>
/// 一个包装 ReactiveProperty 的类,支持旧值记录与变化事件
/// 使得订阅时能同时获取到属性的旧值和新值。
/// </summary>
public class ObservableProperty<T>
{
    private ReactiveProperty<T> _rp; // 内部封装的 ReactiveProperty 实例,负责底层的通知机制
    private T _oldValue; // 用于记录属性上一次的值

    /// <summary>
    /// 构造函数,初始化 ObservableProperty。
    /// 旧值和当前值都将首先被设置为 initialValue。
    /// </summary>
    /// <param name="initialValue">属性的初始值。</param>
    public ObservableProperty(T initialValue)
    {
        _oldValue = initialValue; // 初始时,旧值就等于我们传入的初始值
        _rp = new ReactiveProperty<T>(initialValue); // 内部的 ReactiveProperty 也用初始值进行初始化
    }

    /// <summary>
    /// 属性的当前值。读写此属性将间接操作内部的 ReactiveProperty。
    /// </summary>
    public T Value
    {
        get => _rp.Value; // 获取当前值,等同于获取内部 ReactiveProperty 的值
        set => _rp.Value = value; // 设置值时,会自动触发内部 _rp 的通知机制
    }

    /// <summary>
    /// 订阅属性的变化事件。回调函数将同时接收到旧值和新值。
    /// </summary>
    /// <param name="onChanged">当属性值变化时触发的回调函数,第一个参数是旧值,第二个参数是新值。</param>
    /// <returns>一个 IDisposable 对象,用于取消订阅,防止内存泄漏。</returns>
    public IDisposable Subscribe(Action<T, T> onChanged)
    {
        return _rp
            .Skip(1) // UniRx 操作符:跳过 ReactiveProperty 在订阅时立即发射的第一个(初始)值。
                     // 我们只关心真正的"变化"发生时的通知。
            .Subscribe(newVal => // 订阅 _rp 后续的值变化
            {
                // 调用传入的回调函数,同时提供我们记录的 _oldValue 和当前最新的 newVal
                onChanged?.Invoke(_oldValue, newVal);

                // 核心逻辑:通知完成后,更新 _oldValue 为当前的新值,为下一次变化做准备
                _oldValue = newVal;
            });
    }
}
关键点解析:
  1. private T _oldValue; : 这个私有变量是整个 ObservableProperty 实现旧值记录的关键。它就像一个记忆装置,总能记住上一次的值。

  2. 构造函数 ObservableProperty(T initialValue) : 在这里,_oldValue 和内部的 _rp 都被赋予了相同的 initialValue。这确保了在属性的生命周期开始时,所有状态都是同步的。

  3. Value 属性 : 这是一个简单的封装层。我们通过它来访问和修改内部 _rp.Value。当你 set 新值时,_rp 会自动触发它的通知机制。

  4. public IDisposable Subscribe(Action<T, T> onChanged): 这是这个类的核心对外接口。

    • Action<T, T> onChanged : 注意这个委托的签名。它明确地表示你的回调函数需要接收两个 T 类型的参数,这正是我们期望的旧值新值

    • .Skip(1) : 这是 UniRx 中一个非常常用的操作符 。它的作用是"跳过序列中的第一个元素"。为什么需要跳过呢?因为 ReactiveProperty 在被订阅时,会"礼貌性地"立即发送一次它当前的值。但在 ObservableProperty 的上下文中,我们通常只关心"值从 A 变为 B"这种变化 ,而不是初始状态的通知。Skip(1) 确保了 onChanged 回调只在值真正改变之后才被调用。

    • onChanged?.Invoke(_oldValue, newVal); : 当内部 _rp 的值发生变化时,这个 Lambda 表达式会被执行。我们在这里调用了用户传入的 onChanged 回调函数,并巧妙地将 _oldValue (我们之前记录的上一次的值) 和 newVal (当前最新的值) 一同传递了过去。?.Invoke 是 C# 6.0 引入的空条件运算符,它确保只有当 onChanged 不为 null 时才调用 Invoke,防止潜在的空引用异常。

    • _oldValue = newVal; : 这一步是整个旧值追踪逻辑的关键! 在通知了所有订阅者之后,我们将 _oldValue 更新为当前的 newVal。这样,当属性在未来再次发生变化时,_oldValue 就能准确地代表它前一个值,从而确保了整个机制的正确性。

ObservableProperty<T> 的使用示例

现在,让我们通过一个具体的 Unity 游戏场景来展示 ObservableProperty<T> 的实际用法,看看它如何让你的代码更清晰、更强大:

cs 复制代码
using UnityEngine;
using UniRx; // 别忘了在你的脚本顶部加上这个
using System; // 也需要这个,因为 Action 和 IDisposable 在这里

public class PlayerStatusManager : MonoBehaviour
{
    // 定义一个 ObservableProperty<int> 来追踪玩家的当前血量,初始值为100
    public ObservableProperty<int> CurrentHealth = new ObservableProperty<int>(100);

    // 定义一个 ObservableProperty<bool> 来追踪玩家是否处于中毒状态
    public ObservableProperty<bool> IsPoisoned = new ObservableProperty<bool>(false);

    void Start()
    {
        Debug.Log("--- 游戏开始,初始化玩家状态 ---");

        // 1. 订阅玩家血量变化
        // 注意,我们的回调函数 now 可以同时接收 oldHealth (旧血量) 和 newHealth (新血量)
        CurrentHealth.Subscribe((oldHealth, newHealth) =>
        {
            Debug.Log($"玩家血量变化:从 {oldHealth} 变为 {newHealth}");

            // 根据新旧值,进行更精细的逻辑判断和表现
            if (newHealth <= 0 && oldHealth > 0) // 从活着到死亡
            {
                Debug.Log("<color=red>玩家死亡!触发游戏结束逻辑。</color>");
                // 比如:播放死亡动画,显示游戏结束界面
            }
            else if (newHealth < oldHealth) // 血量减少(受到伤害)
            {
                Debug.Log("玩家受到了伤害,播放受击音效或显示伤害数字。");
                // 比如:GetComponent<AudioSource>().PlayOneShot(damageSound);
                // 或:显示血条减少动画
            }
            else if (newHealth > oldHealth) // 血量增加(受到治疗)
            {
                Debug.Log("玩家获得治疗,播放治疗特效或显示治疗数字。");
                // 比如:Instantiate(healEffectPrefab, transform.position, Quaternion.identity);
            }
        }).AddTo(this); // 使用 AddTo(this) 管理订阅生命周期,避免内存泄漏

        // 2. 订阅中毒状态变化
        IsPoisoned.Subscribe((oldState, newState) =>
        {
            Debug.Log($"玩家中毒状态变化:从 {oldState} 变为 {newState}");

            if (newState && !oldState) // 从未中毒变为中毒
            {
                Debug.Log("<color=green>玩家中毒了!开始持续掉血。</color>");
                // 可以在这里启动一个中毒DOT (Damage Over Time) 协程
            }
            else if (!newState && oldState) // 从中毒变为解毒
            {
                Debug.Log("<color=cyan>玩家解毒了!停止持续掉血。</color>");
                // 可以在这里停止中毒DOT协程
            }
        }).AddTo(this);

        // 模拟游戏中的事件
        Invoke("TakeDamage", 2f);         // 2秒后受到伤害
        Invoke("ApplyPoison", 4f);        // 4秒后中毒
        Invoke("Heal", 6f);               // 6秒后回血
        Invoke("TakeFatalDamage", 8f);    // 8秒后受到致命伤害
    }

    void TakeDamage()
    {
        Debug.Log("\n--- 模拟:玩家受到攻击 ---");
        CurrentHealth.Value -= 30; // 改变血量,会自动触发订阅
    }

    void ApplyPoison()
    {
        Debug.Log("\n--- 模拟:玩家中毒 ---");
        IsPoisoned.Value = true; // 改变中毒状态,会自动触发订阅
    }

    void Heal()
    {
        Debug.Log("\n--- 模拟:玩家获得治疗 ---");
        CurrentHealth.Value += 20; // 改变血量
    }

    void TakeFatalDamage()
    {
        Debug.Log("\n--- 模拟:玩家受到致命攻击 ---");
        CurrentHealth.Value = 0; // 改变血量,触发死亡
    }

    // 当这个 MonoBehaviour 被销毁时,所有通过 AddTo(this) 注册的订阅都会自动被 Dispose。
}

为什么选择 ObservableProperty<T>

通过 ObservableProperty<T>,我们获得了以下显著优势:

  • 更丰富的变化信息: 同时获得旧值和新值,让你的逻辑判断更加精准和灵活,尤其适用于需要根据变化方向或幅度执行不同行为的场景。

  • 更简洁的订阅代码: 无需在每次订阅时手动处理 Skip(1) 和旧值记录的逻辑,ObservableProperty<T> 已经为你将这些细节封装起来。

  • 统一的接口: 无论什么类型的数据(int, string, bool, enum 甚至自定义类),你都可以用统一的 Subscribe(Action<T, T>) 接口来监听其变化,提高代码的可读性和一致性。

  • 数据驱动逻辑: 鼓励你使用数据变化来驱动游戏逻辑,而不是依赖传统的 Update() 或复杂的事件链,从而构建更清晰、更模块化、更具响应性的架构。

  • UniRx 生态集成: 作为 UniRx 家族的一员,ObservableProperty<T> 能够无缝地与其他 UniRx 操作符(如 Where, Select, Throttle, Debounce 等)结合使用,进一步增强其处理复杂数据流的能力。

总结

ReactiveProperty<T> 是 UniRx 库中一个非常强大和实用的概念,是构建响应式系统的基石。而 ObservableProperty<T> 则是对其能力的进一步拓展和封装,它完美解决了在数据变化时同时获取旧值和新值的需求,使得基于数据变化的逻辑处理更加优雅和高效。

希望这篇笔记能帮助你深入理解 ObservableProperty<T> 的设计思想、实现原理和实际应用。将其融入你的 Unity 框架中,你将能够构建更加健壮、可维护且响应迅速的游戏系统。

如果你对 UniRx 的其他高级操作符感兴趣,或者想了解如何将 ObservableProperty<T> 应用到更复杂的场景中,比如结合 MVVM 模式进行 UI 绑定,欢迎在评论区留言讨论!

响应式编程入门教程第一节:揭秘 UniRx 核心 - ReactiveProperty - 让你的数据动起来!-CSDN博客

响应式编程入门教程第二节:构建 ObservableProperty<T> --- 封装 ReactiveProperty 的高级用法-CSDN博客

相关推荐
19H1 小时前
Flink-Source算子状态恢复分析
c#·linq
weixin_472339463 小时前
高效处理大体积Excel文件的Java技术方案解析
java·开发语言·excel
Eiceblue5 小时前
【免费.NET方案】CSV到PDF与DataTable的快速转换
开发语言·pdf·c#·.net
m0_555762905 小时前
Matlab 频谱分析 (Spectral Analysis)
开发语言·matlab
浪裡遊6 小时前
React Hooks全面解析:从基础到高级的实用指南
开发语言·前端·javascript·react.js·node.js·ecmascript·php
lzb_kkk7 小时前
【C++】C++四种类型转换操作符详解
开发语言·c++·windows·1024程序员节
好开心啊没烦恼7 小时前
Python 数据分析:numpy,说人话,说说数组维度。听故事学知识点怎么这么容易?
开发语言·人工智能·python·数据挖掘·数据分析·numpy
简佐义的博客8 小时前
破解非模式物种GO/KEGG注释难题
开发语言·数据库·后端·oracle·golang