修正资源加载取消与同步音频语义

This commit is contained in:
JSD\13999
2026-05-19 21:20:52 +08:00
parent afcb02591d
commit 05ecb0916c
5 changed files with 248 additions and 21 deletions

View File

@@ -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)
{

View File

@@ -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;

View File

@@ -12,4 +12,10 @@ namespace FlowScope.Resources
IResourceGroup CreateGroup();
}
public interface ICompletedResourceService
{
bool TryLoadCompleted<T>(string key, out IResourceHandle<T> handle)
where T : class;
}
}

View File

@@ -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();

View File

@@ -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)