扩展 P1 UI 导航和预加载

This commit is contained in:
JSD\13999
2026-05-20 16:22:10 +08:00
parent 1efe6c9974
commit a0508ff630
12 changed files with 615 additions and 6 deletions

View File

@@ -1,5 +1,7 @@
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using FlowScope.Resources;
using UnityEngine;
namespace FlowScope.UI namespace FlowScope.UI
{ {
@@ -7,6 +9,7 @@ namespace FlowScope.UI
{ {
Task PreloadAsync<TPanel>(CancellationToken cancellationToken); Task PreloadAsync<TPanel>(CancellationToken cancellationToken);
bool IsPreloaded<TPanel>(); bool IsPreloaded<TPanel>();
bool TryGetPreloadedHandle<TPanel>(out IResourceHandle<GameObject> handle);
void Release<TPanel>(); void Release<TPanel>();
void ReleaseAll(); void ReleaseAll();
} }

View File

@@ -10,11 +10,13 @@ namespace FlowScope.UI
public sealed class UIManager public sealed class UIManager
{ {
private readonly IResourceService _resources; private readonly IResourceService _resources;
private readonly IUIPreloadService _preloadService;
private readonly Dictionary<string, UILayer> _layers = new(); private readonly Dictionary<string, UILayer> _layers = new();
public UIManager(IResourceService resources) public UIManager(IResourceService resources, IUIPreloadService preloadService = null)
{ {
_resources = resources ?? throw new ArgumentNullException(nameof(resources)); _resources = resources ?? throw new ArgumentNullException(nameof(resources));
_preloadService = preloadService;
} }
public void RegisterLayer( public void RegisterLayer(
@@ -58,7 +60,7 @@ namespace FlowScope.UI
} }
var path = ResolvePath(typeof(TPanel), attribute); var path = ResolvePath(typeof(TPanel), attribute);
var handle = await _resources.LoadAsync<GameObject>(path, cancellationToken); var handle = await LoadPanelHandleAsync<TPanel>(path, cancellationToken);
if (handle == null || handle.Asset == null) if (handle == null || handle.Asset == null)
{ {
handle?.Dispose(); handle?.Dispose();
@@ -66,6 +68,7 @@ namespace FlowScope.UI
} }
GameObject instance = null; GameObject instance = null;
var ownsHandle = !IsPreloadedHandle<TPanel>(handle);
try try
{ {
instance = UnityEngine.Object.Instantiate(handle.Asset, layer.Canvas.transform, false); instance = UnityEngine.Object.Instantiate(handle.Asset, layer.Canvas.transform, false);
@@ -79,7 +82,8 @@ namespace FlowScope.UI
panel.Bind(viewModel); panel.Bind(viewModel);
instance.SetActive(true); instance.SetActive(true);
var record = new UIPanelRecord(typeof(TPanel), layer.Name, panel, handle, strategy); var recordHandle = ownsHandle ? (IDisposable)handle : RetainedPanelHandle.Instance;
var record = new UIPanelRecord(typeof(TPanel), layer.Name, panel, recordHandle, strategy);
if (strategy == PanelStrategy.Cache) if (strategy == PanelStrategy.Cache)
{ {
layer.Cache(record); layer.Cache(record);
@@ -95,7 +99,11 @@ namespace FlowScope.UI
UnityEngine.Object.Destroy(instance); UnityEngine.Object.Destroy(instance);
} }
handle.Dispose(); if (ownsHandle)
{
handle.Dispose();
}
throw; throw;
} }
} }
@@ -188,7 +196,27 @@ namespace FlowScope.UI
record.Handle.Dispose(); record.Handle.Dispose();
} }
private static UIPanelAttribute GetPanelAttribute(Type panelType) private async Task<IResourceHandle<GameObject>> LoadPanelHandleAsync<TPanel>(
string path,
CancellationToken cancellationToken)
{
if (_preloadService != null &&
_preloadService.TryGetPreloadedHandle<TPanel>(out var preloadedHandle))
{
return preloadedHandle;
}
return await _resources.LoadAsync<GameObject>(path, cancellationToken);
}
private bool IsPreloadedHandle<TPanel>(IResourceHandle<GameObject> handle)
{
return _preloadService != null &&
_preloadService.TryGetPreloadedHandle<TPanel>(out var preloadedHandle) &&
ReferenceEquals(preloadedHandle, handle);
}
internal static UIPanelAttribute GetPanelAttribute(Type panelType)
{ {
var attribute = (UIPanelAttribute)Attribute.GetCustomAttribute( var attribute = (UIPanelAttribute)Attribute.GetCustomAttribute(
panelType, panelType,
@@ -209,7 +237,7 @@ namespace FlowScope.UI
return attribute; return attribute;
} }
private static string ResolvePath(Type panelType, UIPanelAttribute attribute) internal static string ResolvePath(Type panelType, UIPanelAttribute attribute)
{ {
if (!string.IsNullOrWhiteSpace(attribute.Path)) if (!string.IsNullOrWhiteSpace(attribute.Path))
{ {
@@ -225,5 +253,14 @@ namespace FlowScope.UI
return $"UI/{name}/Prefab"; return $"UI/{name}/Prefab";
} }
private sealed class RetainedPanelHandle : IDisposable
{
public static readonly RetainedPanelHandle Instance = new();
public void Dispose()
{
}
}
} }
} }

View File

@@ -0,0 +1,89 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using FlowScope.Resources;
using UnityEngine;
namespace FlowScope.UI
{
public sealed class UIPreloadService : IUIPreloadService, IDisposable
{
private readonly IResourceService _resources;
private readonly Dictionary<Type, IResourceHandle<GameObject>> _handles = new();
public UIPreloadService(IResourceService resources)
{
_resources = resources ?? throw new ArgumentNullException(nameof(resources));
}
public async Task PreloadAsync<TPanel>(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var panelType = typeof(TPanel);
if (IsPreloaded<TPanel>())
{
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)
{
handle?.Dispose();
throw new InvalidOperationException($"UI panel resource is missing: {path}");
}
_handles[panelType] = handle;
}
public bool IsPreloaded<TPanel>()
{
return _handles.TryGetValue(typeof(TPanel), out var handle) &&
handle != null &&
!handle.IsDisposed &&
handle.Asset != null;
}
public bool TryGetPreloadedHandle<TPanel>(out IResourceHandle<GameObject> handle)
{
if (IsPreloaded<TPanel>())
{
handle = _handles[typeof(TPanel)];
return true;
}
handle = null;
return false;
}
public void Release<TPanel>()
{
var panelType = typeof(TPanel);
if (!_handles.TryGetValue(panelType, out var handle))
{
return;
}
_handles.Remove(panelType);
handle.Dispose();
}
public void ReleaseAll()
{
foreach (var handle in _handles.Values)
{
handle.Dispose();
}
_handles.Clear();
}
public void Dispose()
{
ReleaseAll();
}
}
}

View File

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

View File

@@ -0,0 +1,50 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace FlowScope.UI
{
public sealed class UIScreenNavigator : IUIScreenNavigator
{
private readonly UIManager _uiManager;
private readonly Stack<Action> _closeStack = new();
public UIScreenNavigator(UIManager uiManager)
{
_uiManager = uiManager ?? throw new ArgumentNullException(nameof(uiManager));
}
public bool CanGoBack => _closeStack.Count > 1;
public async Task<TPanel> PushAsync<TPanel, TViewModel>(
TViewModel viewModel,
CancellationToken cancellationToken)
where TPanel : UIPanelBase<TViewModel>
{
var panel = await _uiManager.OpenAsync<TPanel, TViewModel>(viewModel, cancellationToken);
_closeStack.Push(() => _uiManager.Close<TPanel>());
return panel;
}
public void GoBack()
{
if (!CanGoBack)
{
return;
}
var close = _closeStack.Pop();
close();
}
public void Clear()
{
while (_closeStack.Count > 0)
{
var close = _closeStack.Pop();
close();
}
}
}
}

View File

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

View File

