新增 FlowScope Git 包雏形

This commit is contained in:
JSD\13999
2026-06-04 14:54:49 +08:00
parent 37e6d4ca57
commit f5887da9fb
127 changed files with 5330 additions and 0 deletions

View File

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

View File

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

View File

@@ -0,0 +1,19 @@
using System.Threading;
using System.Threading.Tasks;
namespace FlowScope.UI
{
public interface IUIScreenNavigator
{
Task<TPanel> PushAsync<TPanel, TViewModel>(
TViewModel viewModel,
CancellationToken cancellationToken)
where TPanel : UIPanelBase<TViewModel>;
bool CanGoBack { get; }
void GoBack();
void Clear();
}
}

View File

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

View File

@@ -0,0 +1,8 @@
namespace FlowScope.UI
{
public enum PanelStrategy
{
Destroy,
Cache
}
}

View File

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

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,266 @@
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 IUIPreloadService _preloadService;
private readonly Dictionary<string, UILayer> _layers = new();
public UIManager(IResourceService resources, IUIPreloadService preloadService = null)
{
_resources = resources ?? throw new ArgumentNullException(nameof(resources));
_preloadService = preloadService;
}
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 LoadPanelHandleAsync<TPanel>(path, cancellationToken);
if (handle == null || handle.Asset == null)
{
handle?.Dispose();
throw new InvalidOperationException($"UI panel resource is missing: {path}");
}
GameObject instance = null;
var ownsHandle = !IsPreloadedHandle<TPanel>(handle);
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 recordHandle = ownsHandle ? (IDisposable)handle : RetainedPanelHandle.Instance;
var record = new UIPanelRecord(typeof(TPanel), layer.Name, panel, recordHandle, strategy);
if (strategy == PanelStrategy.Cache)
{
layer.Cache(record);
}
layer.Push(record);
return panel;
}
catch
{
if (instance != null)
{
UnityEngine.Object.Destroy(instance);
}
if (ownsHandle)
{
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.Destroy(record.Panel.gameObject);
record.Handle.Dispose();
}
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(
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;
}
internal 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";
}
private sealed class RetainedPanelHandle : IDisposable
{
public static readonly RetainedPanelHandle Instance = new();
public void Dispose()
{
}
}
}
}

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

@@ -0,0 +1,42 @@
using System;
namespace FlowScope.UI
{
[AttributeUsage(AttributeTargets.Class)]
public sealed class UIPanelAttribute : Attribute
{
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 path,
PanelStrategy? overrideStrategy)
{
Layer = layer;
Path = path;
OverrideStrategy = overrideStrategy;
}
public string Layer { get; }
public string Path { get; }
public PanelStrategy? OverrideStrategy { get; }
}
}

View File

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

View File

@@ -0,0 +1,31 @@
using R3;
using UnityEngine;
namespace FlowScope.UI
{
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 CompositeDisposable Disposables { get; } = new();
public void Bind(TViewModel viewModel)
{
ViewModel = viewModel;
OnBind(viewModel);
}
public virtual void Unbind()
{
Disposables.Clear();
}
protected abstract void OnBind(TViewModel viewModel);
}
}

