Init(args) - A next-gen DI framework

You could also use Service.AddInstanceChangedListener instead of SceneManager.loadScene. This way the system would be a bit more flexible, and e.g. wouldn’t break if you relocate the service to another scene, and would be more easily testable.

You can use a factory method (or a factory class) to encapsulate all the complexity of creating and setting up the PocoClass.

If you don’t want to have to worry about always null-checking ISecondSceneService inside PocoClass, and risk running into NullReferenceExceptions, then here’s a couple of alternative approaches you can consider:

Task<IMyService>

Task<IMyService> provides a convenient mechanism for easily deferring execution of all methods in PocoClass until the required service is available:

public class PocoClass
{
	private readonly Task<ISecondSceneService> secondSceneService;

	private PocoClass(Task<ISecondSceneService> secondSceneService) => this.secondSceneService = secondSceneService;

	// Factory method for easy creation
	public static PocoClass Create()
	{
		if(Service.TryGet(out ISecondSceneService secondSceneService))
		{
			return new(Task.FromResult(secondSceneService));
		}

		var taskCompletionSource = new TaskCompletionSource<ISecondSceneService>();
		Service.AddInstanceChangedListener<ISecondSceneService>(OnSecondSceneServiceChanged);
		return new(taskCompletionSource.Task);
		
		void OnSecondSceneServiceChanged(Clients clients, ISecondSceneService oldInstance, ISecondSceneService newInstance)
		{
			Service.RemoveInstanceChangedListener<ISecondSceneService>(OnSecondSceneServiceChanged);
			taskCompletionSource.SetResult(newInstance);
		}
	}

	// Async usage example
	public async Task DoSomethingUsingSecondServiceAsync() => (await secondSceneService).DoSomething();

	// Sync usage example	
	public bool TryDoSomethingUsingSecondService()
	{
		if(!secondSceneService.IsCompletedSuccessfully)
		{
			return false;
		}

		secondSceneService.Result.DoSomething();
		return true;
	}
}

Null Object Pattern

You could also use the Null Object pattern, to first initialize PocoClass with a placeholder service, that does basically nothing when its members are called. Then once the real service becomes available, it can be swapped in to take the place of the placeholder.

This way you would never need to worry about null-checking the service, and you wouldn’t need to add any sort of IsReady flag to PocoClass, and require all its clients to always to check the flag before using any of its members.

public class PocoClass
{
	private ISecondSceneService secondSceneService;

	public PocoClass(ISecondSceneService secondSceneService) => this.secondSceneService = secondSceneService;

	public static PocoClass Create()
	{
		if(Service.TryGet(out ISecondSceneService secondSceneService))
		{
			return new(secondSceneService);
		}

		var result = new PocoClass(new SecondSceneServicePlaceholder());
		Service.AddInstanceChangedListener<ISecondSceneService>(OnSecondSceneServiceChanged);
		return result;

		void OnSecondSceneServiceChanged(Clients clients, ISecondSceneService oldInstance, ISecondSceneService newInstance)
		{
			Service.RemoveInstanceChangedListener<ISecondSceneService>(OnSecondSceneServiceChanged);
			result.secondSceneService = newInstance;
		}
	}

	// Sync usage example
	public void DoSomethingUsingSecondService() => secondSceneService.DoSomething();
	
	private sealed class SecondSceneServicePlaceholder : ISecondSceneService
	{
		public void DoSomething() { }
	}
}

This seemed to be the best implementation for what I need. But I ran into an issue.

The SecondSceneService goes through its own IInitializer and has a few things injected into it, then is set to DontDestroyOnLoad.

void IIntializable<IDep1, IDep2, IDep3>.Init(...) {
    // set deps
    DontDestroyOnLoad(this);
}

Implementing the NullObject approach, it was as if the method never fired. None of its injected dependencies were set (all of which were created in the first scene and also in DDOL), and it was not moved into DDOL. It also hit a null object exception in the Start() method which needed to get a value from one of those dependencies.

I’ve listed some reasons for a client not receiving services here:

I’ve read through them. My SecondSceneService does not use MonoBehaviour<T …>.

It’s signature follows this:

[Service(typeof(ISecondSceneService), typeof(ISupport), FindFromScene = true)]
public class SecondSceneService : MonoBehaviour, ISecondSceneService, ISupport, IInitializable<GlobalConfig,IMessageBus,IMatchmaker>
{

