08. Services

  03. Features No Comments

What Are Services?

Services are objects that provide services to one or more clients that depend on them.

Init(args) automatically caches a single instance of each service and makes it simple to share that globally across all clients.

Defining Services In Code

ServiceAttribute

To define a class as a service, simply add the Service attribute to it.

[Service]
public class InputManager
{
     public bool MoveLeft => Keyboard.current[KeyCode.LeftArrow].isPressed;
     public bool MoveRight => Keyboard.current[KeyCode.RightArrow].isPressed;
}

This results in a single instance of the InputManager class being automatically created and cached behind the scenes.

Services can be plain old class objects or derive from MonoBehaviour or ScriptableObject.

Dependencies Between Service Classes

Services registered using the Service attribute should always have a parameterless default constructor, so that it is possible for the framework to create an instance of it automatically.

If your services need to make use of other services to function, you can implement an IInitializable<T…> interface with its generic types matching the types of other services. The other services will get injected to the client service through its Init method as part of the service initialization process.

[Service]
public class PlayerManager : IInitializable<InputManager>
{
     private InputManager inputManager;
     public void Init(InputManager inputManager)
     {
          this.inputManager = inputManager;
     }
}

Unity Events For Services

Your Services, even if they don’t derive from MonoBehaviour, can still receive callbacks during select Unity events by implementing one of the following interfaces:

  1. IAwake – Receive a callback to an Awake function immediately following the initialization of the service instance. The Init functions of all services that implement an IInitializable<T> interface are always executed before any Awake functions are.
  2. IOnEnable – Receive a callback to an OnEnable function following the initialization of the service instance. The Awake functions of all services that implement IAwake are always executed before any OnEnable functions are.
  3. IStart – Receive a callback to a Start function following the initialization of the service instance. The OnEnable functions of all services that implement IOnEnable are always executed before any Start functions are.
  4. IUpdate – Receive callback during the Update event.
  5. IFixedUpdate – Receive callback during the FixedUpdate event.
  6. ILateUpdate – Receive callback during the LateUpdate event.
  7. IOnDisable – Receive callback when the application is quitting or when exiting play mode in the editor.
  8. IOnDestroy – Receive callback when the application is quitting or when exiting play mode in the editor.

Additionally if your service class implements the IDiposable interface then IDispose will be called for it when the application is quitting or when exiting play mode in the editor

Services and Interfaces

One big benefit with the service system is that it supports caching and receiving services using an interface as the defining type.

This makes it very easy to decouple your classes from relying on specific concrete classes. This has numerous benefits such as making it easier to create unit tests for your classes and making it much easier to swap your services with other ones midway through development.

To achieve this, first we need to define the interface that all clients objects can use to communicate with the service object.

public interface IInputManager
{
    bool MoveLeft { get; }
    bool MoveRight { get; }
}

Next let’s return to our InputManager class and make it implement the interface, as well as specify in the Service attribute that this service should be accessed via the interface type.

[Service(typeof(IInputManager))]
public class InputManager : IInputManager
{
    public bool MoveLeft => Keyboard.current[KeyCode.LeftArrow].isPressed;
    public bool MoveRight => Keyboard.current[KeyCode.RightArrow].isPressed;
}

After this everything can work exactly the same on the client side, except now instead of referring to the InputManager directly, we use the IInputManager interface instead.

public class Player : MonoBehaviour<IInputManager>
{
    private IInputManager inputManager;

    protected override void Init(IInputManager inputManager)
    {
        this.inputManager = inputManager;
    }
}

Using interfaces can be a powerful way to decouple your client classes from relying on specific service classes.

This makes it really easy to swap your services with other ones whenever you want, such as during unit testing.

It also makes it trivial to dynamically select the active service from multiple options for example using define directives.

#if UNITY_STANDALONE
[Service(typeof(IInputManager))]
#endif
public class KeyboardInputManager : MonoBehaviour, IInputManager
{
    public bool MoveLeft => Keyboard.current[KeyCode.LeftArrow].isPressed;
    public bool MoveRight => Keyboard.current[KeyCode.RightArrow].isPressed;
}
#if !UNITY_STANDALONE
[Service(typeof(IInputManager))]
#endif
public class GamepadInputManager : MonoBehaviour : IInputManager
{
    public bool MoveLeft => Gamepad.current[GamepadButton.DpadLeft].isPressed;
    public bool MoveRight => Gamepad.current[GamepadButton.DpadRight].isPressed;

    private void Awake()
    {
        if(Application.isConsolePlatform)
        {
            Service.SetInstance<IInputManager>(this);
        }
    }
}

Lazy Initialization

It is also possible to delay the initialization of services until the moment they are first needed.

If you set LazyInit = true then an instance of the service will not be created when the game starts like usually, but this will be delayed to only take place when the first client requests an instance.

This makes it possible to use the ServiceAttribute with FindFromScene load method, even in cases where the service component doesn’t exist in the first scene of the game when the game is loaded.

[Service(LazyInit = true, FindFromScene = true)]
public class UIManager : MonoBehaviour
{
    [SerializeField]
    private Panels[] panels;

    public Panel ShowPanel(string panelId)
    {
        return panels.Where(panel => panel.Id == panelId).Show();
    }
}

In games with lots of services, lazy initialization can also be used to reduce the initial loading time of the game by spreading loading to happen over a longer period of time.

Lazy initialization was implemented using the static constructor feature in C# which means it is guaranteed to only happen once per service type and to be fully thread safe.
It also means that subsequently clients can retrieve instances of a service through a simple auto-implemented property without any additional overhead being introduced with reference fetching always happening through something like a Lazy<T> wrapper.

Services and Thread Safety

Instances of services class that have the Service attribute are created during initialization of the game, before the first scene is loaded. After this process has finished the service framework will not make any changes to these service instances.

Because the service instances will remain unchanged throughout the applications lifetime by default, accessing services through Service<T>.Instance is a thread safe operation, provided that this only occurs after the initialization process has finished and that you don’t manually change services instance at runtime in your code.

If however you manually change Service instances at runtime using the Service.SetInstance method, accessing the service from other threads using Service<T>.Instance is no longer a safe operation.

Defining Services with the Inspector

In addition to using the ServiceAttribute, it is also possible to define services without the need for any code, using just the Inspector.

The methods can be useful for handling dynamic services that only exist in the scene hierarchy at particular times.
Think things like UI Panels that only become active when the user presses a button and become deactive again when the user closes them.

While all services registered using the ServiceAttribute are globally accessible to any clients, scene-based dynamic services can be limited to only be accessible to a limited subset of clients based on where they exist in the scene hierarchies relative to the GameObject that holds the service.

Service Tags

The simplest way to to define a single component as a service is to select the Make Service Of Type… option in the context menu for the component.
This opens up a dropdown menu that lets you select the defining type for the service – i.e. the type that clients can use to retrieve an instance of the service.

After selecting the defining type a blue Service Tag should appear in the header of the component in the Inspector. This acts as a marker to let you easily know which components are services, and also gives you access to some useful functionality when you left-click or right-click the Service Tag.

If you left-click the Service Tag the Defining Type menu will open again.
If you click the previously selected defining type of the service again, the service tag will be removed and the component will no longer be a service.
You can add more than one defining types for one component by selecting multiple options in the dropdown menu.

If you right-click the Service Tag a context menu will open, giving you access to additional commands.

Set Availability…

If you pick the Set Availability… menu item then a dropdown menu will open, letting you choose one of the following options to define which clients will have access to the service:

  1. In GameObject – Only clients that are attached to the same GameObject as this component can receive services from it.
  2. In Children – Only clients that are attached to the same GameObject as this component, or any of its children (including nested children), can receive services from it.
  3. In Parents – Only clients that are attached to the same GameObject as this component, or any of its parents (including nested parents), can receive services from it.
  4. In Hierarchy Root Children – Only clients that are attached to the GameObject which is at the root of this component’s hierarchy, or any of the children of the root (including nested children), can receive services from it.
  5. In Scene – Only clients belonging to the same scene as this component can receive services from it.
  6. In All Scenes – All scene objects can receive services from this component, regardless of which active scene they belong to.
  7. Everywhere – All clients can receive services from this component, regardless of of which active scene they belong to, or if they belong a scene at all.

Find Defining Object

Pick the Find Defining Object menu item to highlight the Object that defines the service in the Hierarchy or Project view.

If the service is defined by the ServiceAttribute, then the script asset that contains the attribute will be highlighted.