@@ -0,0 +1,115 @@
using System.Collections;
using System.Collections.Generic;
using System.Threading;
using FlowScope.UI;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;
namespace FlowScope.Tests.PlayMode.UI
{
public sealed class UIPreloadServiceTests
{
private readonly List<GameObject> _objects = new();
[TearDown]
public void TearDown()
{
foreach (var obj in _objects)
{
if (obj != null)
{
UnityEngine.Object.DestroyImmediate(obj);
}
}
_objects.Clear();
}
[UnityTest]
public IEnumerator PreloadAsync_LoadsPanelResourceAndKeepsHandle()
{
var prefab = CreatePrefab<ExplicitPathPanel>("ExplicitPathPrefab");
var resources = new UITestResources();
resources.Register("Custom/Explicit", prefab);
var preload = new UIPreloadService(resources);
yield return UITestAsync.Await(preload.PreloadAsync<ExplicitPathPanel>(CancellationToken.None));
Assert.That(resources.LoadedKeys, Is.EqualTo(new[] { "Custom/Explicit" }));
Assert.That(preload.IsPreloaded<ExplicitPathPanel>(), Is.True);
Assert.That(preload.TryGetPreloadedHandle<ExplicitPathPanel>(out var handle), Is.True);
Assert.That(handle.Asset, Is.SameAs(prefab));
Assert.That(resources.Handles[0].IsDisposed, Is.False);
}
[UnityTest]
public IEnumerator PreloadAsync_WhenRepeated_DoesNotLoadAgain()
{
var prefab = CreatePrefab<ExplicitPathPanel>("ExplicitPathPrefab");
var resources = new UITestResources();
resources.Register("Custom/Explicit", prefab);
var preload = new UIPreloadService(resources);
yield return UITestAsync.Await(preload.PreloadAsync<ExplicitPathPanel>(CancellationToken.None));
yield return UITestAsync.Await(preload.PreloadAsync<ExplicitPathPanel>(CancellationToken.None));
Assert.That(resources.LoadedKeys, Is.EqualTo(new[] { "Custom/Explicit" }));
Assert.That(resources.Handles, Has.Count.EqualTo(1));
}
[UnityTest]
public IEnumerator OpenAsync_WhenPanelIsPreloaded_ReusesPreloadedHandle()
{
var canvas = CreateCanvas("popup");
var prefab = CreatePrefab<ExplicitPathPanel>("ExplicitPathPrefab");
var resources = new UITestResources();
resources.Register("Custom/Explicit", prefab);
var preload = new UIPreloadService(resources);
var manager = new UIManager(resources, preload);
manager.RegisterLayer("popup", canvas, 0);
yield return UITestAsync.Await(preload.PreloadAsync<ExplicitPathPanel>(CancellationToken.None));
yield return UITestAsync.Await(
manager.OpenAsync<ExplicitPathPanel, TestViewModel>(
new TestViewModel("preloaded"),
CancellationToken.None));
manager.Close<ExplicitPathPanel>();
Assert.That(resources.LoadedKeys, Is.EqualTo(new[] { "Custom/Explicit" }));
Assert.That(resources.Handles[0].IsDisposed, Is.False);
Assert.That(preload.IsPreloaded<ExplicitPathPanel>(), Is.True);
}
[UnityTest]
public IEnumerator Release_DisposesPreloadedHandle()
{
var prefab = CreatePrefab<ExplicitPathPanel>("ExplicitPathPrefab");
var resources = new UITestResources();
resources.Register("Custom/Explicit", prefab);
var preload = new UIPreloadService(resources);
yield return UITestAsync.Await(preload.PreloadAsync<ExplicitPathPanel>(CancellationToken.None));
preload.Release<ExplicitPathPanel>();
Assert.That(resources.Handles[0].IsDisposed, Is.True);
Assert.That(preload.IsPreloaded<ExplicitPathPanel>(), 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;
}
}
}

View File

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

View File

@@ -0,0 +1,156 @@
using System.Collections;
using System.Collections.Generic;
using System.Threading;
using FlowScope.UI;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;
namespace FlowScope.Tests.PlayMode.UI
{
public sealed class UIScreenNavigatorTests
{
private readonly List<GameObject> _objects = new();
[TearDown]
public void TearDown()
{
foreach (var obj in _objects)
{
if (obj != null)
{
UnityEngine.Object.DestroyImmediate(obj);
}
}
_objects.Clear();
}
[UnityTest]
public IEnumerator PushAsync_OpensPanelAndRecordsHistory()
{
var manager = CreateManager(out _);
var navigator = new UIScreenNavigator(manager);
ExplicitPathPanel panel = null;
yield return UITestAsync.Await(
navigator.PushAsync<ExplicitPathPanel, TestViewModel>(
new TestViewModel("first"),
CancellationToken.None),
result => panel = result);
Assert.That(panel, Is.Not.Null);
Assert.That(manager.IsOpen<ExplicitPathPanel>(), Is.True);
Assert.That(navigator.CanGoBack, Is.False);
}
[UnityTest]
public IEnumerator GoBack_ClosesTopPanel()
{
var manager = CreateManager(out _);
var navigator = new UIScreenNavigator(manager);
yield return UITestAsync.Await(
navigator.PushAsync<ExplicitPathPanel, TestViewModel>(
new TestViewModel("first"),
CancellationToken.None));
yield return UITestAsync.Await(
navigator.PushAsync<SecondPanel, TestViewModel>(
new TestViewModel("second"),
CancellationToken.None));
Assert.That(navigator.CanGoBack, Is.True);
navigator.GoBack();
Assert.That(manager.IsOpen<SecondPanel>(), Is.False);
Assert.That(manager.IsOpen<ExplicitPathPanel>(), Is.True);
Assert.That(navigator.CanGoBack, Is.False);
}
[UnityTest]
public IEnumerator Clear_ClosesAllNavigatorPanels()
{
var manager = CreateManager(out _);
var navigator = new UIScreenNavigator(manager);
yield return UITestAsync.Await(
navigator.PushAsync<ExplicitPathPanel, TestViewModel>(
new TestViewModel("first"),
CancellationToken.None));
yield return UITestAsync.Await(
navigator.PushAsync<SecondPanel, TestViewModel>(
new TestViewModel("second"),
CancellationToken.None));
navigator.Clear();
Assert.That(manager.IsOpen<ExplicitPathPanel>(), Is.False);
Assert.That(manager.IsOpen<SecondPanel>(), Is.False);
Assert.That(navigator.CanGoBack, Is.False);
}
[UnityTest]
public IEnumerator GoBack_WhenOnlyOnePanelOpen_DoesNothing()
{
var manager = CreateManager(out _);
var navigator = new UIScreenNavigator(manager);
yield return UITestAsync.Await(
navigator.PushAsync<ExplicitPathPanel, TestViewModel>(
new TestViewModel("first"),
CancellationToken.None));
navigator.GoBack();
Assert.That(manager.IsOpen<ExplicitPathPanel>(), Is.True);
Assert.That(navigator.CanGoBack, Is.False);
}
[UnityTest]
public IEnumerator Clear_DoesNotClosePanelsOpenedOutsideNavigator()
{
var manager = CreateManager(out _);
var navigator = new UIScreenNavigator(manager);
yield return UITestAsync.Await(
manager.OpenAsync<ExplicitPathPanel, TestViewModel>(
new TestViewModel("outside"),
CancellationToken.None));
yield return UITestAsync.Await(
navigator.PushAsync<SecondPanel, TestViewModel>(
new TestViewModel("inside"),
CancellationToken.None));
navigator.Clear();
Assert.That(manager.IsOpen<SecondPanel>(), Is.False);
Assert.That(manager.IsOpen<ExplicitPathPanel>(), Is.True);
}
private UIManager CreateManager(out UITestResources resources)
{
var canvas = CreateCanvas("popup");
resources = new UITestResources();
resources.Register("Custom/Explicit", CreatePrefab<ExplicitPathPanel>("ExplicitPathPrefab"));
resources.Register("UI/Second/Prefab", CreatePrefab<SecondPanel>("SecondPrefab"));
var manager = new UIManager(resources);
manager.RegisterLayer("popup", canvas, 0);
return manager;
}
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;
}
}
}

