修复 P2 前复审问题

This commit is contained in:
JSD\13999
2026-05-21 15:05:56 +08:00
parent 558bc919ef
commit 4c9eaff7d8
6 changed files with 734 additions and 47 deletions

View File

@@ -9,9 +9,10 @@ namespace FlowScope.UI
{
public sealed class UIPreloadService : IUIPreloadService, IDisposable
{
private readonly object _gate = new();
private readonly IResourceService _resources;
private readonly Dictionary<Type, IResourceHandle<GameObject>> _handles = new();
private readonly Dictionary<Type, Task> _inFlightLoads = new();
private readonly Dictionary<Type, InFlightPreload> _inFlightLoads = new();
public UIPreloadService(IResourceService resources)
{
@@ -23,71 +24,83 @@ namespace FlowScope.UI
cancellationToken.ThrowIfCancellationRequested();
var panelType = typeof(TPanel);
if (IsPreloaded<TPanel>())
InFlightPreload inFlightLoad;
lock (_gate)
{
return;
if (TryGetPreloadedHandle(panelType, out _))
{
return;
}
if (!_inFlightLoads.TryGetValue(panelType, out inFlightLoad))
{
var attribute = UIManager.GetPanelAttribute(panelType);
var path = UIManager.ResolvePath(panelType, attribute);
inFlightLoad = new InFlightPreload();
_inFlightLoads.Add(panelType, inFlightLoad);
inFlightLoad.Task = LoadAndStoreAsync(panelType, path, inFlightLoad);
}
inFlightLoad.WaiterCount++;
}
if (_inFlightLoads.TryGetValue(panelType, out var inFlightLoad))
{
await inFlightLoad;
return;
}
var attribute = UIManager.GetPanelAttribute(panelType);
var path = UIManager.ResolvePath(panelType, attribute);
var loadTask = LoadAndStoreAsync(panelType, path, cancellationToken);
_inFlightLoads.Add(panelType, loadTask);
try
{
await loadTask;
await WaitForSharedLoadAsync(inFlightLoad.Task, cancellationToken);
}
finally
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
_inFlightLoads.Remove(panelType);
ForgetWaitingCaller(panelType, inFlightLoad);
throw;
}
}
public bool IsPreloaded<TPanel>()
{
return _handles.TryGetValue(typeof(TPanel), out var handle) &&
handle != null &&
!handle.IsDisposed &&
handle.Asset != null;
lock (_gate)
{
return TryGetPreloadedHandle(typeof(TPanel), out _);
}
}
public bool TryGetPreloadedHandle<TPanel>(out IResourceHandle<GameObject> handle)
{
if (IsPreloaded<TPanel>())
lock (_gate)
{
handle = _handles[typeof(TPanel)];
return true;
return TryGetPreloadedHandle(typeof(TPanel), out handle);
}
handle = null;
return false;
}
public void Release<TPanel>()
{
var panelType = typeof(TPanel);
if (!_handles.TryGetValue(panelType, out var handle))
IResourceHandle<GameObject> handle;
lock (_gate)
{
return;
if (!_handles.TryGetValue(panelType, out handle))
{
return;
}
_handles.Remove(panelType);
}
_handles.Remove(panelType);
handle.Dispose();
}
public void ReleaseAll()
{
foreach (var handle in _handles.Values)
List<IResourceHandle<GameObject>> handles;
lock (_gate)
{
handles = new List<IResourceHandle<GameObject>>(_handles.Values);
_handles.Clear();
}
foreach (var handle in handles)
{
handle.Dispose();
}
_handles.Clear();
}
public void Dispose()
@@ -98,16 +111,95 @@ namespace FlowScope.UI
private async Task LoadAndStoreAsync(
Type panelType,
string path,
InFlightPreload inFlightLoad)
{
IResourceHandle<GameObject> handle = null;
try
{
handle = await _resources.LoadAsync<GameObject>(path, CancellationToken.None);
if (handle == null || handle.Asset == null)
{
handle?.Dispose();
throw new InvalidOperationException($"UI panel resource is missing: {path}");
}
var shouldCache = false;
lock (_gate)
{
_inFlightLoads.Remove(panelType);
if (inFlightLoad.WaiterCount > 0)
{
_handles[panelType] = handle;
shouldCache = true;
}
}
if (!shouldCache)
{
handle.Dispose();
}
}
catch
{
lock (_gate)
{
_inFlightLoads.Remove(panelType);
}
throw;
}
}
private void ForgetWaitingCaller(Type panelType, InFlightPreload inFlightLoad)
{
lock (_gate)
{
if (_inFlightLoads.TryGetValue(panelType, out var current) &&
ReferenceEquals(current, inFlightLoad) &&
inFlightLoad.WaiterCount > 0)
{
inFlightLoad.WaiterCount--;
}
}
}
private bool TryGetPreloadedHandle(
Type panelType,
out IResourceHandle<GameObject> handle)
{
return _handles.TryGetValue(panelType, out handle) &&
handle != null &&
!handle.IsDisposed &&
handle.Asset != null;
}
private static async Task WaitForSharedLoadAsync(
Task loadTask,
CancellationToken cancellationToken)
{
var handle = await _resources.LoadAsync<GameObject>(path, cancellationToken);
if (handle == null || handle.Asset == null)
if (!cancellationToken.CanBeCanceled)
{
handle?.Dispose();
throw new InvalidOperationException($"UI panel resource is missing: {path}");
await loadTask;
return;
}
_handles[panelType] = handle;
var cancellation = new TaskCompletionSource<bool>(
TaskCreationOptions.RunContinuationsAsynchronously);
using (cancellationToken.Register(() => cancellation.TrySetResult(true)))
{
if (await Task.WhenAny(loadTask, cancellation.Task) == cancellation.Task)
{
throw new OperationCanceledException(cancellationToken);
}
}
await loadTask;
}
private sealed class InFlightPreload
{
public Task Task { get; set; }
public int WaiterCount { get; set; }
}
}
}

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Runtime.ExceptionServices;
using System.Threading;
using System.Threading.Tasks;
using FlowScope.Audio;
@@ -30,7 +31,7 @@ namespace FlowScope.Samples.MainMenuP0
private PlayerData _playerData;
private CancellationTokenSource _lifetime;
private bool _started;
private bool _shutdownStarted;
private bool _shutdownInProgress;
private bool _shutdown;
public int ReleasedResourceCount => _sampleBackend?.ReleaseCount ?? 0;
@@ -106,25 +107,65 @@ namespace FlowScope.Samples.MainMenuP0
public async Task ShutdownAsync(CancellationToken cancellationToken)
{
if (_shutdownStarted)
if (_shutdown || _shutdownInProgress)
{
return;
}
_shutdownStarted = true;
_shutdownInProgress = true;
var exceptions = new List<Exception>();
if (_saveService != null && _playerData != null)
try
{
await _saveService.SaveAsync(SaveKey, _playerData, cancellationToken);
if (_saveService != null && _playerData != null)
{
try
{
await _saveService.SaveAsync(SaveKey, _playerData, cancellationToken);
}
catch (Exception exception)
{
exceptions.Add(exception);
}
}
if (_gameFlow != null && _gameFlow.State != GameFlowState.Disposed)
{
try
{
await _gameFlow.ShutdownAsync(cancellationToken);
}
catch (Exception exception)
{
exceptions.Add(exception);
}
}
try
{
_preloadService?.ReleaseAll();
}
catch (Exception exception)
{
exceptions.Add(exception);
}
_shutdown = true;
}
finally
{
_shutdownInProgress = false;
}
if (_gameFlow != null && _gameFlow.State != GameFlowState.Disposed)
if (exceptions.Count == 1)
{
await _gameFlow.ShutdownAsync(cancellationToken);
ExceptionDispatchInfo.Capture(exceptions[0]).Throw();
}
_preloadService?.ReleaseAll();
_shutdown = true;
if (exceptions.Count > 1)
{
throw new AggregateException("MainMenuP0 shutdown completed with failures.", exceptions);
}
}
private void OnApplicationQuit()

