文章目录
初阶教程
理论
在日常工作中,几乎每个人都会遇到这样的问题,代码中存在两个类(A类和B类),两者需要进行通讯,在不了解Signal之前解决方案可能是这样的:
- 从A中直接调用B上的方法。
- 反转依赖关系,让B类观察A类上的Event。
作为第三种解决方案,我们认为A类和B类互不了解可能会更好,这种情况下代码将保持松散耦合关系,使A类与B类通过中间对象(zenject signals)进行交互而不是直接交互来解决通信问题。
注:使用Zenject Signal进行交互会让代码松散耦合,但是并不是所有情况都适合。使用前必须要综合考虑是否适合这种解决方案,防止Signal像其他编程模式一样被滥用。
快速入门
首先提供一个简单的示例代码,快速上手Signal。
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 , 将上面的代码粘贴进去;创建一个新场景,添加scene context ,将GameInstaller 添加到scene context。
有几种创建信号处理程序的方法,逐一列举:
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();
}
}
还可以和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集成,参看 超链接 中的 UniRx联合使用。
通过代码可以看到,可以直接使用BindSignal将处理方法绑定到Signal(示例1),可以将处理方法以订阅的方式添加到Signal。
下面将对各个环节进行详细的讲述。
创建Signals类并完成声明
在声明一个信号之前,需要创建一个代表这个信号的类,例如:
public class PlayerDiedSignal
{
}
如果这个信号需要携带参数,参数可以作为公共成员变量或属性添加:
public class WeaponEquippedSignal
{
public Player Player;
public IWeapon Weapon;
}
如果需要信号类携带的参数不被改变,参考下列代码:
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;
}
}
这种操作不是必须的,但是可以确保信号处理程序不会修改传递信号的参数。(假设存在两个信号处理程序,其中一个在处理时修改了信号的参数,第二个程序处理的就可能使错误的信号参数)
完成Signal类的创建,需要在Installer种进行声明:
public override void InstallBindings()
{
Container.DeclareSignal<PlayerDiedSignal>();
}
声明它的Container及其 sub container中的所有对象都可以监听信号和触发信号。
DeclareSignal 语法详解
DeclareSignal语句的格式如下:
Container.DeclareSignal<SignalType>()
.WithId(Identifier)
.(RequiredSubscriber|OptionalSubscriber|OptionalSubscriberWithWarning)()
.(RunAsync|RunSync)()
.WithTickPriority(TickPriority)
.(Copy|Move)Into(All|Direct)SubContainers();
- **SignalType **:signal的自定义类。
- **Identifier **:标识绑定的唯一值,大部分情况下用不到。只有当为同一个SignalType 绑定多个不同信号时使用。
- **RequiredSubscriber/OptionalSubscriber/OptionalSubscriberWithWarning **:与订阅消息的订阅者数目管。默认是OptionalSubscriber(可以在ZenjectSettings中更改设定),设置OptionalSubscriber时不执行任何操作;设置RequiredSubscriber时,如果订阅者为零,则将引发异常。设置OptionalSubscriberWithWarning时,控制台输入错误日志而不是抛出异常。选择哪一种模式取决于你对程序的严格程度,以及是否一定要有订阅者。
- **RunAsync/RunSync **:控制信号是同步触发还是异步触发。RunSync - 这意味着,当通过调用SignalBus.Fire触发信号时,将立即调用所有订阅的处理方法。RunAsync-这意味着当触发信号时,满足条件才会调用订阅的方法(如TickPriority参数所指定)。默认值是RunSync,可通过ZenjectSettings修改。
- TickPriority :执行信号处理方法的优先级,请注意,这仅在使用RunAsync时适用。
- (Copy|Move)Into(All|Direct)SubContainers:和Binding中概念一样。
请注意,可以通过更改ZenjectSettings覆盖RunSync / RunAsync和RequiredSubscriber / OptionalSubscriber的默认值。
Signal 信号发射
要触发信号,添加对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认为该信号已经声明,如果未声明该信号,则将引发异常。如果不确定信号是否被声明,使用TryFire();
public class UserJoinedSignal
{
}
public class UserManager
{
readonly SignalBus _signalBus;
public UserManager(SignalBus signalBus)
{
_signalBus = signalBus;
}
public void DoSomething()
{
// Generic version
_signalBus.TryFire<UserJoinedSignal>(); // Nothing happens if UserJoinedSignal is NOT declared
// Non-Generic version
_signalBus.TryFire(new UserJoinedSignal()); // Nothing happens if UserJoinedSignal is NOT declared
}
}
如何通过BindSignal绑定信号
如上所述,除了直接订阅信号总线上的信号(通过SignalBus.Subscribe或SignalBus.GetStream)之外,还可以将信号直接绑定到installer中的处理类。
BindSignal命令的格式为:
Container.BindSignal<SignalType>()
.WithId(Identifier)
.ToMethod(Handler)
.From(ConstructionMethod)
.(Copy|Move)Into(All|Direct)SubContainers();
- SignalType: signal的自定义类。
- **Identifier **: 用于唯一标识绑定的值。 在大多数情况下,这可以忽略。 注意,如果使用Identifier,您必须对DeclareSignal/Fire/Subscribe等使用相同的标识符
- **ConstructionMethod **:当绑定到一个实例上是,需要定义该实例的来源。
- (Copy|Move)Into(All|Direct)SubContainers:和Binding中一样。
- **Handler **:信号触发时应触发的方法。这有几种变体:
- Static method:
Container.BindSignal<UserJoinedSignal>().ToMethod(s => Debug.Log("Hello user " + s.Username));
无参数版本:
Container.BindSignal<UserJoinedSignal>().ToMethod(() => Debug.Log("Received UserJoinedSignal signal"))
另请注意,在这种情况下,由于不需要实例,因此无法为From提供值
- Instance method directly
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,但是我们也可以使用我们想要的任何一种构造方法。 FromResolve等同于:
Container.BindSignal<UserJoinedSignal>().ToMethod<Greeter>(x => x.SayHello).From(x => x.FromResolve().AsCached());
还可以快速新建一个实例,简写形式是FromNew :
// These are both equivalent
Container.BindSignal<UserJoinedSignal>().ToMethod<Greeter>(x => x.SayHello).FromNew();
Container.BindSignal<UserJoinedSignal>().ToMethod<Greeter>(x => x.SayHello).From(x => x.AsCached());
如果其他地方没有注入Greeter class 的需要,也可以这么写:
public class Greeter
{
public void SayHello(UserJoinedSignal signal)
{
Debug.Log("Hello " + signal.Username + "!");
}
}
Container.BindSignal<UserJoinedSignal>().ToMethod<Greeter>(x => x.SayHello).FromNew();
这样,我们根本不需要为Greeter设置单独的绑定。 您还可以为From提供其他多种参数,包括绑定到延迟实例化的MonoBehaviour,工厂方法,自定义工厂,子容器中的facade 等。
- 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的可选功能。如果不想包含Signal功能,导入时不包含OptionalExtras / Signals文件夹即可就可以了。Signal系统不会自动启动,因此需要手动在进行Installer中调用SignalBusInstaller.Install(Container)。
可以在ProjectContext的Installer中执行一次,也可以在指定场景的SceneContext的Installer中执行此操作。安装Container之后,其本身和Sub_Container均可以使用,这就是为什么在ProjectContainer中只需要安装一次。
详解Signals使用时机
在以下情况下,信号最适合用作通信机制:
- 多个接收器同时监听信号
- 发送者不需从接收者那里获取反馈
- 发送者不在乎有没有接收者,不在乎接收者是否接收到消息,不在乎接收者接收到消息作何反应
- 发送者发送的频次不高,发送的时间可预测。
这些只是经验法则,但在使用信号时要牢记一些有用的知识。 发送方与接收方的响应行为之间的逻辑耦合越少,则与其他形式的通信(如直接方法调用,接口,C#事件类成员等)相比,发送方与接收方的响应就越合适。
当事件驱动程序被滥用时,有可能会陷入“callback hell”,那里的事件触发了其他事件,使整个系统无法理解。 因此,通常应谨慎使用信号。 我个人喜欢将信号用于高层级的事件,然后将其他形式的通信(Unirx流,c#事件,直接方法调用,接口)用于大多数其他事情。
进阶教程
使用 Abstract Signals
游戏逻辑规定当玩家成功通过一个关卡时,保存游戏进度;同时,关卡中设置多个检查点,在Player到达检查点时,保存游戏进度。代码如下:
public class Example
{
SignalBus signalBus;
public Example(Signalbus signalBus) => this.signalBus = signalBus;
public void CheckpointReached() => signalBus.Fire<SignalCheckpointReached>();
public void CompleteLevel() => signalBus.Fire<SignalLevelCompleted>();
}
public class SaveGameSystem
{
public SaveGameSystem(SignalBus signalBus)
{
signalBus.Subscribe<SignalCheckpointReached>(x => SaveGame());
signalBus.Subscribe<SignalLevelCompleted>(x => SaveGame());
}
void SaveGame() { /*Saves the game*/ }
}
//in your installer
Container.DeclareSignal<SignalLevelCompleted>();
Container.DeclareSignal<SignalCheckpointReached>();
//your signal types
public struct SignalCheckpointReached{}
public struct SignalLevelCompleted{}
很容易发现此时正在将类型signalLevelCompleted和SignalCheckpointReached耦合到SaveGameSystem。如果遵循单一职责原则, SaveGameSystem之应该实现保存逻辑,不关心是什么具体的行为触发了游戏保存。是时候展现接口的力量了:
public class Example
{
SignalBus signalBus;
public Example(Signalbus signalBus) => this.signalBus = signalBus;
public void CheckpointReached() => signalBus.AbstractFire<SignalCheckpointReached>();
public void CompleteLevel() => signalBus.AbstractFire<SignalLevelCompleted>();
}
public class SaveGameSystem
{
public SaveGameSystem(SignalBus signalBus)
{
signalBus.Subscribe<ISignalGameSaver>(x => SaveGame());
}
void SaveGame() { /*Saves the game*/ }
}
//in your installer
Container.DeclareSignalWithInterfaces<SignalLevelCompleted>();
Container.DeclareSignalWithInterfaces<SignalCheckpointReached>();
//your signal types
public struct SignalCheckpointReached : ISignalGameSaver{}
public struct SignalLevelCompleted : ISignalGameSaver{}
public interface ISignalGameSaver{}
这样一来,SaveGameSystem不知道是关卡通关触发的保存还是检查点触发的保存,但完美的执行了保存任务,只是在声明和触发的时候有区别:
- DeclareSignalWithInterfaces的工作方式类似于DeclareSignal,但它也声明了接口。
- AbstractFire与Fire相同,但它仅在您声明了带有接口的信号后才触发接口,否则它将引发异常。
接下来让信号类来实现多个接口,展示这种技术的强大之处。需求,到达检查点时保存游戏进度并播放编号为2的音效,完成毁灭世界的游戏任务时解锁“世界毁灭者”成就并播放编号为4的音效。
public class Example
{
//保持一个对SignalBus的引用,要通过signalBus来发送事件消极
SignalBus signalBus;
public Example(Signalbus signalBus) => this.signalBus = signalBus;
//在Player到达检查点时触发
public void CheckpointReached() => signalBus.AbstractFire<SignalCheckpointReached>();
//在Player完成毁灭世界的任务时触发
public void DestroyWorld() => signalBus.AbstractFire<SignalWorldDestroyed>();
}
public class SoundSystem
{
//构造函数中就完成消息的订阅
public SoundSystem(SignalBus signalBus)
{
//订阅ISignalSoundPlayer类型的消息,播放音乐
signalBus.Subscribe<ISignalSoundPlayer>(x => PlaySound(x.soundId));
}
//根据音乐ID编号播放不同的音效
void PlaySound(int soundId) { /*Plays the sound with the given id*/ }
}
public class AchievementSystem
{
public AchievementSystem(SignalBus signalBus)
{
//订阅ISignalAchievementUnlocker类型的消息,解锁成就
signalBus.Subscribe<ISignalAchievementUnlocker>(x => UnlockAchievement(x.achievementKey));
}
//根据Key值解锁不同的消息
void UnlockAchievement(string key) { /*Unlocks the achievement with the given key*/ }
}
//声明Signal信号
Container.DeclareSignalWithInterfaces<SignalCheckpointReached>();
Container.DeclareSignalWithInterfaces<SignalWorldDestroyed>();
//到达检查点时触发保存,播放编号为2的音效
public struct SignalCheckpointReached : ISignalGameSaver, ISignalSoundPlayer
{
public int SoundId { get => 2} //or configured in a scriptable with constants instead of hardcoded
}
//毁灭世界是解锁key值为"WORLD_DESTROYED"的成就,播放编号为4的音效
public struct SignalWorldDestroyed : ISignalAchievementUnlocker, ISignalSoundPlayer
{
public int SoundId { get => 4}
public string AchievementKey { get => "WORLD_DESTROYED"}
}
//信号接口
public interface ISignalGameSaver{}
public interface ISignalSoundPlayer{ int SoundId {get;}}
public interface ISignalAchievementUnlocker{ string AchievementKey {get;}}
Subcontainers与Signals
Signals 仅在声明它们的Container和其Sub_Container中可见。例如,您可以使用Unity的多场景支持,并将您的游戏分为GUI场景和Environment场景。在GUI场景中,您可能会触发一个信号,指示GUI暂停窗口 的状态(打开/关闭),以便Environment场景可以 暂停/恢复 活动。
第一种实现方案:直接在ProjectContext的Installer中声明Signals,然后在Environment场景中进行订阅,GUI场景中就可以将其触发。
第二种实现方案: shared scene parent方式。我觉得与其设置半天,直接用第一种方案不香吗?
Asynchronous Signals(异步信号)
在某些情况下,可能需要异步运行Signals。 异步信号具有以下优点:
- 信号处理程序的执行顺序可预测。 当使用同步信号时,信号处理程序方法在发射信号的同时执行,可以在帧期间的任何时间触发,或者在某些情况下,如果多次发射信号,则在多个位置触发。 这可能会导致一些更新顺序问题。 对于异步信号,信号处理程序总是在TickPriority配置的帧中同时执行。
- 使用异步信号鼓励发送方和接收方减少耦合。 如上所述,信号在发送者不关心任何接收者行为的情况下,发完就忘是最好的。相当于我把消息告诉你,你作何反应和我无关。通过使信号异步,它可以强制执行这种分离,因为信号处理程序方法将在以后执行,因此发送方实际上无法直接使用处理程序行为的结果。
- 仅触发一个信号时,可能会发生意外的状态变化。。举个例子,对象A触发了一个信号,这个信号将触发一些逻辑倒置A被删除。如果是同步执行的,则调用堆栈最终可能返回到触发信号的对象A,对象A执行一些操作,此时很肯引发错误(因为对象A已经被删除)
这并不是说异步信号优于同步信号。 异步信号也有其自身的风险:
- 调试可能会更加困难,因为从堆栈跟踪中尚不清楚触发信号的位置。
- 状态的某些部分可能彼此不同步。 如果A类触发了需要B类做出响应的异步信号,则在触发信号与调用B类中的处理程序方法之间会有一段时间,其中B与A不同步,这可能导致 一些错误。
- 整个系统可能比使用同步信号时更复杂,因此更难以理解。
Signal Settings
信号的大多数默认设置都可以通过ProjectContext上的settings属性来覆盖。 也可以通过设置DiContainer.Settings属性在每个容器级别上对其进行配置。 对于信号,这包括以下内容:
**Default Sync Mode **:此值控制DeclareSignal的属性RunSync / RunAsync。 默认情况下将其设置为RunSync。 因此,如果希望使用异步信号,则可以将其设置为RunAsync。
**Missing Handler Default Response **:DeclareSignal时关于订阅者有三种模式RequiredSubscriber / OptionalSubscriber / OptionalSubscriberWithWarning, 默认情况下,它设置为OptionalSubscriber。
Require Strict Unsubscribe:设置为true时,如果场景结束并且仍存在尚未取消订阅的信号处理程序,则将引发异常。 默认情况下为false。
**Default Async Tick Priority **:如果使用了RunAsync但是没有在代码中显示的设置WithTickPriority,将使用此默认值,默认为1,这将导致在调用所有tickables 之后立即调用信号处理程序。选择此默认值是因为它将确保在触发信号的同一帧中处理信号,如果信号影响帧的渲染方式,则这很重要。