Unity高性能依赖注入框架Extenject(Zenject)---- Signals系统

初阶教程

理论

在日常工作中,几乎每个人都会遇到这样的问题,代码中存在两个类(A类和B类),两者需要进行通讯,在不了解Signal之前解决方案可能是这样的:

  1. 从A中直接调用B上的方法。
  2. 反转依赖关系,让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 **:信号触发时应触发的方法。这有几种变体:
  1. Static method:
Container.BindSignal<UserJoinedSignal>().ToMethod(s => Debug.Log("Hello user " + s.Username));

无参数版本:

Container.BindSignal<UserJoinedSignal>().ToMethod(() => Debug.Log("Received UserJoinedSignal signal"))

另请注意,在这种情况下,由于不需要实例,因此无法为From提供值

  1. 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 等。

  1. 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使用时机

在以下情况下,信号最适合用作通信机制:

  1. 多个接收器同时监听信号
  2. 发送者不需从接收者那里获取反馈
  3. 发送者不在乎有没有接收者,不在乎接收者是否接收到消息,不在乎接收者接收到消息作何反应
  4. 发送者发送的频次不高,发送的时间可预测。

这些只是经验法则,但在使用信号时要牢记一些有用的知识。 发送方与接收方的响应行为之间的逻辑耦合越少,则与其他形式的通信(如直接方法调用,接口,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。 异步信号具有以下优点:

  1. 信号处理程序的执行顺序可预测。 当使用同步信号时,信号处理程序方法在发射信号的同时执行,可以在帧期间的任何时间触发,或者在某些情况下,如果多次发射信号,则在多个位置触发。 这可能会导致一些更新顺序问题。 对于异步信号,信号处理程序总是在TickPriority配置的帧中同时执行。
  2. 使用异步信号鼓励发送方和接收方减少耦合。 如上所述,信号在发送者不关心任何接收者行为的情况下,发完就忘是最好的。相当于我把消息告诉你,你作何反应和我无关。通过使信号异步,它可以强制执行这种分离,因为信号处理程序方法将在以后执行,因此发送方实际上无法直接使用处理程序行为的结果。
  3. 仅触发一个信号时,可能会发生意外的状态变化。。举个例子,对象A触发了一个信号,这个信号将触发一些逻辑倒置A被删除。如果是同步执行的,则调用堆栈最终可能返回到触发信号的对象A,对象A执行一些操作,此时很肯引发错误(因为对象A已经被删除)

这并不是说异步信号优于同步信号。 异步信号也有其自身的风险:

  1. 调试可能会更加困难,因为从堆栈跟踪中尚不清楚触发信号的位置。
  2. 状态的某些部分可能彼此不同步。 如果A类触发了需要B类做出响应的异步信号,则在触发信号与调用B类中的处理程序方法之间会有一段时间,其中B与A不同步,这可能导致 一些错误。
  3. 整个系统可能比使用同步信号时更复杂,因此更难以理解。

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 之后立即调用信号处理程序。选择此默认值是因为它将确保在触发信号的同一帧中处理信号,如果信号影响帧的渲染方式,则这很重要。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值