实现 P0 资源加载和引用计数
This commit is contained in:
8
My project/Assets/FlowScope/Runtime.meta
Normal file
8
My project/Assets/FlowScope/Runtime.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8eaa19d06f5e46268a8362862c912da8
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c9294e0aadd246de906d8cfb96d81878
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 02477283d77540ed9be0b002bb05f37a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 896ce428279a4740b44cebeb2c0b635e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ab4e98e842c34a09a324062b57b09009
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
My project/Assets/FlowScope/Tests.meta
Normal file
8
My project/Assets/FlowScope/Tests.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3fc2c875b65a431fb05975eee30c358a
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
My project/Assets/FlowScope/Tests/EditMode.meta
Normal file
8
My project/Assets/FlowScope/Tests/EditMode.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a01312d2a2ae4e38bb24ac988df7ed8a
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: fb94c8e882a64036908cf7520c43fbb5
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ec0e64759485405a99ee94f3fc5cae52
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
My project/Assets/FlowScope/Tests/PlayMode.meta
Normal file
8
My project/Assets/FlowScope/Tests/PlayMode.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 83547470a4584f6dbc0590f5ebfe9392
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cd8c9e7d9aa8490bacb57789aab34daa
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6a8dd01aed1f4fac97325a858dc56e78
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user