If the service is defined by a Services component, then the GameObject that contains the Services component will be highlighted.

If the service is defined by a Service Tag added via the Make Service Of Type… menu item then Find Defining Object will not appear in the menu.

Find Clients In Scenes

Pick the Find Clients In Scenes menu item to select all objects in the scene hierarchies that depend on the service.

Services Component

Another way to define scene services is through using the Services component.

Start by adding the Services component to a GameObject in the scene and adding a new entry to its Provides Services list.

Then drag the component you want to define as a Service to this list.
If you drag a GameObject to the list a popup will open to allow you to specify which component from that GameObject you want to define as a service.

After you’ve dragged a component to the list, press the button that appears on the right side of the Object field to open the defining type dropdown menu. Select the defining type for the service from the menu.

You can define as many services as you want using a single Services component.

Registering Services Manually In Code

In addition to registering services automatically using the Service attribute, it is possible to register them manually in code as well.

This might be useful for example if you want to dynamically swap your services with different ones based on some conditional logic.
For example you could swap the KeyboardInputManager to GamepadInputManager when the user changes their input method in the settings.

To manually register a service use the Service.SetInstance method.

public class InputManager : MonoBehaviour
{
    private void Awake()
    {
        Service.SetInstance<InputManager>(this);
    }
}

Using Services

To automatically receive the InputManager service during initialization you can have your class derive from MonoBehaviour<InputManager>. It will then receive the InputManager object in its Init function, making it possible to assign it to a member variable.

public class Player : MonoBehaviour<InputManager>
{
    private InputManager inputManager;

    protected override void Init(InputManager inputManager)
    {
        this.inputManager = inputManager;
    }
}

This means that the Player component could exist as part of a scene that is loaded or a prefab that is instantiated without any arguments being provided manually, and Init would still get called with the InputManager service.

Note that the Init function will only get automatically called during initialization if all the Init arguments the client expects are services.

If one or more arguments are not services, then you will need to manually provide all the arguments when initializing the client.

For example, consider this client that requires one service object and one object that is not a service:

public class Player : MonoBehaviour<InputManager, Camera>
{
    private InputManager inputManager;
    private Camera firstPersonCamera;

    protected override void Init(InputManager inputManager, Camera firstPersonCamera)
    {
        this.inputManager = inputManager;
        this.firstPersonCamera = firstPersonCamera;
    }
}

You can retrieve the cached instance of any service manually using Service<T>.Instance.

var inputManager = Service<InputManager>.Instance;

Then you need to pass that instance to the client when it is being created.

For example if you are creating the client by instantiating it from a prefab, you can use the Prefab.Instantiate function.

var inputManager = Service<InputManager>.Instance;
var firstPersonCamera = Camera.main;

var playerPrefab = Resources.Load<Player>("Player");
playerPrefab.Instantiate(inputManager, firstPersonCamera);

Or if the client is being built from scratch at runtime, you can use the GameObject.AddComponent function that accepts initialization arguments.

var playerGameObject = new GameObject("Player");
playerGameObject.AddComponent(inputManager, firstPersonCamera);

If the client is a scene object, you can generate an Initializer to provide the arguments to the client.
When using Initializers all service arguments are passed to the client automatically, so you only need to assign the other arguments using the Inspector.

Reacting To Changing Services

In cases where your services might change throughout the lifetime of the application, you may want to make sure clients of the services are always using the most recently registered services.

To do this you can subscribe receive callbacks a service instance changes to a different, using the Service.AddInstanceChangedListener method.

When you do this you should also remember to unsubscribe using Service.RemoveInstanceChangedListener when the listener gets destroyed.

public class Player : MonoBehaviour<IInputManager>
{
    private IInputManager inputManager;

    protected override void Init(IInputManager inputManager)
    {
        this.inputManager = inputManager;
    }

    private void OnEnable()
    {
        Service.AddInstanceChangedListener(OnInputManagerInstanceChanged);
    }

    private void OnDisable()
    {
        Service.RemoveInstanceChangedListener(OnInputManagerInstanceChanged);
    }

    private void OnInputManagerInstanceChanged(IInputManager newInstance)
    {
        inputManager = newInstance;
    }
}

Leave a Reply

Your email address will not be published. Required fields are marked *