View File

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

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,274 @@
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 object _gate = new();
private readonly IResourceService _resources;
private readonly Dictionary<Type, IResourceHandle<GameObject>> _handles = new();
private readonly Dictionary<Type, InFlightPreload> _inFlightLoads = new();
private int _releaseGeneration;
private bool _disposed;
public UIPreloadService(IResourceService resources)
{
_resources = resources ?? throw new ArgumentNullException(nameof(resources));
}
public async Task PreloadAsync<TPanel>(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var panelType = typeof(TPanel);
InFlightPreload inFlightLoad;
bool startLoad = false;
string path = null;
lock (_gate)
{
ThrowIfDisposed();
if (TryGetPreloadedHandle(panelType, out _))
{
return;
}
if (!_inFlightLoads.TryGetValue(panelType, out inFlightLoad) ||
inFlightLoad.Invalidated ||
inFlightLoad.ReleaseGeneration != _releaseGeneration)
{
var attribute = UIManager.GetPanelAttribute(panelType);
path = UIManager.ResolvePath(panelType, attribute);
inFlightLoad = new InFlightPreload();
inFlightLoad.ReleaseGeneration = _releaseGeneration;
_inFlightLoads[panelType] = inFlightLoad;
startLoad = true;
}
inFlightLoad.WaiterCount++;
if (startLoad)
{
inFlightLoad.Task = LoadAndStoreAsync(panelType, path, inFlightLoad);
}
}
try
{
await WaitForSharedLoadAsync(inFlightLoad.Task, cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
ForgetWaitingCaller(panelType, inFlightLoad);
throw;
}
}
public bool IsPreloaded<TPanel>()
{
lock (_gate)
{
if (_disposed)
{
return false;
}
return TryGetPreloadedHandle(typeof(TPanel), out _);
}
}
public bool TryGetPreloadedHandle<TPanel>(out IResourceHandle<GameObject> handle)
{
lock (_gate)
{
if (_disposed)
{
handle = null;
return false;
}
return TryGetPreloadedHandle(typeof(TPanel), out handle);
}
}
public void Release<TPanel>()
{
var panelType = typeof(TPanel);
IResourceHandle<GameObject> handle = null;
lock (_gate)
{
if (_disposed)
{
return;
}
InvalidateInFlightLoad(panelType);
if (_handles.TryGetValue(panelType, out handle))
{
_handles.Remove(panelType);
}
}
handle?.Dispose();
}
public void ReleaseAll()
{
List<IResourceHandle<GameObject>> handles;
lock (_gate)
{
_releaseGeneration++;
handles = new List<IResourceHandle<GameObject>>(_handles.Values);
_handles.Clear();
}
foreach (var handle in handles)
{
handle.Dispose();
}
}
public void Dispose()
{
lock (_gate)
{
if (_disposed)
{
return;
}
_disposed = true;
}
ReleaseAll();
}
private async Task LoadAndStoreAsync(
Type panelType,
string path,
InFlightPreload inFlightLoad)
{
IResourceHandle<GameObject> handle = null;
try
{
handle = await _resources.LoadAsync<GameObject>(path, CancellationToken.None);
if (handle == null || handle.Asset == null)
{
handle?.Dispose();
throw new InvalidOperationException($"UI panel resource is missing: {path}");
}
var shouldCache = false;
lock (_gate)
{
ForgetInFlightLoad(panelType, inFlightLoad);
if (!_disposed &&
!inFlightLoad.Invalidated &&
inFlightLoad.ReleaseGeneration == _releaseGeneration &&
inFlightLoad.WaiterCount > 0)
{
_handles[panelType] = handle;
shouldCache = true;
}
}
if (!shouldCache)
{
handle.Dispose();
}
}
catch
{
lock (_gate)
{
ForgetInFlightLoad(panelType, inFlightLoad);
}
throw;
}
}
private void ForgetInFlightLoad(Type panelType, InFlightPreload inFlightLoad)
{
if (_inFlightLoads.TryGetValue(panelType, out var current) &&
ReferenceEquals(current, inFlightLoad))
{
_inFlightLoads.Remove(panelType);
}
}
private void InvalidateInFlightLoad(Type panelType)
{
if (_inFlightLoads.TryGetValue(panelType, out var inFlightLoad))
{
inFlightLoad.Invalidated = true;
}
}
private void ForgetWaitingCaller(Type panelType, InFlightPreload inFlightLoad)
{
lock (_gate)
{
if (_inFlightLoads.TryGetValue(panelType, out var current) &&
ReferenceEquals(current, inFlightLoad) &&
inFlightLoad.WaiterCount > 0)
{
inFlightLoad.WaiterCount--;
}
}
}
private bool TryGetPreloadedHandle(
Type panelType,
out IResourceHandle<GameObject> handle)
{
return _handles.TryGetValue(panelType, out handle) &&
handle != null &&
!handle.IsDisposed &&
handle.Asset != null;
}
private void ThrowIfDisposed()
{
if (_disposed)
{
throw new ObjectDisposedException(nameof(UIPreloadService));
}
}
private static async Task WaitForSharedLoadAsync(
Task loadTask,
CancellationToken cancellationToken)
{
if (!cancellationToken.CanBeCanceled)
{
await loadTask;
return;
}
var cancellation = new TaskCompletionSource<bool>(
TaskCreationOptions.RunContinuationsAsynchronously);
using (cancellationToken.Register(() => cancellation.TrySetResult(true)))
{
if (await Task.WhenAny(loadTask, cancellation.Task) == cancellation.Task)
{
throw new OperationCanceledException(cancellationToken);
}
}
await loadTask;
}
private sealed class InFlightPreload
{
public Task Task { get; set; }
public int WaiterCount { get; set; }
public int ReleaseGeneration { get; set; }
public bool Invalidated { get; set; }
}
}
}

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: