Procházet zdrojové kódy

Zenject IInitializable Fix

Steffen Cole Blake před 2 roky
rodič
revize
beeae270f0

+ 37 - 104
content/post/unity/building-a-gamestate-with-zenject-in-unity.md

@@ -43,14 +43,8 @@ We will need to do some initial bootstrapping of the project to get all the basi
 
 4. Drag and Drop the `StartupInstaller` script onto our `Startup` to add it as a component.
 
-3. While we are at it, add a new script and call it `GameEngine` (Add Component > New Script)
-
 4. Drag the `Startup Installer` script component up to the `Mono Installers` list in the `Scene Context` component. (You may need to hit the + button, and you can just drag and drop the whole `Startup` object onto the field if you like, Unity will figure it out)
 
-5. Open up the `MonoInstaller` script, and add a Serialized private field named `GameEngine` of type `GameEngine` (the class we just made) to it and save. A field for it should show up in the inspector now. Drag and drop our Game Engine component onto that field. (The same trick as #4 works here, you can drag and drop the entire Startup object if you like)
-
-6. Register the GameEngine field we just made inside of the `MonoInstaller` on the container as a singleton. This `MonoBehavior` will now act as our single point of entry for the application, everything else we do will be in some way or another a piece of the tree descended down from this one entry point.
-
 Your `Startup` object should now look like this: 
 ![Startup Example one](/images/building-a-gamestate-with-zenject-in-unity/startup-step-1.png)
 
@@ -59,12 +53,8 @@ And your `StartupInstaller` script like so:
 ```csharp {linenos=table}
 public class StartupInstaller : MonoInstaller
 {
-    [SerializeField]
-    private GameEngine gameEngine;
-
     public override void InstallBindings()
     {
-        Container.Bind<GameEngine>().FromInstance(gameEngine);
     }
 }
 ```
@@ -75,33 +65,33 @@ This is now the bare bones basic of registering for Dependency Injection, lets n
 
 Let's put together a few pieces to demonstrate the Game State system in action. To Begin with, we need a couple cs files that provide handy utility for the purpose of this project. You can download and save them to the following path (you'll need to make the folder for it): `Assets/Scripts/Utilities`
 
-## File one: ExpressionExtensions.cs
+## PropertyWatcherBase.cs
 
-This file utilizes a handy extension method I have made for Expression trees. At its core, it stringifies the full member path of a lambda expression, for example the lamba `m => m.Foo.Bar.Phi` will serialize to `"Foo.Bar.Phi"`, it's pretty simple but the code can be a bit esoteric as it utilizes serializing expressions.
+This file has a very handy base class that implements the `INotifyPropertyChanged` event, which is going to be the key lynchpin used to apply Single Source of Truth logistics to our project. Our Game State class we make later will make heavy use of this class.
 
-{{< rawhtml >}}
-<a href="/scripts/building-a-gamestate-with-zenject-in-unity/ExpressionExtensions.cs" target="_blank">ExpressionExtensions.cs</a>
-{{< /rawhtml >}}
+I currently have it hosted up as a gist on my github, you can feel free to check it out. It has a bunch of utility classes and methods that culminate in the functionality needed for this project. You can use them as you wish!
 
