Asset Provider

Async asset loading over Addressables, with reference counting per key and scope-bound batch cleanup.

One-line async asset loading with automatic sharing and leak-proof cleanup.

InterfaceIAssetProvider
Off switchNullAssetProvider
AssemblyCommonGameSystem.Assets (optional — requires the Addressables package)
StartupRegistered automatically at boot — no setup needed

What it does

The Asset Provider wraps Unity's Addressables system behind a simple, reference-counted interface. Load an asset by its address string, and every caller of that key shares the same loaded asset — the reference count tracks how many callers hold it, and Release(key) only unloads once the last caller has released. That turns "who unloads this texture?" from a coordination problem into bookkeeping the framework does for you.

You can also group loads into a scope: create one with CreateScope(), load through it, and disposing the scope releases everything it loaded in one call. A scope is the natural unit for "everything this screen needed."

There is no memory budget and no automatic eviction — just load, count references, and clean up by scope. What you hold is exactly what stays in memory.

The service lives in the optional CommonGameSystem.Assets assembly. If you remove the Addressables package from your project, the assembly excludes itself and the rest of the framework keeps compiling.

Quick start

Resolve the service and cache it in Awake:

using CommonGameSystem.Core;
using UnityEngine;

[DefaultExecutionOrder(100)] // Run after the framework has started.
public class MyGameManager : MonoBehaviour
{
    private IAssetProvider _assets;

    private void Awake()
    {
        _assets = ServiceLocator.Resolve<IAssetProvider>();
    }

    private async void OnEnable()
    {
        var prefab = await _assets.LoadAsync<GameObject>("assets/my-prefab");
        if (prefab != null)
            Instantiate(prefab);
    }
}

You can also use the short alias: IAssetProvider assets = SL.Resolve<IAssetProvider>();

API reference

Load / Release (reference counted)

  • Task<T> LoadAsync<T>(string key, CancellationToken ct = default) — Load the asset at the given address as type T (GameObject, ScriptableObject, Sprite, TextAsset, AudioClip, and so on). Loading the same key again returns the same asset and increments its reference count. The returned Task<T> is never null. Runtime failures — a missing key or a failed load — return default(T) and log a warning; they never throw.
  • void Release(string key) — Decrement the key's reference count. When the count reaches zero, the asset unloads. If the count is already zero, the call logs a warning and does nothing; it never throws.

Preload / Query

  • Task PreloadAsync<T>(string key, CancellationToken ct = default) — Load and hold an asset in the cache without returning it. It uses the same reference counting as LoadAsync<T>, so pair it with a later Release(key). Useful for warming up the next scene's assets while the current one is still playing.
  • bool IsLoaded(string key) — Returns true if the key's reference count is above zero. Never throws, never logs.
  • int LoadedCount { get; } — The number of distinct keys currently held. This is a leak signal: it counts keys, not the sum of all reference counts.

Scopes

  • IAssetScope CreateScope() — Create a scope. Load through the scope, and Dispose() releases everything the scope loaded — one Release(key) per recorded load.
  • IAssetScope — Offers the same LoadAsync<T>, PreloadAsync<T>, IsLoaded, and LoadedCount members, plus Dispose() for the batch cleanup.

Examples

Load a scene's prefabs, then clean them all up when the work is done:

using CommonGameSystem.Core;
using UnityEngine;

[DefaultExecutionOrder(100)]
public class ScenePopulator : MonoBehaviour
{
    private IAssetProvider _assets;

    private async void Start()
    {
        _assets = ServiceLocator.Resolve<IAssetProvider>();
        using var scope = _assets.CreateScope();

        var player = await scope.LoadAsync<GameObject>("assets/player");
        var ui = await scope.LoadAsync<GameObject>("assets/ui-root");

        if (player != null) Instantiate(player, transform);
        if (ui != null) Instantiate(ui);

        // When the scope is disposed, it releases both
        // "assets/player" and "assets/ui-root".
    }
}

How reference counting behaves with a shared key:

var go1 = await _assets.LoadAsync<GameObject>("assets/coin");  // reference count 1
var go2 = await _assets.LoadAsync<GameObject>("assets/coin");  // reference count 2, SAME asset
_assets.Release("assets/coin");  // reference count 1
_assets.Release("assets/coin");  // reference count 0 — the asset unloads

Turning it off

ServiceLocator.Replace<IAssetProvider>(new NullAssetProvider());

This mutes all loading. LoadAsync<T>() returns an already-completed task holding default(T), IsLoaded is always false, and LoadedCount is always 0. Addressables is never touched. Useful for tests, or for disabling asset features entirely. It logs one warning when constructed — not per call — so you know it is active.

Common pitfalls

  • The reference count is the only unload trigger. There is no memory budget, no least-recently-used eviction, and no unload timer. If you load and never release, the asset stays in memory. Watch LoadedCount to spot leaks.
  • LoadAsync<T> is a compile-time generic method. For IL2CPP builds, the asset types you load (GameObject, ScriptableObject, Sprite, TextAsset, AudioClip) must be preserved in your project's link.xml. The package README's IL2CPP section shows the exact entries.
  • Scopes catch in-flight loads. If a scope is disposed while one of its loads is still running, the load completes normally and is then released immediately. Nothing is ever orphaned.
  • A type mismatch is safe. If a key was first loaded as LoadAsync<GameObject>("key") and you later call LoadAsync<Sprite>("key"), the second call returns null and logs a warning — no exception. The reference count belongs to the key name, not the type.
  • Null or empty keys are treated as bugs in your code. LoadAsync(null) throws ArgumentNullException, and a whitespace key throws ArgumentException. These are programming errors, unlike the runtime data faults above, which never throw.
  • Main thread only. All public members — including Release, IsLoaded, CreateScope, and a scope's Dispose — must run on the main thread. From a worker thread, use the Scheduler's Post() to run the call back on the main thread.