完成 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;
}
}
}
}

View File

@@ -2,15 +2,15 @@
## Bootstrap
Bootstrap 负责组合运行时依赖,而不是把业务流程写进场景脚本。推荐顺序是创建根 `Container`、注册 Config/Resource/Save/UI/Audio 等服务、注册首个 Feature 的工厂,最后调用 `GameFlow.StartupAsync<TInitialFeature>`
Bootstrap 负责组合运行时依赖,而不是把业务流程写进场景脚本。推荐顺序是创建根 `Container`、注册 Config/Resource/Save/UI/Audio 等服务、用 transient/scoped 生命周期注册首个 Feature最后调用 `GameFlow.StartupAsync<TInitialFeature>`
Bootstrap 必须持有生命周期 `CancellationToken`,退出时先保存必要状态,再调用 `GameFlow.ShutdownAsync`,最后释放预加载资源、音频句柄或其他根级缓存。
Bootstrap 必须持有生命周期 `CancellationToken`,退出时先保存必要状态,再调用 `GameFlow.ShutdownAsync`,最后释放预加载资源、音频句柄或其他根级缓存。样例启动入口应暴露可观察的 `Task` 并统一记录异常,避免在 `Awake` / `OnApplicationQuit` 中直接使用 `async void`
## Container 注册规则
容器注册应显式、可读、可追踪。稳定服务使用 `RegisterInstance`,带运行时参数或 Feature 私有依赖使用 `RegisterFactory`。不要依赖类型扫描或名称约定来猜测注册关系
容器注册应显式、可读、可追踪。稳定外部实例使用 `RegisterInstance`;根级懒加载单例使用 `RegisterSingletonFactory`Feature、ViewModel、短生命周期服务使用 `RegisterTransient``RegisterScoped``RegisterFactory` 仅作为旧式 lazy singleton 兼容入口,不要用于 Feature 或临时业务对象
Feature 只从自己的 scope 解析依赖。跨 Feature 的共享服务放在 root containerFeature 内部临时对象放在 Feature scope并随 `FeatureContext` 一起释放。
Feature 只从自己的 scope 解析依赖。跨 Feature 的共享服务放在 root containerFeature 内部临时对象放在 Feature scope并随 `FeatureContext` 一起释放。父容器中的 transient/scoped 注册被子 scope 解析时,由请求的子 scope 持有和释放singleton 仍由注册所在 scope 持有。
## Feature 生命周期

View File

@@ -33,6 +33,6 @@ P1 在 P0 核心接口稳定后推进,目标是补齐第一批生产化扩展
## 当前验收状态
代码级编译验证中,`FlowScope.Runtime``FlowScope.Tests.EditMode` 可通过。`FlowScope.Tests.PlayMode` 在排除 Addressables 适配测试后可通过
代码级编译验证中,`FlowScope.Runtime``FlowScope.Tests.EditMode` `FlowScope.Tests.PlayMode` 均可通过;仍存在 Unity 生成项目的引用版本冲突 warning
完整 PlayMode 与 Unity Test Runner 验收仍依赖 Unity 完成 `com.unity.addressables` 包解析;当前机器上主工程被其他 Unity 实例占用,且 `Library/PackageCache` 尚未出现 Addressables 包,因此本轮不能标记为完整人工验收通过
`packages-lock.json` 已包含 Addressables 相关包,旧的 PlayMode 编译阻塞已解除。完整 Unity Test Runner 与 MainMenuP0 人工场景验收仍需要单独补跑,不能仅用 `dotnet build` 替代

View File

