响应式编程入门教程第三节:ReactiveCommand 与 UI 交互

166 阅读6分钟

在前面的教程中,我们了解了 ReactiveProperty 如何帮助我们管理和响应数据的变化。现在,我们将步入响应式编程在 Unity UI 交互中的另一个核心角色:ReactiveCommand。它不仅仅是一个简单的命令模式实现,更是将命令的执行与 UI 状态、异步操作以及数据流紧密结合的强大工具。


1. 传统命令模式的局限性与响应式需求

在传统的 Unity UI 开发中,我们经常使用按钮的 onClick.AddListener() 来触发某个操作。当操作逻辑变得复杂,比如需要判断前置条件(玩家是否有足够的金币施放技能)、操作是异步的(网络请求),或者需要根据操作状态更新 UI(按钮禁用、加载动画),我们往往需要写大量的条件判断和状态管理代码。

考虑一个简单的例子:一个技能按钮。

  • 点击后施放技能。

  • 施放技能需要消耗能量。

  • 能量不足时,按钮应该禁用。

  • 技能施放过程中,按钮也应该禁用,并显示冷却时间。

传统实现中,这些逻辑会散布在按钮的点击回调、Update 函数、以及各种状态变量中,导致代码耦合、难以维护和测试。

响应式编程的目标就是解决这类问题:将状态的变化、事件的发生都视为数据流,然后通过操作符对其进行转换、组合和响应。ReactiveCommand 正是这种理念在命令执行上的体现。


2. ReactiveCommand 核心概念

ReactiveCommand 本质上是一个 ICommand 的响应式实现。它包含两个核心功能:

  • 执行命令: 当命令被触发时,执行预设的逻辑。

  • 判断能否执行: 提供一个 IObservable<bool> 流,该流的值决定了命令当前是否可执行。当此流的值变为 false 时,任何绑定到该命令的 UI 元素(如按钮)会自动禁用;变为 true 时则会启用。

让我们看看它的基本构造:

using UnityEngine;
using UniRx;

public class SkillSystem : MonoBehaviour
{
    // ReactiveProperty 用于管理玩家能量
    public ReactiveProperty<int> PlayerEnergy = new ReactiveProperty<int>(100);

    // ReactiveCommand 用于施放技能
    public ReactiveCommand ReleaseSkillCommand { get; private set; }

    private void Awake()
    {
        // 1. 创建 ReactiveCommand
        // 参数是一个 IObservable<bool>,用于决定命令是否可执行
        // 这里表示当 PlayerEnergy.Value >= 10 时,命令可执行
        ReleaseSkillCommand = PlayerEnergy
            .Select(energy => energy >= 10) // 根据能量值判断是否可施放
            .ToReactiveCommand(); // 将 IObservable<bool> 转换为 ReactiveCommand

        // 2. 订阅命令的执行
        // 当命令被执行时,扣除能量并打印日志
        ReleaseSkillCommand.Subscribe(_ =>
        {
            PlayerEnergy.Value -= 10;
            Debug.Log("技能施放成功!当前能量:" + PlayerEnergy.Value);
        }).AddTo(this); // 生命周期管理:当 GameObject 销毁时,自动取消订阅

        // 3. 订阅命令的可执行状态变化
        // 这可以用于在控制台观察按钮的禁用启用状态,实际UI绑定后会自动处理
        ReleaseSkillCommand.CanExecute
            .Subscribe(canExecute => Debug.Log("技能按钮是否可用: " + canExecute))
            .AddTo(this);
    }

    // 假设这是 UI 按钮的点击事件处理器
    // 我们会直接将按钮绑定到 ReleaseSkillCommand,所以这个方法通常不需要手动调用
    public void OnSkillButtonClick()
    {
        // 手动执行命令 (通常通过 UI 绑定自动触发)
        ReleaseSkillCommand.Execute();
    }
}

在上面的例子中,ReleaseSkillCommand 的可执行性完全由 PlayerEnergy 的值决定。当 PlayerEnergy.Value 低于 10 时,ReleaseSkillCommand.CanExecute 流会发出 false,此时任何绑定到此命令的 UI 元素将自动禁用。


3. UI 按钮绑定与数据驱动

这是 ReactiveCommand 最直观的应用场景。UniRx 提供了方便的扩展方法,可以直接将 ButtonToggle 等 UI 元素绑定到 ReactiveCommand

步骤:

  1. 在 Unity Inspector 中创建一个 UI Button

  2. 确保你的脚本 (SkillSystemWithUI) 挂载在一个 GameObject 上。

  3. Button 拖拽到脚本中相应的公共字段(skillButton)进行赋值。

代码实现:

using UnityEngine;
using UnityEngine.UI;
using UniRx;
using UniRx.Triggers; // 用于将 UI 事件转换为 Observable

public class SkillSystemWithUI : MonoBehaviour
{
    public ReactiveProperty<int> PlayerEnergy = new ReactiveProperty<int>(100);
    public ReactiveCommand ReleaseSkillCommand { get; private set; }

    public Button skillButton; // 在 Inspector 中拖拽赋值
    public Text energyText; // 用于显示能量值的Text

    private void Awake()
    {
        // 创建 ReactiveCommand
        ReleaseSkillCommand = PlayerEnergy
            .Select(energy => energy >= 10)
            .ToReactiveCommand();

        // 订阅命令的执行逻辑
        ReleaseSkillCommand.Subscribe(_ =>
        {
            PlayerEnergy.Value -= 10;
            Debug.Log("技能施放成功!当前能量:" + PlayerEnergy.Value);
        }).AddTo(this);

        // UI 绑定:将按钮的点击事件与 ReleaseSkillCommand 关联
        // BindTo 扩展方法会自动处理按钮的禁用启用状态
        // 这一行代码会同时处理:
        // 1. 当 ReleaseSkillCommand.CanExecute 变化时,自动更新 skillButton.interactable
        // 2. 当 skillButton 被点击时,自动调用 ReleaseSkillCommand.Execute()
        ReleaseSkillCommand.BindTo(skillButton).AddTo(this);

        // 绑定能量值到 Text
        PlayerEnergy.SubscribeToText(energyText, energy => $"能量: {energy}")
            .AddTo(this);
    }
}

ReleaseSkillCommand.BindTo(skillButton).AddTo(this); 这一行中,UniRx 帮我们做了两件事:

  1. ReleaseSkillCommand.CanExecute 的值变化时,自动更新 skillButton.interactable 属性。

  2. skillButton 被点击时,自动调用 ReleaseSkillCommand.Execute()

这样一来,按钮的禁用启用状态完全由 PlayerEnergy 的值驱动,我们无需手动在 Update 或其他地方去修改按钮的 interactable 属性。这就是数据驱动 UI 的魅力!


4. 异步命令与命令的禁用

很多时候,我们的命令执行会涉及到异步操作,比如网络请求、加载资源、播放动画等。ReactiveCommand 能够很好地处理这些异步场景,并且在异步操作进行时,自动将命令标记为不可执行。

核心机制: ToReactiveCommand 的重载方法可以接受一个 IObservable<bool> 作为 canExecute 源,而当命令被执行时,它会内部管理一个 IsExecutingReactiveProperty。当 Execute 被调用时,IsExecuting 变为 true,直到内部订阅的 IObservable 完成(Next 或 Error 或 Complete),IsExecuting 才变为 false。我们可以将这个 IsExecuting 结合到 canExecute 的逻辑中。

using UnityEngine;
using UnityEngine.UI;
using UniRx;
using System;
using Cysharp.Threading.Tasks; // 为了使用 UniTask

public class AsyncSkillSystem : MonoBehaviour
{
    public ReactiveProperty<int> PlayerEnergy = new ReactiveProperty<int>(100);
    public ReactiveCommand AsyncSkillCommand { get; private set; }

    public Button asyncSkillButton;
    public Text energyText;
    public Text statusText; // 用于显示异步操作状态

    private void Awake()
    {
        // 组合条件:能量充足
        var canExecuteSource = PlayerEnergy
            .Select(energy => energy >= 10);

        // 创建异步 ReactiveCommand
        // ToReactiveCommand() 不传入参数时,它会自动处理 IsExecuting 状态,
        // 并在异步操作执行期间将命令标记为不可执行。
        AsyncSkillCommand = canExecuteSource.ToReactiveCommand();

        // 订阅命令的执行逻辑 (异步操作)
        // 注意:这里我们使用 SelectMany 来处理异步操作
        AsyncSkillCommand
            .SelectMany(_ => SimulateAsyncTask()) // 当命令执行时,触发异步任务
            .Subscribe(
                _ => { /* 异步任务完成 */ },
                ex => Debug.LogError("异步技能施放失败: " + ex.Message) // 错误处理
            )
            .AddTo(this);

        // 监听命令的执行状态 (用于显示加载动画或禁用其他UI)
        AsyncSkillCommand.IsExecuting
            .Subscribe(isExecuting =>
            {
                statusText.text = isExecuting ? "技能冷却中..." : "准备就绪";
                // Button 的 interactable 属性将由 AsyncSkillCommand.BindTo 自动管理,
                // 确保在异步执行期间按钮被禁用。
            })
            .AddTo(this);

        // 将 AsyncSkillCommand 绑定到按钮
        // BindTo 会自动处理 CanExecute 和 IsExecuting 的组合逻辑,
        // 使得按钮在能量不足或异步操作进行中时自动禁用
        AsyncSkillCommand.BindTo(asyncSkillButton).AddTo(this);

        // 绑定能量值到 Text
        PlayerEnergy.SubscribeToText(energyText, energy => $"能量: {energy}")
            .AddTo(this);
    }

    // 模拟一个异步任务,例如网络请求或耗时计算
    private async UniTask<Unit> SimulateAsyncTask()
    {
        PlayerEnergy.Value -= 10; // 假设在技能开始时扣除能量
        Debug.Log("开始施放异步技能...");
        await UniTask.Delay(TimeSpan.FromSeconds(2)); // 模拟2秒的延迟
        Debug.Log("异步技能施放完成!当前能量:" + PlayerEnergy.Value);
        return Unit.Default; // Unit.Default 表示一个空值,类似于 void
    }
}

在这个例子中:

  1. AsyncSkillCommand 的可执行性不仅取决于 PlayerEnergy,还隐式地取决于它内部的 IsExecuting 状态。

  2. 当点击按钮触发 AsyncSkillCommand.Execute() 时,SimulateAsyncTask() 会被调用。

  3. SimulateAsyncTask() 执行期间(2秒),AsyncSkillCommand.IsExecuting 会为 true,导致 asyncSkillButton 自动禁用,并且 statusText 显示“技能冷却中...”。

  4. SimulateAsyncTask() 完成后,AsyncSkillCommand.IsExecuting 变回 false,按钮和状态文本恢复正常。

这种处理异步命令的方式极大地简化了状态管理代码,让开发者可以专注于业务逻辑本身。


5. ReactiveCommand 的高级用法与注意事项

  • 组合多个 CanExecute 源: 你可以通过 CombineLatestZip 等操作符,组合多个 IObservable<bool> 来决定一个 ReactiveCommand 的可执行性。例如,一个按钮可能需要同时满足“玩家在线”和“有足够的金币”两个条件才能点击。
using UnityEngine;
using UnityEngine.UI;
using UniRx;

public class CombinedCommandExample : MonoBehaviour
{
    public ReactiveProperty<bool> IsOnline = new ReactiveProperty<bool>(true);
    public ReactiveProperty<int> Gold = new ReactiveProperty<int>(50);
    public Button purchaseButton;

    private void Start()
    {
        var canExecuteSource = IsOnline
            .CombineLatest(Gold, (online, gold) => online && gold >= 20); // 必须在线且金币足够

        var purchaseCommand = canExecuteSource.ToReactiveCommand();

        purchaseCommand.Subscribe(_ =>
        {
            Gold.Value -= 20;
            Debug.Log("购买成功!当前金币: " + Gold.Value);
        }).AddTo(this);

        purchaseCommand.BindTo(purchaseButton).AddTo(this); // 绑定到UI按钮

        // 模拟状态变化
        Observable.Timer(TimeSpan.FromSeconds(2))
            .Subscribe(_ => IsOnline.Value = false) // 2秒后掉线
            .AddTo(this);
        Observable.Timer(TimeSpan.FromSeconds(4))
            .Subscribe(_ => IsOnline.Value = true) // 4秒后上线
            .AddTo(this);
    }
}

  • 指定执行参数: ReactiveCommand<TParam> 允许你在执行命令时传入参数。
using UnityEngine;
using UnityEngine.UI;
using UniRx;

public class ParametrizedCommandExample : MonoBehaviour
{
    public ReactiveProperty<int> PlayerEnergy = new ReactiveProperty<int>(100);
    public ReactiveCommand<int> SpendEnergyCommand { get; private set; }
    public Button spend5Button;
    public Button spend15Button;
    public Text energyText;

    private void Start()
    {
        SpendEnergyCommand = PlayerEnergy
            .Select(energy => energy > 0) // 只要有能量就能执行此命令
            .ToReactiveCommand<int>(); // 指定参数类型为 int

        SpendEnergyCommand.Subscribe(amount =>
        {
            PlayerEnergy.Value -= amount;
            Debug.Log($"花费了 {amount} 能量。当前能量:{PlayerEnergy.Value}");
        }).AddTo(this);

        // 绑定按钮,并在绑定时指定 Execute 的参数
        SpendEnergyCommand.BindTo(spend5Button, 5).AddTo(this); // 点击消耗5能量
        SpendEnergyCommand.BindTo(spend15Button, 15).AddTo(this); // 点击消耗15能量

        PlayerEnergy.SubscribeToText(energyText, energy => $"能量: {energy}").AddTo(this);
    }
}

  • 错误处理: 如果命令的订阅链中发生错误,错误会传播并可能导致订阅终止。你可以使用 CatchOnErrorResumeNext 等操作符来处理这些错误,保持命令的健壮性。

  • 生命周期管理: 再次强调,务必使用 AddTo(this)CompositeDisposable 来管理订阅的生命周期,避免内存泄漏。当 GameObject 被销毁时,所有通过 AddTo(this) 添加的订阅都会自动取消。


6. 总结与展望

ReactiveCommand 极大地提升了 Unity UI 交互开发的效率和代码质量。它提供了一种声明式的方式来管理命令的可执行性,优雅地处理了异步操作,并与 UI 元素进行了无缝集成。通过将命令视为数据流的一部分,我们能够构建更加响应式、可维护和可测试的 Unity 应用程序。

在下一篇教程中,我们将探讨 响应式集合(ReactiveCollection/ReactiveDictionary),以及它们如何与 UI 列表(如 ScrollView)结合,实现动态数据的自动绑定和刷新,进一步解锁数据驱动 UI 的潜力。