合并 P0 生命周期编排

# Conflicts:
#	My project/Assets/FlowScope/Tests.meta
#	My project/Assets/FlowScope/Tests/EditMode.meta
This commit is contained in:
JSD\13999
2026-05-18 12:05:22 +08:00
9 changed files with 775 additions and 0 deletions

View File

@@ -0,0 +1,31 @@
using System.Threading.Tasks;
namespace FlowScope.Flow
{
public abstract class FeatureBase : IFeature
{
protected FeatureContext Context { get; private set; }
public virtual Task LoadAsync(FeatureContext context)
{
Context = context;
return Task.CompletedTask;
}
public virtual Task EnterAsync(FeatureContext context)
{
return Task.CompletedTask;
}
public virtual Task ExitAsync(FeatureContext context)
{
context?.Disposables.Clear();
return Task.CompletedTask;
}
public virtual void Dispose()
{
Context = null;
}
}
}

View File

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

View File

@@ -0,0 +1,285 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using FlowScope.Config;
using FlowScope.Resources;
using R3;
using UnityEngine;
using ContainerType = FlowScope.Container.Container;
namespace FlowScope.Flow
{
public sealed class GameFlow
{
private ContainerType _root;
private IFeature _activeFeature;
private FeatureContext _activeContext;
public GameFlowState State { get; private set; } = GameFlowState.Idle;
public async Task StartupAsync<TInitialFeature>(
ContainerType root,
CancellationToken cancellationToken)
where TInitialFeature : IFeature
{
EnsureState(nameof(StartupAsync), GameFlowState.Idle);
_root = root ?? throw new ArgumentNullException(nameof(root));
State = GameFlowState.Starting;
IFeature feature = null;
FeatureContext context = null;
try
{
var config = _root.Resolve<IConfigProvider>();
await config.LoadAllAsync(cancellationToken);
(feature, context) = CreateFeature<TInitialFeature>(cancellationToken);
await feature.LoadAsync(context);
await feature.EnterAsync(context);
_activeFeature = feature;
_activeContext = context;
State = GameFlowState.Running;
}
catch
{
BestEffortDisposeFeature(feature);
BestEffortDisposeContext(context);
_activeFeature = null;
_activeContext = null;
State = GameFlowState.Idle;
throw;
}
}
public async Task SwitchToAsync<TFeature>(CancellationToken cancellationToken)
where TFeature : IFeature
{
EnsureState(
nameof(SwitchToAsync),
GameFlowState.Running,
GameFlowState.NoActiveFeature);
State = GameFlowState.Switching;
await CleanupActiveFeatureAsync();
IFeature nextFeature = null;
FeatureContext nextContext = null;
try
{
(nextFeature, nextContext) = CreateFeature<TFeature>(cancellationToken);
await nextFeature.LoadAsync(nextContext);
await nextFeature.EnterAsync(nextContext);
_activeFeature = nextFeature;
_activeContext = nextContext;
State = GameFlowState.Running;
}
catch
{
BestEffortDisposeFeature(nextFeature);
BestEffortDisposeContext(nextContext);
_activeFeature = null;
_activeContext = null;
State = GameFlowState.NoActiveFeature;
throw;
}
}
public async Task ShutdownAsync(CancellationToken cancellationToken)
{
EnsureState(
nameof(ShutdownAsync),
GameFlowState.Running,
GameFlowState.NoActiveFeature);
State = GameFlowState.ShuttingDown;
Exception cancellationException = null;
try
{
cancellationToken.ThrowIfCancellationRequested();
}
catch (OperationCanceledException exception)
{
cancellationException = exception;
}
await CleanupActiveFeatureAsync();
BestEffortDisposeRoot();
State = GameFlowState.Disposed;
if (cancellationException != null)
{
throw cancellationException;
}
}
private (IFeature Feature, FeatureContext Context) CreateFeature<TFeature>(
CancellationToken cancellationToken)
where TFeature : IFeature
{
ContainerType scope = null;
IResourceGroup resources = null;
FeatureContext context = null;
try
{
scope = _root.CreateScope();
var resourceService = _root.Resolve<IResourceService>();
resources = resourceService.CreateGroup();
context = new FeatureContext(
scope,
resources,
new CompositeDisposable(),
cancellationToken);
var feature = scope.Resolve<TFeature>();
return (feature, context);
}
catch
{
if (context != null)
{
BestEffortDisposeContext(context);
}
else
{
BestEffortDisposePartialContext(resources, scope);
}
throw;
}
}
private async Task CleanupActiveFeatureAsync()
{
var feature = _activeFeature;
var context = _activeContext;
_activeFeature = null;
_activeContext = null;
if (feature != null)
{
try
{
await feature.ExitAsync(context);
}
catch (Exception exception)
{
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)
{
return;
}
try
{
context.Disposables.Clear();
}
catch (Exception exception)
{
Debug.LogError(exception);
}
try
{
context.Resources?.Dispose();
}
catch (Exception exception)
{
Debug.LogError(exception);
}
try
{
context.Scope?.Dispose();
}
catch (Exception exception)
{
Debug.LogError(exception);
}
}
private void BestEffortDisposePartialContext(
IResourceGroup resources,
ContainerType scope)
{
try
{
resources?.Dispose();
}
catch (Exception exception)
{
Debug.LogError(exception);
}
try
{
scope?.Dispose();
}
catch (Exception exception)
{
Debug.LogError(exception);
}
}
private void BestEffortDisposeRoot()
{
try
{
_root?.Dispose();
}
catch (Exception exception)
{
Debug.LogError(exception);
}
finally
{
_root = null;
}
}
private void EnsureState(string operation, params GameFlowState[] allowedStates)
{
foreach (var allowedState in allowedStates)
{
if (State == allowedState)
{
return;
}
}
throw new InvalidOperationException(
$"{operation} cannot run while GameFlow state is {State}.");
}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,89 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using FlowScope.Container;
using FlowScope.Flow;
using FlowScope.Resources;
using NUnit.Framework;
using R3;
namespace FlowScope.Tests.EditMode.Flow
{
public sealed class FeatureBaseTests
{
[Test]
public async Task LoadAsync_StoresContext()
{
var context = CreateContext();
var feature = new TestFeature();
await feature.LoadAsync(context);
Assert.That(feature.ExposedContext, Is.SameAs(context));
}
[Test]
public async Task ExitAsync_ClearsContextDisposables()
{
var context = CreateContext();
var disposable = new SpyDisposable();
context.Disposables.Add(disposable);
var feature = new TestFeature();
await feature.ExitAsync(context);
Assert.That(disposable.IsDisposed, Is.True);
}
[Test]
public void Dispose_IsIdempotentAndClearsContextReference()
{
var context = CreateContext();
var feature = new TestFeature();
feature.LoadAsync(context).GetAwaiter().GetResult();
feature.Dispose();
feature.Dispose();
Assert.That(feature.ExposedContext, Is.Null);
}
private static FeatureContext CreateContext()
{
return new FeatureContext(
new Container(),
new TestResourceGroup(),
new CompositeDisposable(),
CancellationToken.None);
}
private sealed class TestFeature : FeatureBase
{
public FeatureContext ExposedContext => Context;
}
private sealed class SpyDisposable : IDisposable
{
public bool IsDisposed { get; private set; }
public void Dispose()
{
IsDisposed = true;
}
}
private sealed class TestResourceGroup : IResourceGroup
{
public int Count => 0;
public void Add<T>(IResourceHandle<T> handle)
where T : class
{
}
public void Dispose()
{
}
}
}
}

View File

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

View File

@@ -0,0 +1,318 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using FlowScope.Config;
using FlowScope.Container;
using FlowScope.Flow;
using FlowScope.Resources;
using FlowScope.Save;
using NUnit.Framework;
namespace FlowScope.Tests.EditMode.Flow
{
public sealed class GameFlowTests
{
[SetUp]
public void SetUp()
{
FirstFeature.Instance = null;
SecondFeature.Instance = null;
FailingLoadFeature.Instance = null;
FailingEnterFeature.Instance = null;
ThrowingExitAndDisposeFeature.Instance = null;
}
[Test]
public async Task StartupAsync_LoadsConfigCreatesContextAndEntersInitialFeature()
{
var services = new TestServices();
var root = services.CreateRoot();
using var cts = new CancellationTokenSource();
var flow = new GameFlow();
await flow.StartupAsync<FirstFeature>(root, cts.Token);
Assert.That(flow.State, Is.EqualTo(GameFlowState.Running));
Assert.That(services.Config.LoadCalls, Is.EqualTo(1));
Assert.That(FirstFeature.Instance.Calls, Is.EqualTo(new[] { "Load", "Enter" }));
Assert.That(FirstFeature.Instance.Context.CancellationToken, Is.EqualTo(cts.Token));
Assert.That(FirstFeature.Instance.Context.Scope, Is.Not.SameAs(root));
Assert.That(FirstFeature.Instance.Context.Resources, Is.SameAs(services.Resource.Groups[0]));
}
[Test]
public async Task SwitchToAsync_ExitsDisposesAndReleasesOldContextBeforeEnteringNextFeature()
{
var services = new TestServices();
var root = services.CreateRoot();
var flow = new GameFlow();
await flow.StartupAsync<FirstFeature>(root, CancellationToken.None);
await flow.SwitchToAsync<SecondFeature>(CancellationToken.None);
Assert.That(flow.State, Is.EqualTo(GameFlowState.Running));
Assert.That(FirstFeature.Instance.Calls, Is.EqualTo(new[] { "Load", "Enter", "Exit", "Dispose" }));
Assert.That(services.Resource.Groups[0].IsDisposed, Is.True);
Assert.That(SecondFeature.Instance.Calls, Is.EqualTo(new[] { "Load", "Enter" }));
Assert.That(SecondFeature.Instance.Context.Resources, Is.SameAs(services.Resource.Groups[1]));
}
[Test]
public void StartupAsync_WhenFeatureLoadFails_CleansCreatedFeatureAndReturnsIdle()
{
var services = new TestServices();
var root = services.CreateRoot();
var flow = new GameFlow();
Assert.ThrowsAsync<InvalidOperationException>(
async () => await flow.StartupAsync<FailingLoadFeature>(root, CancellationToken.None));
Assert.That(flow.State, Is.EqualTo(GameFlowState.Idle));
Assert.That(FailingLoadFeature.Instance.Calls, Is.EqualTo(new[] { "Load", "Dispose" }));
Assert.That(services.Resource.Groups[0].IsDisposed, Is.True);
}
[Test]
public async Task SwitchToAsync_WhenNextEnterFails_CleansNextFeatureAndLeavesNoActiveFeature()
{
var services = new TestServices();
var root = services.CreateRoot();
var flow = new GameFlow();
await flow.StartupAsync<FirstFeature>(root, CancellationToken.None);
Assert.ThrowsAsync<InvalidOperationException>(
async () => await flow.SwitchToAsync<FailingEnterFeature>(CancellationToken.None));
Assert.That(flow.State, Is.EqualTo(GameFlowState.NoActiveFeature));
Assert.That(FirstFeature.Instance.Calls, Does.Contain("Dispose"));
Assert.That(FailingEnterFeature.Instance.Calls, Is.EqualTo(new[] { "Load", "Enter", "Dispose" }));
Assert.That(services.Resource.Groups[1].IsDisposed, Is.True);
}
[Test]
public void SwitchToAsync_WhenIdle_ThrowsWithOperationAndCurrentState()
{
var flow = new GameFlow();
var exception = Assert.ThrowsAsync<InvalidOperationException>(
async () => await flow.SwitchToAsync<FirstFeature>(CancellationToken.None));
Assert.That(exception.Message, Does.Contain(nameof(GameFlow.SwitchToAsync)));
Assert.That(exception.Message, Does.Contain(GameFlowState.Idle.ToString()));
}
[Test]
public async Task ShutdownAsync_WhenFeatureCleanupThrows_StillReachesDisposed()
{
var services = new TestServices();
var root = services.CreateRoot();
var flow = new GameFlow();
await flow.StartupAsync<ThrowingExitAndDisposeFeature>(root, CancellationToken.None);
await flow.ShutdownAsync(CancellationToken.None);
Assert.That(flow.State, Is.EqualTo(GameFlowState.Disposed));
Assert.That(services.Resource.Groups[0].IsDisposed, Is.True);
}
[Test]
public async Task ShutdownAsync_WhenCancellationIsAlreadyRequested_CleansUpThenThrowsCancellation()
{
var services = new TestServices();
var root = services.CreateRoot();
var flow = new GameFlow();
await flow.StartupAsync<FirstFeature>(root, CancellationToken.None);
using var cts = new CancellationTokenSource();
cts.Cancel();
Assert.ThrowsAsync<OperationCanceledException>(
async () => await flow.ShutdownAsync(cts.Token));
Assert.That(flow.State, Is.EqualTo(GameFlowState.Disposed));
Assert.That(FirstFeature.Instance.Calls, Does.Contain("Dispose"));
Assert.That(services.Resource.Groups[0].IsDisposed, Is.True);
}
private sealed class TestServices
{
public TestConfigProvider Config { get; } = new();
public TestSaveService Save { get; } = new();
public TestResourceService Resource { get; } = new();
public Container CreateRoot()
{
var root = new Container();
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());
return root;
}
}
private sealed class TestConfigProvider : IConfigProvider
{
public int LoadCalls { get; private set; }
public Task LoadAllAsync(CancellationToken cancellationToken)
{
LoadCalls++;
return Task.CompletedTask;
}
public T Get<T>(int id)
where T : class, IConfigRow => null;
public IReadOnlyList<T> GetAll<T>()
where T : class, IConfigRow => Array.Empty<T>();
}
private sealed class TestSaveService : ISaveService
{
public Task SaveAsync<T>(string key, T data, CancellationToken cancellationToken) => Task.CompletedTask;
public Task<T> LoadAsync<T>(string key, T defaultValue, CancellationToken cancellationToken) => Task.FromResult(defaultValue);
public void Delete(string key) { }
public bool Exists(string key) => false;
}
private sealed class TestResourceService : IResourceService
{
public List<TestResourceGroup> Groups { get; } = new();
public Task<IResourceHandle<T>> LoadAsync<T>(string key, CancellationToken cancellationToken)
where T : class => Task.FromResult<IResourceHandle<T>>(null);
public IResourceGroup CreateGroup()
{
var group = new TestResourceGroup();
Groups.Add(group);
return group;
}
}
private sealed class TestResourceGroup : IResourceGroup
{
public bool IsDisposed { get; private set; }
public int Count => 0;
public void Add<T>(IResourceHandle<T> handle)
where T : class
{
}
public void Dispose()
{
IsDisposed = true;
}
}
private abstract class RecordingFeature : IFeature
{
public List<string> Calls { get; } = new();
public FeatureContext Context { get; private set; }
public virtual Task LoadAsync(FeatureContext context)
{
Context = context;
Calls.Add("Load");
return Task.CompletedTask;
}
public virtual Task EnterAsync(FeatureContext context)
{
Calls.Add("Enter");
return Task.CompletedTask;
}
public virtual Task ExitAsync(FeatureContext context)
{
Calls.Add("Exit");
return Task.CompletedTask;
}
public virtual void Dispose()
{
Calls.Add("Dispose");
}
}
private sealed class FirstFeature : RecordingFeature
{
public static FirstFeature Instance { get; set; }
public FirstFeature()
{
Instance = this;
}
}
private sealed class SecondFeature : RecordingFeature
{
public static SecondFeature Instance { get; set; }
public SecondFeature()
{
Instance = this;
}
}
private sealed class FailingLoadFeature : RecordingFeature
{
public static FailingLoadFeature Instance { get; set; }
public FailingLoadFeature()
{
Instance = this;
}
public override Task LoadAsync(FeatureContext context)
{
base.LoadAsync(context);
throw new InvalidOperationException("load failed");
}
}
private sealed class FailingEnterFeature : RecordingFeature
{
public static FailingEnterFeature Instance { get; set; }
public FailingEnterFeature()
{
Instance = this;
}
public override Task EnterAsync(FeatureContext context)
{
base.EnterAsync(context);
throw new InvalidOperationException("enter failed");
}
}
private sealed class ThrowingExitAndDisposeFeature : RecordingFeature
{
public static ThrowingExitAndDisposeFeature Instance { get; set; }
public ThrowingExitAndDisposeFeature()
{
Instance = this;
}
public override Task ExitAsync(FeatureContext context)
{
base.ExitAsync(context);
throw new InvalidOperationException("exit failed");
}
public override void Dispose()
{
base.Dispose();
throw new InvalidOperationException("dispose failed");
}
}
}
}

View File

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