修正资源加载取消与同步音频语义
This commit is contained in:
@@ -187,14 +187,14 @@ namespace FlowScope.Audio
|
||||
{
|
||||
try
|
||||
{
|
||||
var task = _resources.LoadAsync<AudioClip>(key, CancellationToken.None);
|
||||
if (!task.IsCompleted)
|
||||
if (!(_resources is ICompletedResourceService completedResources) ||
|
||||
!completedResources.TryLoadCompleted(key, out IResourceHandle<AudioClip> handle))
|
||||
{
|
||||
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)
|
||||
{
|
||||
|
||||
@@ -6,11 +6,11 @@ using System.Threading.Tasks;
|
||||
|
||||
namespace FlowScope.Resources
|
||||
{
|
||||
public sealed class AddressablesResourceService : IResourceService
|
||||
public sealed class AddressablesResourceService : IResourceService, ICompletedResourceService
|
||||
{
|
||||
private readonly object _gate = 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 Action<string, object> _releaseAsset;
|
||||
|
||||
@@ -39,7 +39,7 @@ namespace FlowScope.Resources
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var resourceKey = new ResourceKey(key, typeof(T));
|
||||
Task<ResourceEntry> loadTask;
|
||||
SharedLoad sharedLoad;
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
@@ -48,36 +48,64 @@ namespace FlowScope.Resources
|
||||
return entry.CreateHandle<T>();
|
||||
}
|
||||
|
||||
if (!_loads.TryGetValue(resourceKey, out loadTask))
|
||||
if (!_loads.TryGetValue(resourceKey, out sharedLoad))
|
||||
{
|
||||
loadTask = LoadEntryAsync<T>(key, cancellationToken);
|
||||
_loads.Add(resourceKey, loadTask);
|
||||
sharedLoad = new SharedLoad();
|
||||
_loads.Add(resourceKey, sharedLoad);
|
||||
}
|
||||
|
||||
sharedLoad.WaiterCount++;
|
||||
if (sharedLoad.LoadTask == null)
|
||||
{
|
||||
sharedLoad.LoadTask = LoadAndOwnEntryAsync<T>(resourceKey, key, sharedLoad);
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var loaded = await loadTask;
|
||||
var loaded = await WaitForSharedLoad(sharedLoad.LoadTask, cancellationToken);
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_entries.ContainsKey(resourceKey))
|
||||
{
|
||||
_entries.Add(resourceKey, loaded);
|
||||
}
|
||||
|
||||
_loads.Remove(resourceKey);
|
||||
return _entries[resourceKey].CreateHandle<T>();
|
||||
return loaded.CreateHandle<T>();
|
||||
}
|
||||
}
|
||||
catch
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_loads.Remove(resourceKey);
|
||||
if (sharedLoad.WaiterCount > 0)
|
||||
{
|
||||
sharedLoad.WaiterCount--;
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -113,6 +141,61 @@ namespace FlowScope.Resources
|
||||
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)
|
||||
{
|
||||
lock (_gate)
|
||||
@@ -123,6 +206,12 @@ namespace FlowScope.Resources
|
||||
_releaseAsset(key, asset);
|
||||
}
|
||||
|
||||
private sealed class SharedLoad
|
||||
{
|
||||
public Task<ResourceEntry> LoadTask;
|
||||
public int WaiterCount;
|
||||
}
|
||||
|
||||
private readonly struct ResourceKey : IEquatable<ResourceKey>
|
||||
{
|
||||
private readonly string _key;
|
||||
|
||||
@@ -12,4 +12,10 @@ namespace FlowScope.Resources
|
||||
|
||||
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"));
|
||||
|
||||
Assert.That(exception.Message, Does.Contain("slow-bgm"));
|
||||
Assert.That(resources.LoadRequests, Is.EqualTo(0));
|
||||
Assert.That(Object.FindObjectsOfType<AudioSource>().Length, Is.EqualTo(sourceCountBeforePlay));
|
||||
pending.SetResult(bgmA);
|
||||
}
|
||||
@@ -186,6 +187,7 @@ namespace FlowScope.Tests.PlayMode.Audio
|
||||
var exception = Assert.Throws<InvalidOperationException>(() => service.PlaySfx("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));
|
||||
pending.SetResult(sfx);
|
||||
}
|
||||
@@ -261,10 +263,11 @@ namespace FlowScope.Tests.PlayMode.Audio
|
||||
return clip;
|
||||
}
|
||||
|
||||
private sealed class FakeResourceService : IResourceService
|
||||
private sealed class FakeResourceService : IResourceService, ICompletedResourceService
|
||||
{
|
||||
private readonly Dictionary<string, AudioClip> clips = new();
|
||||
private readonly Dictionary<string, TaskCompletionSource<AudioClip>> pendingClips = new();
|
||||
public int LoadRequests { get; private set; }
|
||||
|
||||
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)
|
||||
where T : class
|
||||
{
|
||||
LoadRequests++;
|
||||
if (typeof(T) != typeof(AudioClip))
|
||||
{
|
||||
throw new InvalidOperationException($"missing resource: {key}");
|
||||
@@ -299,6 +303,19 @@ namespace FlowScope.Tests.PlayMode.Audio
|
||||
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()
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FlowScope.Resources;
|
||||
using NUnit.Framework;
|
||||
using UnityEngine;
|
||||
using UnityEngine.TestTools;
|
||||
|
||||
namespace FlowScope.Tests.PlayMode.Resources
|
||||
@@ -58,6 +59,99 @@ namespace FlowScope.Tests.PlayMode.Resources
|
||||
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
|
||||
{
|
||||
@@ -76,6 +170,27 @@ namespace FlowScope.Tests.PlayMode.Resources
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user