Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
## [3.1.0] - 2026-06-21

### Added
- **Optional Addressables support.** A new `AddressableServiceKitBehaviour` loads its `ServiceKitLocator` through Addressables (via a typed `ServiceKitLocatorAssetReference`) instead of a direct serialized reference — so the locator isn't pulled into every bundle/scene that references it, and its Addressables handle is released on destroy. It is compiled only when the Addressables package is installed (the `SERVICEKIT_ADDRESSABLES` define) and costs nothing otherwise.
- `ServiceKitBehaviour.PrepareLocatorAsync()` — a `protected virtual` hook (default no-op, completes synchronously) run in `Awake` before registration, so a subclass can source its locator from an asynchronous origin and have `ResolveLocator()` return it. Normal behaviours are unaffected and still register synchronously within `Awake`.

## [3.0.0] - 2026-06-08

### Changed
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -879,6 +879,22 @@ Create a settings asset via `Assets > Create > ServiceKit > Settings` (loaded fr
| `DebugLogging` | `false` | Verbose registration/ready logging (editor) |
| `DefaultServiceKitLocator` | — | Locator used for auto-assignment; takes highest priority |

### Addressable Locators (optional)

If the [Addressables](https://docs.unity3d.com/Packages/com.unity.addressables@latest) package is installed, derive from `AddressableServiceKitBehaviour` instead of `ServiceKitBehaviour` to load the locator by Addressables reference rather than a direct serialized one. The locator loads asynchronously before the service registers (so it isn't pulled into every bundle that references it) and its handle is released on destroy:

```csharp
[Service(typeof(IPlayerService))]
public class PlayerService : AddressableServiceKitBehaviour, IPlayerService
{
// Assign the locator via the ServiceKitLocatorAssetReference field in the inspector;
// leave the inherited ServiceKitLocator field empty.
protected override void InitializeService() { /* ... */ }
}
```

This is gated behind the `SERVICEKIT_ADDRESSABLES` define (set automatically when Addressables is present) and compiles to nothing otherwise. Under the hood it overrides the `protected virtual ServiceKitBehaviour.PrepareLocatorAsync()` hook — the same hook you can override to source a locator from any other asynchronous origin.

## Best Practices

### Service Design
Expand Down
81 changes: 81 additions & 0 deletions Runtime/AddressableServiceKitBehaviour.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
#if SERVICEKIT_ADDRESSABLES
using System;
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;

#if SERVICEKIT_UNITASK
using Cysharp.Threading.Tasks;
#else
using System.Threading.Tasks;
#endif

namespace Nonatomic.ServiceKit
{
/// <summary>
/// A typed Addressables reference to a <see cref="ServiceKitLocator"/>, so the inspector slot only
/// accepts locator assets.
/// </summary>
[Serializable]
public class ServiceKitLocatorAssetReference : AssetReferenceT<ServiceKitLocator>
{
public ServiceKitLocatorAssetReference(string guid) : base(guid) { }
}

/// <summary>
/// A <see cref="ServiceKitBehaviour"/> whose locator is loaded through Addressables instead of a direct
/// serialized reference. The locator is loaded asynchronously before the service registers (so it is
/// not pulled into every bundle or scene that references it), and the Addressables handle is released on
/// destroy. Only compiled when the Addressables package is installed (the <c>SERVICEKIT_ADDRESSABLES</c>
/// define). Assign the locator via the AssetReference field below; leave the inherited serialized
/// <c>ServiceKitLocator</c> field empty.
/// </summary>
public abstract class AddressableServiceKitBehaviour : ServiceKitBehaviour
{
[SerializeField] private ServiceKitLocatorAssetReference _serviceKitLocatorReference;

private ServiceKitLocator _loadedLocator;
private AsyncOperationHandle<ServiceKitLocator> _loadHandle;
private bool _hasLoadHandle;

// Return the addressable-loaded locator; the inherited serialized field is unused for this variant.
protected override IServiceLocator ResolveLocator() => _loadedLocator;

#if SERVICEKIT_UNITASK
protected override async UniTask PrepareLocatorAsync()
#else
protected override async Task PrepareLocatorAsync()
#endif
{
if (_serviceKitLocatorReference == null || !_serviceKitLocatorReference.RuntimeKeyIsValid())
{
Debug.LogError($"[ServiceKit] {GetType().Name} has an invalid or missing Addressables reference to a ServiceKitLocator.", this);
return;
}

_loadHandle = _serviceKitLocatorReference.LoadAssetAsync<ServiceKitLocator>();
_hasLoadHandle = true;

// AsyncOperationHandle.Task is awaitable from both the UniTask and the Task build, and avoids a
// dependency on UniTask's optional Addressables integration.
_loadedLocator = await _loadHandle.Task;

if (_loadHandle.Status != AsyncOperationStatus.Succeeded || _loadedLocator == null)
{
Debug.LogError($"[ServiceKit] {GetType().Name} failed to load its ServiceKitLocator from Addressables.", this);
}
}

protected override void OnDestroy()
{
// Unregister via the base first (it still sees the loaded locator), then release the handle.
base.OnDestroy();

if (!_hasLoadHandle) return;
Addressables.Release(_loadHandle);
_hasLoadHandle = false;
_loadedLocator = null;
}
}
}
#endif
2 changes: 2 additions & 0 deletions Runtime/AddressableServiceKitBehaviour.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 19 additions & 0 deletions Runtime/ServiceKitBehaviour.cs
Original file line number Diff line number Diff line change
Expand Up @@ -104,11 +104,30 @@ protected virtual async void Awake()
if (IsObjectDestroyed()) return;

CacheDestroyToken();

// Optional async step: a subclass can source its locator asynchronously here (e.g. load it via
// Addressables) before registration. The default no-op completes synchronously, so behaviours
// that don't override it still register within this same Awake call.
await PrepareLocatorAsync();
if (IsObjectDestroyed()) return;

RegisterServiceWithLocator();

await PerformServiceInitializationSequence();
}

/// <summary>
/// Optional asynchronous step run during Awake, before the service registers. Override it to source
/// the locator from an async origin (e.g. load a <see cref="ServiceKitLocator"/> via Addressables)
/// and have <see cref="ResolveLocator"/> return it. The default returns immediately, so behaviours
/// that don't override it register synchronously within Awake exactly as before.
/// </summary>
#if SERVICEKIT_UNITASK
protected virtual UniTask PrepareLocatorAsync() => UniTask.CompletedTask;
#else
protected virtual Task PrepareLocatorAsync() => Task.CompletedTask;
#endif

private bool IsObjectDestroyed()
{
return !this || !gameObject;
Expand Down
10 changes: 9 additions & 1 deletion Runtime/com.nonatomic.servicekit.runtime.asmdef
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@
"name": "com.nonatomic.servicekit.runtime",
"rootNamespace": "Nonatomic.ServiceKit",
"references": [
"GUID:f51ebe6a0ceec4240a699833d6309b23"
"GUID:f51ebe6a0ceec4240a699833d6309b23",
"GUID:9e24947de15b9834991c9d8411ea37cf",
"GUID:593a5b492d29ac6448b1ebf7f035ef33",
"GUID:84651a3751eca9349aac36a66bba901b"
],
"includePlatforms": [],
"excludePlatforms": [],
Expand All @@ -16,6 +19,11 @@
"name": "com.cysharp.unitask",
"expression": "2.5.10",
"define": "SERVICEKIT_UNITASK"
},
{
"name": "com.unity.addressables",
"expression": "1.0.0",
"define": "SERVICEKIT_ADDRESSABLES"
}
],
"noEngineReferences": false
Expand Down
8 changes: 8 additions & 0 deletions Samples~/12 - Addressable Locator.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

