DungDaj 发表于 2022-1-11 13:58

Unity双向数据绑定的简易实现

1.什么是双向数据绑定

简单来说,就是多个单位对同一个数据实体存在实际的影响,举个非常简单的案例,就比如下面的颜色选择器,颜色数据是一份Vector4类型的数据,它只有一份,但是它可以被多个选择器控制,色相选择器可以控制,RGB通道数值也可以控制,而且每个控制器对这个数据的影响都会传播到其他的选择器。那么我们可以说,这些单位对同一个数据实体都存在实际的影响,(所谓影响就是读取或者写入)之间就形成了双向数据绑定。


2.为什么需要双向数据绑定

对于很多数据来说并不需要实现双向的数据绑定,比如对于玩家的金币数量,启动商店时可以读取金币的数量,交易完毕之后再写入数量。此时能够对金币数量产生影响的单位只有一个。
那么更多的,在游戏开发中,需要双向数据绑定的地方更多的是一些需要多个单位读取同一个对象、或者当某一个值需要被【观察】的时候。
所谓观察特指当某些单位对这个值的临界状态非常敏感,例如某些敌人AI会根据玩家的血量或其他的什么数值来调整自己的AI决策
比如玩家的血条,玩家的血条对于多个单位来说都是一个需要输出的值,血条需要被一些实体单位观察,也需要被输出到屏幕上,也会影响某些后期处理效果。那么肯定希望有个简单的方案,当玩家的血条改变的时候,其他所有的需要改变的单位都能同时改变自己的渲染参数或者逻辑参数。
那么对于我们来说,除了实现双向数据绑定的功能,这种相对更为抽象的系统应该是可以满足大部分的程度的需要的,所以要保持它的简单易用与低耦合。
3.实现双向数据绑定的原理

标题是简单的双向数据绑定,所以我们的原理会相对来说非常简单,这个原理来源于CS服务器架构,也就是由Server-Client构成的交互模式。服务器会提供部分API供客户端访问,Client可以通过向Server发送请求来实现对Server的控制。
如果我们要实现聊天的功能,简单来说就是ClientA给Server发送了一条消息,要求Server把这个消息广播给其他的Client,那么接受到ClientA的消息和ClientA的ID之后,Server会给所有不是ClientA的其他Client广播这条消息。
这种广播机制正好可以满足双向数据绑定的实现要求。


4.代码实战

由于数据接收端不确定到底是什么,也许是一个Unity的UI组件,或者是一个类或者一个状态机,所以显然,对于接收端来说,我们需要定义相关的接口,对任何需要观察一个数据或者调整数据的实体单位来说,只需要继承该接口,并实现即可。
public interface IDataReceiver<T>{
    int receiverId{get;set;}                // 接收器ID
}
同时,对于一个数据来说,它首先需要维护一个泛型数据,其次就是它必须维护一组IDataReceiver<T>,所以我们定义一个叫做TwoWayData<T>的类来表示这个数据
public class TwoWayData<T>{
   
    private T data;                // 所维护的数据
    private List<IDataaReceiver<T>> receivers;        //所有已经绑定的接收器
}
那么首先第一个要实现的功能就是TwoWayData<T>可以把当前的数据广播到其他所有的接收器,当然为了避免被广播的对象再次将数据传播回来,我们需要用ID来区分发起广播的接收器。
所以我们先给IDataReceiver<T>增加一个函数用于写入这个接收器的值,对于不同的接收器来说,如何利用这个值肯定是不一样的,所以也符合接口的设计。
public interface IDataReceiver<T>{
    int receiverId{get;set;}                // 接收器ID
    void receive(T value);                        // 覆写这个接收器的数据
    void unbind();                                        // 当解除绑定时,执行该函数
}
所以这样,TwoWayData<T>可以利用receive函数来给其他接收器广播当前的值。
public class TwoWayData<T>{
   
    private T data;                // 所维护的数据
    private List<IDataaReceiver<T>> receivers;        //所有已经绑定的接收器
    public TwoWayData(T dft){
      this.data = dft;
      receivers = new List<IDataReceiver<T>>();
    }
    public void broadcast(int id, T value){
      this.data = value;
               foreach(IDataReceiver<T> receiver in receivers){
            if(receiver.receiverId != id){
                receiver.receive(value);
            }
      }
    }
}
Ok,完成之后,这个架构就已经成形了,轻量级架构就是非常的好用。接着我们开始增加一些新的函数用于绑定和解绑接收器。
public class TwoWayData<T>{
   
    private T data;                                                // 所维护的数据
    private List<IDataaReceiver<T>> receivers;                      // 所有已经绑定的接收器
    private Queue<int> recycleIds;                                // 用于存放所有已经解绑的ID号
   
    public TwoWayData(T dft){
      this.data = dft;
      receivers = new List<IDataReceiver<T>>();
    }
    public void broadcast(int id, T value){
      /* 将当前的值广播给除id之外的其他接受器 */
      
      this.data = value;
               foreach(IDataReceiver<T> receiver in receivers){
            if(receiver.receiverId != id){
                receiver.receive(value);
            }
      }
    }
    public Action bind(IDataReceiver<T> receiver){
               /* 给当前数据绑定一个新的接收器,返回解绑该receiver的委托 */
      
      receiver.receiverId = nextId();                                        // 发放一个新的Id号
      receivers.Add(receiver);                                        // 增加一个接收器
      receiver.receive(data);                                                // 设定当前的值
      return () => unbind(receiver);                                        // 返回解绑委托
    }
    public void unbind(IDataReceiver<T> receiver){
      /* 解绑给定的接收器 */
      
      recycleIds.Enqueue(receiver.receiverId);                // 回收接收器的ID
      receiver.unbind();
      receivers.Remove(receiver);
    }
    public void nextId(){
      /* 当绑定一个新的接收器时,该函数用于计算一个新的ID号给目标接收器 */
      return recycleIds.Count > 0 ? recycleIds.Dequeue() : receivers.Count;
    }
}
Ok,大概就是这样,还有最后一个功能点,接收器不能单纯的接收数据,有的时候接收器也需要改写数据,于是我们还需要给IDataReceiver<T>增加一个write函数,用于覆写当前的数据
public interface IDataReceiver<T>{
    int receiverId{get;set;}                // 接收器ID
    Action<int, T> write{get;set;}        // 覆写数据
    void receive(T value);                // 覆写这个接收器的数据
    void unbind();                        // 当解除绑定时,执行该函数
}
那么同时,在绑定和解绑的时候,我们需要给write一个值。
    public Action bind(IDataReceiver<T> receiver){
               /* 给当前数据绑定一个新的接收器,返回解绑该receiver的委托 */
      
      receiver.receiverId = nextId();                                        // 发放一个新的Id号
      receiver.write = broadcast;
      receivers.Add(receiver);                                        // 增加一个接收器
      receiver.receive(data);                                                // 设定当前的值
      return () => unbind(receiver);                                        // 返回解绑委托
    }
    public void unbind(IDataReceiver<T> receiver){
      /* 解绑给定的接收器 */
      
      recycleIds.Enqueue(receiver.receiverId);                // 回收接收器的ID
      receiver.write = null;
      receiver.unbind();
      receivers.Remove(receiver);
    }
以上就是全部的代码内容了,接着我们来增加两个实体单位测试一下。首先增加一个静态类,用于存放目标数据。
public static class TwoWayTest{
        public static TwoWayData<float> myvalue = new TwoWayData<float>(0);
}
然后增加用两种不同的UI来作为接收器,一个是Text,一个是Slider。
public class TextReceiver : MonoBehaviour, IDataReceiver<float>{
   
    private string receiverName;
    private Text output;

    private void Start(){
      output = GetComponent<Text>();
      TwoWayTest.myvalue.bind(this);
    }
   
    // ------ 实现接收器 ------
    public int receiverId{get;set;}
    public Action<int, float> write{get;set;}
    public void receive(float value){
      output.text = $"{receiverName}: {value.ToString()}";
    }
    public void unbind(){}
}

public class SliderReceiver : MonoBehaviour, IDataReceiver<float>{
   
    private Slider slider;

    private void Start(){
      slider = GetComponent<Slider>();
      slider.onValueChanged.AddListener((float v)=>write(receiverId, v));
      TwoWayTest.myvalue.bind(this);
    }
   
    // ------ 实现接收器 ------
    public int receiverId{get;set;}
    public Action<int, float> write{get;set;}
    public void receive(float value){
      slider.value = value;
    }
    public void unbind(){
      slider.onValueChanged.RemoveAllListeners();
    }
}
注意,在Slider中,我们手动将接收器的write委托连接到了onValueChanged上,所以解绑的时候,需要清空所有的监听器,这也是为什么接收器需要定义unbind函数。
下面是最终的效果:


https://www.zhihu.com/video/1463941873034911744

RhinoFreak 发表于 2022-1-11 14:07

这算是非常糟糕的设计了。简直令系统耦合度爆炸[捂脸]

zt3ff3n 发表于 2022-1-11 14:12

C#默认的INotifyPropertyChanged就是这样的架构,只不过它没有采用脏标记模式去传播数据。不过我的实现方法确实有一个不可忽略的缺点,就是对于一条数据来说,认为载体是一个类,这样的绑定粒度太粗了。

RecursiveFrog 发表于 2022-1-11 14:16

INotifyPropertyChanged里包含了一个Handler事件。这个事件的最后会用到PropertyChangedEventArgs这个东西会用到一个基类EventArgs。这样做的好处是事件参数可以用缓存池。然而同时这里的言下之意是是事件接受的一方,不能篡改EventArgs或缓存该引用。也就是说它是一个单向的事件分发设计。

super1 发表于 2022-1-11 14:18

同样对于双向绑定的需求,你可以参考Unity的Editor代码。虽然它哪个问题很多,但是是一个能用的设计。

Ylisar 发表于 2022-1-11 14:23

首先我文章里也说了,双向数据绑定用的并不是非常广泛,甚至很多游戏中可以通过不同的方式来避免使用双向数据绑定,它本身就不是一个重型需求,此外就是,这个系列的文章只会去写轻量级或者微型架构,或者简易的立刻就能用的做法。

zt3ff3n 发表于 2022-1-11 14:31

而且这篇文章的重点并不是它如何被绑定的,如果你觉得这篇文章没有用的,可以选择忽视[微笑]

JamesB 发表于 2022-1-11 14:40

突然还想起来一个点,就是IDataReceiver接口中的大部分数据都是因为IDataReceiver默认了是双向数据绑定,对于那些只需要观察数据的对象来说,它们只需要实现receive函数即可,也是单向事件分发。

JamesB 发表于 2022-1-11 14:41

你这人啊,讨论技术不纯粹了不是。

super1 发表于 2022-1-11 14:48

你说的避免双向绑定在游戏运行时开发确实要避免,但在编辑器开发时属于痛点,其中还有Undo Redo的需求,这都是非常常见的需求。
页: [1] 2
查看完整版本: Unity双向数据绑定的简易实现