UniRx Learning(一)

 

这篇文章简要介绍了 UniRx 的用途及其特性和语法,并在最后通过 UniRx 实现一个简易 MVP 架构。

何为 UniRx

UniRx 是⼀个 Unity3D 的编程框架。

专注于解决异步逻辑,使得异步逻辑的实现更加简洁优雅。

UniRx 设计动机

在游戏中,大部分的逻辑在时间上是异步的。往往在实现异步逻辑的时候经常用到大量的回调,最终随着项目的扩张导致传说中的”回调地狱”。

相对较好的方法则是使用消息/事件的发送,结果导致”消息满天飞”,导致代码非常难以阅读。

使用 Coroutine 也不错,但是 Coroutine 本身的定义,是以一个方法的格式定义的,写起来是非常面向过程的。当逻辑稍微复杂一点,就很容易造成 Coroutine 嵌套 Coroutine,代码是非常不利于阅读的(强耦合)。

⽽ UniRx 的出现刚好解决了这个问题,它介于回调和事件之间。

它有事件的概念,只不过他的事件是像水一样流过来,而我们要做的则是简单地进行组织、变换、过滤、合并。

它也⽤用到了回调,只不过事件组织之后,只有简单⼀个回调就可以进行事件的处理了。

它的原理和 Coroutine(迭代器模式) 非常类似,但是比 Coroutine 强大得多。

UniRx 将(时间上)异步事件转化为 (响应式的) 事件序列,通过 LINQ 操作可以很简单地组合起来, 还支持时间操作。

为什么要用 UniRx

总结为⼀句话就是,游戏本身有大量的(时间上)异步逻辑,而 UniRx 恰好擅长处理(时间上)异步逻辑,使用 UniRx 可以节省我们的时间,同时让代码更简洁易读。

Rx 只是⼀套标准,在其他语言也有实现,如果在 Unity 中熟悉了这套标准,在其他语言上也是可以很 快上手的。比如 RxJava、Rx.cpp、SwiftRx 等等。

UniRx 介绍

UniRx 的基本语法格式

先来看一看 UniRx 的基本语法。

Observable.XXX().Subscribe() 是非常典型的 UniRx 格式。

关键字

Observable: 可观察的,形容词,形容后边的词(Timer) 是可观察的,我们可以粗暴地把 Observable 后边的理解成发布者。

Timer: 定时器,名词,被 Observable 描述,所以是发布者,是事件的发送方。

Subscribe: 订阅,动词,订阅谁呢?当然是前边的 Timer,这里可以理解成订阅者,也就是事件的接收方。

连起来则是:可被观察(监听)的.Timer( ).订阅( ) -> 订阅可被观察的定时器。

逻辑关系:

  • Timer 是可观察的。
  • 可观察的才能被订阅

但是 UniRx 的侧重点,不是发布者和订阅者这两个概念如何使用,而是事件从发布者到订阅者之间的过程如何处理。

所以两个点不重要,重要的是两点之间的线,也就是事件的传递过程,接下来将逐步了解这个过程。

使用 UniRx 实现一个定时器: Timer

Observable.Timer(TimeSpan.FromSeconds(5))
          .Subscribe(_ =>
          {
            //do something
          });

UniRx 的一些简单API

Update

在某种情况下,我们不得不让 Update 中充斥着大量的判断,以让程序选择不同的分支进行执行。但 UniRx 可以将多个分支选择语句独立出来,每一种情况对应一个独立的Update。

private void Update()
{
  if (A) {  ...  }

  if (B)
  {
    ...
    if (D) {  ...  }
    else {    }
  }

  switch (C) {  ...  }

  if (Input.GetMouseButtonUp(0)) {  ...  }
}

使用 UniRx 改写后:

void Start()
{
  // A 逻辑,实现了了 xx
  Observable.EveryUpdate()
            .Subscribe(_ => { if (A) { ... } })
            .AddTo(this);
  // B 逻辑,实现了了 xx
  Observable.EveryUpdate()
            .Subscribe(_ => { if (B) { ... if (D) { ... } else {} } })
            .AddTo(this);
  // C 逻辑,实现了了 xx
  Observable.EveryUpdate()
            .Subscribe(_ => { switch (C) { ... }})
            .AddTo(this);
  // ⿏鼠标点击检测逻辑
  Observable.EveryUpdate()
            .Subscribe(_ => { if (Input.GetMouseButtonUp(0)) { ... } })
            .AddTo(this);
}

AddTo

字面意思很简单,就是添加到。添加到哪里呢?添加到 Unity 的 GameObject 或者 MonoBehaviour。

为什么要添加到 GameObject 或者 MonoBehaviour

是因为,GameObject 和 MonoBehaviour 可以获取到 OnDestroy 事件。也就是 GameObject 或者 MonoBehaviour 的销毁事件。 这个销毁事件可以通过 AddTo 操作符 与 UniRx 进行销毁事件的绑定,也就是当 GameObject 或者 MonoBehaviour 被销毁时,同样去销毁正在进行的 UniRx 任务。

