From 90c79e05543812ef4a2570b05cbe5b4fe046f028 Mon Sep 17 00:00:00 2001 From: Paul Stamp Date: Sun, 21 Jun 2026 12:30:10 +0100 Subject: [PATCH 1/2] Add optional Addressables locator support (3.1.0) AddressableServiceKitBehaviour loads its ServiceKitLocator via Addressables (a typed ServiceKitLocatorAssetReference) instead of a direct serialized reference - the locator isn't pulled into every bundle that references it, and its handle is released on destroy. Gated behind SERVICEKIT_ADDRESSABLES (set when the Addressables package is present); compiles to nothing otherwise. Backed by a new protected virtual ServiceKitBehaviour.PrepareLocatorAsync() hook (default no-op, completes synchronously) run in Awake before registration, so a subclass can source its locator asynchronously. Normal behaviours are unaffected. Validated: default EditMode 115 / PlayMode 19; compiles cleanly with Addressables (Task path) and with Addressables + UniTask (UniTask path). --- CHANGELOG.md | 6 ++ README.md | 16 ++++ Runtime/AddressableServiceKitBehaviour.cs | 81 +++++++++++++++++++ .../AddressableServiceKitBehaviour.cs.meta | 2 + Runtime/ServiceKitBehaviour.cs | 19 +++++ .../com.nonatomic.servicekit.runtime.asmdef | 10 ++- package.json | 2 +- 7 files changed, 134 insertions(+), 2 deletions(-) create mode 100644 Runtime/AddressableServiceKitBehaviour.cs create mode 100644 Runtime/AddressableServiceKitBehaviour.cs.meta diff --git a/CHANGELOG.md b/CHANGELOG.md index c872d01..95cd91f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index cbd3d45..f9d3c34 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/Runtime/AddressableServiceKitBehaviour.cs b/Runtime/AddressableServiceKitBehaviour.cs new file mode 100644 index 0000000..1a5ca92 --- /dev/null +++ b/Runtime/AddressableServiceKitBehaviour.cs @@ -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 +{ + /// + /// A typed Addressables reference to a , so the inspector slot only + /// accepts locator assets. + /// + [Serializable] + public class ServiceKitLocatorAssetReference : AssetReferenceT + { + public ServiceKitLocatorAssetReference(string guid) : base(guid) { } + } + + /// + /// A 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 SERVICEKIT_ADDRESSABLES + /// define). Assign the locator via the AssetReference field below; leave the inherited serialized + /// ServiceKitLocator field empty. + /// + public abstract class AddressableServiceKitBehaviour : ServiceKitBehaviour + { + [SerializeField] private ServiceKitLocatorAssetReference _serviceKitLocatorReference; + + private ServiceKitLocator _loadedLocator; + private AsyncOperationHandle _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(); + _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 diff --git a/Runtime/AddressableServiceKitBehaviour.cs.meta b/Runtime/AddressableServiceKitBehaviour.cs.meta new file mode 100644 index 0000000..3322a9a --- /dev/null +++ b/Runtime/AddressableServiceKitBehaviour.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 62c10afa7e32e5b4a84e8bb49cc421a2 \ No newline at end of file diff --git a/Runtime/ServiceKitBehaviour.cs b/Runtime/ServiceKitBehaviour.cs index 236c5d1..77602f5 100644 --- a/Runtime/ServiceKitBehaviour.cs +++ b/Runtime/ServiceKitBehaviour.cs @@ -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(); } + /// + /// Optional asynchronous step run during Awake, before the service registers. Override it to source + /// the locator from an async origin (e.g. load a via Addressables) + /// and have return it. The default returns immediately, so behaviours + /// that don't override it register synchronously within Awake exactly as before. + /// +#if SERVICEKIT_UNITASK + protected virtual UniTask PrepareLocatorAsync() => UniTask.CompletedTask; +#else + protected virtual Task PrepareLocatorAsync() => Task.CompletedTask; +#endif + private bool IsObjectDestroyed() { return !this || !gameObject; diff --git a/Runtime/com.nonatomic.servicekit.runtime.asmdef b/Runtime/com.nonatomic.servicekit.runtime.asmdef index 78777c5..ed08b61 100644 --- a/Runtime/com.nonatomic.servicekit.runtime.asmdef +++ b/Runtime/com.nonatomic.servicekit.runtime.asmdef @@ -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": [], @@ -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 diff --git a/package.json b/package.json index d44ac53..8def3cb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "com.nonatomic.servicekit", - "version": "3.0.0", + "version": "3.1.0", "displayName": "Service Kit", "description": "A lightweight dependency injection and service locator framework for Unity. Attribute-based registration, async resolution, fluent API, optional dependencies, service tags, scene-aware lifecycle, and UniTask support.", "unity": "2022.3", From a9dcb5d9857725f92323d81c9ceef08ababbe79c Mon Sep 17 00:00:00 2001 From: Paul Stamp Date: Sun, 21 Jun 2026 22:27:36 +0100 Subject: [PATCH 2/2] Validate addressable feature: PrepareLocatorAsync hook test + sample - PrepareLocatorAsync hook PlayMode test: proves registration is deferred until an async locator source completes, then the service registers and initializes. This is the mechanism AddressableServiceKitBehaviour relies on, tested deterministically without the Addressables package via a TaskCompletionSource stand-in. - Sample 12 (Addressable Locator): a runnable example of AddressableServiceKitBehaviour plus a setup README, gated behind SERVICEKIT_ADDRESSABLES. A self-contained automated test of the real Addressables load is impractical in a package harness (Addressables config is project-level and batchmode init is flaky), so the sample is the human-verifiable runtime proof. --- Samples~/12 - Addressable Locator.meta | 8 ++ Samples~/12 - Addressable Locator/README.md | 33 ++++++++ .../12 - Addressable Locator/README.md.meta | 7 ++ .../12 - Addressable Locator/Scripts.meta | 8 ++ .../Scripts/ExampleAddressableService.cs | 36 +++++++++ .../Scripts/ExampleAddressableService.cs.meta | 2 + ...erviceKitSamples.AddressableLocator.asmdef | 22 ++++++ ...eKitSamples.AddressableLocator.asmdef.meta | 7 ++ Tests/PlayMode/PrepareLocatorAsyncTests.cs | 75 +++++++++++++++++++ .../PlayMode/PrepareLocatorAsyncTests.cs.meta | 2 + package.json | 5 ++ 11 files changed, 205 insertions(+) create mode 100644 Samples~/12 - Addressable Locator.meta create mode 100644 Samples~/12 - Addressable Locator/README.md create mode 100644 Samples~/12 - Addressable Locator/README.md.meta create mode 100644 Samples~/12 - Addressable Locator/Scripts.meta create mode 100644 Samples~/12 - Addressable Locator/Scripts/ExampleAddressableService.cs create mode 100644 Samples~/12 - Addressable Locator/Scripts/ExampleAddressableService.cs.meta create mode 100644 Samples~/12 - Addressable Locator/ServiceKitSamples.AddressableLocator.asmdef create mode 100644 Samples~/12 - Addressable Locator/ServiceKitSamples.AddressableLocator.asmdef.meta create mode 100644 Tests/PlayMode/PrepareLocatorAsyncTests.cs create mode 100644 Tests/PlayMode/PrepareLocatorAsyncTests.cs.meta diff --git a/Samples~/12 - Addressable Locator.meta b/Samples~/12 - Addressable Locator.meta new file mode 100644 index 0000000..b81ec4d --- /dev/null +++ b/Samples~/12 - Addressable Locator.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: e1f2a3b4c5d6e7f8091a2b3c4d5e6f70 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Samples~/12 - Addressable Locator/README.md b/Samples~/12 - Addressable Locator/README.md new file mode 100644 index 0000000..3ce0473 --- /dev/null +++ b/Samples~/12 - Addressable Locator/README.md @@ -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`. diff --git a/Samples~/12 - Addressable Locator/README.md.meta b/Samples~/12 - Addressable Locator/README.md.meta new file mode 100644 index 0000000..84608e4 --- /dev/null +++ b/Samples~/12 - Addressable Locator/README.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: b4c5d6e7f8091a2b3c4d5e6f70819203 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Samples~/12 - Addressable Locator/Scripts.meta b/Samples~/12 - Addressable Locator/Scripts.meta new file mode 100644 index 0000000..ab2f314 --- /dev/null +++ b/Samples~/12 - Addressable Locator/Scripts.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: f2a3b4c5d6e7f8091a2b3c4d5e6f7081 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Samples~/12 - Addressable Locator/Scripts/ExampleAddressableService.cs b/Samples~/12 - Addressable Locator/Scripts/ExampleAddressableService.cs new file mode 100644 index 0000000..a1128ca --- /dev/null +++ b/Samples~/12 - Addressable Locator/Scripts/ExampleAddressableService.cs @@ -0,0 +1,36 @@ +#if SERVICEKIT_ADDRESSABLES +using Nonatomic.ServiceKit; +using UnityEngine; + +namespace ServiceKitSamples.AddressableLocator +{ + public interface IExampleAddressableService + { + void DoSomething(); + } + + /// + /// Example service whose locator is loaded through Addressables rather than a direct serialized + /// reference. Because it derives from : + /// - assign a ServiceKitLocator AssetReference on this component in the inspector + /// (and leave the inherited ServiceKitLocator 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). + /// + [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 diff --git a/Samples~/12 - Addressable Locator/Scripts/ExampleAddressableService.cs.meta b/Samples~/12 - Addressable Locator/Scripts/ExampleAddressableService.cs.meta new file mode 100644 index 0000000..bc222c5 --- /dev/null +++ b/Samples~/12 - Addressable Locator/Scripts/ExampleAddressableService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c5d6e7f8091a2b3c4d5e6f7081920314 diff --git a/Samples~/12 - Addressable Locator/ServiceKitSamples.AddressableLocator.asmdef b/Samples~/12 - Addressable Locator/ServiceKitSamples.AddressableLocator.asmdef new file mode 100644 index 0000000..70282be --- /dev/null +++ b/Samples~/12 - Addressable Locator/ServiceKitSamples.AddressableLocator.asmdef @@ -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 +} diff --git a/Samples~/12 - Addressable Locator/ServiceKitSamples.AddressableLocator.asmdef.meta b/Samples~/12 - Addressable Locator/ServiceKitSamples.AddressableLocator.asmdef.meta new file mode 100644 index 0000000..f6d57c9 --- /dev/null +++ b/Samples~/12 - Addressable Locator/ServiceKitSamples.AddressableLocator.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: a3b4c5d6e7f8091a2b3c4d5e6f708192 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/PlayMode/PrepareLocatorAsyncTests.cs b/Tests/PlayMode/PrepareLocatorAsyncTests.cs new file mode 100644 index 0000000..c7bacda --- /dev/null +++ b/Tests/PlayMode/PrepareLocatorAsyncTests.cs @@ -0,0 +1,75 @@ +using System.Collections; +using System.Threading.Tasks; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.TestTools; + +namespace Nonatomic.ServiceKit.Tests.PlayMode +{ + /// + /// Validates the 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 AddressableServiceKitBehaviour relies on, + /// tested here without the Addressables package - a stands + /// in for the async load so the core contract is verified deterministically. + /// + public class PrepareLocatorAsyncTests + { + public interface IAsyncLocatorService { } + + [Service(typeof(IAsyncLocatorService))] + private sealed class AsyncLocatorConsumer : ServiceKitBehaviour, IAsyncLocatorService + { + public ServiceKitLocator LocatorToLoad; + public readonly TaskCompletionSource LoadGate = new TaskCompletionSource(); + 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(); + var go = new GameObject(nameof(AsyncLocatorConsumer)); + go.SetActive(false); + var consumer = go.AddComponent(); + 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(), + "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(), + "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); + } + } +} diff --git a/Tests/PlayMode/PrepareLocatorAsyncTests.cs.meta b/Tests/PlayMode/PrepareLocatorAsyncTests.cs.meta new file mode 100644 index 0000000..7ece178 --- /dev/null +++ b/Tests/PlayMode/PrepareLocatorAsyncTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 48ec21e8b46481745ab26b1dfaa6a0ff \ No newline at end of file diff --git a/package.json b/package.json index 8def3cb..e15491d 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,11 @@ "displayName": "11 - Error Handling", "description": "TryResolveService, timeout recovery, circular dependency exemption, and custom error handlers.", "path": "Samples~/11 - Error Handling" + }, + { + "displayName": "12 - Addressable Locator", + "description": "Loading a ServiceKitLocator via Addressables with AddressableServiceKitBehaviour (requires the Addressables package).", + "path": "Samples~/12 - Addressable Locator" } ] }