双向依赖介绍与单向依赖的限制

双向依赖

依赖除了有单向的,还有双向的,即父节点调用子节点的方法,子节点也调用父节点的方法。

我们要坚决杜绝双向依赖,代码要高内聚,松耦合。耦合指的就是对象之间的依赖程度。双向依赖,则是依赖程度最强的一种情况。

在这里不管是自顶向下或自底向上的双向依赖,还是兄弟之间的双向依赖,只要是双向依赖,都要坚决杜绝的。

自顶向下单向依赖的限制

我们知道自顶向下的单向依赖指的是,父节点调用子节点的方法。

那么我们在很多情况下,也有子节点通知父节点的情况。比如当子节点播放一段动画,动画播放完毕后,要通知父节点,动画播放完毕。

在这种情况下,单向依赖就有了限制,而且不可以使用双向依赖。

我们引入委托和事件,来解决这个问题。

子节点使用委托

单向依赖的限制,即子节点不能调用父节点的方法。

要解决这个问题,很容易想到C#中的委托。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
using UnityEngine;

namespace FrameworkDesign2021
{
public class PlayerCtrl : MonoBehaviour
{
PlayerAnimationCtrl mAnimationCtrl;

void Start()
{
mAnimationCtrl = transform.Find("Animation").GetComponent<PlayerAnimationCtrl>();
//监听事件
mAnimationCtrl.OnDoSomethingDone += ()=>
{
Debug.Log("动画播放完毕");
};
mAnimationCtrl.DoSomething();
}
}
public class PlayerAnimationCtrl : MonoBehaviour
{
public Action OnDoSomethingDone = () => {};
public void DoSomething()
{
OnDoSomethingDone?.Invoke();
}
}
}

这样就可以做到,PlayerCtrl只依赖PlayerAnimationCtrl而PlayerAnimationCtrl不需要依赖PlayerCtrl。

但是委托也有一些问题

委托的弊端

委托除了需要注册之外,还需要考虑注销问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
using UnityEngine;

namespace FrameworkDesign2021
{
public class PlayerCtrl : MonoBehaviour
{
PlayerAnimationCtrl mAnimationCtrl;

void Start()
{
mAnimationCtrl = transform.Find("Animation").GetComponent<PlayerAnimationCtrl>();
//监听事件
mAnimationCtrl.OnDoSomethingDone += OnDoSomethingDone;

mAnimationCtrl.DoSomething();
}

void OnDoSomethingDone()
{
Debug.Log("动画播放完毕");
}

void OnDestroy()
{
mAnimationCtrl.OnDoSomethingDone -= OnDoSomethingDone;
}
}
//...
}

在这段代码中,不注销委托也是没问题的,因为父节点销毁了,子节点也会跟着销毁。不大可能出现空引用的问题。但是如果是模块间注册委托,就一定要记住及时注销。

想要用好委托,就一定要养成注册和注销成对出现的习惯,这个习惯实际上是内存申请/释放的习惯。

委托除了注意注销问题,还要注意代码质量问题:

1
2
3
4
5
6
7
8
9
this.Delay(1,() => {
this.Delay(1,() => {
if(a == b) {
this.Delay(1,() =>{

});
}
});
});

被称为“回调地狱”,这种情况要避免。

除了回调地狱问题,还有就是代码量增多的问题。

每当我们要监听一些事情的时候,就需要写一些声名委托的代码,如下所示

1
2
3
4
5
6
7
8
9
10
namespace QFramework
{
public abstract class NodeAction : IAction
{
public Action OnBeginCallback = null;
public Action OnEndCallback = null;
public Action OnDisposedCallback = null;
//...
}
}

所以委托的另一个不好的地方,就是要一些声明的代码。

委托小结

通过委托,我们可以解除单向依赖的限制,写少量委托是没有问题的。委托在C#中,也算是非常传统方式来解决单向依赖限制的问题了。

最后,再补充一条,同级之间的单向依赖,不可以使用委托。原因是同级一般情况下是无法得到对方的状态的,很容易出现添加监听是空的情况。

使用架构中的事件机制

在第一季中,我们提供了TypeEventSystem,这个System的思想和架构的思想是相同的,它内部也维护了一个用type作为key的字典,也就是说我们声明的事件类型也可以是接口。我们看一下例子

定义几个事件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class GameStartEvent
{

}

public class GameOverEvent
{
public int Score;//携带的参数以变量的形式存在
}

public interface ISkillEvent{}

