实现 P0 资源加载和引用计数

This commit is contained in:
JSD\13999
2026-05-18 11:54:49 +08:00
parent 49318c50a4
commit dfd1c4cfae
18 changed files with 620 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 8eaa19d06f5e46268a8362862c912da8
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,235 @@
using System;
using System.Collections.Generic;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
namespace FlowScope.Resources
{
public sealed class AddressablesResourceService : IResourceService
{
private readonly object _gate = new();
private readonly Dictionary<ResourceKey, ResourceEntry> _entries = new();
private readonly Dictionary<ResourceKey, Task<ResourceEntry>> _loads = new();
private readonly Func<string, Type, CancellationToken, Task<object>> _loadAssetAsync;
private readonly Action<string, object> _releaseAsset;
public AddressablesResourceService()
: this(AddressablesReflectionBackend.LoadAsync, AddressablesReflectionBackend.Release)
{
}
public AddressablesResourceService(
Func<string, Type, CancellationToken, Task<object>> loadAssetAsync,
Action<string, object> releaseAsset)
{
_loadAssetAsync = loadAssetAsync ?? throw new ArgumentNullException(nameof(loadAssetAsync));
_releaseAsset = releaseAsset ?? throw new ArgumentNullException(nameof(releaseAsset));
}
public async Task<IResourceHandle<T>> LoadAsync<T>(
string key,
CancellationToken cancellationToken)
where T : class
{
if (string.IsNullOrWhiteSpace(key))
{
throw new ArgumentException("Resource key is required.", nameof(key));
}
cancellationToken.ThrowIfCancellationRequested();
var resourceKey = new ResourceKey(key, typeof(T));
Task<ResourceEntry> loadTask;
lock (_gate)
{
if (_entries.TryGetValue(resourceKey, out var entry))
{
return entry.CreateHandle<T>();
}
if (!_loads.TryGetValue(resourceKey, out loadTask))
{
loadTask = LoadEntryAsync<T>(key, cancellationToken);
_loads.Add(resourceKey, loadTask);
}
}
try
{
var loaded = await loadTask;
lock (_gate)
{
if (!_entries.ContainsKey(resourceKey))
{
_entries.Add(resourceKey, loaded);
}
_loads.Remove(resourceKey);
return _entries[resourceKey].CreateHandle<T>();
}
}
catch
{
lock (_gate)
{
_loads.Remove(resourceKey);
}
throw;
}
}
public IResourceGroup CreateGroup()
{
return new ResourceGroup();
}
private async Task<ResourceEntry> LoadEntryAsync<T>(string key, CancellationToken cancellationToken)
where T : class
{
object asset;
try
{
asset = await _loadAssetAsync(key, typeof(T), cancellationToken);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception exception)
{
throw new InvalidOperationException(
$"Failed to load Addressables resource '{key}' as {typeof(T).Name}.",
exception);
}
if (asset == null)
{
throw new InvalidOperationException(
$"Addressables resource '{key}' returned null for {typeof(T).Name}.");
}
return new ResourceEntry(key, typeof(T), asset, ReleaseEntry);
}
private void ReleaseEntry(string key, Type assetType, object asset)
{
lock (_gate)
{
_entries.Remove(new ResourceKey(key, assetType));
}
_releaseAsset(key, asset);
}
private readonly struct ResourceKey : IEquatable<ResourceKey>
{
private readonly string _key;
private readonly Type _type;
public ResourceKey(string key, Type type)
{
_key = key;
_type = type;
}
public bool Equals(ResourceKey other)
{
return _key == other._key && _type == other._type;
}
public override bool Equals(object obj)
{
return obj is ResourceKey other && Equals(other);
}
public override int GetHashCode()
{
unchecked
{
return ((_key != null ? _key.GetHashCode() : 0) * 397) ^
(_type != null ? _type.GetHashCode() : 0);
}
}
}
private static class AddressablesReflectionBackend
{
private const string AddressablesTypeName =
"UnityEngine.AddressableAssets.Addressables, Unity.Addressables";
public static async Task<object> LoadAsync(
string key,
Type assetType,
CancellationToken cancellationToken)
{
var addressablesType = Type.GetType(AddressablesTypeName);
if (addressablesType == null)
{
throw new InvalidOperationException(
"Unity Addressables package is not available. P0 ResourceService supports Addressables only.");
}
var method = FindLoadAssetMethod(addressablesType).MakeGenericMethod(assetType);
var handle = method.Invoke(null, new object[] { key });
var task = (Task)handle.GetType().GetProperty("Task", BindingFlags.Instance | BindingFlags.Public)
?.GetValue(handle);
if (task == null)
{
throw new InvalidOperationException("Addressables LoadAssetAsync handle did not expose Task.");
}
await WaitWithCancellation(task, cancellationToken);
return task.GetType().GetProperty("Result")?.GetValue(task);
}
public static void Release(string key, object asset)
{
var addressablesType = Type.GetType(AddressablesTypeName);
var release = addressablesType?.GetMethod(
"Release",
BindingFlags.Static | BindingFlags.Public,
null,
new[] { typeof(object) },
null);
release?.Invoke(null, new[] { asset });
}
private static MethodInfo FindLoadAssetMethod(Type addressablesType)
{
foreach (var method in addressablesType.GetMethods(BindingFlags.Static | BindingFlags.Public))
{
if (method.Name == "LoadAssetAsync" &&
method.IsGenericMethodDefinition &&
method.GetParameters().Length == 1)
{
return method;
}
}
throw new InvalidOperationException("Addressables.LoadAssetAsync<T>(key) was not found.");
}
private static async Task WaitWithCancellation(Task task, CancellationToken cancellationToken)
{
if (!cancellationToken.CanBeCanceled)
{
await task;
return;
}
var cancellation = new TaskCompletionSource<bool>();
using (cancellationToken.Register(() => cancellation.TrySetResult(true)))
{
if (await Task.WhenAny(task, cancellation.Task) == cancellation.Task)
{
throw new OperationCanceledException(cancellationToken);
}
}
await task;
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c9294e0aadd246de906d8cfb96d81878
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,61 @@
using System;
namespace FlowScope.Resources
{
internal sealed class ResourceEntry
{
private readonly Action<string, Type, object> _release;
private int _referenceCount;
private bool _released;
public ResourceEntry(string key, object asset, Action<string, object> release)
: this(key, asset?.GetType(), asset, (_, _, releasedAsset) => release(key, releasedAsset))
{
}
public ResourceEntry(string key, Type assetType, object asset, Action<string, Type, object> release)
{
Key = string.IsNullOrWhiteSpace(key)
? throw new ArgumentException("Resource key is required.", nameof(key))
: key;
AssetType = assetType ?? throw new ArgumentNullException(nameof(assetType));
Asset = asset ?? throw new ArgumentNullException(nameof(asset));
_release = release ?? throw new ArgumentNullException(nameof(release));
}
public string Key { get; }
public Type AssetType { get; }
public object Asset { get; private set; }
public int ReferenceCount => _referenceCount;
public ResourceHandle<T> CreateHandle<T>() where T : class
{
if (_released)
{
throw new ObjectDisposedException(nameof(ResourceEntry));
}
_referenceCount++;
return new ResourceHandle<T>(this);
}
public void ReleaseHandle()
{
if (_released)
{
return;
}
_referenceCount--;
if (_referenceCount > 0)
{
return;
}
_released = true;
var asset = Asset;
Asset = null;
_release(Key, AssetType, asset);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 02477283d77540ed9be0b002bb05f37a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,44 @@
using System;
using System.Collections.Generic;
namespace FlowScope.Resources
{
public sealed class ResourceGroup : IResourceGroup
{
private readonly HashSet<IDisposable> _handles = new();
private bool _disposed;
public int Count => _handles.Count;
public void Add<T>(IResourceHandle<T> handle) where T : class
{
if (_disposed)
{
throw new ObjectDisposedException(nameof(ResourceGroup));
}
if (handle == null)
{
throw new ArgumentNullException(nameof(handle));
}
_handles.Add(handle);
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
foreach (var handle in _handles)
{
handle.Dispose();
}
_handles.Clear();
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 896ce428279a4740b44cebeb2c0b635e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,51 @@
using System;
namespace FlowScope.Resources
{
public sealed class ResourceHandle<T> : IResourceHandle<T> where T : class
{
private readonly ResourceEntry _entry;
private readonly Action<string, object> _release;
private T _asset;
internal ResourceHandle(ResourceEntry entry)
{
_entry = entry ?? throw new ArgumentNullException(nameof(entry));
Key = entry.Key;
_asset = (T)entry.Asset;
}
public ResourceHandle(string key, T asset, Action<string, object> release)
{
Key = string.IsNullOrWhiteSpace(key)
? throw new ArgumentException("Resource key is required.", nameof(key))
: key;
_asset = asset ?? throw new ArgumentNullException(nameof(asset));
_release = release ?? throw new ArgumentNullException(nameof(release));
}
public string Key { get; }
public T Asset => IsDisposed ? null : _asset;
public bool IsDisposed { get; private set; }
public void Dispose()
{
if (IsDisposed)
{
return;
}
IsDisposed = true;
var asset = _asset;
_asset = null;
if (_entry != null)
{
_entry.ReleaseHandle();
return;
}
_release(Key, asset);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ab4e98e842c34a09a324062b57b09009
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 3fc2c875b65a431fb05975eee30c358a
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: a01312d2a2ae4e38bb24ac988df7ed8a
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: fb94c8e882a64036908cf7520c43fbb5
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,56 @@
using FlowScope.Resources;
using NUnit.Framework;
namespace FlowScope.Tests.EditMode.Resources
{
public sealed class ResourceGroupTests
{
[Test]
public void DisposeHandle_ReturnsNullAssetAndReleasesOnce()
{
var releaseCount = 0;
var asset = new TestAsset();
var handle = new ResourceHandle<TestAsset>(
"ui/main",
asset,
(_, _) => releaseCount++);
Assert.AreSame(asset, handle.Asset);
handle.Dispose();
handle.Dispose();
Assert.IsTrue(handle.IsDisposed);
Assert.IsNull(handle.Asset);
Assert.AreEqual(1, releaseCount);
}
[Test]
public void DisposeGroup_DisposesHandlesAndIgnoresDuplicateAdd()
{
var releaseCount = 0;
var handle = new ResourceHandle<TestAsset>(
"ui/main",
new TestAsset(),
(_, _) => releaseCount++);
var group = new ResourceGroup();
group.Add(handle);
group.Add(handle);
Assert.AreEqual(1, group.Count);
group.Dispose();
group.Dispose();
Assert.AreEqual(0, group.Count);
Assert.IsTrue(handle.IsDisposed);
Assert.IsNull(handle.Asset);
Assert.AreEqual(1, releaseCount);
}
private sealed class TestAsset
{
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ec0e64759485405a99ee94f3fc5cae52
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 83547470a4584f6dbc0590f5ebfe9392
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: cd8c9e7d9aa8490bacb57789aab34daa
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,59 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using FlowScope.Resources;
using NUnit.Framework;
namespace FlowScope.Tests.PlayMode.Resources
{
public sealed class AddressablesResourceServiceTests
{
[Test]
public async Task LoadAsync_WhenSameKeyLoadsConcurrently_SharesOneBackendLoad()
{
var backendLoads = 0;
var releaseCount = 0;
var gate = new TaskCompletionSource<object>();
var service = new AddressablesResourceService(
async (_, _, _) =>
{
backendLoads++;
return await gate.Task;
},
(_, _) => releaseCount++);
var first = service.LoadAsync<TestAsset>("shared", CancellationToken.None);
var second = service.LoadAsync<TestAsset>("shared", CancellationToken.None);
gate.SetResult(new TestAsset());
var firstHandle = await first;
var secondHandle = await second;
Assert.AreEqual(1, backendLoads);
Assert.AreSame(firstHandle.Asset, secondHandle.Asset);
firstHandle.Dispose();
Assert.AreEqual(0, releaseCount);
secondHandle.Dispose();
Assert.AreEqual(1, releaseCount);
}
[Test]
public void LoadAsync_WhenBackendFails_IncludesKeyAndBackend()
{
var service = new AddressablesResourceService(
(_, _, _) => throw new InvalidOperationException("missing"),
(_, _) => { });
var exception = Assert.ThrowsAsync<InvalidOperationException>(
() => service.LoadAsync<TestAsset>("missing-key", CancellationToken.None));
StringAssert.Contains("missing-key", exception.Message);
StringAssert.Contains("Addressables", exception.Message);
}
private sealed class TestAsset
{
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 6a8dd01aed1f4fac97325a858dc56e78
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: