Dependency Injection
AlchemyBow.Core uses dependency injection (DI) to automatically associate the various components of your game.
In this article, we use the following terminology:
- Client: An object that receives other objects(services) it depends on.
- Service: An object that can be injected into other objects(clients).
- Key: An exposed type of service that clients may require.
- Value: A concrete service associated with a key.
- Container: A unit responsible for managing and resolving dependencies.
- Installer: A unit that creates values, associates them with keys, and adds them to the container
Introduction
To help you understand how dependency injection works, let’s walk through a simple example.
In the following, we have two classes:
using UnityEngine;
public class SampleService : MonoBehaviour
{
// The service does something on its own.
public void DoServiceJob() { }
}
public class SampleClient : MonoBehaviour
{
[SerializeField]
private SampleService sampleService;
// The client needs the service to perform its job.
public void DoClientJob()
{
sampleService.DoServiceJob();
// ...
}
}
The SampleClient
class depends on the SampleService
, so in a simple case, you can drag the SampleService
reference into the appropriate slot in the Unity editor, and everything works fine. However, when there are a large number of dependencies, especially when they are scattered throughout the scene, dragging references in the editor becomes cumbersome, error-prone, and inefficient.
In this case, a better solution is to write scripts that automatically resolve the dependencies for us. However, the challenge remains: creating an efficient and scalable dependency architecture can be time-consuming and complex.
In AlchemyBow.Core, these problems are solved with dependency injection. Let’s start by updating the scripts.
using AlchemyBow.Core.IoC;
using UnityEngine;
// No changes
public class SampleService : MonoBehaviour
{
public void DoServiceJob() { }
}
[InjectionTarget] // <-- see
public class SampleClient : MonoBehaviour
{
[Inject] // <-- see
private readonly SampleService sampleService; // <-- see
public void DoClientJob()
{
sampleService.DoServiceJob();
// ...
}
}
Explanation of changes:
- The [InjectionTarget] attribute is applied to the
SampleClient
class. This tells the framework that this class should be analyzed for fields decorated with the [Inject] attribute. - The [Inject] attribute is added to the
sampleService
field. This instructs the framework to automatically inject the value associated with theSampleService
key into this field. - The
readonly
keyword is added to thesampleService
field. This is a best practice because the value ofsampleService
should generally not change after injection, ensuring immutability and improving code clarity.
To test it out, for both SampleService
and SampleClient
:
- Create a GameObject in the scene.
- Attach the respective script to the GameObject.
- Add a UnityObjectMonoInstaller component to the GameObject and assign the script to it.
- Drag the installer into the MonoInstallers list in the CoreController.
- Run the scene.
To streamline the process, instead of dragging the installers one by one, you can add an instance of ChildrenCompositeMonoInstaller to the list and ensure that the SampleService
and SampleClient
game objects are children of the game object containing the ChildrenCompositeMonoInstaller
.
In short, you can create objects that require dependencies to work (clients), objects which are dependencies to other objects (services), and objects that perform both functions. Using installers and attributes, you define these roles, and the framework takes care of the rest, offering extreme performance and safety benefits compared to methods like FindObjectOfType
.
Binding
The easiest way to understand the binding process is to think of a DI container as a collection of keys and values. Each added value can request dependencies associated with the added keys. In other words, keys represent services, and values represent clients.
Bindings can be defined in several places:
- The serialized list of MonoInstallers in the CoreController.
- The CoreController.InstallAdditionalBindings method.
- The serialized list of MonoInstallers in the CoreProjectContext.
- The CoreProjectContext.InstallAdditionalBindings method.
(All methods take an IBindOnlyContainer as a parameter.)
Bind
Bind<TKey>(TKey value)
is the most common operation performed on the container. It creates a key-value pair where:
TKey
becomes a potential service that can be requested by clients.value
becomes a potential client that can request services.
using AlchemyBow.Core.IoC;
using UnityEngine;
public class SampleServiceInstaller : MonoInstaller
{
[SerializeField]
private SampleService sampleService;
public override void InstallBindings(IBindOnlyContainer container)
{
container.Bind(sampleService); // container.Bind<SampleService>(sampleService);
}
}
Bind(Type key, object value)
is a non-generic alternative to Bind<TKey>(TKey value)
. This method allows for more advanced binding logic.
For example, suppose you have several classes that inherit from a common parent class. Instead of creating a separate field/installer for each instance, you can use a collection to bind them all at once:
using AlchemyBow.Core.IoC;
using UnityEngine;
public class MonoBehavioursInstaller : MonoInstaller
{
[SerializeField]
private MonoBehaviour[] items;
public override void InstallBindings(IBindOnlyContainer container)
{
foreach (var item in items)
{
// The actual item types (key) must be unique,
// otherwise, the binding will fail.
container.Bind(item.GetType(), item);
}
}
}
Tip
One of the greatest advantages of using dependency injection (DI) is the ability to swap between different service implementations easily. For example, you can define an interface and use it as a key. Then, for different scenes, you can create and bind different values to that key.
BindInaccesible
BindInaccesible(object instance)
informs the container that the instance is a client but not a service. This means the instance's fields will be injected, but it won’t be exposed as a service or associated with a key.
This method is particularly useful in the following scenarios:
- When you want an object to have its fields injected but do not need to expose it as a service.
- When you need multiple objects of the same type to have their fields injected.
- When you want elements of a dynamic collection binding to have their fields injected.
To better understand the common use of this method, consider the example below:
using AlchemyBow.Core.IoC;
public class SomeSystemInstaller : MonoInstaller
{
public override void InstallBindings(IBindOnlyContainer container)
{
var subsystemA = new SomeSubsystemA();
var subsystemB = new SomeSubsystemB();
var system = new SomeSystem(subsystemA, subsystemB);
container.BindInaccessible(subsystemA); // Fields are injected
container.BindInaccessible(subsystemB); // Fields are injected
container.Bind(system); // Fields are injected and it can be injected as a service
}
}
AddToDynamicCollectionBinding
The dynamic collection binding feature allows you to create and extend collections during the binding stage. This is particularly useful when you want an injectable collection to be constructed collaboratively by multiple independent installers.
using AlchemyBow.Core.IoC;
using UnityEngine;
using System.Collections.Generic;
public class SampleMonoInstaller1 : MonoInstaller
{
public override void InstallBindings(IBindOnlyContainer container)
{
container.AddToDynamicCollectionBinding<List<int>, int>(1);
container.AddToDynamicCollectionBinding
<Dictionary<string, string>, KeyValuePair<string, string>>
(new KeyValuePair<string, string>("Alchemy", "Bow"));
}
}
public class SampleMonoInstaller2 : MonoInstaller
{
[SerializeField]
private int[] someValues;
public override void InstallBindings(IBindOnlyContainer container)
{
container.AddRangeToDynamicCollectionBinding
<List<int>, int>(someValues);
}
}
[InjectionTarget]
public class SampleClient
{
[Inject]
private readonly List<int> sampleListBinding;
[Inject]
private readonly Dictionary<string, string> sampleDictionaryBinding;
}
There are three default methods you can use to create dynamic collection bindings:
EnsureDynamicCollectionBinding<TCollection>()
: Ensures that the dynamic collection binding exists.AddToDynamicCollectionBinding<TCollection, TItem>(TItem item)
: Ensures that the dynamic collection binding exists and adds an item.AddRangeToDynamicCollectionBinding<TCollection, TItem>(IEnumerable<TItem> items)
: Ensures that the dynamic collection binding exists and adds a range of items.
Tip
Sometimes the default methods can become too lengthy. A good way to simplify them is by using extension methods. There are already several shortcuts available for the most commonly used collections - see DynamicCollectionBindingUtility.
Note
You can use any collection that implements both IEnumerable
Note
The items added to the dynamic collection binding are not automatically bound. If you need them to become services or clients, you must explicitly use the bind methods on them.
Warning
The dynamic collection binding cannot be shared between CoreController and CoreProjectContext.
Fluent Binding
AlchemyBow.Core also provides a fluent binding syntax, which is particularly useful when you need to perform multiple binding operations on a single instance. Check out FluentBinding for more details.
using AlchemyBow.Core.IoC;
using AlchemyBow.Core.Extras.FluentBindings;
public class SomeSystemInstaller : MonoInstaller
{
public override void InstallBindings(IBindOnlyContainer container)
{
container.StartFluentBinding(new SomeSystem())
.BindInaccessible()
.AddToCoreLoadingCallbacksHandlers();
}
}
Tip
You can introduce your own fluent methods by adding extension methods to the FluentBinding class.
Dynamic Injection
At some point, you may need to inject dependencies into objects created after the binding phase. To achieve this, you can use dynamic injectors - objects that hold the necessary dependencies (injected during binding) and can inject them into objects of a specific type. Here's how to create one:
- Create an instance of the DynamicInjector
class using the target class as a generic parameter. - Bind the injector.
- Later, use the
DynamicInjector<T>.Inject(T)
method to inject the dependencies into objects of the target class.
A similar approach to using dynamic injectors is to create factories—objects that store the required dependencies during the binding stage and can later be used for object creation. Notably, in AlchemyBow.Core, factories are frequently constructed using injectors.
Let's walk through a complete example:
using AlchemyBow.Core.IoC;
[InjectionTarget]
public class SampleClient // We want to dynamically create instances of this class
{
[Inject]
private readonly SampleService someService;
private readonly int someCreationParameter;
public SampleClient(int someCreationParameter)
{
this.someCreationParameter = someCreationParameter;
}
[InjectionTarget]
public class Factory // The factory class is nested for clarity.
{
[Inject]
private readonly DynamicInjector<SampleClient> injector;
// This method allows us to create instances of the `SampleClient` class.
public SampleClient Create(int someCreationParameter)
{
return injector.Inject(new SampleClient(someCreationParameter));
}
}
}
using AlchemyBow.Core.IoC;
public class SampleClientFactoryInstaller : MonoInstaller
{
public override void InstallBindings(IBindOnlyContainer container)
{
container.Bind(new DynamicInjector<SampleClient>());
container.Bind(new SampleClient.Factory());
}
}
Note
Injectors and factories can also be created manually. However, using the mechanism provided by the DynamicInjector