diff --git a/src/ObjectReference.Unity/Packages/jp.andantetribe.objectreference/Runtime/AddressableObjectReference.cs b/src/ObjectReference.Unity/Packages/jp.andantetribe.objectreference/Runtime/AddressableObjectReference.cs index 6838d04..7de1e17 100644 --- a/src/ObjectReference.Unity/Packages/jp.andantetribe.objectreference/Runtime/AddressableObjectReference.cs +++ b/src/ObjectReference.Unity/Packages/jp.andantetribe.objectreference/Runtime/AddressableObjectReference.cs @@ -2,6 +2,7 @@ #nullable enable using System; +using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using Cysharp.Threading.Tasks; @@ -53,41 +54,83 @@ public AddressableObjectReference(string address) _address = address ?? throw new ArgumentNullException(nameof(address), "Address cannot be null."); } - public async ValueTask LoadAsync(CancellationToken cancellationToken) + public ValueTask LoadAsync(CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - if (!_handle.IsValid()) + if (_handle.IsValid() && _handle.Status == AsyncOperationStatus.Succeeded) { - _handle = Addressables.LoadAssetAsync(_address); + return new ValueTask(_handle.Result); } - return await _handle.ToUniTask(cancellationToken: cancellationToken, autoReleaseWhenCanceled: true); + return LoadAsyncCore(this, cancellationToken); + + static async ValueTask LoadAsyncCore(AddressableObjectReference reference, CancellationToken cancellationToken) + { + if (!reference._handle.IsValid()) + { + reference._handle = Addressables.LoadAssetAsync(reference._address); + } + + try + { + return await reference._handle.ToUniTask(cancellationToken: cancellationToken); + } + catch + { + reference.Release(); + throw; + } + } } /// - public async ValueTask LoadAsync(IProgress progress, CancellationToken cancellationToken) + public ValueTask LoadAsync(IProgress progress, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - if (!_handle.IsValid()) + if (_handle.IsValid() && _handle.Status == AsyncOperationStatus.Succeeded) { - _handle = Addressables.LoadAssetAsync(_address); + progress.Report(1.0f); + return new ValueTask(_handle.Result); } - var result = await _handle.ToUniTask(progress: progress, cancellationToken: cancellationToken, autoReleaseWhenCanceled: true); - progress.Report(1.0f); - return result; + return LoadAsyncCore(this, progress, cancellationToken); + + static async ValueTask LoadAsyncCore(AddressableObjectReference reference, IProgress progress, CancellationToken cancellationToken) + { + if (!reference._handle.IsValid()) + { + reference._handle = Addressables.LoadAssetAsync(reference._address); + } + + T result; + try + { + result = await reference._handle.ToUniTask(progress: progress, cancellationToken: cancellationToken); + } + catch + { + reference.Release(); + throw; + } + + progress.Report(1.0f); + return result; + } } /// - public void Dispose() + public void Dispose() => Release(); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void Release() { if (_handle.IsValid()) { - Addressables.Release(_handle); + _handle.Release(); _handle = default; } } } } -#endif \ No newline at end of file +#endif diff --git a/src/ObjectReference.Unity/Packages/jp.andantetribe.objectreference/Runtime/SerializableAddressableObjectReference.cs b/src/ObjectReference.Unity/Packages/jp.andantetribe.objectreference/Runtime/SerializableAddressableObjectReference.cs index fc03563..a39b54f 100644 --- a/src/ObjectReference.Unity/Packages/jp.andantetribe.objectreference/Runtime/SerializableAddressableObjectReference.cs +++ b/src/ObjectReference.Unity/Packages/jp.andantetribe.objectreference/Runtime/SerializableAddressableObjectReference.cs @@ -2,6 +2,7 @@ #nullable enable using System; +using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using Cysharp.Threading.Tasks; @@ -18,38 +19,74 @@ internal sealed class SerializableAddressableObjectReference : IObjectReferen private T? _cached; /// - public async ValueTask LoadAsync(CancellationToken cancellationToken) + public ValueTask LoadAsync(CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - if (_cached == null) + if (_cached != null) { - _cached = await _value.LoadAssetAsync().ToUniTask(cancellationToken: cancellationToken, autoReleaseWhenCanceled: true); + return new ValueTask(_cached); + } + + return LoadAsyncCore(this, cancellationToken); + + static async ValueTask LoadAsyncCore(SerializableAddressableObjectReference reference, CancellationToken cancellationToken) + { + try + { + reference._cached = await reference._value.LoadAssetAsync().ToUniTask(cancellationToken: cancellationToken); + return reference._cached; + } + catch + { + reference.Release(); + throw; + } } - return _cached; } /// - public async ValueTask LoadAsync(IProgress progress, CancellationToken cancellationToken) + public ValueTask LoadAsync(IProgress progress, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - if (_cached == null) + if (_cached != null) + { + progress.Report(1.0f); + return new ValueTask(_cached); + } + + return LoadAsyncCore(this, progress, cancellationToken); + + static async ValueTask LoadAsyncCore(SerializableAddressableObjectReference reference, IProgress progress, CancellationToken cancellationToken) { - _cached ??= await _value.LoadAssetAsync().ToUniTask(progress: progress, cancellationToken: cancellationToken, autoReleaseWhenCanceled: true); + try + { + reference._cached = await reference._value.LoadAssetAsync().ToUniTask(progress: progress, cancellationToken: cancellationToken); + } + catch + { + reference.Release(); + throw; + } + + progress.Report(1.0f); + return reference._cached; } - progress.Report(1.0f); - return _cached; } /// - public void Dispose() + public void Dispose() => Release(); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void Release() { - if (_cached != null) + if (_value.OperationHandle.IsValid()) { _value.ReleaseAsset(); - _cached = null; } + + _cached = null; } } } -#endif \ No newline at end of file +#endif diff --git a/src/ObjectReference.Unity/Packages/jp.andantetribe.objectreference/Tests/Runtime/ObjectReferenceTestEnvironment.cs b/src/ObjectReference.Unity/Packages/jp.andantetribe.objectreference/Tests/Runtime/ObjectReferenceTestEnvironment.cs index 72dfe1f..4b31482 100644 --- a/src/ObjectReference.Unity/Packages/jp.andantetribe.objectreference/Tests/Runtime/ObjectReferenceTestEnvironment.cs +++ b/src/ObjectReference.Unity/Packages/jp.andantetribe.objectreference/Tests/Runtime/ObjectReferenceTestEnvironment.cs @@ -18,8 +18,10 @@ internal sealed class ObjectReferenceTestEnvironment : IDisposable { public const string CubeAddress = "objectreference-tests-cube"; public const string MaterialAddress = "objectreference-tests-material"; + public const string MissingAddress = "objectreference-tests-missing"; public const string CubeGuid = "11111111111111111111111111111111"; public const string MaterialGuid = "22222222222222222222222222222222"; + public const string MissingGuid = "33333333333333333333333333333333"; private const BindingFlags InstanceFieldFlags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; @@ -70,11 +72,13 @@ public ObjectReferenceTestEnvironment() }); Addressables.ResourceManager.ResourceProviders.Add(_provider); - _locator = new ResourceLocationMap("ObjectReferenceTests", 4); + _locator = new ResourceLocationMap("ObjectReferenceTests", 6); AddLocation(CubeAddress, typeof(GameObject)); AddLocation(MaterialAddress, typeof(Material)); + AddLocation(MissingAddress, typeof(GameObject)); AddLocation(CubeGuid, typeof(GameObject)); AddLocation(MaterialGuid, typeof(Material)); + AddLocation(MissingGuid, typeof(GameObject)); Addressables.AddResourceLocator(_locator); } @@ -92,6 +96,24 @@ public IObjectReference CreateSerializableAddressableReference(string guid "SerializableAddressableObjectReference`1", new AssetReferenceT(guid)); + public bool HasValidOperationHandle(IObjectReference reference) + where T : UnityEngine.Object + { + var valueField = reference.GetType().GetField("_value", InstanceFieldFlags) + ?? throw new MissingFieldException(reference.GetType().FullName, "_value"); + var assetReference = (AssetReference)valueField.GetValue(reference)!; + return assetReference.OperationHandle.IsValid(); + } + + public bool HasValidOperationHandle(AddressableObjectReference reference) + where T : UnityEngine.Object + { + var handleField = typeof(AddressableObjectReference).GetField("_handle", InstanceFieldFlags) + ?? throw new MissingFieldException(typeof(AddressableObjectReference).FullName, "_handle"); + var handle = (AsyncOperationHandle)handleField.GetValue(reference)!; + return handle.IsValid(); + } + public void Dispose() { Addressables.RemoveResourceLocator(_locator); @@ -171,4 +193,4 @@ private async UniTaskVoid CompleteAsync(ProvideHandle provideHandle) provideHandle.Complete(value, status: true, exception: null); } } -} \ No newline at end of file +} diff --git a/src/ObjectReference.Unity/Packages/jp.andantetribe.objectreference/Tests/Runtime/ObjectReferenceTests.cs b/src/ObjectReference.Unity/Packages/jp.andantetribe.objectreference/Tests/Runtime/ObjectReferenceTests.cs index 730832a..ffc436a 100644 --- a/src/ObjectReference.Unity/Packages/jp.andantetribe.objectreference/Tests/Runtime/ObjectReferenceTests.cs +++ b/src/ObjectReference.Unity/Packages/jp.andantetribe.objectreference/Tests/Runtime/ObjectReferenceTests.cs @@ -113,14 +113,72 @@ public void LoadAsync_WithProgress_SerializableObjectReference_NullValue_ThrowsN } [UnityTest] - public IEnumerator LoadAsync_Addressable_SecondCall_ReturnsCachedValue() => new ToCoroutineEnumerator(async () => + public IEnumerator LoadAsync_Addressable_SecondCall_ReturnsCachedValue( + [Values(false, true)] bool withProgress) => new ToCoroutineEnumerator(async () => { using var reference = Environment.CreateSerializableAddressableReference( ObjectReferenceTestEnvironment.CubeGuid); var first = await reference.LoadAsync(CancellationToken.None); - var second = await reference.LoadAsync(CancellationToken.None); + var progress = new ProgressRecorder(); + var second = withProgress + ? await reference.LoadAsync(progress, CancellationToken.None) + : await reference.LoadAsync(CancellationToken.None); Assert.That(first, Is.SameAs(Environment.Cube)); Assert.That(second, Is.SameAs(first)); + if (withProgress) + { + Assert.That(progress.Value, Is.EqualTo(1.0f).Within(0.001f)); + } + }); + + [UnityTest] + public IEnumerator LoadAsync_Addressable_CachedProgressThrows_PreservesHandle() => new ToCoroutineEnumerator(async () => + { + using var reference = Environment.CreateSerializableAddressableReference( + ObjectReferenceTestEnvironment.CubeGuid); + var first = await reference.LoadAsync(CancellationToken.None); + + Exception? caught = null; + try + { + await reference.LoadAsync(new ThrowingProgress(), CancellationToken.None); + } + catch (Exception exception) + { + caught = exception; + } + + Assert.That(caught, Is.TypeOf()); + Assert.That(Environment.HasValidOperationHandle(reference), Is.True); + var second = await reference.LoadAsync(CancellationToken.None); + Assert.That(second, Is.SameAs(first)); + }); + + [UnityTest] + public IEnumerator LoadAsync_Addressable_WhenLoadingFails_ReleasesHandle( + [Values(false, true)] bool withProgress) => new ToCoroutineEnumerator(async () => + { + using var reference = Environment.CreateSerializableAddressableReference( + ObjectReferenceTestEnvironment.MissingGuid); + Exception? caught = null; + try + { + if (withProgress) + { + await reference.LoadAsync(new ProgressRecorder(), CancellationToken.None); + } + else + { + await reference.LoadAsync(CancellationToken.None); + } + } + catch (Exception exception) + { + caught = exception; + } + + Assert.That(caught, Is.TypeOf()); + Assert.That(Environment.HasValidOperationHandle(reference), Is.False); }); private IObjectReference CreateGameObjectReference(ReferenceKind kind) => kind switch @@ -228,14 +286,45 @@ public void Dispose_CalledTwice_DoesNotThrow() }); [UnityTest] - public IEnumerator LoadAsync_SecondCall_ReusesCachedHandle() => new ToCoroutineEnumerator(async () => + public IEnumerator LoadAsync_SecondCall_ReusesCachedHandle( + [Values(false, true)] bool withProgress) => new ToCoroutineEnumerator(async () => { using var reference = new AddressableObjectReference( ObjectReferenceTestEnvironment.CubeAddress); var first = await reference.LoadAsync(CancellationToken.None); - var second = await reference.LoadAsync(CancellationToken.None); + var progress = new ProgressRecorder(); + var second = withProgress + ? await reference.LoadAsync(progress, CancellationToken.None) + : await reference.LoadAsync(CancellationToken.None); Assert.That(first, Is.SameAs(Environment.Cube)); Assert.That(second, Is.SameAs(first)); + if (withProgress) + { + Assert.That(progress.Value, Is.EqualTo(1.0f).Within(0.001f)); + } + }); + + [UnityTest] + public IEnumerator LoadAsync_CachedProgressThrows_PreservesHandle() => new ToCoroutineEnumerator(async () => + { + using var reference = new AddressableObjectReference( + ObjectReferenceTestEnvironment.CubeAddress); + var first = await reference.LoadAsync(CancellationToken.None); + + Exception? caught = null; + try + { + await reference.LoadAsync(new ThrowingProgress(), CancellationToken.None); + } + catch (Exception exception) + { + caught = exception; + } + + Assert.That(caught, Is.TypeOf()); + Assert.That(Environment.HasValidOperationHandle(reference), Is.True); + var second = await reference.LoadAsync(CancellationToken.None); + Assert.That(second, Is.SameAs(first)); }); [UnityTest] @@ -249,6 +338,33 @@ public void Dispose_CalledTwice_DoesNotThrow() Assert.That(progress.Value, Is.EqualTo(1.0f).Within(0.001f)); }); + [UnityTest] + public IEnumerator LoadAsync_WhenLoadingFails_ReleasesHandle( + [Values(false, true)] bool withProgress) => new ToCoroutineEnumerator(async () => + { + using var reference = new AddressableObjectReference( + ObjectReferenceTestEnvironment.MissingAddress); + Exception? caught = null; + try + { + if (withProgress) + { + await reference.LoadAsync(new ProgressRecorder(), CancellationToken.None); + } + else + { + await reference.LoadAsync(CancellationToken.None); + } + } + catch (Exception exception) + { + caught = exception; + } + + Assert.That(caught, Is.TypeOf()); + Assert.That(Environment.HasValidOperationHandle(reference), Is.False); + }); + [UnityTest] public IEnumerator Dispose_AfterLoad_ReleasesHandle() => new ToCoroutineEnumerator(async () => { @@ -266,4 +382,9 @@ internal sealed class ProgressRecorder : IProgress public void Report(float value) => Value = value; } -} \ No newline at end of file + + internal sealed class ThrowingProgress : IProgress + { + public void Report(float value) => throw new InvalidOperationException("Progress reporting failed."); + } +} diff --git a/src/ObjectReference.Unity/Packages/jp.andantetribe.objectreference/package.json b/src/ObjectReference.Unity/Packages/jp.andantetribe.objectreference/package.json index 44a5e08..7e4da45 100644 --- a/src/ObjectReference.Unity/Packages/jp.andantetribe.objectreference/package.json +++ b/src/ObjectReference.Unity/Packages/jp.andantetribe.objectreference/package.json @@ -5,11 +5,11 @@ "name": "AndanteTribe", "url": "https://github.com/AndanteTribe" }, - "version": "1.0.1", + "version": "1.0.2", "unity": "6000.0", "description": "Provides a simple interface and implementation for asynchronously loading Unity objects.", "license": "MIT", "dependencies": { "com.unity.modules.uielements": "1.0.0" } -} +} \ No newline at end of file