@@ -9,7 +9,7 @@
FlowScope 当前已经具备一个轻量游戏框架的基本形态,适合用于小型项目、原型项目或作为后续框架演进的 P0 基座。它的优势在于分层清晰、抽象克制、测试意识较好,并且围绕 Unity 常见横切能力建立了可替换接口。
当前不建议直接定义为稳定生产框架。主要原因是资源异步、运行时对象销毁、Addressables 依赖方式和自研 JSON 转换器仍存在生产风险。这些问题不影响它作为框架雏形继续推进,但应作为进入生产复用前的前置整改项。
当前不建议直接定义为稳定生产框架。主要原因是运行时对象销毁、自研 JSON 转换器、AOT/linker 验证和真实 Unity Test Runner 验收仍存在生产风险。这些问题不影响它作为框架雏形继续推进,但应作为进入生产复用前的前置整改项。
## 2. 技术基线
@@ -57,7 +57,7 @@ FlowScope 当前已经具备一个轻量游戏框架的基本形态,适合用
代表文件:`Runtime/Container/Container.cs`
自研容器实现轻量,支持实例注册、工厂注册、作用域、父子容器、循环依赖检测和拥有对象释放。
自研容器实现轻量,支持实例注册、singleton factory、scoped、transient、父子容器、循环依赖检测和拥有对象释放。
优点:
@@ -70,30 +70,29 @@ FlowScope 当前已经具备一个轻量游戏框架的基本形态,适合用
- `GeneratedFactories` 需要外部注册,长期需要配套生成器或明确手写规范。
- 反射工厂适合开发期,但移动端/AOT 环境需要持续验证。
- 当前没有生命周期枚举,例如 transient、scoped、singleton实际行为需要文档明确
- `RegisterFactory` 仍保留为旧式 lazy singleton 兼容入口,需要避免在新代码中用于 Feature/ViewModel/短生命周期对象
评价P0 阶段可接受。后续重点不是扩大能力,而是写清注册规范和 AOT 策略。
评价P0/P1 阶段可接受。后续重点不是扩大能力,而是保持生命周期语义稳定、写清注册规范和 AOT 策略。
### 3.3 Resources
代表文件:`Runtime/Resources/AddressablesResourceService.cs`
代表文件:`Runtime/Resources/ResourceService.cs``Addressables/AddressablesResourceBackend.cs`
资源层抽象了 `IResourceService``IResourceHandle<T>``IResourceGroup`,并实现了 Addressables 加载、引用计数和分组释放
资源层抽象了 `IResourceService``IResourceHandle<T>``IResourceGroup``IResourceBackend`Runtime 核心负责共享加载、引用计数和分组释放Addressables 适配位于独立 asmdef
优点:
- 使用句柄释放资源,方向正确。
- 支持同 key 同类型加载去重。
- `ResourceGroup` 适合跟随 Feature 生命周期批量释放。
- PlayMode 测试覆盖了复用、引用计数取消等路径。
- EditMode / PlayMode 测试覆盖了复用、引用计数取消等待和完成后释放等路径。
风险:
- Addressables 通过字符串反射调用,编译期无法发现 API 变化
- Runtime asmdef 没有显式引用 Addressables当前设计牺牲了类型安全
- 取消等待后底层 Addressables 任务可能仍继续执行,后续需要确认资源完成后的释放策略。
- Addressables 真实 load/release 仍缺少最小集成测试资源
- 取消等待后底层共享加载仍会继续执行,这是设计选择;需要继续用测试保护“所有等待者取消后完成即释放”的语义
评价:抽象方向正确, Addressables 适配层应从 Runtime 核心拆出,改为显式依赖的独立 asmdef
评价:抽象方向正确,Runtime 核心与 Addressables 适配层已经拆开;下一步重点是真实 Addressables 集成验收
### 3.4 UI
@@ -130,11 +129,11 @@ UI 模块基于 `UIPanelAttribute` 标记层级和路径,通过 `UIManager`
风险:
- `LoadClip` 内部使用 `.GetAwaiter().GetResult()` 同步等待异步资源加载,在 Unity 主线程和 Addressables 场景下存在卡顿或死锁风险
- 同步播放 API 只适合读取已经预加载或已完成的资源;调用者如果误用未预加载资源,会得到明确异常而不是阻塞等待
- 音频服务是 `MonoBehaviour`,但依赖通过 `Initialize` 注入,需要文档明确创建流程。
- 当前播放接口是同步方法,与异步资源系统存在模型冲突
- 新业务仍应优先使用 `PlayBgmAsync` / `PlaySfxAsync`,避免把同步 API 当作资源加载入口
评价:功能够用,但异步资源加载方式必须调整后才适合生产复用
评价:功能够用,旧的同步阻塞风险已降低;生产复用前仍需继续明确同步 API 的使用边界
### 3.6 Save / Config / Data
@@ -183,12 +182,11 @@ UI 模块基于 `UIPanelAttribute` 标记层级和路径,通过 `UIManager`
| 优先级 | 风险 | 影响 | 建议 |
| --- | --- | --- | --- |
| P0 | `AudioService` 同步等待异步资源 | 可能导致主线程卡顿或死锁 | 改为异步播放或预加载机制 |
| P0 | Runtime 使用 `DestroyImmediate` | 运行时生命周期不稳 | 改为 `Destroy`,测试按帧等待 |
| P1 | Addressables 反射调用 | 类型安全弱,升级风险高 | 拆独立 Addressables 适配 asmdef |
| P1 | Addressables 真实集成测试不足 | 适配层编译通过不等于真实 load/release 验收通过 | 增加最小 Addressables 测试资源或人工验收步骤 |
| P1 | 自研 JSON 转换器 | 边界和维护成本高 | 明确 DTO 限制或接入成熟 JSON 库 |
| P1 | `GameFlow` 固定加载配置 | 启动流程不够弹性 | 配置加载移到 Bootstrap 或可选策略 |
| P2 | 容器生命周期语义文档不足 | 使用者容易误判对象生命周期 | 补充注册和释放规范 |
| P2 | `RegisterFactory` 兼容入口仍可能误用 | 使用者可能把 lazy singleton 用于短生命周期对象 | 新代码使用 `RegisterSingletonFactory` / `RegisterScoped` / `RegisterTransient` |
## 6. 审批意见
@@ -209,9 +207,9 @@ UI 模块基于 `UIPanelAttribute` 标记层级和路径,通过 `UIManager`
前置整改项:
1.`UIManager` 运行时销毁逻辑从 `DestroyImmediate` 调整为 `Destroy`
2. `AudioService` 的资源加载从同步阻塞改为异步或预加载模型
3. 为 Addressables 适配明确依赖策略,优先拆出独立 asmdef
4. 补充框架使用约定文档,包括 Bootstrap、Feature、资源释放、UI 路径、配置 DTO 限制
2. 为 Addressables 适配增加真实 load/release 最小验收
3. 明确 Config/Save DTO 与 AOT/linker 限制
4. 在进入 P2 包分发前补一次 Unity Test Runner 全量验收记录
## 7. 后续建议
@@ -239,23 +237,23 @@ UI 模块基于 `UIPanelAttribute` 标记层级和路径,通过 `UIManager`
| --- | --- | --- |
| 架构清晰度 | 良好 | 模块边界清楚,抽象克制 |
| 可测试性 | 良好 | 已有较完整 EditMode/PlayMode 测试 |
| Unity 生命周期安全 | 一般 | `DestroyImmediate` 和同步等待需修正 |
| Unity 生命周期安全 | 一般 | `DestroyImmediate` 与 Unity Test Runner 验收仍需收口 |
| 生产稳定性 | 一般 | 仍处于 P0/P1 框架雏形 |
| 可演进性 | 良好 | 适合继续拆模块、补规范、稳定 API |
综合评级B+。
结论摘要FlowScope 是一个有工程意识的轻量 Unity 框架骨架,值得继续推进;但在正式生产复用前,需要优先修正异步资源、运行时销毁 Addressables 依赖方式三个关键问题。
结论摘要FlowScope 是一个有工程意识的轻量 Unity 框架骨架,值得继续推进;但在正式生产复用前,需要优先修正运行时销毁、真实 Addressables 验收和 AOT/JSON 边界三个关键问题。
# P1 更新记录2026-05-20
P1 已完成第一批生产化硬化,评审风险状态同步如下:
| 原风险 | P1 状态 | 说明 |
| --- | --- | --- |
| Addressables 反射调用 | 已降低 | Addressables 适配已从 Runtime 核心拆出到 `FlowScope.Addressables` 独立 asmdef核心资源系统通过 `IResourceBackend` 工作。最终验收仍等待 Unity 完成 Addressables 包解析。 |
| Addressables 反射调用 | 已降低 | Addressables 适配已从 Runtime 核心拆出到 `FlowScope.Addressables` 独立 asmdef核心资源系统通过 `IResourceBackend` 工作。包解析与生成项目编译阻塞已解除,仍缺真实 load/release 验收。 |
| 自研 JSON 转换器缺少扩展点 | 已降低 | Config 增加 `IConfigSource` / `IConfigParser`Save 增加版本 envelope 与 `ISaveMigration` 链。长期是否替换成熟 JSON 库仍是 P2 决策。 |
| UI 缺少轻量导航与预加载 | 已降低 | 新增 `UIScreenNavigator``UIPreloadService`,并补充 PlayMode 编译级测试。 |
| 样例不能体现生产化装配 | 已降低 | MainMenuP0 已改为 P1 装配方式,覆盖启动、点击、保存恢复和资源释放。 |
| Package/editor tooling | 保持 P2 | 本轮没有做包分发、编辑器工具和跨项目模板化。 |
当前结论仍是“有条件通过”P1 代码与文档已落地,但完整 Unity Test Runner 和人工场景验收需要在 Unity 工程未被占用、Addressables 包解析完成后补跑。
当前结论仍是“有条件通过”P1 代码与文档已落地,P2 前整改已收束 Container/Feature 生命周期、样例 Bootstrap 启动模型、资源取消与 UI 预加载边界;完整 Unity Test Runner 和人工场景验收仍需补跑。