//使用接口
public class PlayerSkillAEvent : ISkillEvent
{

}
public class PlayerSkillBEvent : ISkillEvent
{

}

注册几个事件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class TypeEventSystemExample : MonoBehaviour
{
private void Start()
{
TypeEventSystem.Global.Register<GameStartEvent>(OnGameStartEvent);
TypeEventSystem.Global.Register<GameOverEvent>(OnGameOverEvent);
TypeEventSystem.Global.Register<ISkillEvent>(OnSkillEvent);
//...
}

void OnGameStartEvent(GameStartEvent gameStartEvent)
{
Debug.Log("游戏开始了");
}
void OnGameOverEvent(GameOverEvent gameOverEvent)
{
Debug.Log($"游戏结束了,分数{gameOverEvent.Score}");
}
void OnSkillEvent(ISkillEvent skillEvent)
{
if(skillEvent is PlayerSkillAEvent)
{
Debug.Log("A 技能释放");
}
else if(skillEvent is PlayerSkillBEvent)
{
Debug.Log("B 技能释放");
}
}
//...
}

接着就可以通过TypeEventSystem发送事件了

1
2
3
4
5
6
7
8
9
10
11
TypeEventSystem.Global.Send<GameStartEvent>();
TypeEventSystem.Global.Send(
new GameOverEvent()
{
Score = 100
}
);

//使用接口发送事件
TypeEventSystem.Global.Send<ISkillEvent>(new PlayerSkillAEvent());
TypeEventSystem.Global.Send<ISkillEvent>(new PlayerSkillBEvent());

TypeEventSystem的出现,是为了简化消息机制,它会让我们的各种事件更加清晰直观。

使用事件机制解除单向依赖的限制

事件(消息)机制可以把两个对象之间的耦合降低到极致。

我们通过事件解除单向依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
using QF;

public class OnAnimationDone{}

public class A
{
public A()
{
TypeEventSystem.Gloabl.Register<OnAnimationDone>(_ =>{
//...
});

var b = new B();
b.PlayAnimation();
}
}

public class B
{
public void PlayAniamtion()
{
TypeEventSystem.Gloabl.Send<OnAnimationDone>();
}
}

事件机制的弊端

事件机制降低耦合,使两个对象不用访问彼此。但是也有坏处

首先,会影响代码的阅读体验。如果我们把单向依赖+委托就能搞定的逻辑全部都通过事件实现,那样逻辑就会非常碎,后来的人需要多翻好几个文件才能把逻辑搞懂。不适合合作。

其次,事件很容易破坏单向依赖的结构,事件的发送方和接收方不会受到任何限制,在使用事件时很不容易掌握事件的使用范围,从而导致设计时的犹豫或者是使用范围的不统一。使用范围不统一指的是,一开始事件的范围只是某一个模块内之间,结果到最后发现整个项目之间全部使用事件了。

基于以上,我们得出结论:模块之间可以用事件,对象之间不能用事件就不用事件,事件所负责的逻辑,其颗粒度尽量要大一点,比如GameStart、GameOver,而不是小逻辑,比如SetAToPatent这种。

上面的代码,如果A对B是单向依赖,最佳实践应该是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
using QF;

public class A
{
public A()
{
var b = new B();
b.PlayAnimation(_ => {
//...
});
}
}

public class B
{
public void PlayAniamtion(Action OnAnimationDone)
{
//...
OnAnimationDone.Invoke();
}
}

我们既不用声明事件,也不用想使用委托那样在B里面声明一个委托,直接在PlayAnimation里面添加一个回调就可以了

小结

委托和事件,算是两种极端的方式,委托的范围几乎只有一条线,而事件的范围又太广。

这时候我们就可以引入命令模式,使用命令是折中的好选择。

在决定版架构中,使用ICanSendEventICanSendEvent来对事件的使用进行了限制,修改一些数据推荐使用命令来实现,做一些查询可以直接方法调用或者使用Query来实现,数据变更之后,做一些通知的事情,这个可以使用事件来实现,或者直接使用BIndableProperty实现。并且决定版架构中也提供了主动注销和使用拓展方法UnregisterWhenGameObjectDestroy注销的使用方式。

  • 委托
    • 范围最窄
    • 需要注册注销成对
    • 需要访问对象
    • 注意回到地狱
    • 推荐自底向上单向依赖使用
  • 事件
    • 范围最广
    • 需要注册注销成对
    • 不需要访问对象
    • 容易消息满天飞
    • 推荐模块之间、同级之间使用