View File

@@ -1,9 +1,11 @@
using System;
using System.Collections;
using System.IO;
using System.Reflection;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using FlowScope.Save;
using FlowScope.Samples.MainMenuP0;
using NUnit.Framework;
using UnityEngine;
@@ -135,6 +137,66 @@ namespace FlowScope.Tests.PlayMode.Samples
LogAssert.NoUnexpectedReceived();
}
[UnityTest]
public IEnumerator Shutdown_WhenSaveFails_StillShutsDownFlowAndReleasesPreloads()
{
DeleteSampleSave();
var root = CreateInactiveBootstrap("bootstrap-save-fails-test", out var bootstrap);
yield return AwaitTask(bootstrap.StartupAsync(CancellationToken.None));
SetPrivateField(bootstrap, "_saveService", new ThrowingSaveService(new InvalidOperationException("save failed")));
var panel = Object.FindObjectOfType<MainMenuPanel>(true);
yield return AwaitExpected<InvalidOperationException>(bootstrap.ShutdownAsync(CancellationToken.None));
yield return null;
Assert.That(bootstrap.IsShutdown, Is.True);
Assert.That(bootstrap.ReleasedResourceCount, Is.EqualTo(1));
Assert.That(panel == null, Is.True);
Object.DestroyImmediate(root);
DeleteSampleSave();
LogAssert.NoUnexpectedReceived();
}
[UnityTest]
public IEnumerator Shutdown_WhenGameFlowShutdownFails_StillReleasesPreloads()
{
DeleteSampleSave();
var root = CreateInactiveBootstrap("bootstrap-flow-fails-test", out var bootstrap);
yield return AwaitTask(bootstrap.StartupAsync(CancellationToken.None));
SetPrivateField(bootstrap, "_saveService", new SuccessfulSaveService());
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
yield return AwaitExpected<OperationCanceledException>(bootstrap.ShutdownAsync(cancellation.Token));
Assert.That(bootstrap.IsShutdown, Is.True);
Assert.That(bootstrap.ReleasedResourceCount, Is.EqualTo(1));
Object.DestroyImmediate(root);
DeleteSampleSave();
LogAssert.NoUnexpectedReceived();
}
[UnityTest]
public IEnumerator Shutdown_AfterFailure_DoesNotSkipCleanupOnSecondCall()
{
DeleteSampleSave();
var root = CreateInactiveBootstrap("bootstrap-shutdown-retry-test", out var bootstrap);
yield return AwaitTask(bootstrap.StartupAsync(CancellationToken.None));
SetPrivateField(bootstrap, "_saveService", new ThrowOnceSaveService(new InvalidOperationException("save failed once")));
yield return AwaitExpected<InvalidOperationException>(bootstrap.ShutdownAsync(CancellationToken.None));
yield return AwaitTask(bootstrap.ShutdownAsync(CancellationToken.None));
Assert.That(bootstrap.IsShutdown, Is.True);
Assert.That(bootstrap.ReleasedResourceCount, Is.EqualTo(1));
Object.DestroyImmediate(root);
DeleteSampleSave();
LogAssert.NoUnexpectedReceived();
}
private static IEnumerator AwaitTask(Task task)
{
while (!task.IsCompleted)
@@ -153,6 +215,24 @@ namespace FlowScope.Tests.PlayMode.Samples
}
}
private static IEnumerator AwaitExpected<TException>(Task task)
where TException : Exception
{
while (!task.IsCompleted)
{
yield return null;
}
Assert.That(task.IsFaulted || task.IsCanceled, Is.True);
if (task.IsCanceled)
{
Assert.That(typeof(TException), Is.EqualTo(typeof(OperationCanceledException)));
yield break;
}
Assert.That(task.Exception.GetBaseException(), Is.TypeOf<TException>());
}
private static GameObject CreateInactiveBootstrap(string name, out GameBootstrap bootstrap)
{
var root = new GameObject(name);
@@ -161,6 +241,18 @@ namespace FlowScope.Tests.PlayMode.Samples
return root;
}
private static void SetPrivateField<TValue>(
GameBootstrap bootstrap,
string fieldName,
TValue value)
{
var field = typeof(GameBootstrap).GetField(
fieldName,
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.That(field, Is.Not.Null);
field.SetValue(bootstrap, value);
}
private static void DeleteSampleSave()
{
var path = Path.Combine(
@@ -172,5 +264,83 @@ namespace FlowScope.Tests.PlayMode.Samples
File.Delete(path);
}
}
private sealed class SuccessfulSaveService : ISaveService
{
public Task SaveAsync<T>(string key, T data, CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public Task<T> LoadAsync<T>(string key, T defaultValue, CancellationToken cancellationToken)
{
return Task.FromResult(defaultValue);
}
public void Delete(string key)
{
}
public bool Exists(string key) => false;
}
private sealed class ThrowingSaveService : ISaveService
{
private readonly Exception _exception;
public ThrowingSaveService(Exception exception)
{
_exception = exception;
}
public Task SaveAsync<T>(string key, T data, CancellationToken cancellationToken)
{
throw _exception;
}
public Task<T> LoadAsync<T>(string key, T defaultValue, CancellationToken cancellationToken)
{
return Task.FromResult(defaultValue);
}
public void Delete(string key)
{
}
public bool Exists(string key) => false;
}
private sealed class ThrowOnceSaveService : ISaveService
{
private readonly Exception _exception;
private bool _thrown;
public ThrowOnceSaveService(Exception exception)
{
_exception = exception;
}
public Task SaveAsync<T>(string key, T data, CancellationToken cancellationToken)
{
if (_thrown)
{
return Task.CompletedTask;
}
_thrown = true;
throw _exception;
}
public Task<T> LoadAsync<T>(string key, T defaultValue, CancellationToken cancellationToken)
{
return Task.FromResult(defaultValue);
}
public void Delete(string key)
{
}
public bool Exists(string key) => false;
}
}
}

