拆分 P1 资源后端适配层
This commit is contained in:
8
My project/Assets/FlowScope/Addressables.meta
Normal file
8
My project/Assets/FlowScope/Addressables.meta
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: e74ee61e34d742b9b44057032c6431bb
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using FlowScope.Resources;
|
||||||
|
using UnityEngine.AddressableAssets;
|
||||||
|
|
||||||
|
namespace FlowScope.Addressables
|
||||||
|
{
|
||||||
|
public sealed class AddressablesResourceBackend : IResourceBackend
|
||||||
|
{
|
||||||
|
public string Name => "Addressables";
|
||||||
|
|
||||||
|
public async Task<T> LoadAsync<T>(string key, CancellationToken cancellationToken)
|
||||||
|
where T : class
|
||||||
|
{
|
||||||
|
var handle = Addressables.LoadAssetAsync<T>(key);
|
||||||
|
await WaitWithCancellation(handle.Task, cancellationToken);
|
||||||
|
return handle.Result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Release<T>(string key, T asset)
|
||||||
|
where T : class
|
||||||
|
{
|
||||||
|
Addressables.Release(asset);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
fileFormatVersion: 2
|
fileFormatVersion: 2
|
||||||
guid: 6a8dd01aed1f4fac97325a858dc56e78
|
guid: f65b4f61d3a34358bf48957eb3a14dd7
|
||||||
MonoImporter:
|
MonoImporter:
|
||||||
externalObjects: {}
|
externalObjects: {}
|
||||||
serializedVersion: 2
|
serializedVersion: 2
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using FlowScope.Addressables;
|
||||||
|
|
||||||
|
namespace FlowScope.Resources
|
||||||
|
{
|
||||||
|
public sealed class AddressablesResourceService : IResourceService, ICompletedResourceService
|
||||||
|
{
|
||||||
|
private readonly ResourceService _inner;
|
||||||
|
|
||||||
|
public AddressablesResourceService()
|
||||||
|
: this(new AddressablesResourceBackend())
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public AddressablesResourceService(IResourceBackend backend)
|
||||||
|
{
|
||||||
|
_inner = new ResourceService(backend);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<IResourceHandle<T>> LoadAsync<T>(string key, CancellationToken cancellationToken)
|
||||||
|
where T : class
|
||||||
|
{
|
||||||
|
return _inner.LoadAsync<T>(key, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IResourceGroup CreateGroup()
|
||||||
|
{
|
||||||
|
return _inner.CreateGroup();
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryLoadCompleted<T>(string key, out IResourceHandle<T> handle)
|
||||||
|
where T : class
|
||||||
|
{
|
||||||
|
return _inner.TryLoadCompleted(key, out handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
fileFormatVersion: 2
|
fileFormatVersion: 2
|
||||||
guid: c9294e0aadd246de906d8cfb96d81878
|
guid: 62c87792aac14414b46ac0eb3e716f25
|
||||||
MonoImporter:
|
MonoImporter:
|
||||||
externalObjects: {}
|
externalObjects: {}
|
||||||
serializedVersion: 2
|
serializedVersion: 2
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "FlowScope.Addressables",
|
||||||
|
"rootNamespace": "FlowScope.Addressables",
|
||||||
|
"references": [
|
||||||
|
"FlowScope.Runtime",
|
||||||
|
"Unity.Addressables"
|
||||||
|
],
|
||||||
|
"includePlatforms": [],
|
||||||
|
"excludePlatforms": [],
|
||||||
|
"allowUnsafeCode": false,
|
||||||
|
"overrideReferences": false,
|
||||||
|
"precompiledReferences": [],
|
||||||
|
"autoReferenced": true,
|
||||||
|
"defineConstraints": [],
|
||||||
|
"versionDefines": [],
|
||||||
|
"noEngineReferences": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 917e69d4247b4f548a5951d84e7d35db
|
||||||
|
AssemblyDefinitionImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -57,5 +57,18 @@ namespace FlowScope.Resources
|
|||||||
Asset = null;
|
Asset = null;
|
||||||
_release(Key, AssetType, asset);
|
_release(Key, AssetType, asset);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void ReleaseWithoutHandle()
|
||||||
|
{
|
||||||
|
if (_released)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_released = true;
|
||||||
|
var asset = Asset;
|
||||||
|
Asset = null;
|
||||||
|
_release(Key, AssetType, asset);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,20 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Reflection;
|
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
namespace FlowScope.Resources
|
namespace FlowScope.Resources
|
||||||
{
|
{
|
||||||
public sealed class AddressablesResourceService : IResourceService, ICompletedResourceService
|
public sealed class ResourceService : IResourceService, ICompletedResourceService
|
||||||
{
|
{
|
||||||
private readonly object _gate = new();
|
private readonly object _gate = new();
|
||||||
|
private readonly IResourceBackend _backend;
|
||||||
private readonly Dictionary<ResourceKey, ResourceEntry> _entries = new();
|
private readonly Dictionary<ResourceKey, ResourceEntry> _entries = new();
|
||||||
private readonly Dictionary<ResourceKey, SharedLoad> _loads = new();
|
private readonly Dictionary<ResourceKey, SharedLoad> _loads = new();
|
||||||
private readonly Func<string, Type, CancellationToken, Task<object>> _loadAssetAsync;
|
|
||||||
private readonly Action<string, object> _releaseAsset;
|
|
||||||
|
|
||||||
public AddressablesResourceService()
|
public ResourceService(IResourceBackend backend)
|
||||||
: this(AddressablesReflectionBackend.LoadAsync, AddressablesReflectionBackend.Release)
|
|
||||||
{
|
{
|
||||||
}
|
_backend = backend ?? throw new ArgumentNullException(nameof(backend));
|
||||||
|
|
||||||
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>(
|
public async Task<IResourceHandle<T>> LoadAsync<T>(
|
||||||
@@ -81,10 +71,6 @@ namespace FlowScope.Resources
|
|||||||
|
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
catch
|
|
||||||
{
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool TryLoadCompleted<T>(string key, out IResourceHandle<T> handle)
|
public bool TryLoadCompleted<T>(string key, out IResourceHandle<T> handle)
|
||||||
@@ -113,32 +99,33 @@ namespace FlowScope.Resources
|
|||||||
return new ResourceGroup();
|
return new ResourceGroup();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<ResourceEntry> LoadEntryAsync<T>(string key, CancellationToken cancellationToken)
|
private async Task<ResourceEntry> LoadEntryAsync<T>(string key)
|
||||||
where T : class
|
where T : class
|
||||||
{
|
{
|
||||||
object asset;
|
T asset;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
asset = await _loadAssetAsync(key, typeof(T), cancellationToken);
|
asset = await _backend.LoadAsync<T>(key, CancellationToken.None);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||||
{
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
$"Failed to load Addressables resource '{key}' as {typeof(T).Name}.",
|
$"Failed to load resource '{key}' as {typeof(T).Name} from backend '{_backend.Name}'.",
|
||||||
exception);
|
exception);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (asset == null)
|
if (asset == null)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
$"Addressables resource '{key}' returned null for {typeof(T).Name}.");
|
$"Resource '{key}' returned null for {typeof(T).Name} from backend '{_backend.Name}'.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return new ResourceEntry(key, typeof(T), asset, ReleaseEntry);
|
return new ResourceEntry(
|
||||||
|
key,
|
||||||
|
typeof(T),
|
||||||
|
asset,
|
||||||
|
(entryKey, assetType, releasedAsset) =>
|
||||||
|
ReleaseBackendEntry(entryKey, assetType, (T)releasedAsset));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<ResourceEntry> LoadAndOwnEntryAsync<T>(
|
private async Task<ResourceEntry> LoadAndOwnEntryAsync<T>(
|
||||||
@@ -150,7 +137,7 @@ namespace FlowScope.Resources
|
|||||||
ResourceEntry entry = null;
|
ResourceEntry entry = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
entry = await LoadEntryAsync<T>(key, CancellationToken.None);
|
entry = await LoadEntryAsync<T>(key);
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
{
|
{
|
||||||
_loads.Remove(resourceKey);
|
_loads.Remove(resourceKey);
|
||||||
@@ -161,7 +148,7 @@ namespace FlowScope.Resources
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ReleaseEntry(entry.Key, entry.AssetType, entry.Asset);
|
ReleaseEntry(entry);
|
||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
@@ -196,14 +183,20 @@ namespace FlowScope.Resources
|
|||||||
return await loadTask;
|
return await loadTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ReleaseEntry(string key, Type assetType, object asset)
|
private void ReleaseEntry(ResourceEntry entry)
|
||||||
|
{
|
||||||
|
entry.ReleaseWithoutHandle();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ReleaseBackendEntry<T>(string key, Type assetType, T asset)
|
||||||
|
where T : class
|
||||||
{
|
{
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
{
|
{
|
||||||
_entries.Remove(new ResourceKey(key, assetType));
|
_entries.Remove(new ResourceKey(key, assetType));
|
||||||
}
|
}
|
||||||
|
|
||||||
_releaseAsset(key, asset);
|
_backend.Release(key, asset);
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class SharedLoad
|
private sealed class SharedLoad
|
||||||
@@ -242,83 +235,5 @@ namespace FlowScope.Resources
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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: d123a5c6220843b18a09611f77e8cd31
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using FlowScope.Resources;
|
||||||
|
using NUnit.Framework;
|
||||||
|
|
||||||
|
namespace FlowScope.Tests.EditMode.Resources
|
||||||
|
{
|
||||||
|
public sealed class ResourceServiceBackendTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public async Task LoadAsync_WhenSameKeyLoadsConcurrently_UsesOneBackendLoad()
|
||||||
|
{
|
||||||
|
var backend = new FakeResourceBackend();
|
||||||
|
var service = new ResourceService(backend);
|
||||||
|
|
||||||
|
var first = service.LoadAsync<TestAsset>("shared", CancellationToken.None);
|
||||||
|
var second = service.LoadAsync<TestAsset>("shared", CancellationToken.None);
|
||||||
|
backend.Gate.SetResult(new TestAsset());
|
||||||
|
|
||||||
|
using var firstHandle = await first;
|
||||||
|
using var secondHandle = await second;
|
||||||
|
|
||||||
|
Assert.AreEqual(1, backend.LoadCalls);
|
||||||
|
Assert.AreSame(firstHandle.Asset, secondHandle.Asset);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task LoadAsync_WhenCanceled_DoesNotCreateHandle()
|
||||||
|
{
|
||||||
|
var backend = new FakeResourceBackend();
|
||||||
|
var service = new ResourceService(backend);
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
|
||||||
|
var task = service.LoadAsync<TestAsset>("canceled", cts.Token);
|
||||||
|
cts.Cancel();
|
||||||
|
|
||||||
|
await ExpectThrowsAsync<OperationCanceledException>(task);
|
||||||
|
Assert.IsFalse(service.TryLoadCompleted<TestAsset>("canceled", out var handle));
|
||||||
|
Assert.IsNull(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task DisposeLastHandle_ReleasesBackendAsset()
|
||||||
|
{
|
||||||
|
var backend = new FakeResourceBackend();
|
||||||
|
var service = new ResourceService(backend);
|
||||||
|
|
||||||
|
var first = service.LoadAsync<TestAsset>("shared", CancellationToken.None);
|
||||||
|
var second = service.LoadAsync<TestAsset>("shared", CancellationToken.None);
|
||||||
|
backend.Gate.SetResult(new TestAsset());
|
||||||
|
var firstHandle = await first;
|
||||||
|
var secondHandle = await second;
|
||||||
|
|
||||||
|
firstHandle.Dispose();
|
||||||
|
Assert.AreEqual(0, backend.ReleaseCalls);
|
||||||
|
|
||||||
|
secondHandle.Dispose();
|
||||||
|
Assert.AreEqual(1, backend.ReleaseCalls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task DisposeLastHandle_RemovesCompletedCacheEntry()
|
||||||
|
{
|
||||||
|
var backend = new FakeResourceBackend();
|
||||||
|
var service = new ResourceService(backend);
|
||||||
|
|
||||||
|
var load = service.LoadAsync<TestAsset>("cached", CancellationToken.None);
|
||||||
|
backend.Gate.SetResult(new TestAsset());
|
||||||
|
var handle = await load;
|
||||||
|
|
||||||
|
Assert.IsTrue(service.TryLoadCompleted<TestAsset>("cached", out var cached));
|
||||||
|
cached.Dispose();
|
||||||
|
handle.Dispose();
|
||||||
|
|
||||||
|
Assert.IsFalse(service.TryLoadCompleted<TestAsset>("cached", out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task CreateGroup_DisposesAllHandles()
|
||||||
|
{
|
||||||
|
var backend = new FakeResourceBackend();
|
||||||
|
var service = new ResourceService(backend);
|
||||||
|
var group = service.CreateGroup();
|
||||||
|
|
||||||
|
var first = service.LoadAsync<TestAsset>("first", CancellationToken.None);
|
||||||
|
backend.Gate.SetResult(new TestAsset());
|
||||||
|
var firstHandle = await first;
|
||||||
|
group.Add(firstHandle);
|
||||||
|
|
||||||
|
backend.Gate = new TaskCompletionSource<object>();
|
||||||
|
var second = service.LoadAsync<TestAsset>("second", CancellationToken.None);
|
||||||
|
backend.Gate.SetResult(new TestAsset());
|
||||||
|
var secondHandle = await second;
|
||||||
|
group.Add(secondHandle);
|
||||||
|
|
||||||
|
group.Dispose();
|
||||||
|
|
||||||
|
Assert.IsTrue(firstHandle.IsDisposed);
|
||||||
|
Assert.IsTrue(secondHandle.IsDisposed);
|
||||||
|
Assert.AreEqual(2, backend.ReleaseCalls);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeResourceBackend : IResourceBackend
|
||||||
|
{
|
||||||
|
public string Name => "Fake";
|
||||||
|
public int LoadCalls { get; private set; }
|
||||||
|
public int ReleaseCalls { get; private set; }
|
||||||
|
public TaskCompletionSource<object> Gate { get; set; } = new();
|
||||||
|
|
||||||
|
public async Task<T> LoadAsync<T>(string key, CancellationToken cancellationToken)
|
||||||
|
where T : class
|
||||||
|
{
|
||||||
|
LoadCalls++;
|
||||||
|
return (T)await Gate.Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Release<T>(string key, T asset)
|
||||||
|
where T : class
|
||||||
|
{
|
||||||
|
ReleaseCalls++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TestAsset
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task ExpectThrowsAsync<TException>(Task task)
|
||||||
|
where TException : Exception
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await task;
|
||||||
|
}
|
||||||
|
catch (TException)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Fail($"Expected {typeof(TException).Name}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: eb5a9158d4884293847009acc433b248
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
"name": "FlowScope.Tests.PlayMode",
|
"name": "FlowScope.Tests.PlayMode",
|
||||||
"rootNamespace": "FlowScope.Tests",
|
"rootNamespace": "FlowScope.Tests",
|
||||||
"references": [
|
"references": [
|
||||||
|
"FlowScope.Addressables",
|
||||||
"FlowScope.Runtime",
|
"FlowScope.Runtime",
|
||||||
"FlowScope.Samples.MainMenuP0",
|
"FlowScope.Samples.MainMenuP0",
|
||||||
"R3.Unity"
|
"R3.Unity"
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using FlowScope.Addressables;
|
||||||
|
using NUnit.Framework;
|
||||||
|
|
||||||
|
namespace FlowScope.Tests.PlayMode.Resources
|
||||||
|
{
|
||||||
|
public sealed class AddressablesResourceBackendTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public void Name_ReturnsAddressables()
|
||||||
|
{
|
||||||
|
var backend = new AddressablesResourceBackend();
|
||||||
|
|
||||||
|
Assert.AreEqual("Addressables", backend.Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 596056ac39b54c0cb812046585dddf65
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -1,218 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using FlowScope.Resources;
|
|
||||||
using NUnit.Framework;
|
|
||||||
using UnityEngine;
|
|
||||||
using UnityEngine.TestTools;
|
|
||||||
|
|
||||||
namespace FlowScope.Tests.PlayMode.Resources
|
|
||||||
{
|
|
||||||
public sealed class AddressablesResourceServiceTests
|
|
||||||
{
|
|
||||||
[UnityTest]
|
|
||||||
public IEnumerator 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());
|
|
||||||
IResourceHandle<TestAsset> firstHandle = null;
|
|
||||||
IResourceHandle<TestAsset> secondHandle = null;
|
|
||||||
yield return AwaitTask(first, handle => firstHandle = handle);
|
|
||||||
yield return AwaitTask(second, handle => secondHandle = handle);
|
|
||||||
|
|
||||||
Assert.AreEqual(1, backendLoads);
|
|
||||||
Assert.AreSame(firstHandle.Asset, secondHandle.Asset);
|
|
||||||
|
|
||||||
firstHandle.Dispose();
|
|
||||||
Assert.AreEqual(0, releaseCount);
|
|
||||||
secondHandle.Dispose();
|
|
||||||
Assert.AreEqual(1, releaseCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
[UnityTest]
|
|
||||||
public IEnumerator LoadAsync_WhenBackendFails_IncludesKeyAndBackend()
|
|
||||||
{
|
|
||||||
var service = new AddressablesResourceService(
|
|
||||||
(_, _, _) => throw new InvalidOperationException("missing"),
|
|
||||||
(_, _) => { });
|
|
||||||
|
|
||||||
InvalidOperationException exception = null;
|
|
||||||
yield return ThrowsAsync<InvalidOperationException>(
|
|
||||||
() => service.LoadAsync<TestAsset>("missing-key", CancellationToken.None),
|
|
||||||
caught => exception = caught);
|
|
||||||
|
|
||||||
StringAssert.Contains("missing-key", exception.Message);
|
|
||||||
StringAssert.Contains("Addressables", exception.Message);
|
|
||||||
}
|
|
||||||
|
|
||||||
[UnityTest]
|
|
||||||
public IEnumerator LoadAsync_WhenOneConcurrentCallerCancels_OtherCallerStillReceivesSharedLoad()
|
|
||||||
{
|
|
||||||
var backendLoads = 0;
|
|
||||||
var gate = new TaskCompletionSource<object>();
|
|
||||||
var service = new AddressablesResourceService(
|
|
||||||
async (_, _, cancellationToken) =>
|
|
||||||
{
|
|
||||||
backendLoads++;
|
|
||||||
Assert.That(cancellationToken.CanBeCanceled, Is.False);
|
|
||||||
return await gate.Task;
|
|
||||||
},
|
|
||||||
(_, _) => { });
|
|
||||||
using var cts = new CancellationTokenSource();
|
|
||||||
|
|
||||||
var canceled = service.LoadAsync<TestAsset>("shared", cts.Token);
|
|
||||||
var survivor = service.LoadAsync<TestAsset>("shared", CancellationToken.None);
|
|
||||||
cts.Cancel();
|
|
||||||
|
|
||||||
yield return ThrowsAsync<OperationCanceledException>(canceled);
|
|
||||||
gate.SetResult(new TestAsset());
|
|
||||||
IResourceHandle<TestAsset> survivorHandle = null;
|
|
||||||
yield return AwaitTask(survivor, handle => survivorHandle = handle);
|
|
||||||
|
|
||||||
Assert.AreEqual(1, backendLoads);
|
|
||||||
Assert.IsNotNull(survivorHandle.Asset);
|
|
||||||
survivorHandle.Dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
[UnityTest]
|
|
||||||
public IEnumerator LoadAsync_WhenOnlyCallerCancels_ReleasesCompletedBackendAsset()
|
|
||||||
{
|
|
||||||
var backendLoads = 0;
|
|
||||||
var releaseCount = 0;
|
|
||||||
var gate = new TaskCompletionSource<object>();
|
|
||||||
var service = new AddressablesResourceService(
|
|
||||||
async (_, _, cancellationToken) =>
|
|
||||||
{
|
|
||||||
backendLoads++;
|
|
||||||
Assert.That(cancellationToken.CanBeCanceled, Is.False);
|
|
||||||
if (backendLoads == 1)
|
|
||||||
{
|
|
||||||
return await gate.Task;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new TestAsset();
|
|
||||||
},
|
|
||||||
(_, _) => releaseCount++);
|
|
||||||
using var cts = new CancellationTokenSource();
|
|
||||||
|
|
||||||
var canceled = service.LoadAsync<TestAsset>("orphan", cts.Token);
|
|
||||||
cts.Cancel();
|
|
||||||
yield return ThrowsAsync<OperationCanceledException>(canceled);
|
|
||||||
gate.SetResult(new TestAsset());
|
|
||||||
|
|
||||||
yield return new WaitUntil(() => releaseCount == 1);
|
|
||||||
Assert.IsFalse(service.TryLoadCompleted<TestAsset>("orphan", out _));
|
|
||||||
|
|
||||||
IResourceHandle<TestAsset> nextHandle = null;
|
|
||||||
yield return AwaitTask(
|
|
||||||
service.LoadAsync<TestAsset>("orphan", CancellationToken.None),
|
|
||||||
handle => nextHandle = handle);
|
|
||||||
|
|
||||||
Assert.AreEqual(2, backendLoads);
|
|
||||||
Assert.IsNotNull(nextHandle.Asset);
|
|
||||||
nextHandle.Dispose();
|
|
||||||
Assert.AreEqual(2, releaseCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
[UnityTest]
|
|
||||||
public IEnumerator TryLoadCompleted_AfterAsyncLoad_ReturnsCachedHandleWithoutBackendLoad()
|
|
||||||
{
|
|
||||||
var backendLoads = 0;
|
|
||||||
var service = new AddressablesResourceService(
|
|
||||||
(_, _, _) =>
|
|
||||||
{
|
|
||||||
backendLoads++;
|
|
||||||
return Task.FromResult<object>(new TestAsset());
|
|
||||||
},
|
|
||||||
(_, _) => { });
|
|
||||||
|
|
||||||
IResourceHandle<TestAsset> first = null;
|
|
||||||
yield return AwaitTask(
|
|
||||||
service.LoadAsync<TestAsset>("cached", CancellationToken.None),
|
|
||||||
handle => first = handle);
|
|
||||||
Assert.IsTrue(service.TryLoadCompleted<TestAsset>("cached", out var second));
|
|
||||||
|
|
||||||
Assert.AreEqual(1, backendLoads);
|
|
||||||
Assert.AreSame(first.Asset, second.Asset);
|
|
||||||
first.Dispose();
|
|
||||||
second.Dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static IEnumerator ThrowsAsync<TException>(Func<Task> action, Action<TException> onCaught)
|
|
||||||
where TException : Exception
|
|
||||||
{
|
|
||||||
var task = action();
|
|
||||||
while (!task.IsCompleted)
|
|
||||||
{
|
|
||||||
yield return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (task.IsFaulted && task.Exception?.GetBaseException() is TException exception)
|
|
||||||
{
|
|
||||||
onCaught(exception);
|
|
||||||
yield break;
|
|
||||||
}
|
|
||||||
|
|
||||||
Assert.Fail($"Expected {typeof(TException).Name}.");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static IEnumerator ThrowsAsync<TException>(Task task)
|
|
||||||
where TException : Exception
|
|
||||||
{
|
|
||||||
while (!task.IsCompleted)
|
|
||||||
{
|
|
||||||
yield return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (task.IsFaulted && task.Exception?.GetBaseException() is TException)
|
|
||||||
{
|
|
||||||
yield break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (task.IsCanceled && typeof(TException) == typeof(OperationCanceledException))
|
|
||||||
{
|
|
||||||
yield break;
|
|
||||||
}
|
|
||||||
|
|
||||||
Assert.Fail($"Expected {typeof(TException).Name}.");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static IEnumerator AwaitTask<T>(Task<T> task, Action<T> onCompleted)
|
|
||||||
{
|
|
||||||
while (!task.IsCompleted)
|
|
||||||
{
|
|
||||||
yield return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (task.IsFaulted)
|
|
||||||
{
|
|
||||||
throw task.Exception.GetBaseException();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (task.IsCanceled)
|
|
||||||
{
|
|
||||||
throw new OperationCanceledException();
|
|
||||||
}
|
|
||||||
|
|
||||||
onCompleted(task.Result);
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class TestAsset
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
"com.cysharp.r3": "https://github.com/Cysharp/R3.git?path=src/R3.Unity/Assets/R3.Unity#1.3.0",
|
"com.cysharp.r3": "https://github.com/Cysharp/R3.git?path=src/R3.Unity/Assets/R3.Unity#1.3.0",
|
||||||
"com.unity.collab-proxy": "2.12.4",
|
"com.unity.collab-proxy": "2.12.4",
|
||||||
"com.unity.feature.development": "1.0.1",
|
"com.unity.feature.development": "1.0.1",
|
||||||
|
"com.unity.addressables": "1.21.21",
|
||||||
"com.unity.textmeshpro": "3.0.7",
|
"com.unity.textmeshpro": "3.0.7",
|
||||||
"com.unity.timeline": "1.7.7",
|
"com.unity.timeline": "1.7.7",
|
||||||
"com.unity.ugui": "1.0.0",
|
"com.unity.ugui": "1.0.0",
|
||||||
|
|||||||
Reference in New Issue
Block a user