实现 P0 UI 管理器

This commit is contained in:
JSD\13999
2026-05-18 11:52:09 +08:00
parent 49318c50a4
commit d37faae707
13 changed files with 719 additions and 4 deletions

View File

@@ -0,0 +1,73 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace FlowScope.UI
{
internal sealed class UILayer
{
private readonly Stack<UIPanelRecord> _stack = new();
private readonly Dictionary<Type, UIPanelRecord> _cachedPanels = new();
public UILayer(string name, Canvas canvas, int sortOrder, PanelStrategy strategy)
{
Name = name;
Canvas = canvas;
Strategy = strategy;
Canvas.sortingOrder = sortOrder;
}
public string Name { get; }
public Canvas Canvas { get; }
public PanelStrategy Strategy { get; }
public int Count => _stack.Count;
public void Push(UIPanelRecord record)
{
record.IsOpen = true;
_stack.Push(record);
}
public UIPanelRecord Peek()
{
return _stack.Peek();
}
public UIPanelRecord Pop()
{
var record = _stack.Pop();
record.IsOpen = false;
return record;
}
public bool TryGetCached(Type panelType, out UIPanelRecord record)
{
return _cachedPanels.TryGetValue(panelType, out record);
}
public void Cache(UIPanelRecord record)
{
_cachedPanels[record.PanelType] = record;
}
public void RemoveCached(Type panelType)
{
_cachedPanels.Remove(panelType);
}
public bool ContainsOpen(Type panelType, out UIPanelRecord record)
{
foreach (var current in _stack)
{
if (current.PanelType == panelType)
{
record = current;
return true;
}
}
record = null;
return false;
}
}
}

View File

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

View File