View File

@@ -1,3 +1,4 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Threading;
@@ -80,6 +81,80 @@ namespace FlowScope.Tests.PlayMode.UI
Assert.That(preload.IsPreloaded<ExplicitPathPanel>(), Is.True);
}
[UnityTest]
public IEnumerator PreloadAsync_WhenFirstWaiterCancels_SecondWaiterStillSucceeds()
{
var prefab = CreatePrefab<ExplicitPathPanel>("ExplicitPathPrefab");
var resources = new GatedUITestResources("Custom/Explicit", prefab);
var preload = new UIPreloadService(resources);
using var firstCancellation = new CancellationTokenSource();
var first = preload.PreloadAsync<ExplicitPathPanel>(firstCancellation.Token);
var second = preload.PreloadAsync<ExplicitPathPanel>(CancellationToken.None);
Assert.That(resources.LoadCalls, Is.EqualTo(1));
Assert.That(resources.LoadCancellationTokenCanBeCanceled, Is.False);
firstCancellation.Cancel();
yield return UITestAsync.AwaitExpected<OperationCanceledException>(first);
resources.Complete();
yield return UITestAsync.Await(second);
Assert.That(resources.LoadCalls, Is.EqualTo(1));
Assert.That(resources.Handle.IsDisposed, Is.False);
Assert.That(preload.IsPreloaded<ExplicitPathPanel>(), Is.True);
}
[UnityTest]
public IEnumerator PreloadAsync_WhenSecondWaiterCancels_FirstWaiterStillSucceeds()
{
var prefab = CreatePrefab<ExplicitPathPanel>("ExplicitPathPrefab");
var resources = new GatedUITestResources("Custom/Explicit", prefab);
var preload = new UIPreloadService(resources);
using var secondCancellation = new CancellationTokenSource();
var first = preload.PreloadAsync<ExplicitPathPanel>(CancellationToken.None);
var second = preload.PreloadAsync<ExplicitPathPanel>(secondCancellation.Token);
Assert.That(resources.LoadCalls, Is.EqualTo(1));
secondCancellation.Cancel();
yield return UITestAsync.AwaitExpected<OperationCanceledException>(second);
resources.Complete();
yield return UITestAsync.Await(first);
Assert.That(resources.LoadCalls, Is.EqualTo(1));
Assert.That(resources.Handle.IsDisposed, Is.False);
Assert.That(preload.IsPreloaded<ExplicitPathPanel>(), Is.True);
}
[UnityTest]
public IEnumerator PreloadAsync_WhenAllWaitersCancel_ReleasesLoadedHandleWithoutCaching()
{
var prefab = CreatePrefab<ExplicitPathPanel>("ExplicitPathPrefab");
var resources = new GatedUITestResources("Custom/Explicit", prefab);
var preload = new UIPreloadService(resources);
using var firstCancellation = new CancellationTokenSource();
using var secondCancellation = new CancellationTokenSource();
var first = preload.PreloadAsync<ExplicitPathPanel>(firstCancellation.Token);
var second = preload.PreloadAsync<ExplicitPathPanel>(secondCancellation.Token);
firstCancellation.Cancel();
secondCancellation.Cancel();
yield return UITestAsync.AwaitExpected<OperationCanceledException>(first);
yield return UITestAsync.AwaitExpected<OperationCanceledException>(second);
resources.Complete();
yield return null;
Assert.That(resources.LoadCalls, Is.EqualTo(1));
Assert.That(resources.Handle.IsDisposed, Is.True);
Assert.That(preload.IsPreloaded<ExplicitPathPanel>(), Is.False);
}
[UnityTest]
public IEnumerator OpenAsync_WhenPanelIsPreloaded_ReusesPreloadedHandle()
{
@@ -150,6 +225,7 @@ namespace FlowScope.Tests.PlayMode.UI
public int LoadCalls { get; private set; }
public UITestResourceHandle<GameObject> Handle { get; }
public bool LoadCancellationTokenCanBeCanceled { get; private set; }
public Task<FlowScope.Resources.IResourceHandle<T>> LoadAsync<T>(
string key,
@@ -157,6 +233,12 @@ namespace FlowScope.Tests.PlayMode.UI
where T : class
{
cancellationToken.ThrowIfCancellationRequested();
LoadCancellationTokenCanBeCanceled = cancellationToken.CanBeCanceled;
if (cancellationToken.CanBeCanceled)
{
cancellationToken.Register(() => _gate.TrySetCanceled(cancellationToken));
}
LoadCalls++;
Assert.That(key, Is.EqualTo(_key));
return AwaitHandle<T>();
@@ -169,7 +251,7 @@ namespace FlowScope.Tests.PlayMode.UI
public void Complete()
{
_gate.SetResult(Handle);
_gate.TrySetResult(Handle);
}
private async Task<FlowScope.Resources.IResourceHandle<T>> AwaitHandle<T>()

View File

@@ -4,6 +4,7 @@ using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using FlowScope.Resources;
using NUnit.Framework;
using UnityEngine;
namespace FlowScope.Tests.PlayMode.UI
@@ -100,5 +101,23 @@ namespace FlowScope.Tests.PlayMode.UI
onCompleted(task.Result);
}
public static IEnumerator AwaitExpected<TException>(Task task)
where TException : Exception
{
while (!task.IsCompleted)
{
yield return null;
}
Assert.That(task.IsFaulted || task.IsCanceled, Is.True);
if (task.IsCanceled)
{
Assert.That(typeof(TException), Is.EqualTo(typeof(OperationCanceledException)));
yield break;
}
Assert.That(task.Exception.GetBaseException(), Is.TypeOf<TException>());
}
}
}