33 changes: 33 additions & 0 deletions Samples~/12 - Addressable Locator/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Addressable Locator

Shows how to load a `ServiceKitLocator` through **Addressables** instead of a direct serialized
reference, using `AddressableServiceKitBehaviour`.

## Why

A direct `[SerializeField] ServiceKitLocator` reference ties the locator asset into every bundle or
scene that references it. Loading it by Addressables reference keeps it a single, shared, ref-counted
asset and gives you explicit load/release. `AddressableServiceKitBehaviour` does the async load before
the service registers, and releases the handle on destroy - you don't write any of that boilerplate.

> Requires the **Addressables** package (`com.unity.addressables`). The sample code is compiled only
> when it is installed (the `SERVICEKIT_ADDRESSABLES` define).

## Setup

1. Create a `ServiceKitLocator` asset: **Assets > Create > ServiceKit > ServiceKitLocator**.
2. Mark it **Addressable** (tick the *Addressable* box in its inspector, or add it to an Addressable
group).
3. Put `ExampleAddressableService` on a GameObject. In its inspector:
- assign the locator asset to the **`Service Kit Locator Reference`** (the AssetReference field);
- leave the inherited **`Service Kit Locator`** field **empty**.
4. Press Play. On `Awake` the service loads the locator via Addressables, registers itself, and runs
`InitializeService()`. On destroy, the Addressables handle is released.

## Notes

- This pattern fits a **service that registers itself** into an addressably-loaded locator. If instead
you have a central object that loads the locator once and populates it from another system, that is a
different shape and this base class won't simplify it - load the locator yourself and use the locator
API directly.
- The async load uses `UniTask` automatically if it is installed, otherwise `System.Threading.Tasks`.
7 changes: 7 additions & 0 deletions Samples~/12 - Addressable Locator/README.md.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions Samples~/12 - Addressable Locator/Scripts.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
#if SERVICEKIT_ADDRESSABLES
using Nonatomic.ServiceKit;
using UnityEngine;

