双向依赖介绍与单向依赖的限制
双向依赖
依赖除了有单向的,还有双向的,即父节点调用子节点的方法,子节点也调用父节点的方法。
我们要坚决杜绝双向依赖,代码要高内聚,松耦合。耦合指的就是对象之间的依赖程度。双向依赖,则是依赖程度最强的一种情况。
在这里不管是自顶向下或自底向上的双向依赖,还是兄弟之间的双向依赖,只要是双向依赖,都要坚决杜绝的。
自顶向下单向依赖的限制
我们知道自顶向下的单向依赖指的是,父节点调用子节点的方法。
那么我们在很多情况下,也有子节点通知父节点的情况。比如当子节点播放一段动画,动画播放完毕后,要通知父节点,动画播放完毕。
在这种情况下,单向依赖就有了限制,而且不可以使用双向依赖。
我们引入委托和事件,来解决这个问题。
子节点使用委托
单向依赖的限制,即子节点不能调用父节点的方法。
要解决这个问题,很容易想到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里面添加一个回调就可以了
小结
委托和事件,算是两种极端的方式,委托的范围几乎只有一条线,而事件的范围又太广。
这时候我们就可以引入命令模式,使用命令是折中的好选择。
在决定版架构中,使用ICanSendEvent
、ICanSendEvent
来对事件的使用进行了限制,修改一些数据推荐使用命令来实现,做一些查询可以直接方法调用或者使用Query来实现,数据变更之后,做一些通知的事情,这个可以使用事件来实现,或者直接使用BIndableProperty
实现。并且决定版架构中也提供了主动注销和使用拓展方法UnregisterWhenGameObjectDestroy
注销的使用方式。
- 委托
- 范围最窄
- 需要注册注销成对
- 需要访问对象
- 注意回到地狱
- 推荐自底向上单向依赖使用
- 事件
- 范围最广
- 需要注册注销成对
- 不需要访问对象
- 容易消息满天飞
- 推荐模块之间、同级之间使用