@@ -0,0 +1,229 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using FlowScope.Resources;
using UnityEngine;
namespace FlowScope.UI
{
public sealed class UIManager
{
private readonly IResourceService _resources;
private readonly Dictionary<string, UILayer> _layers = new();
public UIManager(IResourceService resources)
{
_resources = resources ?? throw new ArgumentNullException(nameof(resources));
}
public void RegisterLayer(
string name,
Canvas canvas,
int sortOrder,
PanelStrategy strategy = PanelStrategy.Destroy)
{
if (string.IsNullOrWhiteSpace(name))
{
throw new ArgumentException("Layer name is required.", nameof(name));
}
if (canvas == null)
{
throw new ArgumentNullException(nameof(canvas));
}
_layers[name] = new UILayer(name, canvas, sortOrder, strategy);
}
public async Task<TPanel> OpenAsync<TPanel, TViewModel>(
TViewModel viewModel,
CancellationToken cancellationToken)
where TPanel : UIPanelBase<TViewModel>
{
cancellationToken.ThrowIfCancellationRequested();
var attribute = GetPanelAttribute(typeof(TPanel));
if (!_layers.TryGetValue(attribute.Layer, out var layer))
{
throw new InvalidOperationException($"UI layer is not registered: {attribute.Layer}");
}
var strategy = attribute.OverrideStrategy ?? layer.Strategy;
if (strategy == PanelStrategy.Cache &&
layer.TryGetCached(typeof(TPanel), out var cached) &&
!cached.IsOpen)
{
return ReopenCached<TPanel, TViewModel>(cached, layer, viewModel);
}
var path = ResolvePath(typeof(TPanel), attribute);
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}");
}
GameObject instance = null;
try
{
instance = UnityEngine.Object.Instantiate(handle.Asset, layer.Canvas.transform, false);
var panel = instance.GetComponent<TPanel>();
if (panel == null)
{
throw new InvalidOperationException(
$"UI panel prefab '{path}' does not contain component {typeof(TPanel).Name}.");
}
panel.Bind(viewModel);
instance.SetActive(true);
var record = new UIPanelRecord(typeof(TPanel), layer.Name, panel, handle, strategy);
if (strategy == PanelStrategy.Cache)
{
layer.Cache(record);
}
layer.Push(record);
return panel;
}
catch
{
if (instance != null)
{
UnityEngine.Object.DestroyImmediate(instance);
}
handle.Dispose();
throw;
}
}
public void Close<TPanel>()
{
var panelType = typeof(TPanel);
foreach (var layer in _layers.Values)
{
if (!layer.ContainsOpen(panelType, out var record))
{
continue;
}
if (!ReferenceEquals(layer.Peek(), record))
{
throw new InvalidOperationException(
$"Cannot close non-top UI panel: {panelType.Name}");
}
CloseTop(layer);
return;
}
}
public void CloseLayer(string layerName)
{
if (!_layers.TryGetValue(layerName, out var layer))
{
throw new InvalidOperationException($"UI layer is not registered: {layerName}");
}
while (layer.Count > 0)
{
CloseTop(layer);
}
}
public void CloseAll()
{
foreach (var layer in _layers.Values)
{
while (layer.Count > 0)
{
CloseTop(layer);
}
}
}
public bool IsOpen<TPanel>()
{
var panelType = typeof(TPanel);
foreach (var layer in _layers.Values)
{
if (layer.ContainsOpen(panelType, out _))
{
return true;
}
}
return false;
}
private static TPanel ReopenCached<TPanel, TViewModel>(
UIPanelRecord record,
UILayer layer,
TViewModel viewModel)
where TPanel : UIPanelBase<TViewModel>
{
var panel = (TPanel)record.Panel;
panel.Bind(viewModel);
panel.gameObject.SetActive(true);
layer.Push(record);
return panel;
}
private static void CloseTop(UILayer layer)
{
var record = layer.Pop();
record.Panel.Unbind();
if (record.Strategy == PanelStrategy.Cache)
{
record.Panel.gameObject.SetActive(false);
return;
}
layer.RemoveCached(record.PanelType);
UnityEngine.Object.DestroyImmediate(record.Panel.gameObject);
record.Handle.Dispose();
}
private static UIPanelAttribute GetPanelAttribute(Type panelType)
{
var attribute = (UIPanelAttribute)Attribute.GetCustomAttribute(
panelType,
typeof(UIPanelAttribute));
if (attribute == null)
{
throw new InvalidOperationException(
$"UI panel must declare UIPanelAttribute: {panelType.Name}");
}
if (string.IsNullOrWhiteSpace(attribute.Layer))
{
throw new InvalidOperationException(
$"UI panel layer is required: {panelType.Name}");
}
return attribute;
}
private static string ResolvePath(Type panelType, UIPanelAttribute attribute)
{
if (!string.IsNullOrWhiteSpace(attribute.Path))
{
return attribute.Path;
}
var name = panelType.Name;
const string suffix = "Panel";
if (name.EndsWith(suffix, StringComparison.Ordinal))
{
name = name.Substring(0, name.Length - suffix.Length);
}
return $"UI/{name}/Prefab";
}
}
}

View File

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

View File