View File

@@ -0,0 +1,423 @@
# FlowScope P1 后架构审核与 P2 前置整改清单
日期2026-05-21
范围FlowScope Game Core 当前 P0/P1 实现、P0/P1 需求文档、P1 completion report、runtime usage guide。
目的:作为后续执行 session 的整改依据。本文只定义问题、风险、建议和验收标准,不直接展开 P2 实施。
---
## 结论
当前 FlowScope 已经形成 P0/P1 轻量 Unity 游戏框架骨架,但不建议直接进入 P2。
核心原因不是功能数量不足,而是若干 P0/P1 内核语义还没有稳定Container 生命周期、Feature scope、GameFlow 与 Bootstrap 职责、样例启动退出模型、资源/UI 取消与预加载边界、文档状态一致性。P2 的包分发、编辑器工具和跨项目复用会固化这些 API若此时推进后续修正成本会显著升高。
建议先执行“P2 前最小整改清单”,确认内核语义稳定后,再进入 P2。
---
## 整改进展2026-05-21
已执行第一轮 P2 前最小整改:
- Container 已明确 `RegisterSingletonFactory` / `RegisterScoped` / `RegisterTransient` 三类生命周期,`RegisterFactory` 仅保留为旧式 lazy singleton 兼容入口。
- Feature 示例和测试改为 transient 注册,`GameFlow``A -> B -> A` 重入会创建新的 Feature 实例Feature 释放归属 Feature scope。
- MainMenuP0 `GameBootstrap` 已移除 `async void Awake` / `async void OnApplicationQuit` 的直接 await 模式,启动任务通过 `StartupTask` 可观察,退出异常集中记录。
- ResourceService 已补共享加载取消边界测试,覆盖单个等待者取消、全部等待者取消后完成即释放且不缓存。
- UIPreloadService 已补同 Panel 并发预加载合并测试,并实现 in-flight load 复用。
- P1 completion report、runtime usage guide、framework review、P1 需求验收状态已同步当前代码状态。
仍未完成:
- Unity Test Runner 全量 EditMode / PlayMode 验收记录。
- Addressables 真实 load/release 最小集成测试或人工验收步骤。
- `UIManager` 运行时 `DestroyImmediate` 风险收口。
- Config/Save DTO 与 AOT/linker 限制文档或验证。
---
## 最新验证证据
本次审核阶段只做代码阅读和轻量编译验证,没有修改运行时代码。
已执行:
```powershell
dotnet build "My project\FlowScope.Runtime.csproj" --no-restore
dotnet build "My project\FlowScope.Tests.EditMode.csproj" --no-restore
dotnet build "My project\FlowScope.Tests.PlayMode.csproj" --no-restore
```
结果:
- 三个 `dotnet build` 均为 0 error。
- 存在 Unity 生成项目的引用版本冲突 warning主要来自 `System.Net.Http``System.Security.Cryptography.Algorithms``System.ComponentModel.Annotations`
- 未在本次审核中重新执行 Unity Test Runner因此不能用本文替代 Unity EditMode / PlayMode 测试结果。
---
## 阻塞级 / 高风险问题
### 1. Container 工厂生命周期与 Feature scope 语义冲突
风险等级:阻塞级
建议P2 前必须修。
证据:
- `My project/Assets/FlowScope/Runtime/Container/ContainerRegistration.cs`
- `ForFactory` 创建的 registration 会缓存 `_instance`
- `Resolve()``_created == true` 后直接返回旧实例。
- 工厂创建的 `IDisposable``_owner.TrackOwnedDisposable` 跟踪。
- `My project/Assets/FlowScope/Runtime/Container/Container.cs`
- 子 scope 找不到注册时会递归返回父 scope 的 registration。
- `My project/Assets/FlowScope/Runtime/Flow/GameFlow.cs`
- `CreateFeature<TFeature>` 创建 feature scope 后调用 `scope.Resolve<TFeature>()`
- `My project/Assets/FlowScope/Samples/MainMenuP0/Scripts/GameBootstrap.cs`
- `MainMenuFeature` 注册在 root`_root.RegisterFactory(_ => new MainMenuFeature(...))`
真实行为:
- `RegisterFactory` 名义上像“工厂”,实际是“懒加载单例”。
- root 注册的 Feature 被 feature scope 解析时,仍使用 root registration。
- 同一个 Feature 类型如果再次进入,可能复用已经 `Dispose` 过的旧实例。
- 由父 scope registration 创建的对象由父 scope 持有释放,削弱 Feature scope 的生命周期隔离。
影响:
- 破坏 P0 文档中的“GameFlow 创建 Feature scopeFeature 临时对象随 FeatureContext 释放”。
- Feature 之间可能共享不该共享的实例状态。
- P2 包化后,使用者会照着 `RegisterFactory` 注册 Feature/ViewModel/临时对象,埋下复用已释放对象的隐性 bug。
整改建议:
1. 明确 Container 生命周期模型,至少区分:
- singleton/root service
- scoped/feature-owned service
- transient/new instance per resolve
2. 调整 API 命名,避免 `RegisterFactory` 继续误导:
- 方案 A保留现有缓存语义但改名或补充 `RegisterSingletonFactory`,另加 `RegisterTransient`
- 方案 B`RegisterFactory` 真正每次 resolve 创建新实例,再另加 singleton API。
3. 明确父注册在子 scope 下解析时的创建者与释放者:
- 若是 singleton应由注册所在 scope 持有。
- 若是 scoped/transient应由请求 scope 持有。
4. GameFlow 创建 Feature 时应确保每次进入得到新 Feature 实例,不能复用已 Dispose 实例。
最低验收:
- 新增测试:`A -> B -> A`,第二次进入 `A` 必须是新实例。
- 新增测试root 注册工厂child scope resolve 时生命周期归属符合文档。
- 新增测试Feature scope dispose 后,不影响 root singleton但会释放 scoped/transient 对象。
- 新增测试:已 Dispose 的 Feature 不会在再次切换时被复用。
- 更新 `docs/guides/flowscope-runtime-usage.md` 的 Container 注册规则。
---
### 2. `RegisterFactory` API 名称和实现语义不一致
风险等级:高
建议P2 前必须修或正式写入限制。
证据:
- P0 文档把 `RegisterFactory<T>(Func<Container, T> factory)` 作为显式工厂注册。
- 实现中该 factory 只会在第一次 Resolve 时执行,后续返回缓存实例。
影响:
- 使用者自然会认为 factory 是“每次创建”或至少“不缓存业务对象”。
- 目前它更接近 lazy singleton。
- 若 P2 后作为公开包 API 发布,后续纠正会是破坏性变更。
整改建议:
- 不要只补文档,应优先调整 API 语义或新增明确 API。
- 如果为了 P1 兼容暂不破坏旧接口,也要让旧接口行为在文档中被明确标注,并禁止用于 Feature/ViewModel/短生命周期对象。
最低验收:
- Container 测试中明确覆盖 singleton/transient/scoped 三类行为。
- 文档中出现清晰示例root service 如何注册Feature 如何注册,临时对象如何注册。
---
### 3. GameFlow 与 Bootstrap 的生命周期职责边界不一致
风险等级:高
建议P2 前必须定案。
证据:
- P0 文档描述 GameFlow 职责包括启动时加载或创建 Data、关闭时保存必要数据。
- 当前 `GameFlow.StartupAsync` 固定调用 `IConfigProvider.LoadAllAsync`
- 当前保存 `PlayerData` 在样例 `GameBootstrap.ShutdownAsync` 中执行,而不是 GameFlow。
真实架构:
- 当前 GameFlow 更像 Feature 生命周期编排器。
- Bootstrap 才是真正的应用装配、数据加载、存档保存入口。
- 但 GameFlow 又硬编码了配置加载,导致职责介于“纯编排器”和“应用启动流程所有者”之间。
影响:
- 使用者不清楚 Data/Save 应该挂在 GameFlow 还是 Bootstrap。
- P2 做模板或包分发时,启动流程样板会变得含混。
- 后续如果加入多 Feature、预加载、场景切换会更难判断谁拥有启动任务列表。
整改建议:
二选一:
1. GameFlow 退回纯 Feature 编排器:
- 移除或策略化 `IConfigProvider.LoadAllAsync`
- Config/Save/Data 由 Bootstrap 或 AppStartupPipeline 负责。
2. GameFlow 正式成为 AppFlow
- 引入明确启动任务/保存任务策略。
- 将 Data load/save 纳入可配置流程,而不是只硬编码 Config。
最低验收:
- 文档和代码一致说明Config 加载、Save 加载、Save 写回分别由谁负责。
- 样例不再与主文档互相矛盾。
- GameFlow 测试覆盖职责定案后的启动/关闭行为。
---
### 4. 样例 Bootstrap 使用 `async void`,容易被业务照抄
风险等级:高
建议P2 前修。
证据:
- `GameBootstrap.Awake()``async void` 并直接 await `StartupAsync`
- `GameBootstrap.OnApplicationQuit()``async void` 并直接 await `ShutdownAsync`
影响:
- 启动异常可能成为未观察异常或只表现为 Unity 日志。
- 退出时异步保存和释放没有统一错误处理入口。
- 这是官方样例,使用者很可能照抄。
整改建议:
- Bootstrap 应有可观察的启动任务和错误处理策略。
- `Awake` 可只创建 lifetime token`Start` 或显式 `RunAsync` 负责启动。
- 异常应写入明确日志,并让测试能够断言。
- 退出保存应考虑重复调用、取消、异常 best-effort 规则。
最低验收:
- 样例启动异常可被测试捕获或至少被统一日志记录。
- 重复 Shutdown 不重复释放、不重复保存或语义明确。
- OnDestroy / OnApplicationQuit 顺序不会造成 token 已释放后继续使用。
---
## 中风险问题
### 5. UI 预加载并发和释放边界偏薄
风险等级:中
证据:
- `UIPreloadService.PreloadAsync<TPanel>` 没有同类型并发去重。
- `UIManager` 对预加载 handle 使用空释放句柄,实际释放依赖 `UIPreloadService.Release` / `ReleaseAll`
影响:
- 两个并发预加载可能重复加载并覆盖 handle。
- UI 实例关闭不释放预加载 handle 是合理设计,但需要更强文档和测试防误用。
整改建议:
-`UIPreloadService` 增加同 Panel 类型的 in-flight load 合并。
- 增加测试:并发 Preload 只触发一次底层加载。
- 增加测试:已打开预加载 Panel 时提前 Release 的行为被定义清楚。
---
### 6. `UIScreenNavigator` 是轻量栈,不应被视为完整路由
风险等级:中
证据:
- `UIScreenNavigator` 只保存 `Action` close stack。
- 外部 `UIManager.Close<TPanel>` / `CloseLayer` / `CloseAll` 不会同步导航栈。
影响:
- 外部关闭后Navigator 内部可能保留已经关闭的历史 action。
- P1 可接受,但 P2 文档不能把它包装成完整页面路由。
整改建议:
- 文档明确它只管理自己 push 的页面。
- 如果 P2 要做完整导航,应重新设计 route/state而不是扩这个 action stack。
---
### 7. 资源共享加载取消语义还需要补边界测试
风险等级:中
证据:
- `ResourceService.LoadEntryAsync` 调用 backend 时传入 `CancellationToken.None`
- 调用者取消只取消等待,不取消共享底层加载。
- 这是合理设计,但行为复杂。
影响:
- “所有等待者取消后,底层加载完成再释放”这类边界如果没测,容易出现泄漏、重复 release 或未观察异常。
整改建议:
- 补测试:
- 多等待者中一个取消,另一个成功拿到 handle。
- 所有等待者取消,底层随后成功,资源被释放且不留 completed cache。
- 所有等待者取消,底层随后失败,没有未观察异常或残留 `_loads`
---
### 8. Addressables 适配层测试过薄
风险等级:中
证据:
- `AddressablesResourceBackendTests` 当前只测 `Name_ReturnsAddressables`
- PlayMode 编译已能通过,但不等于真实 Addressables load/release 验证通过。
整改建议:
- 增加最小 Addressables 测试资源。
- 验证 `AddressablesResourceBackend.LoadAsync<T>` 成功加载、取消、失败、Release。
- 如果测试资源维护成本高,至少增加一条文档化的人工验收步骤。
---
### 9. 自研 JSON / 反射 / AOT 风险仍未收口
风险等级:中
证据:
- `ReactivePropertyJsonConverter` 手写 JSON parse/write。
- `JsonSaveSerializer` 使用 `Activator.CreateInstance`
- Config mapping 使用反射 `MakeGenericMethod` / `Invoke`
影响:
- P1 阶段可接受。
- P2 包化和移动端 IL2CPP 下,需要提前验证或明确限制。
整改建议:
- P2 前至少写清 DTO 限制:
- 必须有无参构造。
- 支持字段/属性类型范围。
- 不支持复杂 polymorphism、Dictionary、UnityEngine.Object 引用等。
- 增加 IL2CPP/linker 验证计划,或改用成熟 JSON 库并保留 R3 适配层。
---
## 文档与实现不一致
### A. P1 completion report 已过时
文档位置:
- `docs/reviews/p1-completion-report.md`
- `docs/requirements/p1-production-hardening.md`
- `docs/reviews/flowscope-framework-review.md`
不一致点:
- 文档仍描述 PlayMode 完整编译因 Addressables 未解析失败。
- 当前 `packages-lock.json` 已包含 `com.unity.addressables`
- 本次审核运行 `dotnet build "My project\FlowScope.Tests.PlayMode.csproj" --no-restore` 为 0 error。
建议:
- 更新完成报告,把“旧阻塞已解除”和“仍需 Unity Test Runner/人工验收确认”分开写。
- 不要继续保留“Addressables 尚未解析导致 PlayMode 编译失败”的当前状态描述。
### B. AudioService 旧风险描述需要更新
文档位置:
- `docs/reviews/flowscope-framework-review.md`
- `docs/reviews/p1-completion-report.md`
不一致点:
- 旧评审提到 AudioService 同步阻塞异步加载。
- 当前实现已经提供 `PlayBgmAsync` / `PlaySfxAsync`,同步方法只允许读取 `ICompletedResourceService` 已完成资源,不再 `.GetAwaiter().GetResult()` 阻塞。
建议:
- 更新文档:旧同步阻塞风险已降低。
- 新风险应改为:同步 API 只适合预加载完成资源,使用者必须理解并处理异常。
### C. GameFlow 职责文档与实现不一致
文档位置:
- `docs/requirements/p0-requirements-set.md`
- `docs/requirements/p0-gameflow.md`
- `docs/guides/flowscope-runtime-usage.md`
不一致点:
- 文档把 GameFlow 写成全局生命周期所有者。
- 实现中保存、Data 加载、预加载释放主要由 Bootstrap 负责。
- GameFlow 只硬编码 Config 加载和 Feature 生命周期。
建议:
- 先做架构决策,再同步文档。
---
## P2 前最小整改清单
必须先修:
1. 修正或定案 Container 生命周期语义。
2. 保证 Feature 每次进入都是新实例,且 Feature scope 拥有自己的短生命周期对象。
3. 明确 GameFlow 与 Bootstrap 职责边界,并让代码和文档一致。
4. 修正样例 Bootstrap 的 `async void` 启动/退出风险。
5. 更新 P1 completion report / runtime usage guide / framework review 中的过时状态。
建议同步补测:
1. Containersingleton / scoped / transient 行为。
2. GameFlow`A -> B -> A` 重入。
3. ResourceService共享加载取消边界。
4. UIPreloadService并发预加载与释放语义。
5. Addressables真实 load/release 最小 PlayMode 验证。
完成以上后再评估 P2。
---
## 执行 session 建议顺序
1. 先只改 Container 和 GameFlow 相关语义,不碰 P2 包分发。
2. 跑 EditMode 测试,确认核心生命周期稳定。
3. 再改样例 Bootstrap让 MainMenuP0 继续作为真实使用样板。
4. 跑 PlayMode 测试,确认 UI、样例、资源释放没有退化。
5. 最后更新文档,确保 report 不再保留旧阻塞状态。
---
## 禁止事项
- 不要在本轮顺手进入 P2。
- 不要扩大到 Luban、YooAsset、云存档、AudioMixer、完整 UI 路由。
- 不要为了修测试而弱化 Feature scope 或资源释放语义。
- 不要只改文档掩盖 Container 生命周期问题。
- 不要把样例变成特殊路径;样例应继续代表推荐使用方式。