View File

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

View File

@@ -0,0 +1,104 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using FlowScope.Resources;
using UnityEngine;
namespace FlowScope.Tests.PlayMode.UI
{
internal sealed class UITestResources : IResourceService
{
private readonly Dictionary<string, GameObject> _assets = new();
public List<string> LoadedKeys { get; } = new();
public List<UITestResourceHandle<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 UITestResourceHandle<GameObject>(key, asset);
Handles.Add(handle);
return Task.FromResult<IResourceHandle<T>>((IResourceHandle<T>)(object)handle);
}
public IResourceGroup CreateGroup()
{
throw new NotSupportedException();
}
}
internal sealed class UITestResourceHandle<T> : IResourceHandle<T>
where T : class
{
public UITestResourceHandle(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;
}
}
internal static class UITestAsync
{
public static IEnumerator Await(Task task)
{
while (!task.IsCompleted)
{
yield return null;
}
if (task.IsFaulted)
{
throw task.Exception.GetBaseException();
}
if (task.IsCanceled)
{
throw new OperationCanceledException();
}
}
public static IEnumerator Await<T>(Task<T> task, Action<T> onCompleted)
{
while (!task.IsCompleted)
{
yield return null;
}
if (task.IsFaulted)
{
throw task.Exception.GetBaseException();
}
if (task.IsCanceled)
{
throw new OperationCanceledException();
}
onCompleted(task.Result);
}
}
}

View File

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