Dependency injection
The AlchemyBow.Core uses dependency injection (DI) to automatically associate the elements of your game.
In this article, we use the following naming:
- Client - is an object that receives other objects that it depends on.
- Service - is an object that can be injected into other objects.
- Key - is an exposed type of a service that clients may require.
- Value - is an object accociated with a key - a concrete service.
- Container - is a unit that is responsible for dependency injection.
- Installer - is a unit that creates values, associates them with keys and adds them to the container.
Introduction
To help you understand how it works, let's cover it with simple examples. At first, we have two classes:
using UnityEngine;
public class SampleService : MonoBehaviour
{
// It can do something on its own.
public void DoServiceJob() { }
}
public class SampleClient : MonoBehaviour
{
[SerializeField]
private SampleService sampleService;
// It can do something, but needs some dependencies.
public void DoClientJob()
{
sampleService.DoServiceJob();
// ...
}
}
The SampleClient
depends on the SampleService
, so all we need to do is drag the reference to the appropriate slot in the editor, and it's ok. However, now imagine if there are 100 different clients that also depend on the same SampleService
. Dragging in the editor is no longer an easy way - it's error prone. A better solution is to write scripts that will get dependencies for us, but there is a problem again. It takes time to create a good dependency architecture, esspecialy for large scale projects.
In AlchemyBow.Core, those problems are solved with dependency injection. At first, let's update 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();
// ...
}
}
The first change is the [InjectionTarget] attribute in the SampleClient
class declaration. In this way we notify the framework that this type should be analized for fields decorated with the [Inject]
attribute.
The [Inject] attribute informs the framework that it should inject the value of the key SampleService
to the decorated field.
Finally, we added the keyword readonly
to the field declaration, this is a good practice as it almost never makes sense to change it manually.
After these changes, nothing will happen yet. This is because we need to install our objects. To do that we can use MonoInstallers, let's create them.
using AlchemyBow.Core.IoC;
using UnityEngine;
public class SampleServiceInstaller : MonoInstaller
{
[SerializeField]
private SampleService sampleService;
public override void InstallBindings(IBindOnlyContainer container)
{
container.Bind(sampleService);
}
}
using AlchemyBow.Core.IoC;
using UnityEngine;
public class SampleClientInstaller : MonoInstaller
{
[SerializeField]
private SampleClient sampleClient;
public override void InstallBindings(IBindOnlyContainer container)
{
container.Bind(sampleClient);
}
}
Now, if you create those objects, and add the installers to the list in your CoreController, it will work. At such a small scale, it might look a bit tedious, but as your dependency tree grows, you'll notice that it's just a matter of creating a little script that will make your new module work immediately. Moreover, later in this article, you'll learn about binding methods so you can reduce the number of installers.
So in short, you can create: objects that require dependencies to work (clients), objects that are used by other objects (services), and objects that perform both of these functions. Installers and attributes are used to define what is what and the framework does the rest.
Binding
The easy way to understand the binding process is to think about a DI container as a collection of keys and values. Each added value can request depndencies that are the added keys. In other words, the keys are services and the values are clients.
There are several places dedicated to binding:
- 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 takes the 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 and can be requested by clients.value
- becomes a potential client and can request services.
Bind(Type key, object value)
is a non-generic alternative of the Bind<TKey>(TKey value)
. It can be used to create more advanced logic. For example, suppose you have several classes that inherit from one parent class. Instead of creating an installer for each instance, you can use a list 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 different,
// otherwise it will fail.
container.Bind(item.GetType(), item);
}
}
}
Tip
One of the biggest advantages of using DI is the ability to swap between different service implementations. For example, you can create 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. Or that the fields of the instance should be injected but there is no need to create a key for it.
It can be helpful:
- If you want an object to have its fields injected but don't want to expose it.
- If you want multiple objects of the same type to have their fields injected.
- If you want elements of a dynamic collection binding to have their fields injected.
Try to analyze the example below to better understand the most common use of this method.
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); // has fields injected
container.BindInaccessible(subsystemB); // has fields injected
container.Bind(system); // has fields injected and can be injected
}
}
AddToDynamicCollectionBinding
The dynamic collection binding is a feature that allows you to create and extend collections during the binding stage. This is very useful if you want an injectable collection to be built by multiple 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 3 default methods that you can use to create dynamic collection bindings:
EnsureDynamicCollectionBinding<TCollection>()
- ensures that there is a key-value pair for the dynamic collection.AddToDynamicCollectionBinding<TCollection, TItem>(TItem item)
- ensures that there is a key-value pair for the dynamic collection and adds the specified item.AddRangeToDynamicCollectionBinding<TCollection, TItem>(IEnumerable<TItem> items)
- ensures that there is a key-value pair for the dynamic collection and adds specified items.
Tip
Sometimes the default methods can become too long. A good way to keep them short is to use extension methods. There are already several shortcuts for the most used collections - see DynamicCollectionBindingUtility.
Note
You can use any collection that implements IEnumerable<T>
and ICollection<T>
.
Note
The elements added to the dynamic collection binding are not marked as clients or services automatically.
Warning
The dynamic collection binding cannot be shared between CoreController and CoreProjectContext.
Dynamic injection
At some point, you may need to inject dependencies into objects created after loading. To do that you can use dynamic injectors - objects that have all the necessary dependencies (injected during loading) and are able to inject them into objects of a specific type. To create one:
- Create an instance of the
DynamicInjector<T>
class whereT
is the injection target. - Bind the injector.
- After loading, use the
DynamicInjector<T>.Inject(T)
method to inject.
A very similar approach is to create factories that are also responsible for creating objects. It is worth noting that in AlchemyBow.Core, it is common to build factories using injectors. Let's see 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 clear code formatting.
{
[Inject]
private readonly DynamicInjector<SampleClient> injector;
// With this method we can 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 be created manually. However, the mechanism provided with the DynamicInjector<T>
class makes your code more scalable, more consistent and less boiler-plate.