View File

@@ -18,16 +18,15 @@
- `dotnet build "My project\FlowScope.Runtime.csproj" --no-restore`通过0 错误;存在 Unity 生成项目的引用版本冲突警告。
- `dotnet build "My project\FlowScope.Tests.EditMode.csproj" --no-restore`通过0 错误;存在相同引用警告。
- `dotnet build "My project\FlowScope.Tests.PlayMode.csproj" --no-restore`完整编译失败,原因是 `FlowScope.Addressables` 生成项目和 `com.unity.addressables` 包尚未由 Unity 解析
- 临时排除 `AddressablesResourceBackendTests.cs` 后的 PlayMode 编译通过0 错误;用于确认 UI 与样例集成未破坏
- `dotnet build "My project\FlowScope.Tests.PlayMode.csproj" --no-restore`通过0 错误;存在相同引用警告。旧的 Addressables 包解析阻塞已解除
- 2026-05-21 P2 前整改补充Container 已区分 `RegisterSingletonFactory` / `RegisterScoped` / `RegisterTransient`Feature 示例改为 transient 注册ResourceService 与 UIPreloadService 增加共享加载/并发预加载边界测试
## 人工验收
尚未完成。主工程当前被其他 Unity 实例占用Unity batchmode 无法打开同一 project path无法执行 Test Runner 全量 PlayMode 和 MainMenuP0 场景人工验收
代码级编译验收已恢复。Unity Test Runner 和 MainMenuP0 场景人工验收仍需在 Unity 工程可用时补跑,不能仅用 `dotnet build` 替代
待 Unity 可用后,需要重新执行:
需要重新执行:
- Unity 包解析,确认 `Packages/packages-lock.json` 更新并出现 `com.unity.addressables`
- PlayMode: `FlowScope.Tests.PlayMode.Samples.MainMenuP0Tests`
- PlayMode 全量测试。
- 打开 `My project/Assets/FlowScope/Samples/MainMenuP0/Scenes/MainMenuP0.unity` 做人工点击、保存恢复、资源释放验收。
@@ -43,8 +42,8 @@
## P2 建议
- Unity 完成 Addressables 包解析后补一次 P1 验收提交,包含 `packages-lock.json` 和 Unity Test Runner 结果
- AudioService 的同步资源加载改为异步或预加载模型
- 补一次 Unity Test Runner 验收记录,确认 EditMode / PlayMode 在 Unity 内全绿
- 继续跟踪 AudioService 同步 API 的使用边界:同步方法只适合读取已预加载/已完成资源,新增业务应优先使用 async API
- 为 Runtime/package 分发建立 asmdef、Samples、Tests 的包结构规范。
- 为 Config/Save 引入更成熟 JSON 库或明确长期 DTO 限制。
- 增加 AOT/linker 验证,避免反射和 JSON 构造在移动端出现隐性问题。