Zenject框架(十八)- 信号(Signals)
理论
假设有需要交互的两个类A和B,通常的做法是:
1.直接在A类中调用B类的方法。在这种情况下,A与B强耦合。
2.通过让B类观察A类中的事件来反转依赖性。在这种情况下,B与A强耦合
作为第三种选择,在某些情况下,两个类都不了解另一个可能更好。这样您的代码就可以保持尽可能松散的耦合。您可以通过让A和B与中间对象(在本例中是Zenject信号)交互来实现这一点,而不是彼此直接交互。
还要注意,虽然结果将更松散地耦合,但这并不总是更好的。信号可以像任何编程模式一样被误用,因此您必须考虑每种情况,以确定它是否适合它们。
快速开始
如果你想立即开始,请看下面基础用法的例子:
public class UserJoinedSignal
{
public string Username;
}
public class GameInitializer : IInitializable
{
readonly SignalBus _signalBus;
public GameInitializer(SignalBus signalBus)
{
_signalBus = signalBus;
}
public void Initialize()
{
_signalBus.Fire(new UserJoinedSignal() { Username = "Bob" });
}
}
public class Greeter
{
public void SayHello(UserJoinedSignal userJoinedInfo)
{
Debug.Log("Hello " + userJoinedInfo.Username + "!");
}
}
public class GameInstaller : MonoInstaller<GameInstaller>
{
public override void InstallBindings()
{
SignalBusInstaller.Install(Container);
Container.DeclareSignal<UserJoinedSignal>();
Container.Bind<Greeter>().AsSingle();
Container.BindSignal<UserJoinedSignal>()
.ToMethod<Greeter>(x => x.SayHello).FromResolve();
Container.BindInterfacesTo<GameInitializer>().AsSingle();
}
}
要运行,只需将上面的代码复制并粘贴到名为GameInstaller的新文件中,然后创建新场景以及场景上下文,并将上述文件添加到场景上下文中。
有很多种创建信号处理程序的方法,另一种方法如下:
public class Greeter : IInitializable, IDisposable
{
readonly SignalBus _signalBus;
public Greeter(SignalBus signalBus)
{
_signalBus = signalBus;
}
public void Initialize()
{
_signalBus.Subscribe<UserJoinedSignal>(OnUserJoined);
}
public void Dispose()
{
_signalBus.Unsubscribe<UserJoinedSignal>(OnUserJoined);
}
void OnUserJoined(UserJoinedSignal args)
{
SayHello(args.Username);
}
public void SayHello(string userName)
{
Debug.Log("Hello " + userName + "!");
}
}
public class GameInstaller : MonoInstaller<GameInstaller>
{
public override void InstallBindings()
{
SignalBusInstaller.Install(Container);
Container.DeclareSignal<UserJoinedSignal>();
// Here, we can get away with just binding the interfaces since they don't refer
// to each other
Container.BindInterfacesTo<Greeter>().AsSingle();
Container.BindInterfacesTo<GameInitializer>().AsSingle();
}
}
作为最后一种替代方法,您还可以将zenject信号与UniRx库结合使用,并将其改为:
public class Greeter : IInitializable, IDisposable
{
readonly SignalBus _signalBus;
readonly CompositeDisposable _disposables = new CompositeDisposable();
public Greeter(SignalBus signalBus)
{
_signalBus = signalBus;
}
public void Initialize()
{
_signalBus.GetStream<UserJoinedSignal>()
.Subscribe(x => SayHello(x.Username)).AddTo(_disposables);
}
public void Dispose()
{
_disposables.Dispose();
}
public void SayHello(string userName)
{
Debug.Log("Hello " + userName + "!");
}
}
请注意,如果您使用此方式,则需要启用UniRx集成。
正如您在上面的示例中所看到的,您可以使用BindSignal直接将处理程序方法绑定到安装程序中的信号(第一个示例),或者您可以让信号处理程序附加并将其自身分离到信号(第二个和第三个示例)
以下各节将介绍其工作原理。
声明信号(Signals Declaration)
在声明信号之前,您需要创建一个代表它的类。 例如:
public class PlayerDiedSignal
{
}
与信号一起传递的任何参数都应作为公共成员或属性添加。 例如:
public class WeaponEquippedSignal
{
public Player Player;
public IWeapon Weapon;
}
您可能还会考虑使信号类不可变,因此WeaponEquippedSignal改为下面的方式更好:
public class WeaponEquippedSignal
{
public WeaponEquippedSignal(Player player, IWeapon weapon)
{
Player = player;
Weapon = weapon;
}
public IWeapon Weapon
{
get; private set;
}
public Player Player
{
get; private set;
}
}
这不是必需的,但最好这样做以确保任何信号处理程序都不会更改信号参数值,以防可能会对其他信号处理程序行为产生负面影响。
在我们创建信号类之后,我们只需要在某个安装程序中声明它:
public override void InstallBindings()
{
Container.DeclareSignal<PlayerDiedSignal>();
}
现在,在声明该信号的容器中的任何对象或子容器都可以监听信号以及发送信号。
声明绑定语法(Declaration Binding Syntax)
声明信号的语法格式如下:
Container.DeclareSignal<SignalType>()
.WithId(Identifier)
.(RequiredSubscriber|OptionalSubscriber|OptionalSubscriberWithWarning)()
.(RunAsync|RunSync)()
.WithTickPriority(TickPriority)
.(Copy|Move)Into(All|Direct)SubContainers();
在这里:
-
SignalType - 代表信号的自定义类
-
Identifier - 用于唯一标识绑定的值。 在大多数情况下,这可以忽略,但在您希望使用相同信号类型定义多个不同信号的情况下,这可能很有用。
-
RequiredSubscriber/OptionalSubscriber/OptionalSubscriberWithWarning - 这些值控制信号在发送但没有与之关联的订阅者时的行为方式。 如果没有在ZenjectSettings中重写,则默认值是OptionalSubscriber,在这种情况下它将不执行任何操作。 设为RequiredSubscriber时,如果订阅者为零,则会抛出异常。 OptionalSubscriberWithWarning是一种出现异常时在控制台输出日志而不是抛出异常的折中方式。 选择哪一个取决于您更喜欢您的应用程序的严格程度,以及给定信号处理与否是否重要。
-
RunAsync/RunSync - 这些值控制信号是同步还是异步触发:
RunSync - 这意味着当通过调用SignalBus.Fire发送信号时,立即调用所有订阅者的处理程序。
RunAsync - 这意味着当发送信号时,稍后才会调用订阅者的处理程序(由TickPriority参数指定)。
请注意,如果没有在ZenjectSettings中重写,则默认是同步(sync)运行。 有关异步信号的讨论以及您有时可能希望使用它的原因,请参见“Asynchronous Signals”章节。 -
TickPriority - 在处执行信号处理程序方法的标记优先级。请注意,这仅在声明为RunAsync时适用。
-
(Copy|Move)Into(All|Direct)SubContainers - 和绑定命令语法中的该参数相同
请注意,可以在ZenjectSettings重写来更改RunSync / RunAsync和RequiredSubscriber / OptionalSubscriber的默认值
发送信号(Signal Firing)
要发送信号,您可以添加对SignalBus类的引用,然后像这样调用Fire方法:
public class UserJoinedSignal
{
}
public class UserManager
{
readonly SignalBus _signalBus;
public UserManager(SignalBus signalBus)
{
_signalBus = signalBus;
}
public void DoSomething()
{
_signalBus.Fire<UserJoinedSignal>();
}
}
如果信号有参数,那么需要创建它的新实例,如下所示:
public class UserJoinedSignal
{
public string Username;
}
public class UserManager
{
readonly SignalBus _signalBus;
public UserManager(SignalBus signalBus)
{
_signalBus = signalBus;
}
public void DoSomething()
{
_signalBus.Fire(new UserJoinedSignal() { Username = "Bob" });
}
}
当调用Fire()时,SignalBus会检查信号是否被声明,如未声明,则抛出异常。 如果想调用Fire()而不考虑信号是否被声明,请使用TryFire()方法忽略未声明的信号。 你可以像这样使用TryFire():
public class UserJoinedSignal
{
}
public class UserManager
{
readonly SignalBus _signalBus;
public UserManager(SignalBus signalBus)
{
_signalBus = signalBus;
}
public void DoSomething()
{
// 泛型版本
_signalBus.TryFire<UserJoinedSignal>(); //如果没有声明UserJoinedSignal的话也不会报错
// 非泛型版本
_signalBus.TryFire(new UserJoinedSignal());
}
}
使用BindSignal绑定信号
如上所述,除了直接在信号总线(signal bus)上订阅信号(通过SignalBus.Subscribe或SignalBus.GetStream)之外,您还可以直接在安装器(installer)中将信号绑定到处理类上。 与直接在处理类中订阅相比,这种方法有好有坏,因此按个人喜好使用。
BindSignal命令格式为:
Container.BindSignal<SignalType>()
.WithId(Identifier)
.ToMethod(Handler)
.From(ConstructionMethod)
.(Copy|Move)Into(All|Direct)SubContainers();
这里:
- SignalType - 代表信号的自定义类
- Identifier- 用于唯一标识绑定的值。 在大多数情况下,这可以忽略。 请注意,使用信号标识符时,您必须使在DeclareSignal(以及Fire,Subscribe等)中使用相同标识符
- ConstructionMethod - 绑定到上面的实例方法时(即ToMethod参数为实例方法时),还需要定义此实例的来源。 有关更多详细信息,请参阅下面的处理程序部分
- (Copy|Move)Into(All|Direct)SubContainers - 和绑定语法中的该参数相同
- Handler - 信号发送时应触发的方法。 有几种情况:
- 静态方法
Container.BindSignal<UserJoinedSignal>().ToMethod(s => Debug.Log("Hello user " + s.Username));
请注意,该方法也可以是无参数的:
Container.BindSignal<UserJoinedSignal>().ToMethod(() => Debug.Log("Received UserJoinedSignal signal"))
注意,在该例中,我们没有写From项,因为不需要实例
-
实例方法
例如:
public class Greeter
{
public void SayHello(UserJoinedSignal signal)
{
Debug.Log("Hello " + signal.Username + "!");
}
}
Container.Bind<Greeter>().AsSingle();
Container.BindSignal<UserJoinedSignal>().ToMethod<Greeter>(x => x.SayHello).FromResolve();
在该例中,我们希望信号触发Greeter.SayHello方法。 请注意,在这种情况下,我们需要为From项提供一个值,因为需要一个实例来调用给定的方法。
与静态方法类类似,也可以绑定到不带参数的方法:
public class Greeter
{
public void SayHello()
{
Debug.Log("Hello there!");
}
}
Container.Bind<Greeter>().AsSingle();
Container.BindSignal<UserJoinedSignal>().ToMethod<Greeter>(x => x.SayHello).FromResolve();
我们使用的是FromResolve,但我们也可以使用任何我们想要的构造方法。 在Zenject中,FromResolve实际扩展到以下内容:
Container.BindSignal<UserJoinedSignal>().ToMethod<Greeter>(x => x.SayHello).From(x => x.FromResolve().AsCached());
当无法在容器中其他位置访问处理程序类时,还有另一个FromNew的简写方法:
// 以下两者相同
Container.BindSignal<UserJoinedSignal>().ToMethod<Greeter>(x => x.SayHello).FromNew();
Container.BindSignal<UserJoinedSignal>().ToMethod<Greeter>(x => x.SayHello).From(x => x.AsCached());
因此,如果我们不需要将Greeter类注入其他地方时,我们也可以按如下方式实现:
public class Greeter
{
public void SayHello(UserJoinedSignal signal)
{
Debug.Log("Hello " + signal.Username + "!");
}
}
Container.BindSignal<UserJoinedSignal>().ToMethod<Greeter>(x => x.SayHello).FromNew();
这样,我们根本不需要为Greeter单独绑定(该例中即Container.Bind<Greeter>().AsSingle())。 您还可以向From提供许多其他类型的参数,包括绑定到延迟实例化的MonoBehaviour,工厂方法,自定义工厂,子容器中的外观等。
-
映射实例方法(Instance method with mapping)
可能还存在处理方法的参数直接包含信号参数的情况。 例如:
public class Greeter
{
public void SayHello(string username)
{
Debug.Log("Hello " + username + "!");
}
}
在这种情况下,您可以将信号绑定到映射了参数的方法上:
Container.Bind<Greeter>().AsSingle();
Container.BindSignal<UserJoinedSignal>().ToMethod<Greeter>((x, s) => x.SayHello(s.Username)).FromResolve()
SignalBusInstaller
信号是Zenject的可选功能。 导入Zenject时,如果您不想包含信号,只需取消选中OptionalExtras / Signals文件夹即可。若如此,信号不会自动启用,要使用信号,必须通过在某个安装程序中调用SignalBusInstaller.Install(Container)来自行安装它们。
您可以在ProjectContext安装程序中执行此操作一次,也可以在每个场景的SceneContext安装程序中执行此操作。 请注意,您只需要执行一次, 就可以在传递给SignalBusInstaller的容器中以及其子容器中使用信号,这就是为什么如果安装到ProjectContext,就不需要安装到SceneContext。
什么时候使用信号
在以下情况下,最适合用信号作为通信机制:
-
有多个接收方监听信号
-
发送方不需要从接收方返回结果
-
发送方不关心信号是否被收到。换句话说,当调用信号以使后续发送方逻辑正常工作时,发送方不应依赖某些状态更改。理想情况下,信号可被视为“即发即忘”事件
-
发送方不经常或在不可预测的时间发送信号
这些只是经验法则,但在使用信号时都很有用。与其他形式的通信(如直接调用方法,接口,C#事件类成员等)相比,发送方与接收方的响应行为的逻辑耦合越少,就越适合用信号。这种情况下也最好考虑使用异步信号(Asynchronous Signals)
当事件驱动程序被滥用时,你可能会发现自己处于“回调地狱”中,事件正在触发其他事件等,这使得整个系统无法理解。因此,一般情况下应谨慎使用信号。我个人喜欢将信号用于高级游戏范围的事件,然后使用其他形式的通信(unirx流,c#事件,直接方法调用,接口)来处理大多数事情。
Signals With Subcontainers
信号仅在声明它们的容器层级及其下方可见。 例如,您使用Unity的多场景支持功能,将您的游戏分为GUI场景和环境场景。 在GUI场景中,您可以发送一个信号,指示已打开/关闭GUI弹出窗口,以便环境场景可以暂停/恢复活动。 实现此目的的一种方法是在ProjectContext安装程序(或共享场景父级)中声明信号,然后在环境场景中订阅它,然后从GUI场景中发送该信号。
异步信号(Asynchronous Signals)
在某些情况下,可能需要异步运行给定信号。 异步信号具有以下优点:
-
触发信号处理程序的更新顺序更容易预测。 当使用同步信号时,信号处理程序方法在发送信号的同时执行,这可以在帧期间的任何时间触发,或者在某些情况下,如果信号被多次发送则触发多次。 这可能会导致一些更新顺序的问题。 对于异步信号,信号处理程序始终在TickPriority配置的帧中同时执行。
-
异步信号可以促使发送方和接收方之间的耦合更少,这通常是您想要的。 如上所述,当信号用于“即发即往”事件时,信号最有效,其中发送方不关心任何监听者的行为。 通过使信号异步,它可以强制执行这种分离,因为信号处理程序方法将延后执行,因此发送方实际上不能直接使用处理程序执行的结果。
-
仅发送一个信号时可能会发生意外的状态变化。 例如,对象A可能触发一个信号,该信号将触发一些逻辑最终导致A被删除。 如果信号是同步执行的,那么调用堆栈最终可能会返回到触发信号的对象A,然后对象A可能会尝试执行导致问题的命令(因为对象A已经被删除)
这并不是说异步信号优于同步信号。 异步信号也有其自身的风险。
-
调试可能更困难,因为从堆栈跟踪中发现信号的位置并不清楚。
-
该状态的某些部分可能彼此不同步。 如果A类发送需要来自B类的响应的异步信号,则在发送信号和调用B类中的处理程序方法之间会有一段时间,其中B与A不同步,这可能导致 一些错误。
-
整个系统可能比使用同步信号更复杂,因此更难理解。
信号设置 (Signal Settings)
通过ProjectContext上的Settings属性可以重写信号的大多数默认设置。也可以通过设置DiContainer.Settings属性在每个容器级别配置它。对于信号,这包括以下内容:
Default Sync Mode - 当未指定时,此值控制DeclareSignal属性RunSync / RunAsync的默认值。默认情况下,它设置为synchronous,因此在调用DeclareSignal时未指定RunSync。因此,如果您是异步信号的粉丝,那么您可以将其设置为异步以假设异步。
Missing Handler Default Response - 当没有为DeclareSignal调用指定RequiredSubscriber / OptionalSubscriber / OptionalSubscriberWithWarning时,此值控制默认值。默认情况下,它设置为OptionalSubscriber。
Require Strict Unsubscribe - 如果为true,则会导致在场景结束时仍然抛出异常,并且仍有信号处理程序尚未取消订阅。默认情况下为false。
Default Async Tick Priority - 当RunAsync与DeclareSignal一起使用但未设置WithTickPriority时,此值控制默认的勾选优先级。默认情况下,它设置为1,这将导致在调用所有正常的tickable之后立即调用信号处理程序。选择此默认值是因为它将确保在触发信号的同一帧中处理信号,如果信号影响帧的渲染方式,这可能很重要。