namespace ServiceKitSamples.AddressableLocator
{
public interface IExampleAddressableService
{
void DoSomething();
}

/// <summary>
/// Example service whose locator is loaded through Addressables rather than a direct serialized
/// reference. Because it derives from <see cref="AddressableServiceKitBehaviour"/>:
/// - assign a ServiceKitLocator <b>AssetReference</b> on this component in the inspector
/// (and leave the inherited <c>ServiceKitLocator</c> field empty);
/// - the locator is loaded asynchronously before this service registers, so it is not pulled
/// into every bundle/scene that references it;
/// - the Addressables handle is released automatically when this object is destroyed.
/// Only compiles when the Addressables package is installed (the SERVICEKIT_ADDRESSABLES define).
/// </summary>
[Service(typeof(IExampleAddressableService))]
public class ExampleAddressableService : AddressableServiceKitBehaviour, IExampleAddressableService
{
protected override void InitializeService()
{
Debug.Log("[Sample] ExampleAddressableService initialized - its locator was loaded via Addressables.");
}

public void DoSomething()
{
Debug.Log("[Sample] ExampleAddressableService.DoSomething()");
}
}
}
#endif

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"name": "ServiceKitSamples.AddressableLocator",
"rootNamespace": "ServiceKitSamples.AddressableLocator",
"references": [
"GUID:1b0144b25b4099e45a5beb8f8f7bee63"
],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [
{
"name": "com.unity.addressables",
"expression": "1.0.0",
"define": "SERVICEKIT_ADDRESSABLES"
}
],
"noEngineReferences": false
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

75 changes: 75 additions & 0 deletions Tests/PlayMode/PrepareLocatorAsyncTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
using System.Collections;
using System.Threading.Tasks;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;

namespace Nonatomic.ServiceKit.Tests.PlayMode
{
/// <summary>
/// Validates the <see cref="ServiceKitBehaviour.PrepareLocatorAsync"/> hook: a behaviour that sources
/// its locator asynchronously must defer registration until the load completes, then register and
/// initialize normally. This is the exact mechanism <c>AddressableServiceKitBehaviour</c> relies on,
/// tested here without the Addressables package - a <see cref="TaskCompletionSource{TResult}"/> stands
/// in for the async load so the core contract is verified deterministically.
/// </summary>
public class PrepareLocatorAsyncTests
{
public interface IAsyncLocatorService { }

[Service(typeof(IAsyncLocatorService))]
private sealed class AsyncLocatorConsumer : ServiceKitBehaviour, IAsyncLocatorService
{
public ServiceKitLocator LocatorToLoad;
public readonly TaskCompletionSource<bool> LoadGate = new TaskCompletionSource<bool>();
private ServiceKitLocator _loaded;
public bool Initialized { get; private set; }

// Null until the async "load" completes - mirrors an addressable locator not yet resolved.
protected override IServiceLocator ResolveLocator() => _loaded;

#if SERVICEKIT_UNITASK
protected override async Cysharp.Threading.Tasks.UniTask PrepareLocatorAsync()
#else
protected override async Task PrepareLocatorAsync()
#endif
{
// Stand-in for an async locator source (e.g. _serviceKitLocatorReference.LoadAssetAsync).
await LoadGate.Task;
_loaded = LocatorToLoad;
}

protected override void InitializeService() => Initialized = true;
}

[UnityTest]
public IEnumerator PrepareLocatorAsync_DefersRegistration_UntilLocatorLoaded()
{
var locator = ScriptableObject.CreateInstance<ServiceKitLocator>();
var go = new GameObject(nameof(AsyncLocatorConsumer));
go.SetActive(false);
var consumer = go.AddComponent<AsyncLocatorConsumer>();
consumer.LocatorToLoad = locator;

go.SetActive(true); // Awake -> CacheDestroyToken -> await PrepareLocatorAsync (gated open)

// While the locator "load" is pending, registration must NOT have happened yet.
for (var i = 0; i < 30; i++) yield return null;
Assert.IsFalse(locator.IsServiceRegistered<IAsyncLocatorService>(),
"Registration must be deferred until PrepareLocatorAsync completes");

consumer.LoadGate.SetResult(true); // complete the async locator load

for (var i = 0; i < 600 && !consumer.Initialized; i++) yield return null;

Assert.IsTrue(locator.IsServiceRegistered<IAsyncLocatorService>(),
"Service must register once the locator has loaded");
Assert.IsTrue(consumer.Initialized,
"InitializeService must run after the async locator load completes");

Object.Destroy(go);
locator.ClearServices();
Object.Destroy(locator);
}
}
}
2 changes: 2 additions & 0 deletions Tests/PlayMode/PrepareLocatorAsyncTests.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading