β | Without using any attributes for injection |
---|---|
β | Quick and easy setup to get started |
β | Using DI without having to add a Using directive to each project file. |
β | Allows for much more flexible, reusable, and encapsulated code to be written |
- Introduction
- Key Features and Concepts
- Motivation
- Installation
- Usage
- Circular Dependency
- Change Log
- Contributing
- License
EasyJection is an easy-to-use Dependency Injection (DI) Framework for C#(.Net) and Unity projects.
The framework implements dependency injection without using attributes. Such an implementation avoids tying your project code to the framework. Write your code without direct dependencies on the framework itself. You will not have to include framework's namespaces everywhere
Since when the attributes (as well as the namespace) are used in your project's class, that class, at least indirectly, begins to know about where it gets its dependency from.
This project is open source.
If you're familiar with dependency injection and see how EasyJection could help your project, check out the installation and key features to see more. If not, read on:
Dependency Injection (DI) is an intimidating word for a simple concept you're likely familiar with. Dependency Injection in simple words, is a software design concept that allows a service to be injected in a way that is completely independent of any client consumption. Dependency Injection separates the creation of a client's dependencies from the client's behavior, which allows program designs to be loosely coupled. A DI container, in pair with a good architecture, can ensure SOLID principles and help you write better code.
In its simpler form it usually looks like this:
More details can be found here: https://en.wikipedia.org/wiki/Dependency_injectionUnfortunately the Unity game engine isn't very SOLID-friendly out of the box. Even the official documentation and examples for it may give a wrong idea on how to write a code correctly. By using a DI container along with Unity, it's possible to write code that is more reusable, extensible and less oriented to use the base class from which every Unity script derives.
- Injection Mechanisms
- Standard C# objects (a.k.a. POCO)
- Constructor injection
- Method injection
- Field injection
- Property injection
- Parameter injection
- Inherited from MonoBehaviour
- Constructor injection (as the Unity documentation says, you shouldn't implement and call constructors for MonoBehaviours. Unity automatically invokes the constructor.)
- Method injection (through Awake() and Start(), or other custom methods)
- Field injection
- Property injection
- Parameter injection
- Standard C# objects (a.k.a. POCO)
- Can inject on non public members.
- Convention based binding.
- Conditional binding (eg. by method name, by signature, etc.)
- Context Aware Injection Support (dependencies can be automatically injected using the components contained in the child and parents)
Allow references to high-level objects (typically managers or services) at a single entry point without using singletons or spaghetti serialization, or endless constructor parameters.
Usually, when developing a project in Unity, it's often necessary for one system of the game object to reference another. For example, a game object needs a reference to a movement component.
β¬οΈ It might look like below:
// Cube.cs
using UnityEngine;
public class Cube : MonoBehaviour
{
[SerializeField]
// The dependency that provides an implementation of the rotating system.
private IRotate m_RotateSystem;
private void Update()
{
m_RotateSystem.DoRotate(0, 0.25f, 0);
}
}
β‘οΈ This approach has some problems:
- βοΈ The need to always assign fields in the inspector.
- βοΈ Unity doesn't support displaying C# interfaces in the Inspector (Interfaces are not serializable).
β¬οΈ There is an attempt at a solution:
π Cube.cs// Cube.cs
using UnityEngine;
public class Cube : MonoBehaviour
{
// The dependency that provides an implementation of the rotating system.
private IRotate m_RotateSystem;
private void Awake()
{
////////////////////////////////////////////////
// Below are 3 ways to resolve the dependency.
////////////////////////////////////////////////
/* Just create a new instance (if a class doesn't inherit from MonoBehaviour)
and pass the 'Cube' class instance through the constructor: */
m_RotateSystem = new Rotate(this); // #1
/* otherwise find a component like this: */
m_RotateSystem = GetComponentInParent<Rotate>(); // #2
// or
m_RotateSystem = FindObjectOfType<Rotate>(); // #3
}
private void Update()
{
m_RotateSystem.DoRotate(0, 0.25f, 0);
}
} |
β‘οΈ Each of these ways is a workable solution, but they all have same disadvantages:
- βοΈ When a class holds its dependencies and tries to manage them itself without any interference from others, it's an anti-pattern named Control Freak.
- βοΈ The need to manually write in the source code of each component.
- βοΈ Extending and maintaining the classes in your project will take a lot more effort.
β¬οΈ We can try to solve the disadvantages described above by using any other popular IOC / DI framework for the Unity game engine:
π Cube.cs// Cube.cs
using UnityEngine;
using AnyOtherDIFramework; // adds the namespace of the framework to the source code of our project
public class Cube : MonoBehaviour
{
// The dependency that provides an implementation of the rotating system.
[Inject]
private IRotate m_RotateSystem;
private void Update()
{
m_RotateSystem.DoRotate(0, 0.25f, 0);
}
} |
β‘οΈ It's almost perfect, but there are some snags:
- βοΈ The need to add the Using directive to each source code file of our project (
using AnyOtherDIFramework;
in this case). - βοΈ The need to manually write attributes in the source code of each component.
- β As in the previous solution, extending and maintaining the classes in your project will take a lot more effort.
- βοΈ The Cube class indirectly begins to know where it gets its dependency from.
β The EasyJection framework aims to solve all this and more!
βΉοΈ In order to start using this framework, you don't need to add using EasyJection;
to each source code file, and also don't need to specify any attributes.
The source files of project are neat and don't contain dependencies on third-party frameworks. (without `using EasyJection;` etc.) |
π Cube.cs// Cube.cs
using UnityEngine;
// Note: Dependency injection occurs when a method or constructor is called,
// it depends on what you specify.
public class Cube : MonoBehaviour
{
private IRotate m_RotateSystem;
[MethodImpl(MethodImplOptions.NoInlining)]
// For injection via the constructor
public Cube()
{
UnityEngine.Debug.Log("Constructor");
}
// For injection via 'Awake' method
private void Awake()
{
UnityEngine.Debug.Log("Awake");
}
private void Update()
{
m_RotateSystem.DoRotate(0, 0.25f, 0);
}
} |
π IRotate.cs// IRotate.cs
using UnityEngine;
public interface IRotate
{
void DoRotate(float x, float y, float z);
} |
π Rotate.cs// Rotate.cs
using UnityEngine;
public class Rotate : IRotate
{
private Cube m_Cube;
public void DoRotate(float x, float y, float z)
{
m_Cube.transform.Rotate(x, y, z);
}
} |
Source code with using EasyJection; directive. |
π EntryPoint.cs// EntryPoint.cs
using UnityEngine;
using EasyJection;
/*
This is the entry point of the application, where EasyJection sets up
all the various dependencies before starting your game scene.
*/
public class EntryPoint
{
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
/* The Unity documentation mention that the order might be undefined
depending on platform, not sure what that means for actual usage.
Methods with RuntimeInitializeLoadType.AfterSceneLoad, or RuntimeInitializeLoadType.BeforeSceneLoad
will only be called for the first scene in a run of the application, not every scene. */
static void OnBeforeSceneLoadRuntimeMethod()
{
var container = new Container();
container.Bind<IRotate>().To<Rotate>();
////////////////////////////////////////////////
// Below are 2 injection ways (use only one of them)
////////////////////////////////////////////////
// #1 when the constructor is called.
container.Bind<Cube>().ToSelf(UseDefaultConstructor: true);
// #2 or when the 'Awake' method is called.
// This way is recommended for objects inherited from MonoBehaviour
container.Bind<Cube>().ToSelf().InjectionTo().MethodVoid("Awake");
/* Note: You can also create a container and set bindings in a class inherited
from MonoInstaller and then add the script to the current active scene.
This script needs to be called first. Verify the script execution order
in Unity by accessing the menu: Edit->Project Settings->Script Execution Order
and add the script to execute before all other scripts. Enter a large
negative number to have this script before all the others on the list. */
}
} |
As you can see, the framework does all the work of resolving the dependencies.
So now the injection will also work fine every time you create a gameobject, something like this:
GameObject cube = GameObject.CreatePrimitive(PrimitiveType.Cube);
cube.AddComponent<Cube>()
β οΈ Attention: Attempting to get any MonoBehaviour component inside a constructor of class 'Rotate' will throw an exception, since the injection is done via a constructor of an object inherited from MonoBehaviour.
The code below throws an UnityException
public class Rotate : IRotate
{
private Cube m_Cube;
private Transform m_Transform;
public Rotate(Cube cube)
{
m_Cube = cube;
m_Transform = cube.transform; // <-- UnityException: get_transform is not allowed to be called from a MonoBehaviour constructor (or instance field initializer), call it in Awake or Start instead. Called from MonoBehaviour 'Cube'.
}
public void DoRotate(float x, float y, float z)
{
m_Cube.transform.Rotate(x, y, z);
}
}
You can use the path query parameter in the Git URL to notify the Package Manager where to find the package.
{
"dependencies": {
"com.imaxs.easyjection": "https://github.com/imaxs/EasyJection.git?path=/UnityPackage"
}
}
Window
β¨ Package Manager
β¨ + sign
β¨ Add package from git URL
: https://github.com/imaxs/EasyJection.git?path=/UnityPackage
- Download the .unitypackage from releases page
- Import EasyJection.X.X.X.unitypackage
- A dependency will be resolved for a field, property, and parameter if its value is NULL.
- If an instance is not found, it will be resolved to NULL.
DI container (a.k.a IoC Container) is a key feature of the dependency injection implementation. The container creates an object of the specified type and then automatically injects all the dependency objects through a constructor, property, field or method at runtime. This is done automatically by the DI (IoC) container so that you donβt have to create and manage these dependency objects manually.
using EasyJection;
...
// Create the container
Container container = new Container();
This is relevant for use in Unity. Create an empty GameObject and add it to Unity's scene, then create an installation script containing the inherited MonoInstaller
class, and attach the script as a component to the created empty GameObject. Create your all bindings inside the InstallBindings()
method.
This script needs to be called first. Verify the script execution order in Unity by accessing the menu: Edit->Project Settings->Script Execution Order and add the script to execute before all other scripts. Enter a large negative number to have this script before all the others on the list.
For example, in one of the samples you can find this code in the installer:
[DefaultExecutionOrder(-800)] // This is an undocumented alternative to this ( Edit->Project Settings->Script Execution Order)
public class Installer : MonoInstaller
{
protected override void InstallBindings()
{
Container.Bind<IRotate>().To<Rotate>();
Container.Bind<ICube>().ToGameObject<Cube>("Cube");
Container.Bind<CircleFormation>().ToSelf().InjectionTo().MethodVoid("Awake");
}
}
The created container should then 'know' how to create all the object instances in your application, by recursively resolving all dependencies for a given object. Therefore, you need to create bindings. Binding is the action of linking a type to another type or instance. EasyJection makes it simple by providing different ways to create them. Each binding must be performed to a specific key type by calling the Bind()
method. For example, given the following class:
// The class implements an interface
public SomeClass : ISomeInterface
{
// ... some code implementing the interface
}
// The class that requires a dependency
public class Foo
{
private ISomeInterface instance;
public Foo(ISomeInterface instance)
{
this.instance = instance;
}
}
You can bind dependencies using the following:
// Binding some interface to its class implementation
container.Bind<ISomeInterface>().To<SomeClass>();
container.Bind<Foo>().ToSelf(UseDefaultConstructor: true);
This is a simple way to bind some interface to its class implementation. This means that any class that requires the ISomeInterface
interface (like Foo) will be given the same instance of type SomeClass
.
Below is the full binding format:
// Binding an interface to an implementation type
container.Bind<KeyInterfaceType>()
.To<ImplementationType>()
.InjectionTo()
.MethodVoid<T1...T9>(methodName).WithArguments<T1...T9>(T1 arg1, ...T9 arg9)
.MethodResult<T1...T9, TResult>(methodName).WithArguments<T1..T9>(T1 arg1, ...T9 arg9)
.Constructor<T1...T9>(UseForInstantiation: True | False).WithArguments<T1...T9>(T1 arg1, ...T9 arg9);
Where:
- KeyInterfaceType β The type of binding for.
- ImplementationType β The type to be bound to.
InjectionTo()
β allows you to set the injection conditions.MethodVoid<T1...T9>(methodName)
β field, property and parameters injection occurs immediately when the non-return method corresponding to the specified method signature is called.- <T1...T9> β types of parameters of a non-return method. Maximum of 9 parameters, where T1...T9 their types.
- methodName β the name of a non-return method.
MethodResult<T1...T9, TResult>(methodName)
β field, property and parameters injection occurs immediately when the method corresponding to the specified method signature is called.- <T1...T9, TResult> β types of method parameters. A maximum of 9 parameters, where T1...T9 their types and the return value is the type specified by the TResult.
- methodName β the name of a method.
Constructor<T1...T9>(UseForInstantiation: True | False)
β field, property and parameters injection occurs immediately when the constructor corresponding to the specified signature is called.- <T1...T9> β types of parameters of a constructor.
- UseForInstantiation β if True, the container will use this constructor to create an instance, otherwise it will use the default constructor.
WithArguments<T1...T9>(T1 arg1, ...T9 arg9)
β arguments used to pass to the called method or constructor.- <T1...T9> β types of arguments passed.
β οΈ Attention:- The types must fully match the signature of a method or constructor.
- The original arguments passed to the called method will be replaced with the specified arguments from the binding.
- <T1...T9> β types of arguments passed.
What is a method signature?
Section 3.6 of the C# Language Specification (version 4.0) contains the following:
The signature of a method consists of the name of the method, the number of type parameters and the type and kind (value, reference, or output) of each of its formal parameters, considered in the order left to right. For these purposes, any type parameter of the method that occurs in the type of a formal parameter is identified not by its name, but by its ordinal position in the type argument list of the method. The signature of a method specifically does not include the return type, the params modifier that may be specified for the right-most parameter, nor the optional type parameter constraints.
The method declaration consists of the following:
- Modifier β It defines access type of the method i.e. from where it can be accessed in your application. In C# there are Public, Protected, Private access modifiers.
- Name of the Method β It describes the name of the user defined method by which the user calls it or refer it. Eg. GetName()
- Return type β It defines the data type returned by the method. It depends upon user as it may also return void value i.e return nothing
- Body of the Method β It refers to the line of code of tasks to be performed by the method during its execution. It is enclosed between braces.
- Parameter list β Comma separated list of the input parameters are defined, preceded with their data type, within the enclosed parenthesis. If there are no parameters, then empty parentheses () have to use out.
Let's look at all the available bindings provided by EasyJection.
There is three types of available bindings:
- Transient β a new instance is created each time a dependency needs to be resolved.
- Singleton β one instance is created and used for any dependencies.
- Factory β creates the instance and returns it.
// A new instance is created each time a dependency needs to be resolved
container.Binder.Bind<ISomeInterface>().To<SomeClass>();
Binds the key type to a singleton instance of the implementation type. The key must be a class.
container.Bind<ISomeInterface>()
.ToSingleton<SomeClass>(UseDefaultConstructor: True | False);
or bind the type as a singleton to itself.
// The key type must be a class!
container.Bind<SomeClass>()
.ToSingleton(UseDefaultConstructor: True | False);
Where:
- UseDefaultConstructor β If True, the injection occurs each time the default constructor is called (from
new()
).
When you need to handle object instantiation manually, you can create a factory class. You can create it in two ways by inheriting your class from EasyJection.Types.IFactory
interface or without it. There are different binding methods for each type of factory.
Let's consider the first case.
The simplest factory inherited from EasyJection.Types.IFactory
interface:
public class MyFactory : EasyJection.Types.IFactory {
/// <summary>
/// Creates an instance of an object of the type created by the factory.
/// </summary>
/// <param name="bindingData">Instance implementing the IBindingData interface</param>
/// <returns>The instance.</returns>
public object CreateInstance(IBindingData bindingData = null) {
//Instantiate and return the object.
var myObject = new SomeClass();
return myObject;
}
}
There are two ways to bind a factory inherited from EasyJection.Types.IFactory
interface
// #1 The container creates the factory itself.
container.Bind<ISomeInterface>()
.ToFactory<MyFactory>(UseDefaultConstructor: True | False);
// #2 or binding an existing factory instance.
container.Bind<ISomeInterface>()
.ToFactory<MyFactory>(factoryInstance);
Where:
- UseDefaultConstructor β If True, the injection occurs each time the default constructor is called (from
new()
).
The second case, when your factory class does NOT inherit from EasyJection.Types.IFactory
interface:
public class MyCustomFactory {
/// Creates an instance of an object of the type
public object CreateInstance() {
var myObject = new SomeClass();
return myObject;
}
}
To bind, you need to specify the factory class and a name of a method that creates instances.
container.Bind<ISomeInterface>()
.ToFactory<MyCustomFactory>("CreateInstance", UseDefaultConstructor: True | False);
Where:
- UseDefaultConstructor β If True, the injection occurs each time the default constructor is called (from
new()
).
You can also bind the key type to an existing instance.
container.Bind<ISomeInterface>()
.ToInstance<SomeClass>(someClassInstance);
// Binds the key type to a transient of itself. The key must be a class.
container.Bind<SomeClass>().ToSelf(UseDefaultConstructor: True | False);
Where:
- UseDefaultConstructor β If True, the injection occurs each time the default constructor is called (from
new()
).
The EasyJection framework allows you to create a binding to a Unity's gameobject that has a type of component you need.
To bind to a gameobject, it must first be added to the installer's gameobjects collection:
The added prefab named Cube
has a Cube
component implementing a ICube
interface and inherited from MonoBehaviour.
After that the specified prefab named Cube
can be used for binding as transient.
// "Cube" is the key name of the gameobject in the installer collection
Container.Bind<ICube>().ToGameObject<Cube>("Cube");
// or so, in this case the type name is used (β οΈ a type name should match a key in the collection)
Container.Bind<ICube>().ToGameObject<Cube>();
EasyJection provides injection through a constructor or method call. Constructor injection forces the dependency to only be resolved once, at instance creation, which is usually what you want. Inject methods are the recommended approach for MonoBehaviours (e.g. 'Awake' and 'Start' methods). Injection conditions are set by calling the InjectionTo()
method. In order to specify a constructor or method for injection, you need to specify its signature.
EasyJection will also always try to resolve any dependencies for constructor or method parameters it might need, using information from its bindings, or trying to instantiate any types that are unknown to the binder. EasyJection allows you to replace the original values of method or constructor arguments with values from the binding
Note: If you donβt provide a constructor for your class, a new instance is created using the default constructor
new()
, C# creates one and sets member variables to the default values. But if you decide to create an instance by callingnew()
(with or without arguments) recommended to provide a constructor with[MethodImpl(MethodImplOptions.NoInlining)]
attribute.
Let's get acquainted with the available injection conditions.
Injection occurs each time the specified constructor is called.
Parameter-less constructor:
container.Bind<SomeClass>()
.ToSelf()
.InjectionTo()
.Constructor(UseForInstantiation: True | False);
Constructor with parameters:
container.Bind<SomeClass>()
.ToSelf()
.InjectionTo()
.Constructor<T1, T2 ... T9>(UseForInstantiation: True | False);
Constructor with passing argument values:
container.Bind<SomeClass>()
.ToSelf()
.InjectionTo()
.Constructor<T1, T2 ... T9>(UseForInstantiation: True | False)
.WithArguments<T1, T2 ... T9>(T1 arg1, T2 arg2 ... T9 arg9);
Where:
- UseForInstantiation β if True, the container will use this constructor to create an instance, otherwise it will use the default constructor.
- <T1, T2 ... T9> β types of constructor parameters.
The method injection works very similar to constructor injection in terms of specifying parameter types. However, there are nuances. There are two types of methods that return values and non-return (named as void).
To specify the non-return method use the MethodVoid()
.
Parameter-less void method:
container.Bind<SomeClass>()
.ToSelf()
.InjectionTo()
.MethodVoid(methodName);
with parameters:
container.Bind<SomeClass>()
.ToSelf()
.InjectionTo()
.MethodVoid<T1, T2 ... T9>(methodName);
with passing argument values:
container.Bind<SomeClass>()
.ToSelf()
.InjectionTo()
.MethodVoid<T1, T2 ... T9>(methodName);
.WithArguments<T1, T2 ... T9>(T1 arg1, T2 arg2 ... T9 arg9);
Where:
- methodName β the name of a non-return method
- <T1, T2 ... T9> β types of parameters.
To specify a method that returns a result, use MethodResult()
.
Parameter-less method:
container.Bind<SomeClass>()
.ToSelf()
.InjectionTo()
.MethodResult<TResult>(methodName);
with parameters:
container.Bind<SomeClass>()
.ToSelf()
.InjectionTo()
.MethodResult<T1, T2 ... T9, TResult>(methodName);
with passing argument values:
container.Bind<SomeClass>()
.ToSelf()
.InjectionTo()
.MethodResult<T1, T2 ... T9, TResult>(methodName);
.WithArguments<T1, T2 ... T9>(T1 arg1, T2 arg2 ... T9 arg9);
Where:
- methodName β the name of a method.
- <T1, T2 ... T9> β types of parameters.
- TResult β type of return value.
EasyJection allows you to pass arguments to a method or constructor. To specify the arguments to be passed use WithArguments()
.
The dependency will be resolved for each parameter if its value is NULL.
With Arguments()
overwrite the original passed values when the method/constructor is called from anywhere in your code.
Look at this sample:
public class SomeClass
{
public string Text;
public int Number;
public SomeClass(string text, int number)
{
this.Text = name;
this.Number = number;
}
}
...
// Binding
container.Bind<OriginalMethod_3>().ToSelf()
.InjectionTo()
.Constructor<string, int>(UseForInstantiation: True | False)
.WithArguments<string, int>("EasyJection", 2023);
// Now, when a constructor is called with arguments, the original arguments will always be overwrite.
var instance = new SomeClass("Hi!", 101);
Console.log(instance.Text == "EasyJection");
Console.log(instance.Number == 2023);
Result:
- True
- True
Where:
- UseForInstantiation β if True, the container will use this constructor to create an instance, otherwise it will use the default constructor.
The EasyJection framework can inject and resolve all registered implementations for each array element in a field.
public class Foo
{
public ISomeInterface[] fieldArray;
[MethodImpl(MethodImplOptions.NoInlining)]
// The array must be created before injection.
public SomeClass()
// the size of the array is 10
: this(new ISomeInterface[10])
{ }
[MethodImpl(MethodImplOptions.NoInlining)]
private SomeClass(ISomeInterface[] array)
{
this.fieldArray = array;
}
}
...
// Binding
var container = new Container();
container.Bind<ISomeInterface>().To<SomeClass>();
container.Bind<Foo>().ToSelf(UseDefaultConstructor: true);
...
// So now the EasyJection framework creates and resolves 10 elements in a field named 'fieldArray'
var instance = new Foo();
// TODO
A circular dependency occurs when two classes depend on each other. For example, class A needs class B, and class B also needs class A. The EasyJection framework supports the resolution of Circular Dependencies.
A Quick Example:
// Defining two beans that depend on each other (via constructor injection):
// #1
public class CircularDependencyA : ICircularDependencyA
{
private ICircularDependencyB circB;
[MethodImpl(MethodImplOptions.NoInlining)]
public CircularDependencyA(ICircularDependencyB circB) {
this.circB = circB;
}
}
// #2
public class CircularDependencyB : ICircularDependencyB
{
private ICircularDependencyA circA;
[MethodImpl(MethodImplOptions.NoInlining)]
public CircularDependencyB(ICircularDependencyA circA) {
this.circA = circA;
}
}
// Defining a class that will use one of the beans
public class App
{
public ICircularDependencyA circA;
[MethodImpl(MethodImplOptions.NoInlining)]
public App() {
this.circA = null;
}
}
Creating a binding:
container.Bind<ICircularDependencyA>()
.To<CircularDependencyA>()
.InjectionTo()
.Constructor<ICircularDependencyB>(UseForInstantiation: True);
container.Bind<ICircularDependencyB>()
.To<CircularDependencyB>()
.InjectionTo()
.Constructor<ICircularDependencyA>(UseForInstantiation: True);
container.Bind<App>().ToSelf(UseDefaultConstructor: true);
Dependencies will be resolved each time an instance of the App
class is created.
var app = new App();
All notable changes to this project will be documented in files:
- This CHANGELOG includes the changes in recent updates of the framework.
- This CHANGELOG only contains changes specific to a package (UnityPackage).
The format is based on Keep a Changelog and this project adheres to Semantic Versioning.
Found a bug or fixed it already?
You are welcome to create an issue on the project's GitHub page or submit a pull request.
Here's how we suggest you make changes to this project:
- Fork this project to your account.
- Create a branch for the change you intend to make.
- Make your changes to your fork.
- Send a pull request from your forkβs branch to our
develop
branch.
Licensed under the Apache-2.0 License. Please see LICENSE for more information.