AssetCacheSystem
Namespace: Vortex.Unity.AssetCacheSystem.*
Сборка: ru.vortex.unity.assetcachesystem (defineConstraints: ENABLE_ADDRESSABLES)
Назначение
Управляемый кэш Addressables-ассетов с пятью ключевыми механиками:
- Owner-tracking — кто из потребителей какие
AssetReferenceдержит и сколькими удержаниями (по одному на каждыйLoad). - Inflight-дедупликация — параллельные запросы одного ref резолвятся одним вызовом
Addressables.LoadAssetAsync. - LRU survivors — отпущенные, но ещё не выгруженные ассеты лежат в буфере; повторный запрос даёт мгновенный HIT.
- Sweep уничтоженных Unity-владельцев — при каждом
ReleaseловитUnityEngine.Object-владельцев (MonoBehaviourи т. п.), удалённых без вызова Release. - Группы — изолированные реестры со своей ёмкостью survivors: поток ассетов одной группы (например, реплик) не вытесняет survivors другой.
Consumer-API — Load и Release плюс синхронный LoadSync (группа — опциональна, без неё работа идёт с дефолтной группой):
UniTask<T> AssetCache.Load<T>(object owner, AssetReference reference, CancellationToken ct = default,
string group = null);
T AssetCache.LoadSync<T>(object owner, AssetReference reference, string group = null);
void AssetCache.Release(object owner, string group = null);
Поверх кэша пакет поставляет двух готовых потребителей: AddressableAssetHandle<T> (ленивая реализация AssetHandle<T>) и DataStorageTransport (префаб, подгружаемый на включение).
Вне ответственности:
- Скрытое преобразование
AssetReference→ конкретный T (Load<T>ожидает что вызывающий знает реальный тип ассета). - Sweep POCO-владельцев (только Unity-объекты; для POCO — дисциплина явного Release).
- Streaming, Addressables-labels, scene-load — это уровень Addressables API.
Зависимости
| Зависимость | Назначение |
|---|---|
Unity.Addressables + Unity.ResourceManager |
Реальная загрузка/выгрузка ассетов |
UniTask + UniTask.Addressables |
Async API |
Vortex.Core.System |
Singleton<T> |
Vortex.Core.AppSystem |
App.OnExit для Dispose |
Vortex.Core.SettingsSystem + Vortex.Unity.SettingsSystem |
Чтение SurvivorCapacity, групп, debug-флага; SettingsPreset |
Vortex.Core.Extensions |
InitValve.Create, IsNullOrWhitespace |
Vortex.Core.AssetHandleSystem |
AssetHandle<T> — база AddressableAssetHandle<T> |
Vortex.Core.LoggerSystem |
Log.Print — предупреждение о повторной загрузке в AddressableAssetHandle<T> |
Vortex.Unity.UI.Misc |
DataStorage — база DataStorageTransport |
Vortex.Unity.Extensions |
AssetDatabaseExt.GetSingletonAsset (Editor-menu, drawer групп) |
Vortex.Unity.EditorTools |
DrawingUtility.DrawSelector — выпадашка [AssetCacheGroup] |
Vortex.Unity.CoreAssetsSystem |
ICoreAsset для SO-маркера |
Odin Inspector (Sirenix) |
Атрибуты инспектора настроек и DataStorageTransport, drawer [AssetCacheGroup] |
defineConstraints: ["ENABLE_ADDRESSABLES"] в asmdef — пакет компилируется только в проектах с включёнными Addressables.
Использование
Регистрация настроек
Ассет AssetCacheSettings в Resources/Settings/ создаётся системой настроек в редакторе, если его нет (Assets/Resources/Settings/AssetCacheSettings.asset); Tools/Vortex/AssetsCache/Config подсвечивает его в Project window.
- Заполнить
SurvivorCapacityдефолтной группы (default 32). - При необходимости добавить именованные группы в
groups: ключ +SurvivorCapacityгруппы. SettingsDriverподхватит автоматически.
Базовый паттерн в MonoBehaviour
public class WeaponView : MonoBehaviour
{
[SerializeField] private AssetReference muzzleFlashRef;
private GameObject _muzzleFlashPrefab;
private async void OnEnable()
{
// owner = this (Unity-объект — sweep подхватит при уничтожении)
_muzzleFlashPrefab = await AssetCache.Load<GameObject>(this, muzzleFlashRef, destroyCancellationToken);
}
private void OnDestroy()
{
// Явный Release не обязателен (sweep ловит уничтожение),
// но полезен для немедленного освобождения / попадания в survivors.
AssetCache.Release(this);
}
}
Загрузка в группу
public class VoiceLine : MonoBehaviour
{
[SerializeField] private AssetReference clipRef;
// Выпадашка с группами из AssetCacheSettings. Пусто — дефолтная группа.
[SerializeField, AssetCacheGroup] private string cacheGroup;
private async UniTask PlayAsync(CancellationToken ct)
{
// Release(this) во время загрузки завершит её OperationCanceledException.
var clip = await AssetCache.Load<AudioClip>(this, clipRef, ct, cacheGroup);
// ...
}
private void OnDisable()
{
// Освобождает кладущий — в той же группе, где грузил.
AssetCache.Release(this, cacheGroup);
}
}
Поле группы уже есть у DataStorageTransport (cacheGroup) и AddressableAssetHandle<T> (group).
POCO-владелец
public class AudioCue
{
private readonly object _ownerKey = new(); // owner = неуничтожимый ключ
private AudioClip _clip;
public async UniTask PreloadAsync(AssetReference clipRef, CancellationToken ct)
{
_clip = await AssetCache.Load<AudioClip>(_ownerKey, clipRef, ct);
}
public void Dispose()
{
// Обязательно — sweep не сработает для POCO-владельца.
AssetCache.Release(_ownerKey);
}
}
AddressableAssetHandle<T>
Реализация AssetHandle<T> поверх кэша: поля reference (AssetReference) и group ([AssetCacheGroup]). Владелец в кэше — сам handle (owner = this), поэтому у handle в Locks всегда ровно один ключ; перекрывающиеся загрузки одного handle считаются удержаниями этого ключа. LoadAsync → AssetCache.Load<T>(this, reference, ct, group); повторный LoadAsync без Release отдаёт тот же экземпляр с warning; Release → AssetCache.Release(this, group) (no-op, пока ничего не загружено). Полный контракт — в README AssetHandleSystem.
DataStorageTransport
Наследник DataStorage для подключения/отключения тяжёлого префаба вместе с GameObject:
OnEnable→AssetCache.Load<GameObject>(this, prefabReference, token, cacheGroup)→InstantiateподtargetForInstance(пусто — свойTransform) → еслиDataне пуст,SetDataвDataStorageинстанса (если он есть).OnDisable→ отмена токена,Destroyинстанса,AssetCache.Release(this, cacheGroup).OperationCanceledExceptionпрерванной загрузки глотается.- В редакторе: кнопки
Connect/Disconnect— инстанцировать префаб черезeditorAsset/ удалить детей таргета для работы с вёрсткой. Пока у таргета есть дети, показывается InfoBox-ошибка.
Подписка на готовность шины
AssetCache.OnReady.Subscribe(() =>
{
// Выполнится немедленно, если bootstrap уже завершён.
Debug.Log($"AssetCache ready. Survivor capacity = {AssetCache.Config.SurvivorCapacity}");
});
Параллельные запросы одного ref
// Все 100 одновременных запросов отработают через ОДИН Addressables-вызов.
var tasks = Enumerable.Range(0, 100).Select(i =>
AssetCache.Load<Sprite>(owners[i], iconRef, ct));
var sprites = await UniTask.WhenAll(tasks);
Отмена waiter'а
var cts = new CancellationTokenSource();
var task = AssetCache.Load<GameObject>(owner, heavyRef, cts.Token);
cts.CancelAfter(100); // дать 100ms
try { var go = await task; }
catch (OperationCanceledException) { /* waiter отменён */ }
// Реальная загрузка ПРОДОЛЖАЕТСЯ — после её завершения ассет уйдёт в survivors
// (если других active-владельцев нет) или сразу станет HIT для другого запроса.
Архитектура
Bus/
AssetCache (static) — public API: Load / LoadSync / Release / OnReady / OnRelease
bootstrap через [RuntimeInitializeOnLoadMethod]
подписка на Settings.OnInit → CreateController → Controller.Init
Controllers/
AssetCacheController : Singleton — реализация IAssetCacheController, partial по темам
├── .cs — Init / Cleanup / IsInitialized / Model / OnInitialized / OnReleased / ResolveGroup
├── .Loading.cs — Load<T> / LoadSync<T> / StartLoad / RegisterOwner / IsSameRecord /
│ UnregisterOwner / ReviveFromSurvivor
└── .Releasing.cs — Release / SweepDeadOwners / ReleaseOwner / IsHeldByAnyOwner / PushSurvivor /
EvictIfOverflow
Abstractions/
IAssetCacheController — контракт consumer-API + lifecycle
Models/
AssetCacheModel — runtime-state: набор групп (Default + именованные), агрегаты для отладки
AssetCacheGroupModel — реестр группы + ёмкость:
Locks — owner → (ref → число удержаний)
Handles — ref → Addressables-handle
Inflight — ref → незавершённая загрузка
Survivors — LRU-очередь (LinkedList) ассетов без удержаний
InflightLoad — DTO незавершённой загрузки: Handle + UniTaskCompletionSource
AddressableAssetHandle<T> — AssetHandle<T> поверх AssetCache (owner = сам handle)
Handler/
DataStorageTransport : DataStorage — на OnEnable грузит префаб через кэш, инстанцирует и передаёт ему
свои Data; на OnDisable отпускает
Attributes/
AssetCacheGroupAttribute — string-поле = ключ группы, выпадашка в инспекторе
Config/
AssetCacheSettings : SettingsPreset, ICoreAsset
— SO в Resources/Settings/: SurvivorCapacity дефолтной группы
(default 32) + список групп (ключ, SurvivorCapacity)
SettingsExt/ — partial-расширение SettingsModel через .asmref
AssetCacheConfig — immutable runtime-конфиг (+ AssetCacheGroupConfig)
SettingsModelExtAssetCache — поля AssetCache + AssetCacheDebugLogs
DebugExt/ — partial-расширение DebugSettings через .asmref
DebugSettingsExtAssetsCache — toggle AssetCacheDebugLogs (учитывает DebugMode)
Editor/
MenuController — пункты меню пакета (AssetsCache/Runtime Index, AssetsCache/Config,
Configs/AssetCache Settings)
AssetCacheIndexWindow — EditorWindow: runtime-инспектор индекса (read-only)
AssetCacheGroupAttributeDrawer — Odin-drawer выпадашки групп
Lifecycle bootstrap
[RuntimeInitializeOnLoadMethod]
↓
AssetCache.Bootstrap()
├── Settings.OnInit += CreateController (если Settings уже инициализированы — вызов сразу)
└── App.OnExit += Dispose
↓
Settings.OnInit (через InitValve)
↓
CreateController()
├── контроллер уже создан → выход
├── Settings.Data() == null → выход
├── Settings.Data().AssetCache → Config; null → Debug.LogError, выход (Controller остаётся null)
├── Controller = AssetCacheController.Instance
├── Controller.OnInitialized += NotifyReady ← открытие InitValve OnReady
├── Controller.OnReleased += NotifyReleased
└── Controller.Init()
↓
Controller.Init()
├── читает SurvivorCapacity, группы + debugLogging из Settings.Data()
├── создаёт AssetCacheModel: дефолтная группа + именованные
│ (пустой ключ / дубликат ключа → Debug.LogError, группа пропускается)
├── IsInitialized = true
└── OnInitialized?.Invoke() → AssetCache.OnReady открывается
↓
Consumer:
AssetCache.OnReady.Subscribe(handler) — выполнится немедленно если уже Ready
App.OnExit → Dispose()
├── Settings.OnInit -= CreateController; App.OnExit -= Dispose
└── Controller.Cleanup() → OnReleased → NotifyReleased: отписка от контроллера, AssetCache.OnRelease
(OnReady остаётся открытым, IsReady == false; сама шина повторно не инициализируется)
Поток Load<T>(owner, reference, ct, group)
Все структуры ниже — реестр группы, в которую резолвится group.
1. ArgNullCheck: owner / reference
2. ResolveGroup(group) ← пусто → Default; неизвестный ключ → LogError + Default
3. RegisterOwner(owner, reference) ← Locks[owner][reference] += 1 (одно удержание на каждый Load);
запоминает запись владельца (поколение)
4. Поиск в Handles
├── HIT → ReviveFromSurvivor, result = (T)handle.Result (мгновенно)
└── miss → продолжаем
5. Поиск в Inflight
├── JOIN → result = await slot.Completion (с поддержкой ct)
└── miss → продолжаем
6. StartLoad<T>:
├── handle = Addressables.LoadAssetAsync<T>(reference)
├── Inflight[reference] = { Handle, Completion }
├── await handle.ToUniTask() ← ct waiter'а НЕ прерывает загрузку
├── прошёл Cleanup (модель сменилась)? → OCE
├── Handles[reference] = handle
├── slot.Completion.TrySetResult(loaded) ← broadcast всем JOIN-waiter'ам
├── if (!IsHeldByAnyOwner(ref)) ← все waiter'ы отменились до завершения
│ PushSurvivor(ref)
├── ct.ThrowIfCancellationRequested() ← OCE waiter'а после успешной загрузки
├── сбой загрузки → Completion.TrySetException (всем waiter'ам), Addressables.Release(handle),
│ Handles.Remove(ref); проброс исключения
└── finally: Inflight.Remove(ref)
7. Перед возвратом: запись владельца всё та же?
└── нет (Release(owner) во время загрузки) → OperationCanceledException: ассет без удержания не отдаётся
8. catch (любое исключение, включая OCE waiter'а и несовпадение каста к T) → UnregisterOwner(owner, ref, record)
├── запись сменилась (Release, затем новые Load владельца) → пропуск: её удержания — чужих Load
├── минус одно удержание; ref уходит из записи при нуле, владелец — из Locks при пустой записи
└── удержаний ref не осталось ни у кого, ассет загружен → PushSurvivor(ref); проброс исключения
Поток LoadSync<T>(owner, reference, group)
Синхронный вариант для мест, где await невозможен. Блокирует main-thread до завершения загрузки.
1. ArgNullCheck: owner / reference
2. ResolveGroup(group); RegisterOwner(owner, reference) ← одно удержание, запоминает запись
3. HIT → ReviveFromSurvivor, return (T)handle.Result
4. Inflight → slot.Handle.WaitForCompletion()
├── сбой → UnregisterOwner(owner, ref, record), return null
└── успех → return (T)result
5. Новая загрузка → Addressables.LoadAssetAsync<T>(reference).WaitForCompletion() ← без Inflight-слота
├── сбой → Addressables.Release(handle), UnregisterOwner(owner, ref, record), return null
└── успех → Handles[reference] = handle, return result
Отличия от Load: сбой возвращается null, а не исключением; проверки поколения на успешном пути нет (вызов синхронный); несовпадение каста к T на HIT/Inflight бросает InvalidCastException без отката — удержание остаётся до Release(owner).
Поток Release(owner, group)
1. ArgNullCheck: owner
2. Model == null (до Init / после Cleanup) → выход
3. Sweep уничтоженных Unity-владельцев — во ВСЕХ группах:
├── Перебор Locks группы, поиск `o is Object uo && uo == null`
└── Для каждого — ReleaseOwner(group, dead, isSweep: true)
4. ResolveGroup(group) → ReleaseOwner(owner, isSweep: false) в этой группе:
├── владелец не найден → warning в debug-mode, выход
├── Locks.Remove(owner) ← запись со всеми удержаниями владельца разом
└── Для каждого ref:
├── IsHeldByAnyOwner(ref)? → пропуск (ещё держит)
└── Handles.Contains(ref)? → PushSurvivor(ref)
PushSurvivor (в survivors группы):
├── Survivors.Remove(ref) ← refresh-положение
├── Survivors.AddLast(ref)
└── EvictIfOverflow:
while (Survivors.Count > SurvivorCapacity группы):
head = Survivors.First
Survivors.RemoveFirst
if (IsHeldByAnyOwner(head)) continue ← кто-то взял снова, handle остаётся
Addressables.Release(handle)
Handles.Remove(head)
AssetCache.Release до bootstrap'а (Controller == null) — no-op.
Контракт
Гарантии
- Inflight-дедупликация: N параллельных
Load(refX)от разных владельцев → одинAddressables.LoadAssetAsync, все ждут на общемUniTaskCompletionSource. - HIT мгновенный: повторный
Loadдля активного или survivor-ассета возвращается синхронно черезawait, без сетевого/диск-IO. - Survivor revive: ассет, попавший в survivors, при повторном запросе возвращается мгновенно —
Addressables.Releaseне вызывается. - Ct-семантика: отмена waiter'а не прерывает реальную загрузку — она нужна другим waiter'ам. Отменённый waiter получает
OperationCanceledException, загрузка продолжается. - Счётчик удержаний: каждый
Loadref добавляет одно удержание в запись своего владельца; проваленный/отменённыйLoadснимает только своё,Release(owner)удаляет запись со всеми удержаниями разом. Откат трогает только ту запись, в которойLoadрегистрировался: если между ними былRelease(owner)и владелец грузил снова, новые удержания остаются. Поэтому отменённая, но ещё летящая загрузка не снимает удержание живогоLoadтого же ref у того же владельца — ни при повторномLoadбезRelease, ни приRelease+ повторномLoad(выключение/включение во время загрузки). Владелец остаётся одним ключом вLocksнезависимо от числа удержаний. - Release во время загрузки: если
Release(owner)прошёл, покаLoadэтого владельца был в полёте,LoadзавершаетсяOperationCanceledExceptionвместо возврата ассета без удержания; ассет уходит в survivors, если его больше никто не держит. - Cleanup идемпотентен: повторный вызов — no-op.
- Один handle на ref в группе: внутри
Handlesгруппы всегда максимум одна запись наAssetReference. - Группы не ломают друг друга: один ref, загруженный в двух группах, держит по handle в каждой. Общий счётчик ссылок ведёт сам Addressables (повторный
LoadAssetAsyncтого же ключа —IncrementReferenceCountна кешированной операции), поэтому eviction/Release в одной группе только уменьшает счётчик — физически ассет выгрузится, когда его отпустят все группы.
Группы
- Дефолтная группа — группа без идентификатора. Вызовы без
group(или с пустой строкой) работают с ней, ёмкость —AssetCacheSettings.SurvivorCapacity. - Именованные группы — список
groupsвAssetCacheSettings: ключ + собственныйSurvivorCapacity. Пустой ключ и дубликаты отбрасываются наInitсDebug.LogError. - Изоляция: у группы свои
Locks/Handles/Inflight/Survivors. HIT, JOIN и вытеснение — только внутри группы. - Освобождает кладущий:
Release(owner, group)снимает владение только в указанной группе. Владелец, грузивший в нескольких группах, освобождает каждую отдельно. - Неизвестный ключ (опечатка, группа удалена из настроек) —
Debug.LogErrorи обработка как с дефолтной группой. Вызов не теряется, мисконфиг виден в логе. - Sweep уничтоженных Unity-владельцев идёт по всем группам при любом
Release.
Дисциплина
- Один экземпляр
AssetCacheSettingsвResources/Settings/— обязателен. - Owner обязан вызывать
Releaseдля каждогоLoad-владения, иначе ассет не выгрузится (см. Sweep ниже). Release— с той же группой, что иLoad. Release в другой группе не найдёт владельца (warning в debug-mode), lock останется до sweep'а (Unity-владелец) или доCleanup(POCO).- Sweep ловит только
UnityEngine.Object-владельцев, уничтоженных без Release. POCO-владельцы (чистые C#-классы) — обязаны Release-discipline вручную, иначе их Locks-запись и связанные handle'ы живут доCleanup. AssetReferenceдолжен запрашиваться с одним и тем же T между разнымиLoad<T>. Каст к T выполняется на стороне waiter'а; для одного ref с разными T —InvalidCastExceptionна втором запросе.Load/LoadSync— только послеOnReady. До bootstrap'аController == null, вызов падает NRE.[RuntimeInitializeOnLoadMethod]срабатывает после загрузки первой сцены, поэтомуOnEnableобъектов этой сцены может прийти раньше.
Ограничения
- Поиск
IsHeldByAnyOwner— O(L) по числу владельцев. Подходит для типичных проектов с десятками-сотнями владельцев. Для тысяч — потребуется обратный индекс (на момент v1 не реализовано — приоритет памяти). ReviveFromSurvivor— O(n) поLinkedList.Remove(value). Для capacity 32-128 — копейки.- Не потокобезопасен — все операции через Unity main-thread.
Граничные случаи
| Ситуация | Поведение |
|---|---|
Load(null, ref) или Load(owner, null) |
ArgumentNullException |
Release(null) |
ArgumentNullException |
Load/LoadSync до bootstrap'а Settings |
NRE на Controller.Load (Controller == null). Подписаться на OnReady перед использованием |
Release до bootstrap'а Settings |
No-op (Controller?.Release) |
Параллельные Load(refX) от 100 владельцев |
Один Addressables.LoadAssetAsync, все ждут на общем Completion. HIT для последующих |
Load<T1>(...) потом Load<T2>(...) для одного ref, T1 ≠ T2 |
Первый каст работает, второй — InvalidCastException; удержание второго Load откатывается |
Отмена ct waiter'а до завершения загрузки |
OCE для этого waiter'а; реальная загрузка продолжается; result уходит другим waiter'ам или в survivors |
Отмена ct единственного waiter'а |
Загрузка завершается, ассет идёт в survivors. Load сам снимает удержание waiter'а (UnregisterOwner), явный Release(owner) не нужен |
Один владелец: Load(ref) отменён, пока ещё в полёте, затем повторный Load(ref) |
Два удержания одного владельца; отменённый снимает только своё, ассет держит живой Load |
Один владелец: Load(ref) в полёте, Release(owner), затем новый Load(ref) (выключение/включение во время загрузки) |
Откат первого Load не трогает новую запись; ассет держит второй Load |
Release(owner) во время Load этого владельца без отмены токена |
Load завершается OperationCanceledException; ассет уходит в survivors, если его больше никто не держит |
Один владелец, несколько успешных Load(ref) |
Несколько удержаний; один Release(owner) снимает все |
| Сбой загрузки Addressables | Исключение получают все waiter'ы ref; handle освобождается, удержание каждого waiter'а откатывается |
Сбой LoadSync |
null, удержание откатывается |
LoadSync с несовпадением T на HIT/Inflight |
InvalidCastException; удержание остаётся до Release(owner) |
Load(this, ref) на MB; MB уничтожен без Release |
Sweep при следующем Release любого владельца обнаружит o is Object && o == null и освободит |
POCO-владелец без Release |
Утечка: Locks/Handles живут до Cleanup |
| Survivor capacity = 0 | Ассет, оставшийся без удержаний (Release или откат последнего удержания), сразу выгружается через Addressables.Release |
Release(owner) для owner'а без активных Locks |
Warning в debug-mode, no-op в release |
Settings.Data().AssetCache == null (нет SO) |
Debug.LogError в CreateController, контроллер не создаётся: Load упадёт NRE, Dispose на App.OnExit — тоже |
App.OnExit |
Dispose → Controller.Cleanup → освобождение всех Handles + Inflight, OnRelease; OnReady остаётся открытым при IsReady == false |
Повторный Init после Cleanup |
На уровне контроллера — новый Model, реестры с нуля. Шина его не вызывает: после Dispose она отписана от Settings.OnInit |
Cleanup (+ повторный Init) во время загрузки |
Загрузка завершается OCE; handle освобождён в Cleanup, в новую модель не попадает |
Load/Release с неизвестной группой |
Debug.LogError, работа идёт с дефолтной группой |
Пустой ключ или дубликат ключа в groups |
Debug.LogError на Init, группа пропускается (вызовы с этим ключом — как с неизвестным) |
| Один ref в двух группах | Два handle; HIT/eviction независимы; физическая выгрузка — после освобождения в обеих (refcount Addressables) |
Release(owner, B) для владельца, грузившего в группе A |
В B владелец не найден (warning в debug-mode); lock в A остаётся до своего Release/sweep'а |
| Владелец грузил в A и B | Release нужен для каждой группы; sweep уничтоженного Unity-владельца снимет обе |
Editor
Tools/Vortex/AssetsCache/Config и Tools/Vortex/Configs/AssetCache Settings — подсветить ассет настроек в Project window. Два входа в одну команду: первый рядом с окном пакета, второй в общем списке конфигов проекта. Использует AssetDatabaseExt.GetSingletonAsset<AssetCacheSettings>() — ожидает один экземпляр в проекте, при множественности логирует ошибку.
Runtime Index
Tools/Vortex/AssetsCache/Runtime Index — окно текущего состояния реестра. Данные есть только в Play Mode: до bootstrap'а модель не создана, вне игры окно показывает подсказку.
Сводка: загружено (active / survivors) и inflight — по всем группам, число уникальных владельцев и отдельно — сколько владельцев уничтожено без Release (их locks доживут до ближайшего sweep'а). Полоса заполнения survivors — на каждую группу относительно её SurvivorCapacity.
Таблица — строка на пару (группа, AssetReference): один ref в двух группах — две строки:
| Колонка | Значение |
|---|---|
| Ассет | Имя загруженного объекта, иначе имя файла по GUID |
| Группа | Ключ группы, <default> — дефолтная |
| Состояние | LOAD (inflight) / ACTIVE / SURVIVOR |
| Ссылок | Сколько владельцев группы держат ref; несколько удержаний одного владельца считаются за одного. IsHeldByAnyOwner решает по тому, больше ли это число нуля |
| Тип | Фактический тип загруженного объекта |
| LRU | Позиция в очереди survivors группы, 1/N — голова, выгрузится первой |
Раскрытие строки даёт GUID, поимённый список владельцев (уничтоженные помечены) и кнопку ping'а ассета в Project window.
Сортировка: LOAD → ACTIVE → SURVIVOR; внутри active — по убыванию ссылок, внутри survivors — по группе и очереди выгрузки. Строка ACTIVE с нулём ссылок подсвечивается жёлтым: по контракту такой ref обязан лежать в survivors, иначе он не попадёт под eviction и провисит до Cleanup — индикатор утечки.
Снимок пересобирается в OnInspectorUpdate (~10 раз/с, только для видимого окна); тумблер «Авто» его выключает, рядом ручное «Обновить», кнопка «Конфиг» (подсветка AssetCacheSettings — той же командой, что и пункт меню) и поиск по имени/группе/GUID. Окно только читает: Release/eviction отсюда не вызываются.
Живёт в Editor/ рантайм-сборки пакета под #if UNITY_EDITOR — поэтому читает internal-словари AssetCacheGroupModel напрямую, без расширения публичного API модели.
Атрибут [AssetCacheGroup]
Ставится на string-поле с ключом группы. Odin-drawer заменяет поле выпадашкой с ключами групп из AssetCacheSettings (список кешируется на 1 с — правка конфига видна почти сразу). Визуал — как у ValueSelector:
- пустое значение (дефолтная группа) — красное
——[NULL]——; - ключ, которого нет в настройках, — красным, с пометкой
[нет в AssetCacheSettings]; - кнопка
×справа сбрасывает выбор — поле возвращается к дефолтной группе.
Debug-логи
Debug-трассировка: в DebugSettings → Log Settings → toggle AssetCacheDebugLogs. Учитывается только при включённом глобальном DebugMode. Вывод: HIT / JOIN / LOAD / REL / SWEEP / EVICT-skip / EVICT, у каждой записи — group=<ключ> (<default> для дефолтной).
Публичный API
namespace Vortex.Unity.AssetCacheSystem.Bus
{
public static class AssetCache
{
public static IAssetCacheController Controller { get; }
public static AssetCacheConfig Config { get; }
public static AssetCacheModel Data { get; }
public static bool IsReady { get; }
public static InitValve OnReady { get; }
public static event Action OnRelease;
public static UniTask<T> Load<T>(object owner, AssetReference reference,
CancellationToken ct = default, string group = null) where T : UnityEngine.Object;
public static T LoadSync<T>(object owner, AssetReference reference,
string group = null) where T : UnityEngine.Object;
public static void Release(object owner, string group = null);
}
}
namespace Vortex.Unity.AssetCacheSystem.Abstractions
{
public interface IAssetCacheController
{
bool IsInitialized { get; }
AssetCacheModel Model { get; }
event Action OnInitialized;
event Action OnReleased;
void Init();
void Cleanup();
UniTask<T> Load<T>(object owner, AssetReference reference, CancellationToken ct = default,
string group = null) where T : UnityEngine.Object; // OCE: ct отменён или владельца отпустили
T LoadSync<T>(object owner, AssetReference reference, string group = null)
where T : UnityEngine.Object; // null при сбое
void Release(object owner, string group = null);
}
}
namespace Vortex.Unity.AssetCacheSystem.Models
{
public sealed class AssetCacheModel
{
public AssetCacheGroupModel Default { get; }
public IReadOnlyList<AssetCacheGroupModel> GetGroups(); // дефолтная первой
public AssetCacheGroupModel GetGroup(string key); // пусто → Default, неизвестный → null
// Агрегаты по всем группам
public bool IsLoaded(AssetReference reference);
public int LoadedCount { get; }
public int InflightCount { get; }
public int SurvivorsCount { get; }
public int OwnersCount { get; } // уникальные владельцы
}
public sealed class AssetCacheGroupModel
{
public string Key { get; } // null — дефолтная
public int SurvivorCapacity { get; }
public bool IsDefault { get; }
public bool IsLoaded(AssetReference reference);
public int LoadedCount { get; }
public int InflightCount { get; }
public int SurvivorsCount { get; }
public int OwnersCount { get; }
}
}
namespace Vortex.Unity.AssetCacheSystem
{
[Serializable]
public sealed class AddressableAssetHandle<T> : AssetHandle<T> where T : UnityEngine.Object
{
public override bool IsLoaded { get; }
public override T Asset { get; }
public override UniTask<T> LoadAsync(CancellationToken ct);
public override void Release();
}
}
namespace Vortex.Unity.AssetCacheSystem.Handler
{
public class DataStorageTransport : DataStorage { } // настраивается в инспекторе
}
namespace Vortex.Unity.AssetCacheSystem.Attributes
{
public class AssetCacheGroupAttribute : PropertyAttribute { }
}
namespace Vortex.Core.SettingsSystem.Model
{
public class AssetCacheConfig
{
public AssetCacheConfig(int survivorCapacity, IReadOnlyList<AssetCacheGroupConfig> groups = null);
public int SurvivorCapacity { get; } // дефолтная группа
public IReadOnlyList<AssetCacheGroupConfig> Groups { get; }
}
public class AssetCacheGroupConfig
{
public AssetCacheGroupConfig(string key, int survivorCapacity);
public string Key { get; }
public int SurvivorCapacity { get; }
}
}
AssetCacheController (Singleton), InflightLoad, AssetCacheSettings (SO), partial-расширения SettingsModel/DebugSettings — внутренние/конфиг-классы, не должны использоваться потребителями напрямую.