AddTo 实现

本质上, AddTo 是一个静态扩展关键字,他对 IDisposable 进行了扩展。

只要任何实现了 IDisposable 的接口,都可以使用 AddTo API,不管是不是 UniRx 的 API。

当 GameObject 销毁时,就会调用 IDisposable 的 OnDispose 这个方法。

AddTo 设计动机

有了 AddTo,在开启 Observable.EveryUpdate 时调用当前脚本的方法,则不会造成引用异常等错误,它使得 UniRx 的使用更加安全。

操作符 Where

Where 可以理解为一个条件语句,相当于 if,用于过滤掉不满足的条件。请观察下列代码的差异:

Observable.EveryUpdate()
          .Subscribe(_ =>
            {
              if (Input.GetMouseButtonUp(0))
              {
                 // do something
              }
            })
          .AddTo(this);

使用 Where 操作符如下:

Observable.EveryUpdate()
          .Where(_ =>  )
          .Subscribe(_ => Input.GetMouseButtonUp(0))
            {
                 // do something
            })
          .AddTo(this);

上面这段代码可以理解为:

  1. EveryUpdate 是事件的发布者。他会每帧会发送一个事件过来。
  2. Subscribe 是事件的接收者,接收的是 EveryUpdate 发送的事件。
  3. Where 则是在事件的发布者和接收者之间的一个过滤操作。会过滤掉不满足条件的事件。

通过两张图可以更容易的理解。

unirx-where

unirx-where-2

事件的本身可以是参数,但是 EveryUpdate 没有参数,所以在 Where 这行代码中不需要接收参数,所以使用 _ 来表示,不用参数。当然 Subscribe 也是⽤用了一个 _ 来接收参数。

操作符 First

两张图解决。

unirx-first

unirx-first-2

操作符 Merge

很简单,作用是将多个事件流合并为一个。

var leftMouseClickStream = Observable.EveryUpdate().Where(_ => Input.GetMouseButtonDown(0));
var rightMouseClickStream = Observable.EveryUpdate().Where(_ => Input.GetMouseButtonDown(1));
Observable.Merge(leftMouseClickStream, rightMouseClickStream)
          .Subscribe(_ =>
          {
            // do something
          });

以上代码的实现的逻辑是“当⿏标左键或右键点击时都会进⾏处理”。

也就是说,Merge 操作符将 leftMouseClickStream 和 rightMouseClickStream 合并成了⼀个事件流。

如下图所示:

unirx-merge

操作符 Select

Select 操作符原本是 LINQ 中的操作符,是选择的意思,⼀般是传⼊⼀个索引 i/index 然后根据索引返回具体的值,请看如下代码:

var testNumbers = new List<int>(){ 1,2,3}
var selectedValue = testNumbers[2];

但是 Select 本质,其实是进行了一次 映射 操作,它将一个变量 x,映射成了 List<T>[x]。这其实是函数式编程的思维。如果把 Select 理解为 映射变换 ,而不单单只是选择列表中的某些元素,那在 UniRx 内,Select 操作符的意义就能和 LINQ 中的意义进行统一了。在 UniRx 中的 Select,请看下图:

unirx-select

对 UGUI 的支持

按钮点击事件注册:

mButton.OnClickAsObservable()
       .Subscribe(_ =>
         {
           // do something
         });

Toggle:

mToggle.OnValueChangedAsObservable()
       .Subscribe(on =>
         {
           if (on)
           {
           // do something
           }
         });

当然 UGUI 的事件也支持操作符。

比如,上述 Toggle 的代码可以简化如下:

mToggle.OnValueChangedAsObservable()
       .Where(on=>on)
       .Subscribe(on =>
         {
           // do some thing
         });

再比如,只能点击一次按钮:

mButton.OnClickAsObservable()
       .First()
       .Subscribe(_ =>
         {
           // do something
         });

不止如此,还⽀支持 EventSystem 的各种 Trigger 接口的监听。 比如:Image 本身是 Graphic 类型的,Graphic 类,只要实现 IDragHandler 就可以进行拖拽事件监听。 但是使用 UniRx 就不用那么麻烦,无需自己实现一些接口。 代码如下:

mImage.OnBeginDragAsObservable().Subscribe(_=>{}); mImage.OnDragAsObservable().Subscribe(eventArgs=>{}); mImage.OnEndDragAsObservable().Subscribe(_=>{})

Unity 的 Event 也是可以使用 AsObservable 进行订阅。 比如:

UnityEvent mEvent;
void Start()
{
  mEvent.AsObservable()
        .Subscribe(_ =>
        {
          // process event
        });
}

上文中 Button 组件的 OnClickAsObservable 方法其实是对 button.onClick() 这个 UnityEvent 进行订阅,即 button.onClick().AsObservable

ReactiveProperty

ReactiveProperty,响应式属性,是 UniRx 中一个十分强大的概念,它可以方便的监听一个值是否发生了改变。通常方法,需要在属性的访问器中进行值的监听,以选择不同的语句分支,如下所示:

public int Age
{
  get { ... }
  set
  {
    if (mAge != value)
    {
      mAge = value; // send event
      OnAgeChanged(); // call delegate
    }
  }
}

public void OnAgeChanged() {}

利用 delegate 还可以在类的外部进行监听。

下面再给出一个 UniRx 实现:

public ReactiveProperty<int> Age = new ReactiveProperty<int>();
void Start()
{
  Age.Subscribe(age =>
  {
    // do age
  });
  Age.Value = 5;
}

也可以把 ReactiveProperty<int> 替换成 IntReactiveProperty 以进行 Json 序列化。

当任何时候,Age 的值被设置,就会通知所有 Subscribe 的回调函数。

而 Age 可以被 Subscribe 多次。

并且同样支持 First、Where 等操作符。

这样可以实现一个叫做 MVP 的架构模式。

也就是在 Ctrl 中,进行 Model 和 View 的绑定。

Model 的所有属性都是用 ReactiveProperty,然后在 Ctrl 中进行订阅。

通过 View 更改 Model 的属性值。

形成一个 View->Ctrl->Model->Ctrl->View 这么一个事件响应环。

MVP 实现

上文提到过, UniRx 对 UGUI 进行了极大的增强,而这种增强,可以令我们十分简单的实现 MVP 框架。

UGUI 增强

UniRx 对 UGUI 的增强原理很简单,就是对 UnityEvent 提供了 AsObservable 方法。 代码如下:

public Button mButton;

void Start()
{
    mButton.onClick.AsObservable().Subscribe(_ => Debug.Log("clicked"));
}

然后我们再看一下 onClick 的定义:

public Button.ButtonClickedEvent onClick
{
  get
  {
    return this.m_OnClick;
  }
  set
  {
    this.m_OnClick = value;
  }
}

然后是 Button.ButtonClickedEvent 的定义:

public class ButtonClickedEvent : UnityEvent
{
}

在此基础上,进一步对每一个 UGUI 控件进行封装,从而可以像这种方式在 UGUI 中使用 UniRx mButton.OnClickAsObservable().Subscribe(_ => Debug.Log("clicked")),其实它等同于 mButton.onClick.AsObservable().Subscribe(_ => Debug.Log("clicked"))

使用 UniRx 可以很容易地实现 MVP(MVRP)设计模式。 MVP 的结构图如下所示:

unirx-mvp

虽然在 Unity 里无法真的实现 Model-View 绑定,但 Observables 可以通知订阅者,功能上也差不多。这种模式叫 Reactive Presenter:

public class ReactivePresenter : MonoBehaviour
{
// Presenter is aware of its View (binded in the inspector)
    public Button mButton;

    public Toggle mToggle;

// State-Change-Events from Model by ReactiveProperty
    Enemy enemy = new Enemy(1000);

    void Start()
    {
// Rx supplies user events from Views and Models in a reactive manner
        mButton.OnClickAsObservable().Subscribe(_ => enemy.CurrentHp.Value -=
            99);
        mToggle.OnValueChangedAsObservable().SubscribeToInteractable(mButton);
// Models notify Presenters via Rx,and Presenters update their views
        enemy.CurrentHp.SubscribeToText(GetComponent<Text>());
        enemy.IsDead.Where(isDead => isDead)
            .Subscribe(_ => { mToggle.interactable = mButton.interactable = false; });
    }
}

// The Mode. All property notify when their values change
public class Enemy
{
    public ReactiveProperty<long> CurrentHp { get; private set; }
    public IReadOnlyReactiveProperty<bool> IsDead { get; private set; }

    public Enemy(int initialHp)
    {
        // Declarative Property
        CurrentHp = new ReactiveProperty<long>(initialHp);
        IsDead = CurrentHp.Select(x => x <= 10).ToReactiveProperty();
    }
}

在 Unity 中,我们把 Scene 中的 GameObject 当做视图层,这些是在 Unity 的 Hierarchy 中定义的。 展示/控制层在 Unity 初始化时将视图层绑定。 SubscribeToText and SubscribeToInteractable 都是简洁的类似绑定的辅助函数。

View -> ReactiveProperty -> Model -> RectiveProperty - View 完全⽤响应式的⽅式连接。UniRx 提供 了所有的适配⽅法和类,不过其他的 MVVM (or MV*) 框架也可以使⽤。UniRx/ReactiveProperty 只是 ⼀个简单的⼯具包。

结束语

到目前为止,我们只谈到了 UniRx 一个极为简短的概括,和它具体实施起来的一些操作,这是我的学习资料导致的。我本人其实更加倾向于写概括性的东西,尤其是在学习的开始阶段,对于整体的把握,我认为要胜过对具体的把握。所以在文章的最后,我到 ReactiveX 官方网站翻了翻文档,他们给出了一个极为全面并且重要的概述,简洁的表达了 ReactiveX 的核心思想,以及几个重要组件。我认为十分有必要翻阅,并且仔细研读。