@@ -5,10 +5,30 @@ namespace FlowScope.UI
[AttributeUsage(AttributeTargets.Class)] [AttributeUsage(AttributeTargets.Class)]
public sealed class UIPanelAttribute : Attribute public sealed class UIPanelAttribute : Attribute
{ {
public UIPanelAttribute( public UIPanelAttribute(string layer)
: this(layer, null, null)
{
}
public UIPanelAttribute(string layer, string path)
: this(layer, path, null)
{
}
public UIPanelAttribute(string layer, PanelStrategy overrideStrategy)
: this(layer, null, overrideStrategy)
{
}
public UIPanelAttribute(string layer, string path, PanelStrategy overrideStrategy)
: this(layer, path, (PanelStrategy?)overrideStrategy)
{
}
private UIPanelAttribute(
string layer, string layer,
string path = null, string path,
PanelStrategy? overrideStrategy = null) PanelStrategy? overrideStrategy)
{ {
Layer = layer; Layer = layer;
Path = path; Path = path;

View File

@@ -3,7 +3,14 @@ using UnityEngine;
namespace FlowScope.UI namespace FlowScope.UI
{ {
public abstract class UIPanelBase<TViewModel> : MonoBehaviour internal interface IUIPanel
{
GameObject gameObject { get; }
Transform transform { get; }
void Unbind();
}
public abstract class UIPanelBase<TViewModel> : MonoBehaviour, IUIPanel
{ {
protected TViewModel ViewModel { get; private set; } protected TViewModel ViewModel { get; private set; }
protected CompositeDisposable Disposables { get; } = new(); protected CompositeDisposable Disposables { get; } = new();

View File

@@ -0,0 +1,28 @@
using System;
namespace FlowScope.UI
{
internal sealed class UIPanelRecord
{
public UIPanelRecord(
Type panelType,
string layerName,
IUIPanel panel,
IDisposable handle,
PanelStrategy strategy)
{
PanelType = panelType;
LayerName = layerName;
Panel = panel;
Handle = handle;
Strategy = strategy;
}
public Type PanelType { get; }
public string LayerName { get; }
public IUIPanel Panel { get; }
public IDisposable Handle { get; }
public PanelStrategy Strategy { get; }
public bool IsOpen { get; set; }
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,290 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using FlowScope.Resources;
using FlowScope.UI;
using NUnit.Framework;
using UnityEngine;
namespace FlowScope.Tests.PlayMode.UI
{
public sealed class UIManagerTests
{
private readonly List<GameObject> _objects = new();
[TearDown]
public void TearDown()
{
foreach (var obj in _objects)
{
if (obj != null)
{
UnityEngine.Object.DestroyImmediate(obj);
}
}
_objects.Clear();
}
[Test]
public async Task OpenAsync_UsesAttributePathAndMountsPanelToRegisteredLayer()
{
var canvas = CreateCanvas("popup");
var prefab = CreatePrefab<ExplicitPathPanel>("ExplicitPathPrefab");
var resources = new FakeResourceService();
resources.Register("Custom/Explicit", prefab);
var manager = new UIManager(resources);
manager.RegisterLayer("popup", canvas, 120);
var viewModel = new TestViewModel("first");
var panel = await manager.OpenAsync<ExplicitPathPanel, TestViewModel>(viewModel, CancellationToken.None);
Assert.That(resources.LoadedKeys, Is.EqualTo(new[] { "Custom/Explicit" }));
Assert.That(panel.transform.parent, Is.EqualTo(canvas.transform));
Assert.That(canvas.sortingOrder, Is.EqualTo(120));
Assert.That(panel.BoundModels, Is.EqualTo(new[] { viewModel }));
Assert.That(panel.gameObject.activeSelf, Is.True);
Assert.That(manager.IsOpen<ExplicitPathPanel>(), Is.True);
}
[Test]
public async Task OpenAsync_WhenPathIsMissing_UsesPanelNameConvention()
{
var canvas = CreateCanvas("hud");
var prefab = CreatePrefab<MainHudPanel>("MainHudPrefab");
var resources = new FakeResourceService();
resources.Register("UI/MainHud/Prefab", prefab);
var manager = new UIManager(resources);
manager.RegisterLayer("hud", canvas, 10);
await manager.OpenAsync<MainHudPanel, TestViewModel>(new TestViewModel("hud"), CancellationToken.None);
Assert.That(resources.LoadedKeys, Is.EqualTo(new[] { "UI/MainHud/Prefab" }));
}
[Test]
public async Task Close_WhenPanelIsNotLayerTop_ThrowsInvalidOperationException()
{
var canvas = CreateCanvas("popup");
var resources = new FakeResourceService();
resources.Register("Custom/Explicit", CreatePrefab<ExplicitPathPanel>("FirstPrefab"));
resources.Register("UI/Second/Prefab", CreatePrefab<SecondPanel>("SecondPrefab"));
var manager = new UIManager(resources);
manager.RegisterLayer("popup", canvas, 0);
await manager.OpenAsync<ExplicitPathPanel, TestViewModel>(new TestViewModel("first"), CancellationToken.None);
await manager.OpenAsync<SecondPanel, TestViewModel>(new TestViewModel("second"), CancellationToken.None);
Assert.Throws<InvalidOperationException>(() => manager.Close<ExplicitPathPanel>());
Assert.That(manager.IsOpen<ExplicitPathPanel>(), Is.True);
Assert.That(manager.IsOpen<SecondPanel>(), Is.True);
}
[Test]
public async Task Close_WithDestroyStrategy_UnbindsDestroysInstanceAndDisposesHandle()
{
var canvas = CreateCanvas("popup");
var prefab = CreatePrefab<ExplicitPathPanel>("ExplicitPathPrefab");
var resources = new FakeResourceService();
resources.Register("Custom/Explicit", prefab);
var manager = new UIManager(resources);
manager.RegisterLayer("popup", canvas, 0, PanelStrategy.Destroy);
var panel = await manager.OpenAsync<ExplicitPathPanel, TestViewModel>(new TestViewModel("first"), CancellationToken.None);
var handle = resources.Handles[0];
manager.Close<ExplicitPathPanel>();
Assert.That(panel.UnbindCount, Is.EqualTo(1));
Assert.That(handle.IsDisposed, Is.True);
Assert.That(panel == null, Is.True);
Assert.That(manager.IsOpen<ExplicitPathPanel>(), Is.False);
}
[Test]
public async Task OpenAsync_WithCacheStrategy_ReusesHiddenInstanceAndBindsAgain()
{
var canvas = CreateCanvas("dialog");
var prefab = CreatePrefab<CachedPanel>("CachedPrefab");
var resources = new FakeResourceService();
resources.Register("UI/Cached/Prefab", prefab);
var manager = new UIManager(resources);
manager.RegisterLayer("dialog", canvas, 0, PanelStrategy.Destroy);
var firstViewModel = new TestViewModel("first");
var secondViewModel = new TestViewModel("second");
var first = await manager.OpenAsync<CachedPanel, TestViewModel>(firstViewModel, CancellationToken.None);
manager.Close<CachedPanel>();
var second = await manager.OpenAsync<CachedPanel, TestViewModel>(secondViewModel, CancellationToken.None);
Assert.That(second, Is.SameAs(first));
Assert.That(second.gameObject.activeSelf, Is.True);
Assert.That(second.BoundModels, Is.EqualTo(new[] { firstViewModel, secondViewModel }));
Assert.That(second.UnbindCount, Is.EqualTo(1));
Assert.That(resources.LoadedKeys, Is.EqualTo(new[] { "UI/Cached/Prefab" }));
Assert.That(resources.Handles[0].IsDisposed, Is.False);
}
[Test]
public async Task CloseLayer_OnlyClosesRequestedLayer()
{
var popupCanvas = CreateCanvas("popup");
var hudCanvas = CreateCanvas("hud");
var resources = new FakeResourceService();
resources.Register("Custom/Explicit", CreatePrefab<ExplicitPathPanel>("PopupPrefab"));
resources.Register("UI/MainHud/Prefab", CreatePrefab<MainHudPanel>("HudPrefab"));
var manager = new UIManager(resources);
manager.RegisterLayer("popup", popupCanvas, 0);
manager.RegisterLayer("hud", hudCanvas, 0);
await manager.OpenAsync<ExplicitPathPanel, TestViewModel>(new TestViewModel("popup"), CancellationToken.None);
await manager.OpenAsync<MainHudPanel, TestViewModel>(new TestViewModel("hud"), CancellationToken.None);
manager.CloseLayer("popup");
Assert.That(manager.IsOpen<ExplicitPathPanel>(), Is.False);
Assert.That(manager.IsOpen<MainHudPanel>(), Is.True);
}
[Test]
public async Task OpenAsync_WhenBindThrows_DisposesHandleAndDestroysCreatedInstance()
{
var canvas = CreateCanvas("popup");
var prefab = CreatePrefab<ThrowingBindPanel>("ThrowingPrefab");
var resources = new FakeResourceService();
resources.Register("UI/ThrowingBind/Prefab", prefab);
var manager = new UIManager(resources);
manager.RegisterLayer("popup", canvas, 0);
Assert.ThrowsAsync<InvalidOperationException>(
() => manager.OpenAsync<ThrowingBindPanel, TestViewModel>(new TestViewModel("boom"), CancellationToken.None));
Assert.That(resources.Handles[0].IsDisposed, Is.True);
Assert.That(canvas.transform.childCount, Is.EqualTo(0));
Assert.That(manager.IsOpen<ThrowingBindPanel>(), Is.False);
}
private Canvas CreateCanvas(string name)
{
var gameObject = new GameObject(name);
_objects.Add(gameObject);
return gameObject.AddComponent<Canvas>();
}
private GameObject CreatePrefab<TPanel>(string name)
where TPanel : Component
{
var gameObject = new GameObject(name);
_objects.Add(gameObject);
gameObject.AddComponent<TPanel>();
return gameObject;
}
private sealed class FakeResourceService : IResourceService
{
private readonly Dictionary<string, GameObject> _assets = new();
public List<string> LoadedKeys { get; } = new();
public List<FakeResourceHandle<GameObject>> Handles { get; } = new();
public void Register(string key, GameObject asset)
{
_assets.Add(key, asset);
}
public Task<IResourceHandle<T>> LoadAsync<T>(string key, CancellationToken cancellationToken)
where T : class
{
cancellationToken.ThrowIfCancellationRequested();
LoadedKeys.Add(key);
if (!_assets.TryGetValue(key, out var asset))
{
throw new InvalidOperationException($"Missing resource: {key}");
}
var handle = new FakeResourceHandle<GameObject>(key, asset);
Handles.Add(handle);
return Task.FromResult<IResourceHandle<T>>((IResourceHandle<T>)(object)handle);
}
public IResourceGroup CreateGroup()
{
throw new NotSupportedException();
}
}
private sealed class FakeResourceHandle<T> : IResourceHandle<T>
where T : class
{
public FakeResourceHandle(string key, T asset)
{
Key = key;
Asset = asset;
}
public string Key { get; }
public T Asset { get; private set; }
public bool IsDisposed { get; private set; }
public void Dispose()
{
IsDisposed = true;
}
}
}
public sealed class TestViewModel
{
public TestViewModel(string name)
{
Name = name;
}
public string Name { get; }
}
[UIPanel("popup", "Custom/Explicit")]
public sealed class ExplicitPathPanel : RecordingPanel
{
}
[UIPanel("hud")]
public sealed class MainHudPanel : RecordingPanel
{
}
[UIPanel("popup")]
public sealed class SecondPanel : RecordingPanel
{
}
[UIPanel("dialog", overrideStrategy: PanelStrategy.Cache)]
public sealed class CachedPanel : RecordingPanel
{
}
[UIPanel("popup")]
public sealed class ThrowingBindPanel : UIPanelBase<TestViewModel>
{
protected override void OnBind(TestViewModel viewModel)
{
throw new InvalidOperationException("bind failed");
}
}
public abstract class RecordingPanel : UIPanelBase<TestViewModel>
{
public List<TestViewModel> BoundModels { get; } = new();
public int UnbindCount { get; private set; }
public override void Unbind()
{
UnbindCount++;
base.Unbind();
}
protected override void OnBind(TestViewModel viewModel)
{
BoundModels.Add(viewModel);
}
}
}

View File

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