拆分 P1 资源后端适配层

This commit is contained in:
JSD\13999
2026-05-20 16:08:20 +08:00
parent ff3dc749f7
commit 1e7ae22ddc
17 changed files with 355 additions and 333 deletions

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: e74ee61e34d742b9b44057032c6431bb
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

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

View File

@@ -1,5 +1,5 @@
fileFormatVersion: 2 fileFormatVersion: 2
guid: 6a8dd01aed1f4fac97325a858dc56e78 guid: f65b4f61d3a34358bf48957eb3a14dd7
MonoImporter: MonoImporter:
externalObjects: {} externalObjects: {}
serializedVersion: 2 serializedVersion: 2

View File

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

View File

@@ -1,5 +1,5 @@
fileFormatVersion: 2 fileFormatVersion: 2
guid: c9294e0aadd246de906d8cfb96d81878 guid: 62c87792aac14414b46ac0eb3e716f25
MonoImporter: MonoImporter:
externalObjects: {} externalObjects: {}
serializedVersion: 2 serializedVersion: 2

View File

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

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 917e69d4247b4f548a5951d84e7d35db
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

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

View File

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

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d123a5c6220843b18a09611f77e8cd31
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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}.");
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: eb5a9158d4884293847009acc433b248
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

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

View File

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

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 596056ac39b54c0cb812046585dddf65
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

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

View File

@@ -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",