完成 P2 前架构整改

This commit is contained in:
JSD\13999
2026-05-21 11:28:56 +08:00
parent 6e45152250
commit 558bc919ef
15 changed files with 895 additions and 94 deletions

View File

@@ -37,6 +37,11 @@ namespace FlowScope.Container
}
public void RegisterFactory<T>(Func<Container, T> factory)
{
RegisterSingletonFactory(factory);
}
public void RegisterSingletonFactory<T>(Func<Container, T> factory)
{
ThrowIfDisposed();
@@ -45,7 +50,31 @@ namespace FlowScope.Container
throw new ArgumentNullException(nameof(factory));
}
RegisterLocal(typeof(T), ContainerRegistration.ForFactory(this, c => factory(c)));
RegisterLocal(typeof(T), ContainerRegistration.ForSingletonFactory(this, c => factory(c)));
}
public void RegisterScoped<T>(Func<Container, T> factory)
{
ThrowIfDisposed();
if (factory == null)
{
throw new ArgumentNullException(nameof(factory));
}
RegisterLocal(typeof(T), ContainerRegistration.ForScopedFactory(this, c => factory(c)));
}
public void RegisterTransient<T>(Func<Container, T> factory)
{
ThrowIfDisposed();
if (factory == null)
{
throw new ArgumentNullException(nameof(factory));
}
RegisterLocal(typeof(T), ContainerRegistration.ForTransientFactory(this, c => factory(c)));
}
public void RegisterType<TInterface, TImplementation>() where TImplementation : TInterface
@@ -121,6 +150,7 @@ namespace FlowScope.Container
_children.Clear();
_ownedDisposables.Clear();
ForgetScopedInstancesFor(this);
_registrations.Clear();
if (_parent != null)
@@ -160,7 +190,7 @@ namespace FlowScope.Container
throw new ArgumentNullException(nameof(factory));
}
RegisterLocal(serviceType, ContainerRegistration.ForFactory(this, factory));
RegisterLocal(serviceType, ContainerRegistration.ForTransientFactory(this, factory));
}
internal void TrackOwnedDisposable(IDisposable disposable)
@@ -209,7 +239,7 @@ namespace FlowScope.Container
_resolutionStack.Push(serviceType);
try
{
return registration.Resolve();
return registration.Resolve(this);
}
finally
{
@@ -236,6 +266,16 @@ namespace FlowScope.Container
return false;
}
private void ForgetScopedInstancesFor(Container scope)
{
foreach (var registration in _registrations.Values)
{
registration.ForgetScope(scope);
}
_parent?.ForgetScopedInstancesFor(scope);
}
private void ThrowIfDisposed()
{
if (_disposed)

View File

@@ -1,50 +1,117 @@
using System;
using System.Collections.Generic;
namespace FlowScope.Container
{
internal sealed class ContainerRegistration
{
private enum Lifetime
{
Instance,
Singleton,
Scoped,
Transient
}
private readonly Container _owner;
private readonly Func<Container, object> _factory;
private readonly bool _ownsCreatedInstance;
private readonly Lifetime _lifetime;
private readonly Dictionary<Container, object> _scopedInstances;
private object _instance;
private bool _created;
private ContainerRegistration(Container owner, Func<Container, object> factory, object instance, bool created, bool ownsCreatedInstance)
private ContainerRegistration(
Container owner,
Func<Container, object> factory,
object instance,
bool created,
Lifetime lifetime)
{
_owner = owner;
_factory = factory;
_instance = instance;
_created = created;
_ownsCreatedInstance = ownsCreatedInstance;
_lifetime = lifetime;
_scopedInstances = lifetime == Lifetime.Scoped
? new Dictionary<Container, object>()
: null;
}
public static ContainerRegistration ForInstance(object instance)
{
return new ContainerRegistration(null, null, instance, true, false);
return new ContainerRegistration(null, null, instance, true, Lifetime.Instance);
}
public static ContainerRegistration ForFactory(Container owner, Func<Container, object> factory)
public static ContainerRegistration ForSingletonFactory(Container owner, Func<Container, object> factory)
{
return new ContainerRegistration(owner, factory, null, false, true);
return new ContainerRegistration(owner, factory, null, false, Lifetime.Singleton);
}
public object Resolve()
public static ContainerRegistration ForScopedFactory(Container owner, Func<Container, object> factory)
{
if (_created)
return new ContainerRegistration(owner, factory, null, false, Lifetime.Scoped);
}
public static ContainerRegistration ForTransientFactory(Container owner, Func<Container, object> factory)
{
return new ContainerRegistration(owner, factory, null, false, Lifetime.Transient);
}
public object Resolve(Container requestScope)
{
switch (_lifetime)
{
return _instance;
case Lifetime.Instance:
return _instance;
case Lifetime.Singleton:
return ResolveSingleton();
case Lifetime.Scoped:
return ResolveScoped(requestScope);
case Lifetime.Transient:
return CreateOwnedInstance(requestScope);
default:
throw new InvalidOperationException($"Unsupported container lifetime {_lifetime}.");
}
}
_instance = _factory(_owner);
_created = true;
public void ForgetScope(Container scope)
{
_scopedInstances?.Remove(scope);
}
if (_ownsCreatedInstance && _instance is IDisposable disposable)
private object ResolveSingleton()
{
if (!_created)
{
_owner.TrackOwnedDisposable(disposable);
_instance = CreateOwnedInstance(_owner);
_created = true;
}
return _instance;
}
private object ResolveScoped(Container requestScope)
{
if (_scopedInstances.TryGetValue(requestScope, out var instance))
{
return instance;
}
instance = CreateOwnedInstance(requestScope);
_scopedInstances.Add(requestScope, instance);
return instance;
}
private object CreateOwnedInstance(Container owner)
{
var instance = _factory(owner);
if (instance is IDisposable disposable)
{
owner.TrackOwnedDisposable(disposable);
}
return instance;
}
}
}

View File

@@ -44,7 +44,6 @@ namespace FlowScope.Flow
}
catch
{
BestEffortDisposeFeature(feature);
BestEffortDisposeContext(context);
_activeFeature = null;
_activeContext = null;
@@ -79,7 +78,6 @@ namespace FlowScope.Flow
}
catch
{
BestEffortDisposeFeature(nextFeature);
BestEffortDisposeContext(nextContext);
_activeFeature = null;
_activeContext = null;
@@ -170,30 +168,11 @@ namespace FlowScope.Flow
{
Debug.LogError(exception);
}
BestEffortDisposeFeature(feature);
}
BestEffortDisposeContext(context);
}
private void BestEffortDisposeFeature(IFeature feature)
{
if (feature == null)
{
return;
}
try
{
feature.Dispose();
}
catch (Exception exception)
{
Debug.LogError(exception);
}
}
private void BestEffortDisposeContext(FeatureContext context)
{
if (context == null)
@@ -212,7 +191,7 @@ namespace FlowScope.Flow
try
{
context.Resources?.Dispose();
context.Scope?.Dispose();
}
catch (Exception exception)
{
@@ -221,7 +200,7 @@ namespace FlowScope.Flow
try
{
context.Scope?.Dispose();
context.Resources?.Dispose();
}
catch (Exception exception)
{

View File

@@ -11,6 +11,7 @@ namespace FlowScope.UI
{
private readonly IResourceService _resources;
private readonly Dictionary<Type, IResourceHandle<GameObject>> _handles = new();
private readonly Dictionary<Type, Task> _inFlightLoads = new();
public UIPreloadService(IResourceService resources)
{
@@ -27,16 +28,24 @@ namespace FlowScope.UI
return;
}
var attribute = UIManager.GetPanelAttribute(panelType);
var path = UIManager.ResolvePath(panelType, attribute);
var handle = await _resources.LoadAsync<GameObject>(path, cancellationToken);
if (handle == null || handle.Asset == null)
if (_inFlightLoads.TryGetValue(panelType, out var inFlightLoad))
{
handle?.Dispose();
throw new InvalidOperationException($"UI panel resource is missing: {path}");
await inFlightLoad;
return;
}
_handles[panelType] = handle;
var attribute = UIManager.GetPanelAttribute(panelType);
var path = UIManager.ResolvePath(panelType, attribute);
var loadTask = LoadAndStoreAsync(panelType, path, cancellationToken);
_inFlightLoads.Add(panelType, loadTask);
try
{
await loadTask;
}
finally
{
_inFlightLoads.Remove(panelType);
}
}
public bool IsPreloaded<TPanel>()
@@ -85,5 +94,20 @@ namespace FlowScope.UI
{
ReleaseAll();
}
private async Task LoadAndStoreAsync(
Type panelType,
string path,
CancellationToken cancellationToken)
{
var handle = await _resources.LoadAsync<GameObject>(path, cancellationToken);
if (handle == null || handle.Asset == null)
{
handle?.Dispose();
throw new InvalidOperationException($"UI panel resource is missing: {path}");
}
_handles[panelType] = handle;
}
}
}

View File

@@ -30,15 +30,17 @@ namespace FlowScope.Samples.MainMenuP0
private PlayerData _playerData;
private CancellationTokenSource _lifetime;
private bool _started;
private bool _shutdownStarted;
private bool _shutdown;
public int ReleasedResourceCount => _sampleBackend?.ReleaseCount ?? 0;
public bool IsShutdown => _shutdown;
public Task StartupTask { get; private set; }
private async void Awake()
private void Awake()
{
_lifetime = new CancellationTokenSource();
await StartupAsync(_lifetime.Token);
StartupTask = RunStartupWithLoggingAsync(_lifetime.Token);
}
public async Task StartupAsync(CancellationToken cancellationToken)
@@ -97,13 +99,20 @@ namespace FlowScope.Samples.MainMenuP0
_root.RegisterInstance<IUIPreloadService>(_preloadService);
_root.RegisterInstance<IUIScreenNavigator>(screenNavigator);
_root.RegisterInstance(_playerData);
_root.RegisterFactory(_ => new MainMenuFeature(screenNavigator, _playerData));
_root.RegisterTransient(_ => new MainMenuFeature(screenNavigator, _playerData));
await _gameFlow.StartupAsync<MainMenuFeature>(_root, cancellationToken);
}
public async Task ShutdownAsync(CancellationToken cancellationToken)
{
if (_shutdownStarted)
{
return;
}
_shutdownStarted = true;
if (_saveService != null && _playerData != null)
{
await _saveService.SaveAsync(SaveKey, _playerData, cancellationToken);
@@ -118,10 +127,10 @@ namespace FlowScope.Samples.MainMenuP0
_shutdown = true;
}
private async void OnApplicationQuit()
private void OnApplicationQuit()
{
_lifetime?.Cancel();
await ShutdownAsync(CancellationToken.None);
_ = RunShutdownWithLoggingAsync(CancellationToken.None);
}
private void OnDestroy()
@@ -130,6 +139,31 @@ namespace FlowScope.Samples.MainMenuP0
_lifetime?.Dispose();
}
private async Task RunStartupWithLoggingAsync(CancellationToken cancellationToken)
{
try
{
await StartupAsync(cancellationToken);
}
catch (Exception exception)
{
Debug.LogException(exception);
throw;
}
}
private async Task RunShutdownWithLoggingAsync(CancellationToken cancellationToken)
{
try
{
await ShutdownAsync(cancellationToken);
}
catch (Exception exception)
{
Debug.LogException(exception);
}
}
private Canvas ResolveHudCanvas()
{
if (hudCanvas != null)

View File

@@ -44,6 +44,71 @@ namespace FlowScope.Tests.EditMode.Container
CollectionAssert.AreEqual(new[] { "second", "first" }, disposeOrder);
}
[Test]
public void RegisterSingletonFactory_Resolve_CreatesOnceAndIsOwnedByRegistrationScope()
{
var disposeOrder = new List<string>();
var parent = new FlowScope.Container.Container();
parent.RegisterSingletonFactory<IService>(_ => new DisposableNamedService("singleton", disposeOrder));
var child = parent.CreateScope();
var first = child.Resolve<IService>();
var second = child.Resolve<IService>();
Assert.AreSame(first, second);
child.Dispose();
CollectionAssert.IsEmpty(disposeOrder);
parent.Dispose();
CollectionAssert.AreEqual(new[] { "singleton" }, disposeOrder);
}
[Test]
public void RegisterTransient_Resolve_CreatesNewInstanceOwnedByRequestScope()
{
var disposeOrder = new List<string>();
var parent = new FlowScope.Container.Container();
parent.RegisterTransient<IService>(_ => new DisposableNamedService("transient", disposeOrder));
var child = parent.CreateScope();
var first = child.Resolve<IService>();
var second = child.Resolve<IService>();
Assert.AreNotSame(first, second);
child.Dispose();
CollectionAssert.AreEqual(new[] { "transient", "transient" }, disposeOrder);
}
[Test]
public void RegisterScoped_Resolve_CreatesOneInstancePerRequestScope()
{
var disposeOrder = new List<string>();
var parent = new FlowScope.Container.Container();
parent.RegisterScoped<IService>(_ => new DisposableNamedService("scoped", disposeOrder));
var firstChild = parent.CreateScope();
var secondChild = parent.CreateScope();
var firstA = firstChild.Resolve<IService>();
var firstB = firstChild.Resolve<IService>();
var second = secondChild.Resolve<IService>();
Assert.AreSame(firstA, firstB);
Assert.AreNotSame(firstA, second);
firstChild.Dispose();
CollectionAssert.AreEqual(new[] { "scoped" }, disposeOrder);
secondChild.Dispose();
CollectionAssert.AreEqual(new[] { "scoped", "scoped" }, disposeOrder);
}
[Test]
public void CreateScope_Resolve_ReadsParentAndCanOverrideWithoutPollutingParent()
{
@@ -152,6 +217,9 @@ namespace FlowScope.Tests.EditMode.Container
Assert.Throws<ObjectDisposedException>(() => container.RegisterInstance<IService>(new NamedService("late")));
Assert.Throws<ObjectDisposedException>(() => container.RegisterFactory<IService>(_ => new NamedService("late")));
Assert.Throws<ObjectDisposedException>(() => container.RegisterSingletonFactory<IService>(_ => new NamedService("late")));
Assert.Throws<ObjectDisposedException>(() => container.RegisterTransient<IService>(_ => new NamedService("late")));
Assert.Throws<ObjectDisposedException>(() => container.RegisterScoped<IService>(_ => new NamedService("late")));
Assert.Throws<ObjectDisposedException>(() => container.Resolve<IService>());
Assert.Throws<ObjectDisposedException>(() => container.CreateScope());
}
@@ -174,6 +242,24 @@ namespace FlowScope.Tests.EditMode.Container
public void Dispose() => IsDisposed = true;
}
private sealed class DisposableNamedService : IService, IDisposable
{
private readonly List<string> _disposeOrder;
public DisposableNamedService(string name, List<string> disposeOrder)
{
Name = name;
_disposeOrder = disposeOrder;
}
public string Name { get; }
public void Dispose()
{
_disposeOrder.Add(Name);
}
}
private sealed class FirstDisposable : IDisposable
{
private readonly List<string> _disposeOrder;

View File

@@ -61,6 +61,25 @@ namespace FlowScope.Tests.EditMode.Flow
Assert.That(SecondFeature.Instance.Context.Resources, Is.SameAs(services.Resource.Groups[1]));
}
[Test]
public void SwitchToAsync_WhenReturningToFeatureType_CreatesNewFeatureInstance()
{
var services = new TestServices();
var root = services.CreateRoot();
var flow = new GameFlow();
flow.StartupAsync<FirstFeature>(root, CancellationToken.None).GetAwaiter().GetResult();
var firstEntry = FirstFeature.Instance;
flow.SwitchToAsync<SecondFeature>(CancellationToken.None).GetAwaiter().GetResult();
flow.SwitchToAsync<FirstFeature>(CancellationToken.None).GetAwaiter().GetResult();
Assert.That(FirstFeature.Instance, Is.Not.SameAs(firstEntry));
Assert.That(firstEntry.Calls, Is.EqualTo(new[] { "Load", "Enter", "Exit", "Dispose" }));
Assert.That(FirstFeature.Instance.Calls, Is.EqualTo(new[] { "Load", "Enter" }));
Assert.That(services.Resource.Groups[0].IsDisposed, Is.True);
Assert.That(services.Resource.Groups[2].IsDisposed, Is.False);
}
[Test]
public void StartupAsync_WhenFeatureLoadFails_CleansCreatedFeatureAndReturnsIdle()
{
@@ -114,7 +133,6 @@ namespace FlowScope.Tests.EditMode.Flow
flow.StartupAsync<ThrowingExitAndDisposeFeature>(root, CancellationToken.None).GetAwaiter().GetResult();
LogAssert.Expect(LogType.Error, new Regex("System\\.InvalidOperationException: exit failed"));
LogAssert.Expect(LogType.Error, new Regex("System\\.InvalidOperationException: dispose failed"));
LogAssert.Expect(LogType.Error, new Regex("System\\.AggregateException: One or more container-owned instances failed to dispose\\. \\(dispose failed\\)"));
flow.ShutdownAsync(CancellationToken.None).GetAwaiter().GetResult();
@@ -168,11 +186,11 @@ namespace FlowScope.Tests.EditMode.Flow
root.RegisterInstance<IConfigProvider>(Config);
root.RegisterInstance<ISaveService>(Save);
root.RegisterInstance<IResourceService>(Resource);
root.RegisterFactory(_ => new FirstFeature());
root.RegisterFactory(_ => new SecondFeature());
root.RegisterFactory(_ => new FailingLoadFeature());
root.RegisterFactory(_ => new FailingEnterFeature());
root.RegisterFactory(_ => new ThrowingExitAndDisposeFeature());
root.RegisterTransient(_ => new FirstFeature());
root.RegisterTransient(_ => new SecondFeature());
root.RegisterTransient(_ => new FailingLoadFeature());
root.RegisterTransient(_ => new FailingEnterFeature());
root.RegisterTransient(_ => new ThrowingExitAndDisposeFeature());
return root;
}
}

View File

@@ -52,6 +52,50 @@ namespace FlowScope.Tests.EditMode.Resources
}
}
[UnityTest]
public IEnumerator LoadAsync_WhenOneSharedWaiterCancels_OtherWaiterReceivesHandle()
{
var backend = new FakeResourceBackend();
var service = new ResourceService(backend);
using var cts = new CancellationTokenSource();
var canceled = service.LoadAsync<TestAsset>("shared", cts.Token);
var retained = service.LoadAsync<TestAsset>("shared", CancellationToken.None);
cts.Cancel();
backend.Gate.SetResult(new TestAsset());
yield return AwaitExpected<OperationCanceledException>(canceled);
yield return Await(retained);
using var handle = Result(retained);
Assert.AreEqual(1, backend.LoadCalls);
Assert.IsNotNull(handle.Asset);
Assert.IsTrue(service.TryLoadCompleted<TestAsset>("shared", out var cached));
cached.Dispose();
}
[UnityTest]
public IEnumerator LoadAsync_WhenAllSharedWaitersCancel_ReleasesCompletedBackendAssetWithoutCaching()
{
var backend = new FakeResourceBackend();
var service = new ResourceService(backend);
using var firstCts = new CancellationTokenSource();
using var secondCts = new CancellationTokenSource();
var first = service.LoadAsync<TestAsset>("shared", firstCts.Token);
var second = service.LoadAsync<TestAsset>("shared", secondCts.Token);
firstCts.Cancel();
secondCts.Cancel();
backend.Gate.SetResult(new TestAsset());
yield return AwaitExpected<OperationCanceledException>(first);
yield return AwaitExpected<OperationCanceledException>(second);
Assert.AreEqual(1, backend.LoadCalls);
Assert.AreEqual(1, backend.ReleaseCalls);
Assert.IsFalse(service.TryLoadCompleted<TestAsset>("shared", out _));
}
[UnityTest]
public IEnumerator DisposeLastHandle_ReleasesBackendAsset()
{

View File

@@ -49,6 +49,26 @@ namespace FlowScope.Tests.PlayMode.Samples
LogAssert.NoUnexpectedReceived();
}
[UnityTest]
public IEnumerator Awake_StartsThroughObservableStartupTask()
{
DeleteSampleSave();
var root = new GameObject("bootstrap-awake-test");
var bootstrap = root.AddComponent<GameBootstrap>();
Assert.That(bootstrap.StartupTask, Is.Not.Null);
yield return AwaitTask(bootstrap.StartupTask);
var panel = Object.FindObjectOfType<MainMenuPanel>(true);
Assert.That(panel, Is.Not.Null);
Assert.That(panel.gameObject.activeSelf, Is.True);
yield return AwaitTask(bootstrap.ShutdownAsync(CancellationToken.None));
Object.DestroyImmediate(root);
DeleteSampleSave();
LogAssert.NoUnexpectedReceived();
}
[UnityTest]
public IEnumerator ClickIncrement_UpdatesGoldTextImmediately()
{

View File

@@ -1,6 +1,7 @@
using System.Collections;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using FlowScope.UI;
using NUnit.Framework;
using UnityEngine;
@@ -58,6 +59,27 @@ namespace FlowScope.Tests.PlayMode.UI
Assert.That(resources.Handles, Has.Count.EqualTo(1));
}
[UnityTest]
public IEnumerator PreloadAsync_WhenSamePanelLoadsConcurrently_UsesOneResourceLoad()
{
var prefab = CreatePrefab<ExplicitPathPanel>("ExplicitPathPrefab");
var resources = new GatedUITestResources("Custom/Explicit", prefab);
var preload = new UIPreloadService(resources);
var first = preload.PreloadAsync<ExplicitPathPanel>(CancellationToken.None);
var second = preload.PreloadAsync<ExplicitPathPanel>(CancellationToken.None);
Assert.That(resources.LoadCalls, Is.EqualTo(1));
resources.Complete();
yield return UITestAsync.Await(first);
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 OpenAsync_WhenPanelIsPreloaded_ReusesPreloadedHandle()
{
@@ -111,5 +133,52 @@ namespace FlowScope.Tests.PlayMode.UI
gameObject.AddComponent<TPanel>();
return gameObject;
}
private sealed class GatedUITestResources : FlowScope.Resources.IResourceService
{
private readonly string _key;
private readonly GameObject _asset;
private readonly TaskCompletionSource<FlowScope.Resources.IResourceHandle<GameObject>> _gate =
new(TaskCreationOptions.RunContinuationsAsynchronously);
public GatedUITestResources(string key, GameObject asset)
{
_key = key;
_asset = asset;
Handle = new UITestResourceHandle<GameObject>(key, asset);
}
public int LoadCalls { get; private set; }
public UITestResourceHandle<GameObject> Handle { get; }
public Task<FlowScope.Resources.IResourceHandle<T>> LoadAsync<T>(
string key,
CancellationToken cancellationToken)
where T : class
{
cancellationToken.ThrowIfCancellationRequested();
LoadCalls++;
Assert.That(key, Is.EqualTo(_key));
return AwaitHandle<T>();
}
public FlowScope.Resources.IResourceGroup CreateGroup()
{
throw new System.NotSupportedException();
}
public void Complete()
{
_gate.SetResult(Handle);
}
private async Task<FlowScope.Resources.IResourceHandle<T>> AwaitHandle<T>()
where T : class
{
var handle = await _gate.Task;
Assert.That(handle.Asset, Is.SameAs(_asset));
return (FlowScope.Resources.IResourceHandle<T>)(object)handle;
}
}
}
}