    void IInitializable<GlobalConfig,IMessageBus,IMatchmaker>.Init(GlobalConfig config, IMessageBus bus,IMatchmaker mm)
    {
        _mm = mm;
        // set other deps ...
    }

    private bool _isHost;

    // no Awake() or OnAwake()

    private void Start() {
        _isHost = _mm.IsHost;  // <-- _mm is null
    }
}

The above works and gets its dependencies exactly as expected until I implement and use the NullObject pattern you suggested for my POCO class. It also instantiates properly with my current code where my parent service to the POCO waits for the second scene to load then passes a reference to the POCO’s SecondSceneService setter.

The three deps that are getting passed into SecondSceneService were all created at game startup with:

[Service(ResourcePath="path/to/resource/folder")]

Thus they are already present, registered, and DDOL’ed when the first scene loads.

So … kinda stumped at the moment. But it works fine with my setter approach, so I’m not desperate for a solution. :slight_smile:

That should not work. I don’t know how it seemed to be working before :thinking:

[Service(FindFromScene = true)] should only be used for retrieving services from the initial scene. So if you have a Bootstrap scene from which you always launch the game, then the attribute can be used to register global services from there.

For subsequent scenes, you would usually use Service Tag to register the service, and have it derive from MonoBehaviour<T…> to have it receive other services it depends on. But in this case, since you want clients to be able to receive an object that can be used to communicate with the service, even before the service exists, custom initialization code is required.

A service initializer can be used to gain full control over how a service is acquired and registered. We could use that to register a decorator in place of the scene service, which initially does nothing, but starts redirecting calls to the scene service once it has been loaded.

So what you could do is:

  1. Add an event to SecondSceneService, so that we will be able to know in our initializer exactly when the service has been loaded (more accurate than SceneManager.sceneLoaded).
  2. Use [InitOrder(Category.Service)] to make sure that the SecondSceneService is initialized before all other components in the scene that might depend on it.
[InitOrder(Category.Service, Order.Early)]
public sealed class SecondSceneService : MonoBehaviour, ISecondSceneService, IInitializable<GlobalConfig, IMessageBus, IMatchmaker>
{
	internal static Action<SecondSceneService> Awaking;

	GlobalConfig _globalConfig;
	IMessageBus _messageBus;
	IMatchmaker _matchmaker;

	void Awake() => Awaking?.Invoke(this);
	
	void IInitializable<GlobalConfig, IMessageBus, IMatchmaker>.Init(GlobalConfig globalConfig, IMessageBus messageBus, IMatchmaker matchmaker)
	{
		_globalConfig = globalConfig;
		_messageBus = messageBus;
		_matchmaker = matchmaker;
	}

	public void DoSomething() => Debug.Log("SecondSceneService.DoSomething()");
}

Create a decorator for the service in the second scene, which can immediately be injected to clients in the first scene when the game starts:

public sealed class SecondSceneServiceDecorator : ISecondSceneService
{
	public SecondSceneService secondSceneService;

	public void DoSomething()
	{
		if(secondSceneService)
		{
			secondSceneService.DoSomething();
		}
		else
		{
			Debug.Log("Ignoring DoSomething call because SecondSceneService has not been loaded yet.");
		}
	}
}

Then we can define an initializer that will handle:

  1. registering the decorator as a global service when the game starts
  2. injecting the scene object to the decorator when its loaded
  3. injecting to the scene object all the other services that it depends on
[Service(typeof(ISecondSceneService))]
public sealed class SecondSceneServiceInitializer : ServiceInitializer<SecondSceneServiceDecorator, GlobalConfig, IMessageBus, IMatchmaker>
{
	public override SecondSceneServiceDecorator InitTarget(GlobalConfig globalConfig, IMessageBus messageBus, IMatchmaker matchmaker)
	{
		var decorator = new SecondSceneServiceDecorator();

		SecondSceneService.Awaking += sceneInstance =>
		{
			((IInitializable<GlobalConfig, IMessageBus, IMatchmaker>)sceneInstance).Init(globalConfig, messageBus, matchmaker);
			Debug.Log("SecondSceneService was initialized.");
			decorator.secondSceneService = sceneInstance;
		};

		return decorator;
	}
}

Ah, probably what happened was you had LazyInit set to true for the service:

[Service(typeof(ISecondSceneService), FindFromScene = true, LazyInit = true)]
public class SecondSceneService : MonoBehaviour

With this, the service could in theory exist in a different scene than the initial one, because Init(args) won’t attempt to initialize the service until the moment that the first client asks for it for the first time.

