拆分 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
|
||||
guid: 6a8dd01aed1f4fac97325a858dc56e78
|
||||
fileFormatVersion: 2
|
||||
guid: f65b4f61d3a34358bf48957eb3a14dd7
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
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
|
||||
guid: c9294e0aadd246de906d8cfb96d81878
|
||||
fileFormatVersion: 2
|
||||
guid: 62c87792aac14414b46ac0eb3e716f25
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
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;
|
||||
_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.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace FlowScope.Resources
|
||||
{
|
||||
public sealed class AddressablesResourceService : IResourceService, ICompletedResourceService
|
||||
public sealed class ResourceService : IResourceService, ICompletedResourceService
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly IResourceBackend _backend;
|
||||
private readonly Dictionary<ResourceKey, ResourceEntry> _entries = 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()
|
||||
: this(AddressablesReflectionBackend.LoadAsync, AddressablesReflectionBackend.Release)
|
||||
public ResourceService(IResourceBackend 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));
|
||||
_backend = backend ?? throw new ArgumentNullException(nameof(backend));
|
||||
}
|
||||
|
||||
public async Task<IResourceHandle<T>> LoadAsync<T>(
|
||||
@@ -81,10 +71,6 @@ namespace FlowScope.Resources
|
||||
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryLoadCompleted<T>(string key, out IResourceHandle<T> handle)
|
||||
@@ -113,32 +99,33 @@ namespace FlowScope.Resources
|
||||
return new ResourceGroup();
|
||||
}
|
||||
|
||||
private async Task<ResourceEntry> LoadEntryAsync<T>(string key, CancellationToken cancellationToken)
|
||||
private async Task<ResourceEntry> LoadEntryAsync<T>(string key)
|
||||
where T : class
|
||||
{
|
||||
object asset;
|
||||
T asset;
|
||||
try
|
||||
{
|
||||
asset = await _loadAssetAsync(key, typeof(T), cancellationToken);
|
||||
asset = await _backend.LoadAsync<T>(key, CancellationToken.None);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
if (asset == null)
|
||||
{
|
||||
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>(
|
||||
@@ -150,7 +137,7 @@ namespace FlowScope.Resources
|
||||
ResourceEntry entry = null;
|
||||
try
|
||||
{
|
||||
entry = await LoadEntryAsync<T>(key, CancellationToken.None);
|
||||
entry = await LoadEntryAsync<T>(key);
|
||||
lock (_gate)
|
||||
{
|
||||
_loads.Remove(resourceKey);
|
||||
@@ -161,7 +148,7 @@ namespace FlowScope.Resources
|
||||
}
|
||||
}
|
||||
|
||||
ReleaseEntry(entry.Key, entry.AssetType, entry.Asset);
|
||||
ReleaseEntry(entry);
|
||||
return entry;
|
||||
}
|
||||
catch
|
||||
@@ -196,14 +183,20 @@ namespace FlowScope.Resources
|
||||
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)
|
||||
{
|
||||
_entries.Remove(new ResourceKey(key, assetType));
|
||||
}
|
||||
|
||||
_releaseAsset(key, asset);
|
||||
_backend.Release(key, asset);
|
||||
}
|
||||
|
||||
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",
|
||||
"rootNamespace": "FlowScope.Tests",
|
||||
"references": [
|
||||
"FlowScope.Addressables",
|
||||
"FlowScope.Runtime",
|
||||
"FlowScope.Samples.MainMenuP0",
|
||||
"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.unity.collab-proxy": "2.12.4",
|
||||
"com.unity.feature.development": "1.0.1",
|
||||
"com.unity.addressables": "1.21.21",
|
||||
"com.unity.textmeshpro": "3.0.7",
|
||||
"com.unity.timeline": "1.7.7",
|
||||
"com.unity.ugui": "1.0.0",
|
||||
|
||||
Reference in New Issue
Block a user