-## File two: PropertyWatcherBase.cs
+Source code: [here](https://gist.github.com/SteffenBlake/ace74a893d0b16c30a7eb2a42d6d9230)
 
-This file has a very handy base class that implements the `INotifyPropertyChanged` event, which is going to be the key lynchpin used to apply Single Source of Truth logistics to our project. Our Game State class we make later will make heavy use of this class.
+Raw Download: [here](https://gist.githubusercontent.com/SteffenBlake/ace74a893d0b16c30a7eb2a42d6d9230/raw/62e6d2149381448fc18c0955cdae0cebbf562c42/PropertyWatcherBase.cs)
 
-{{< rawhtml >}}
-<a href="/scripts/building-a-gamestate-with-zenject-in-unity/PropertyWatcherBase.cs" target="_blank">PropertyWatcherBase.cs</a>
-{{< /rawhtml >}}
+You can just Right Click -> Save as... the second link, and it should work.
 
-There are two methods on this base class, let's dig into how they work. To begin with, the interface `INotifyPropertyChanged` requires the event delegate `PropertyChanged` to exist, which is our Pub/Sub model that our services can publish and subscribe to. This will be our main mechanism for how we inform services that changes have occurred to the Game state.
+There are multiple important methods on this base class, let's dig into how they work. To begin with, the class implements the event delegate `PropertyChanged` to exist, which is our Pub/Sub model that our services can publish and subscribe to. This will be our main mechanism for how we inform services that changes have occurred to the Game state.
 
 The first method, `Mutate`, is our "writer" that will automatically invoke the aforementioned event when we write changes. I'll show how we hook that up to Full Properties on an implementing class in a moment below.
 
-The second method, `BindChild` is a handy tool to let us nest `PropertyWatcherBase` models, so we can have children of the parent GameState (and further nested down, grandchildren and etc), this method will listen to a child's event and appen the parent's name to it in the form of `Parent.Child`, which gives it a unique signature.
+The second method, `BindChild` is a handy tool to let us nest `PropertyWatcherBase` models, so we can have children of the parent GameState (and further nested down, grandchildren and etc), this method will listen to a child's event and append the parent's name to it in the form of `Parent.Child`, which gives it a unique signature.
+
+The third exposed method, `BindTo`, is a handy method subscribers utilize to subscribe to a given event. We will show how to use this farther down.
+
+The fourth method, `OpenTransaction` is a bit more advanced and won't be covered by this tutorial, but you can see examples of how to utilize it on my gist comment here:
 
 # Building our GameState
 
 Alright, time to show it in action! Create a new script file, let's call it `GameState.cs` to keep it simple, and put it in the folder `Assets/Scripts/PropertyWatchers`. Lets also make a second file called `ChildGameState.cs` and put it in that folder too.
 
-Both will inherit from `PropertyWatcherBase`. 
+Both will inherit from `PropertyWatcherBase<TSelf>`, where `TSelf` is the class itself (this needs to be done for some internal code specific reasons, we wont dig into the details here). 
 
 We also want to put a full property on the child, let's make it an int and just call it `Counter` with the backing field `_counter`.
 
@@ -111,12 +101,10 @@ Next, we utilize the `Mutate` method on `Counter`'s `Set` method to hook in the
 
 Since we are utilizing a Dependency Injection Engine, we will want to do Constructor Injection to hand `GameState` its copy of the `ChildGameState`
 
-Finally, I have made a very handy method called `BindTo` that you will want to copy to your `GameState` class, which lets you very easily (and type safely) bind a Selector of any given property of the Game State to an event consumer action (this is the magic sauce that uses that Expression Extension we installed earlier!)
-
 You're two files should now roughly look like this:
 
 ```csharp {linenos=table}
-public class GameState : PropertyWatcherBase
+public class GameState : PropertyWatcherBase<GameState>
 {
     public GameState(ChildGameState child)
     {
@@ -125,23 +113,9 @@ public class GameState : PropertyWatcherBase
 
     private ChildGameState _child;
     public ChildGameState Child { get => _child; private set => BindChild(ref _child, value); }
-
-    public void BindTo<T>(Expression<Func<GameState, T>> selector, Action<T> method)
-    {
-        var fmn = selector.GetFullMemberName();
-        var compiled = selector.Compile();
-
-        PropertyChanged += (_, e) =>
-        {
-            if (e.PropertyName != fmn)
-                return;
-
-            method(compiled(this));
-        };
-    }
 }
 
-public class ChildGameState : PropertyWatcherBase
+public class ChildGameState : PropertyWatcherBase<ChildGameState>
 {
     private int _counter;
     public int Counter { get => _counter; set => Mutate(ref _counter, value); }
@@ -156,10 +130,9 @@ Container.Bind<GameState>().AsSingle();
 Container.Bind<ChildGameState>().AsSingle();
 ```
 
-
 # Basic Project Setup
 
-With our DI Engine up and running, Game State built, and GameEngine bootstrapped, let's do the basic legwork to make a scene that we can actually interact with now!
+With our DI Engine up and running, Game State built, let's do the basic legwork to make a scene that we can actually interact with now!
 
 To start, lets add a `Canvas` + `EventSystem` to our scene (Scene Right Click > UI > Canvas)
 
@@ -180,17 +153,17 @@ You can also do the same for the `Text (TMP)` child of the `Button` to make it's
 Theoretically you should have something that sort of looks like this when you run your game:
 ![Game visual example 1](/images/building-a-gamestate-with-zenject-in-unity/startup-step-2.png)
 
-Clicking the button doesnt do anything yet, but let's start wiring up our `MonoBehavior`s to our `GameEngine`!
+Clicking the button doesnt do anything yet, but let's start wiring up our services!
 
 # Event pushing a MonoBehavior
 
-Now, theoretically we could directly try and access the GameState from any given `MonoBehavior`, but that is the way to spaghetti land. Instead, aside from our single `GameEngine` `MonoBehavior`, we want to utilize Inversion of Control to keep all of our other `MonoBehavior` objects as incredibly simple, lightweight, and "glass box" as possible. For all intents and purposes, each of our remaining `MonoBehavior` scripts should have no concept of any *other* `MonoBehaviors`,*nor* the Game state or engine or anything else. They should purely have two things they do:
+Now, theoretically we could directly try and access the GameState from any given `MonoBehavior`, but that is the way to spaghetti land. Instead, we want to utilize Inversion of Control to keep all of our `MonoBehavior` objects as incredibly simple, lightweight, and "glass box" as possible. For all intents and purposes, each of our `MonoBehavior` scripts should have no concept of any *other* `MonoBehaviors`,*nor* the Game state or engine or anything else. They should purely have two things they do:
 
 1. Expose methods to manipulate their own fields, like transform, sprites, text, etc.
 
 2. Expose agnostic "This thing happened" events that can be subscribed to by the GameEngine and any of its descendants.
 
-To distinguish these "simple" `MonoBehavior`'s, I like to refer to them as `Entities`, unlike the `GameEngine`, they represent actual "things" in the scene. Objects, Sprites, Text, Buttons, etc etc.
+To distinguish these "simple" `MonoBehavior`'s, I like to refer to them as `Entities`, as they represent actual "things" in the scene. Objects, Sprites, Text, Buttons, etc etc.
 
 Let's create two of these as an example, with the `TextMeshPro` we made on the top being a prime example of #1, and the Button we made as an example of #2.
 
@@ -232,9 +205,6 @@ Finally, let's register both of these on our Dependency Injection Engine, to mak
 ```csharp {linenos=table}
 public class StartupInstaller : MonoInstaller
 {
-    [SerializeField]
-    private GameEngine gameEngine;
-
     [SerializeField]
     private TextEntity text;
 
@@ -243,9 +213,6 @@ public class StartupInstaller : MonoInstaller
 
     public override void InstallBindings()
     {
-        // Core Engine
-        Container.Bind<GameEngine>().FromInstance(gameEngine);
-
         // Game State
         Container.Bind<GameState>().AsSingle();
         Container.Bind<ChildGameState>().AsSingle();
@@ -264,9 +231,9 @@ We are now prepared to wire our Scene up to our `GameEngine`!
 
 # Binding the UI to the Engine and GameState
 
-To start, let's make ourselves a Service to delegate out the task of handling these UI objects. Services will be what we refer to individual "modules" of our stack, and they should be POCOs (Plain ole classes), not MonoBehaviors. They can be injected into each other (but try and avoid circular dependency loops!), and make up the majority of our dependency tree directly underneath the `GameEngine`
+To start, let's make ourselves a Service to delegate out the task of handling these UI objects. Services will be what we refer to individual "modules" of our stack, and they should be POCOs (Plain ole classes), not MonoBehaviors. They can be injected into each other (but try and avoid circular dependency loops!), and make up the majority of our dependency tree.
 
-Largely speaking, nearly all of your code should be inside of various `Service`s, and they should act as your way of sorting out your code to different areas that make sense. The `GameEngine` should try its best to hand work off to various `Service`s as fast as possible, keeping it as simple as possible in the `GameEngine` itself. Largely speaking the engine's job should be to simply handle delegating what `Service`s are and are not being fired off during each `Update` tick.
+Largely speaking, nearly all of your code should be inside of various `Service`s, and they should act as your way of sorting out your code to different areas that make sense.
 
 Let's make a folder for these, at `Assets/Scripts/Services`, and make our first service in there. Lets call it `EntityService`, and it will have the job of managing our Entities.
 
@@ -274,15 +241,18 @@ We will want to inject the `GameState`, `TextEntity`, and `ButtonEntity` into it
 
 Next, we can have this service wired up to handle delegation of mutating the Text Entity, and listening for the Button entity's click event. At the core of it, the `EntityService` serves the role of delegating information back and forth between Game State <-> Entities. This isn't completely mandatory as you could just inject the Game State directly into the Entities, but in actual practice there likely would be much more complicated logic than what we have here, and the service should act as the space to put all that logic to keep it compartmentalized off.
 
+We will implement Zenjects `IInitializable` interface to declare this as a "root" service that has initial startup logic to begin running.
+
 You should end up with something a bit like this:
 
 ```csharp {linenos=table}
-public class EntityService
+public class EntityService : IInitializable
 {
     private GameState GameState { get; }
     private TextEntity Text { get; }
     private ButtonEntity Button { get; }
 
+    [Inject]
     public EntityService(GameState gameState, TextEntity text, ButtonEntity button)
     {
         GameState = gameState ?? throw new ArgumentNullException(nameof(gameState));
@@ -290,13 +260,13 @@ public class EntityService
         Button = button ?? throw new ArgumentNullException(nameof(button));
     }
 
-    public void Bind()
+    public void Initialize()
     {
         GameState.BindTo(g => g.Child.Counter, OnChildCounter);
         Button.Clicked += OnButtonClicked;
     }
 
-    private void OnChildCounter(int count)
+    private void OnChildCounter(in int count)
     {
         Text.SetText($"Count: {count}");
     }
@@ -311,34 +281,11 @@ public class EntityService
 And we once again register it as a single on our `StartupInstaller`:
 ```csharp {linenos=table}
 // Services
-Container.Bind<EntityService>().AsSingle();
-```
-
-Finally, we can inject it into our `GameEngine` to spin it up:
-
-```csharp {linenos=table}
-public class GameEngine : MonoBehaviour
-{
-    private EntityService EntityService { get;  set; }
-
-    [Inject]
-    void Init(EntityService entityService)
-    {
-        EntityService = entityService ?? throw new ArgumentNullException(nameof(entityService)); ;
-    }
-
-    void Start()
-    {
-        EntityService.Bind();
-    }
-}
+Container.BindInterfacesTo<EntityService>().AsSingle();
 ```
 
-`MonoBehaviors` cannot use Constructor Injection, and instead utilize a slightly different but similar form where you need to make a method and tag it with the `Inject` attribute. Typically you name it `Init(...)`. Ideally, we only need to do this once in this single place, as prior mentioned this should be the only `MonoBehavior` we inject anything into.
-
 If you run your program and click the button, you should see the count increase! Congrats, you now have a working single source of truth `GameState`!
 
-
 ![TMP and Button Component Working](/images/building-a-gamestate-with-zenject-in-unity/startup-step-4.png)
 
 # Okay but why all the theatrics if we could wire it directly?
@@ -347,12 +294,12 @@ Simple, because now if *any* of your services modifies `GameState.Child.Counter`
 
 Let's do an example with an `async void` to make a sort of background timer that will also automatically increment the count every 5 seconds, on top of any button clicks you do.
 
-To start, let's make a new service for this called `BackgroundTimerService`, and this time we only need to inject the `GameState`
+To start, let's make a new service for this called `BackgroundTimerService`, and this time we only need to inject the `GameState`, and this one will also be an `IInitializable`
 
 We will make a background async method via `async void`, and simply just have an infinite loop that increments the counter every 5 seconds via `Task.Delay(...)`
 
 ```csharp {linenos=table}
-public class BackgroundTimerService
+public class BackgroundTimerService : IInitializable
 {
     private GameState GameState { get; }
     public BackgroundTimerService(GameState gameState)
@@ -360,7 +307,12 @@ public class BackgroundTimerService
         GameState = gameState ?? throw new ArgumentNullException(nameof(gameState));
     }
 
-    public async void Start(int millisecondsDelay)
+    public void Initialize()
+    {
+        Start(5000);
+    }
+
+    private async void Start(int millisecondsDelay)
     {
         while (true)
         {
@@ -371,31 +323,12 @@ public class BackgroundTimerService
 }
 ```
 
-And then we inject it into our `GameEngine` and spin it up:
-
-```csharp {linenos=table}
-private BackgroundTimerService BackgroundTimer { get; set; }
-
-[Inject]
-void Init(..., BackgroundTimerService backgroundTimer)
-{
-    ...
-    BackgroundTimer = backgroundTimer ?? throw new ArgumentNullException(nameof(backgroundTimer));
-}
-
-void Start()
-{
-    ...
-    BackgroundTimer.Start(5000);
-}
-```
-
 And register it on our DI Engine (you know the drill by now!)
 
 ```csharp {linenos=table}
 // Services
 ...
-Container.Bind<BackgroundTimerService>().AsSingle();
+Container.BindInterfacesTo<BackgroundTimerService>().AsSingle();
 ```
 
 And if you run the game now you should see that not only does clicking the button increment our counter, but every 5 seconds it gets incremented by the background service as well!

binární
static/images/building-a-gamestate-with-zenject-in-unity/startup-step-1.png