However, this a fragile way to configure scene-based services, because if anything should ask for the service before its scene has been loaded, the lazy initialization will fail.

This is why the addition of this line in PocoClass.Create made the service’s initialization stop working:

if(Service.TryGet(out ISecondSceneService secondSceneService))

Because there was an attempt to acquire the service before its scene had been loaded, triggering its lazy initialization prematurely, which then failed.

Using the decorator pattern is a more robust option.

I’m not using a LazyInit declaration. In fact I have another service in my second scene that is also using FindFromScene = true without lazy init. Both services are definitely being registered and propagated as needed.

Ah right, I forgot - LazyInit is actually now true by default when using FindFromScene.

Those two just work so well together, it’s very improbable that you wouldn’t want to have LazyInit enabled with FindFromScene, so opt-out makes more sense in this case.

So I tried to set LazyInit = true on the other service in my second scene and something interesting happened. For clarity …

[Service(typeof(ISecondSceneService), ..., FindFromScene = true)]

[Service(typeof(IOtherSecondSceneService), ..., FindFromScene = true, LazyInit = true)]

For the first time when starting up my game, init-args put up a log warning about the ISecondSceneService saying it hadn’t been created even through it had the FindFromScene property set. It did not complain about IOtherSecondSceneService. But the transition into the second scene was borked with the ISecondSceneService not being set correctly.

However, the same error was not repeated in Virtual Player 2 (I’m using Unity 6 multiplayer console), which loaded up and connected both services without issue.

Does this service changed event only work for the global container or is there a way to listen for service changes in gameobject containers as well?

The event works with scene-based local services as well. The event listener receives an argument that indicates what clients the service is accessible to.

class Movable : MonoBehaviour<IInputProvider>
{
	IInputProvider inputProvider;

	protected override void Init(IInputProvider inputProvider)
	{
		Debug.Log("Input Provider changed to: " + inputProvider.GetType().Name);
		this.inputProvider = inputProvider;
	}

	void OnEnable() => Service.AddInstanceChangedListener<IInputProvider>(OnInputProviderChanged);
	void OnDisable() => Service.RemoveInstanceChangedListener<IInputProvider>(OnInputProviderChanged);

	void OnInputProviderChanged(Clients clients, IInputProvider oldInstance, IInputProvider newInstance)
	{
		if(clients == Clients.Everywhere && newInstance is not null)
		{
			Init(newInstance);
		}
	}

	void Update() => transform.position += inputProvider.MoveInput * Time.deltaTime * 5f;
}

The system is currently lacking an easy way to determine if a local service is accessible to a particular client or not, though.

I’ve been thinking about adding a variant of Service.AddInstanceChangedListener that would allow passing in a client as an argument, which could then only notify the client if the service in question is actually accessible to it.

But for the moment it’s best to either only use the event to detect globally accessible services (clients == Clients.Everywhere) changing, or to use Service.TryGetFor inside the event listener to make sure you’re getting an instance that is actually accessible to the client.

void OnInputProviderChanged(Clients clients, IInputProvider oldInstance, IInputProvider newInstance)
{
	if(Service.TryGetFor(this, out IInputProvider accessibleInstance) && inputProvider != accessibleInstance)
	{
		Init(accessibleInstance);
	}
}
1 Like

Hello!

First off. This plugin is amazing, it’s so easy to use. I do however just want to ask/report one thing.

When using the plugin in the editor, any service that is not a MonoBehaviour successfully gets instantiated and injected automatically into other classes constructors/Init()s. But it does not work in builds. All non-MonoBehaviour services are null upon injection.

I’m not very knowledgeable in Unity, is this some code stripping stuff that I can fix through my settings?

Hello,

Great to hear that you’ve found Init(args) easy to use - that has been my top priority with the asset :slight_smile:

Managed code stripping shouldn’t be causing problems with Init(args), because it uses a link.xml file automatically to prevent this. But you can make sure that this is not the source of your issues by setting Stripping Level to Disabled in Project Settings > Player before making a build.

You can also check if the documentation page Problems & Solutions: Client Not Receiving Services contains a solution to your problem. It doesn’t contain any solutions for issues that only manifest themselves in builds, though, so it probably won’t be helpful.

In my own testing non-MonoBehaviour services registered using the [Service] attribute do also get received by clients in builds, so this doesn’t seem to be a universal problem in the latest version.

If you are able to share your project with me in a PM, I can investigate it locally to figure out what the problem is.

The Editor.log might also contain something useful, like an exception that explains the problem, if we are lucky.

1 Like