修正资源加载取消与同步音频语义
This commit is contained in:
@@ -187,14 +187,14 @@ namespace FlowScope.Audio
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var task = _resources.LoadAsync<AudioClip>(key, CancellationToken.None);
|
if (!(_resources is ICompletedResourceService completedResources) ||
|
||||||
if (!task.IsCompleted)
|
!completedResources.TryLoadCompleted(key, out IResourceHandle<AudioClip> handle))
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
$"Audio clip load is asynchronous: {key}. Use PlayBgmAsync or PlaySfxAsync.");
|
$"Audio clip is not loaded synchronously: {key}. Use PlayBgmAsync or PlaySfxAsync.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return ValidateLoadedClip(key, task.Result);
|
return ValidateLoadedClip(key, handle);
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ using System.Threading.Tasks;
|
|||||||
|
|
||||||
namespace FlowScope.Resources
|
namespace FlowScope.Resources
|
||||||
{
|
{
|
||||||
public sealed class AddressablesResourceService : IResourceService
|
public sealed class AddressablesResourceService : IResourceService, ICompletedResourceService
|
||||||
{
|
{
|
||||||
private readonly object _gate = new();
|
private readonly object _gate = new();
|
||||||
private readonly Dictionary<ResourceKey, ResourceEntry> _entries = new();
|
private readonly Dictionary<ResourceKey, ResourceEntry> _entries = new();
|
||||||
private readonly Dictionary<ResourceKey, Task<ResourceEntry>> _loads = new();
|
private readonly Dictionary<ResourceKey, SharedLoad> _loads = new();
|
||||||
private readonly Func<string, Type, CancellationToken, Task<object>> _loadAssetAsync;
|
private readonly Func<string, Type, CancellationToken, Task<object>> _loadAssetAsync;
|
||||||
private readonly Action<string, object> _releaseAsset;
|
private readonly Action<string, object> _releaseAsset;
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ namespace FlowScope.Resources
|
|||||||
|
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
var resourceKey = new ResourceKey(key, typeof(T));
|
var resourceKey = new ResourceKey(key, typeof(T));
|
||||||
Task<ResourceEntry> loadTask;
|
SharedLoad sharedLoad;
|
||||||
|
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
{
|
{
|
||||||
@@ -48,36 +48,64 @@ namespace FlowScope.Resources
|
|||||||
return entry.CreateHandle<T>();
|
return entry.CreateHandle<T>();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!_loads.TryGetValue(resourceKey, out loadTask))
|
if (!_loads.TryGetValue(resourceKey, out sharedLoad))
|
||||||
{
|
{
|
||||||
loadTask = LoadEntryAsync<T>(key, cancellationToken);
|
sharedLoad = new SharedLoad();
|
||||||
_loads.Add(resourceKey, loadTask);
|
_loads.Add(resourceKey, sharedLoad);
|
||||||
|
}
|
||||||
|
|
||||||
|
sharedLoad.WaiterCount++;
|
||||||
|
if (sharedLoad.LoadTask == null)
|
||||||
|
{
|
||||||
|
sharedLoad.LoadTask = LoadAndOwnEntryAsync<T>(resourceKey, key, sharedLoad);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var loaded = await loadTask;
|
var loaded = await WaitForSharedLoad(sharedLoad.LoadTask, cancellationToken);
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
{
|
{
|
||||||
if (!_entries.ContainsKey(resourceKey))
|
return loaded.CreateHandle<T>();
|
||||||
{
|
|
||||||
_entries.Add(resourceKey, loaded);
|
|
||||||
}
|
|
||||||
|
|
||||||
_loads.Remove(resourceKey);
|
|
||||||
return _entries[resourceKey].CreateHandle<T>();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
{
|
{
|
||||||
_loads.Remove(resourceKey);
|
if (sharedLoad.WaiterCount > 0)
|
||||||
|
{
|
||||||
|
sharedLoad.WaiterCount--;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryLoadCompleted<T>(string key, out IResourceHandle<T> handle)
|
||||||
|
where T : class
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(key))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Resource key is required.", nameof(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_entries.TryGetValue(new ResourceKey(key, typeof(T)), out var entry))
|
||||||
|
{
|
||||||
|
handle = entry.CreateHandle<T>();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handle = null;
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public IResourceGroup CreateGroup()
|
public IResourceGroup CreateGroup()
|
||||||
@@ -113,6 +141,61 @@ namespace FlowScope.Resources
|
|||||||
return new ResourceEntry(key, typeof(T), asset, ReleaseEntry);
|
return new ResourceEntry(key, typeof(T), asset, ReleaseEntry);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<ResourceEntry> LoadAndOwnEntryAsync<T>(
|
||||||
|
ResourceKey resourceKey,
|
||||||
|
string key,
|
||||||
|
SharedLoad sharedLoad)
|
||||||
|
where T : class
|
||||||
|
{
|
||||||
|
ResourceEntry entry = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
entry = await LoadEntryAsync<T>(key, CancellationToken.None);
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_loads.Remove(resourceKey);
|
||||||
|
if (sharedLoad.WaiterCount > 0)
|
||||||
|
{
|
||||||
|
_entries.Add(resourceKey, entry);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ReleaseEntry(entry.Key, entry.AssetType, entry.Asset);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_loads.Remove(resourceKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<ResourceEntry> WaitForSharedLoad(
|
||||||
|
Task<ResourceEntry> loadTask,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!cancellationToken.CanBeCanceled)
|
||||||
|
{
|
||||||
|
return await loadTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
var cancellation = new TaskCompletionSource<bool>();
|
||||||
|
using (cancellationToken.Register(() => cancellation.TrySetResult(true)))
|
||||||
|
{
|
||||||
|
if (await Task.WhenAny(loadTask, cancellation.Task) == cancellation.Task)
|
||||||
|
{
|
||||||
|
throw new OperationCanceledException(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return await loadTask;
|
||||||
|
}
|
||||||
|
|
||||||
private void ReleaseEntry(string key, Type assetType, object asset)
|
private void ReleaseEntry(string key, Type assetType, object asset)
|
||||||
{
|
{
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
@@ -123,6 +206,12 @@ namespace FlowScope.Resources
|
|||||||
_releaseAsset(key, asset);
|
_releaseAsset(key, asset);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class SharedLoad
|
||||||
|
{
|
||||||
|
public Task<ResourceEntry> LoadTask;
|
||||||
|
public int WaiterCount;
|
||||||
|
}
|
||||||
|
|
||||||
private readonly struct ResourceKey : IEquatable<ResourceKey>
|
private readonly struct ResourceKey : IEquatable<ResourceKey>
|
||||||
{
|
{
|
||||||
private readonly string _key;
|
private readonly string _key;
|
||||||
|
|||||||
@@ -12,4 +12,10 @@ namespace FlowScope.Resources
|
|||||||
|
|
||||||
IResourceGroup CreateGroup();
|
IResourceGroup CreateGroup();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public interface ICompletedResourceService
|
||||||
|
{
|
||||||
|
bool TryLoadCompleted<T>(string key, out IResourceHandle<T> handle)
|
||||||
|
where T : class;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -172,6 +172,7 @@ namespace FlowScope.Tests.PlayMode.Audio
|
|||||||
var exception = Assert.Throws<InvalidOperationException>(() => service.PlayBgm("slow-bgm"));
|
var exception = Assert.Throws<InvalidOperationException>(() => service.PlayBgm("slow-bgm"));
|
||||||
|
|
||||||
Assert.That(exception.Message, Does.Contain("slow-bgm"));
|
Assert.That(exception.Message, Does.Contain("slow-bgm"));
|
||||||
|
Assert.That(resources.LoadRequests, Is.EqualTo(0));
|
||||||
Assert.That(Object.FindObjectsOfType<AudioSource>().Length, Is.EqualTo(sourceCountBeforePlay));
|
Assert.That(Object.FindObjectsOfType<AudioSource>().Length, Is.EqualTo(sourceCountBeforePlay));
|
||||||
pending.SetResult(bgmA);
|
pending.SetResult(bgmA);
|
||||||
}
|
}
|
||||||
@@ -186,6 +187,7 @@ namespace FlowScope.Tests.PlayMode.Audio
|
|||||||
var exception = Assert.Throws<InvalidOperationException>(() => service.PlaySfx("slow-sfx"));
|
var exception = Assert.Throws<InvalidOperationException>(() => service.PlaySfx("slow-sfx"));
|
||||||
|
|
||||||
Assert.That(exception.Message, Does.Contain("slow-sfx"));
|
Assert.That(exception.Message, Does.Contain("slow-sfx"));
|
||||||
|
Assert.That(resources.LoadRequests, Is.EqualTo(0));
|
||||||
Assert.That(Object.FindObjectsOfType<AudioSource>().Length, Is.EqualTo(sourceCountBeforePlay));
|
Assert.That(Object.FindObjectsOfType<AudioSource>().Length, Is.EqualTo(sourceCountBeforePlay));
|
||||||
pending.SetResult(sfx);
|
pending.SetResult(sfx);
|
||||||
}
|
}
|
||||||
@@ -261,10 +263,11 @@ namespace FlowScope.Tests.PlayMode.Audio
|
|||||||
return clip;
|
return clip;
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class FakeResourceService : IResourceService
|
private sealed class FakeResourceService : IResourceService, ICompletedResourceService
|
||||||
{
|
{
|
||||||
private readonly Dictionary<string, AudioClip> clips = new();
|
private readonly Dictionary<string, AudioClip> clips = new();
|
||||||
private readonly Dictionary<string, TaskCompletionSource<AudioClip>> pendingClips = new();
|
private readonly Dictionary<string, TaskCompletionSource<AudioClip>> pendingClips = new();
|
||||||
|
public int LoadRequests { get; private set; }
|
||||||
|
|
||||||
public void Add(string key, AudioClip clip)
|
public void Add(string key, AudioClip clip)
|
||||||
{
|
{
|
||||||
@@ -281,6 +284,7 @@ namespace FlowScope.Tests.PlayMode.Audio
|
|||||||
public async Task<IResourceHandle<T>> LoadAsync<T>(string key, CancellationToken cancellationToken)
|
public async Task<IResourceHandle<T>> LoadAsync<T>(string key, CancellationToken cancellationToken)
|
||||||
where T : class
|
where T : class
|
||||||
{
|
{
|
||||||
|
LoadRequests++;
|
||||||
if (typeof(T) != typeof(AudioClip))
|
if (typeof(T) != typeof(AudioClip))
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException($"missing resource: {key}");
|
throw new InvalidOperationException($"missing resource: {key}");
|
||||||
@@ -299,6 +303,19 @@ namespace FlowScope.Tests.PlayMode.Audio
|
|||||||
return new FakeResourceHandle<T>(key, clip as T);
|
return new FakeResourceHandle<T>(key, clip as T);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool TryLoadCompleted<T>(string key, out IResourceHandle<T> handle)
|
||||||
|
where T : class
|
||||||
|
{
|
||||||
|
if (typeof(T) == typeof(AudioClip) && clips.TryGetValue(key, out var clip))
|
||||||
|
{
|
||||||
|
handle = new FakeResourceHandle<T>(key, clip as T);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
handle = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
public IResourceGroup CreateGroup()
|
public IResourceGroup CreateGroup()
|
||||||
{
|
{
|
||||||
throw new NotSupportedException();
|
throw new NotSupportedException();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using System.Threading;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using FlowScope.Resources;
|
using FlowScope.Resources;
|
||||||
using NUnit.Framework;
|
using NUnit.Framework;
|
||||||
|
using UnityEngine;
|
||||||
using UnityEngine.TestTools;
|
using UnityEngine.TestTools;
|
||||||
|
|
||||||
namespace FlowScope.Tests.PlayMode.Resources
|
namespace FlowScope.Tests.PlayMode.Resources
|
||||||
@@ -58,6 +59,99 @@ namespace FlowScope.Tests.PlayMode.Resources
|
|||||||
StringAssert.Contains("Addressables", 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)
|
private static IEnumerator ThrowsAsync<TException>(Func<Task> action, Action<TException> onCaught)
|
||||||
where TException : Exception
|
where TException : Exception
|
||||||
{
|
{
|
||||||
@@ -76,6 +170,27 @@ namespace FlowScope.Tests.PlayMode.Resources
|
|||||||
Assert.Fail($"Expected {typeof(TException).Name}.");
|
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)
|
private static IEnumerator AwaitTask<T>(Task<T> task, Action<T> onCompleted)
|
||||||
{
|
{
|
||||||
while (!task.IsCompleted)
|
while (!task.IsCompleted)
|
||||||
|
|||||||
Reference in New Issue
Block a user