diff --git a/My project/Assets/FlowScope/Runtime/UI/IUIPreloadService.cs b/My project/Assets/FlowScope/Runtime/UI/IUIPreloadService.cs index af4f3eb..d605ce2 100644 --- a/My project/Assets/FlowScope/Runtime/UI/IUIPreloadService.cs +++ b/My project/Assets/FlowScope/Runtime/UI/IUIPreloadService.cs @@ -1,5 +1,7 @@ using System.Threading; using System.Threading.Tasks; +using FlowScope.Resources; +using UnityEngine; namespace FlowScope.UI { @@ -7,6 +9,7 @@ namespace FlowScope.UI { Task PreloadAsync(CancellationToken cancellationToken); bool IsPreloaded(); + bool TryGetPreloadedHandle(out IResourceHandle handle); void Release(); void ReleaseAll(); } diff --git a/My project/Assets/FlowScope/Runtime/UI/UIManager.cs b/My project/Assets/FlowScope/Runtime/UI/UIManager.cs index 27d9c5b..db71f29 100644 --- a/My project/Assets/FlowScope/Runtime/UI/UIManager.cs +++ b/My project/Assets/FlowScope/Runtime/UI/UIManager.cs @@ -10,11 +10,13 @@ namespace FlowScope.UI public sealed class UIManager { private readonly IResourceService _resources; + private readonly IUIPreloadService _preloadService; private readonly Dictionary _layers = new(); - public UIManager(IResourceService resources) + public UIManager(IResourceService resources, IUIPreloadService preloadService = null) { _resources = resources ?? throw new ArgumentNullException(nameof(resources)); + _preloadService = preloadService; } public void RegisterLayer( @@ -58,7 +60,7 @@ namespace FlowScope.UI } var path = ResolvePath(typeof(TPanel), attribute); - var handle = await _resources.LoadAsync(path, cancellationToken); + var handle = await LoadPanelHandleAsync(path, cancellationToken); if (handle == null || handle.Asset == null) { handle?.Dispose(); @@ -66,6 +68,7 @@ namespace FlowScope.UI } GameObject instance = null; + var ownsHandle = !IsPreloadedHandle(handle); try { instance = UnityEngine.Object.Instantiate(handle.Asset, layer.Canvas.transform, false); @@ -79,7 +82,8 @@ namespace FlowScope.UI panel.Bind(viewModel); 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) { layer.Cache(record); @@ -95,7 +99,11 @@ namespace FlowScope.UI UnityEngine.Object.Destroy(instance); } - handle.Dispose(); + if (ownsHandle) + { + handle.Dispose(); + } + throw; } } @@ -188,7 +196,27 @@ namespace FlowScope.UI record.Handle.Dispose(); } - private static UIPanelAttribute GetPanelAttribute(Type panelType) + private async Task> LoadPanelHandleAsync( + string path, + CancellationToken cancellationToken) + { + if (_preloadService != null && + _preloadService.TryGetPreloadedHandle(out var preloadedHandle)) + { + return preloadedHandle; + } + + return await _resources.LoadAsync(path, cancellationToken); + } + + private bool IsPreloadedHandle(IResourceHandle handle) + { + return _preloadService != null && + _preloadService.TryGetPreloadedHandle(out var preloadedHandle) && + ReferenceEquals(preloadedHandle, handle); + } + + internal static UIPanelAttribute GetPanelAttribute(Type panelType) { var attribute = (UIPanelAttribute)Attribute.GetCustomAttribute( panelType, @@ -209,7 +237,7 @@ namespace FlowScope.UI return attribute; } - private static string ResolvePath(Type panelType, UIPanelAttribute attribute) + internal static string ResolvePath(Type panelType, UIPanelAttribute attribute) { if (!string.IsNullOrWhiteSpace(attribute.Path)) { @@ -225,5 +253,14 @@ namespace FlowScope.UI return $"UI/{name}/Prefab"; } + + private sealed class RetainedPanelHandle : IDisposable + { + public static readonly RetainedPanelHandle Instance = new(); + + public void Dispose() + { + } + } } } diff --git a/My project/Assets/FlowScope/Runtime/UI/UIPreloadService.cs b/My project/Assets/FlowScope/Runtime/UI/UIPreloadService.cs new file mode 100644 index 0000000..4dd6f01 --- /dev/null +++ b/My project/Assets/FlowScope/Runtime/UI/UIPreloadService.cs @@ -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> _handles = new(); + + public UIPreloadService(IResourceService resources) + { + _resources = resources ?? throw new ArgumentNullException(nameof(resources)); + } + + public async Task PreloadAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var panelType = typeof(TPanel); + if (IsPreloaded()) + { + return; + } + + var attribute = UIManager.GetPanelAttribute(panelType); + var path = UIManager.ResolvePath(panelType, attribute); + var handle = await _resources.LoadAsync(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() + { + return _handles.TryGetValue(typeof(TPanel), out var handle) && + handle != null && + !handle.IsDisposed && + handle.Asset != null; + } + + public bool TryGetPreloadedHandle(out IResourceHandle handle) + { + if (IsPreloaded()) + { + handle = _handles[typeof(TPanel)]; + return true; + } + + handle = null; + return false; + } + + public void Release() + { + 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(); + } + } +} diff --git a/My project/Assets/FlowScope/Runtime/UI/UIPreloadService.cs.meta b/My project/Assets/FlowScope/Runtime/UI/UIPreloadService.cs.meta new file mode 100644 index 0000000..1e636c3 --- /dev/null +++ b/My project/Assets/FlowScope/Runtime/UI/UIPreloadService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6b2cbe6065884f82838b7c882ad5085a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/My project/Assets/FlowScope/Runtime/UI/UIScreenNavigator.cs b/My project/Assets/FlowScope/Runtime/UI/UIScreenNavigator.cs new file mode 100644 index 0000000..aef83b3 --- /dev/null +++ b/My project/Assets/FlowScope/Runtime/UI/UIScreenNavigator.cs @@ -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 _closeStack = new(); + + public UIScreenNavigator(UIManager uiManager) + { + _uiManager = uiManager ?? throw new ArgumentNullException(nameof(uiManager)); + } + + public bool CanGoBack => _closeStack.Count > 1; + + public async Task PushAsync( + TViewModel viewModel, + CancellationToken cancellationToken) + where TPanel : UIPanelBase + { + var panel = await _uiManager.OpenAsync(viewModel, cancellationToken); + _closeStack.Push(() => _uiManager.Close()); + 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(); + } + } + } +} diff --git a/My project/Assets/FlowScope/Runtime/UI/UIScreenNavigator.cs.meta b/My project/Assets/FlowScope/Runtime/UI/UIScreenNavigator.cs.meta new file mode 100644 index 0000000..296aee5 --- /dev/null +++ b/My project/Assets/FlowScope/Runtime/UI/UIScreenNavigator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f991669426b4444098f1973daeb8fb58 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/My project/Assets/FlowScope/Tests/PlayMode/UI/UIPreloadServiceTests.cs b/My project/Assets/FlowScope/Tests/PlayMode/UI/UIPreloadServiceTests.cs new file mode 100644 index 0000000..dc0e519 --- /dev/null +++ b/My project/Assets/FlowScope/Tests/PlayMode/UI/UIPreloadServiceTests.cs @@ -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 _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("ExplicitPathPrefab"); + var resources = new UITestResources(); + resources.Register("Custom/Explicit", prefab); + var preload = new UIPreloadService(resources); + + yield return UITestAsync.Await(preload.PreloadAsync(CancellationToken.None)); + + Assert.That(resources.LoadedKeys, Is.EqualTo(new[] { "Custom/Explicit" })); + Assert.That(preload.IsPreloaded(), Is.True); + Assert.That(preload.TryGetPreloadedHandle(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("ExplicitPathPrefab"); + var resources = new UITestResources(); + resources.Register("Custom/Explicit", prefab); + var preload = new UIPreloadService(resources); + + yield return UITestAsync.Await(preload.PreloadAsync(CancellationToken.None)); + yield return UITestAsync.Await(preload.PreloadAsync(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("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(CancellationToken.None)); + yield return UITestAsync.Await( + manager.OpenAsync( + new TestViewModel("preloaded"), + CancellationToken.None)); + manager.Close(); + + Assert.That(resources.LoadedKeys, Is.EqualTo(new[] { "Custom/Explicit" })); + Assert.That(resources.Handles[0].IsDisposed, Is.False); + Assert.That(preload.IsPreloaded(), Is.True); + } + + [UnityTest] + public IEnumerator Release_DisposesPreloadedHandle() + { + var prefab = CreatePrefab("ExplicitPathPrefab"); + var resources = new UITestResources(); + resources.Register("Custom/Explicit", prefab); + var preload = new UIPreloadService(resources); + + yield return UITestAsync.Await(preload.PreloadAsync(CancellationToken.None)); + preload.Release(); + + Assert.That(resources.Handles[0].IsDisposed, Is.True); + Assert.That(preload.IsPreloaded(), Is.False); + } + + private Canvas CreateCanvas(string name) + { + var gameObject = new GameObject(name); + _objects.Add(gameObject); + return gameObject.AddComponent(); + } + + private GameObject CreatePrefab(string name) + where TPanel : Component + { + var gameObject = new GameObject(name); + _objects.Add(gameObject); + gameObject.AddComponent(); + return gameObject; + } + } +} diff --git a/My project/Assets/FlowScope/Tests/PlayMode/UI/UIPreloadServiceTests.cs.meta b/My project/Assets/FlowScope/Tests/PlayMode/UI/UIPreloadServiceTests.cs.meta new file mode 100644 index 0000000..b036dcc --- /dev/null +++ b/My project/Assets/FlowScope/Tests/PlayMode/UI/UIPreloadServiceTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 737c460841f54ab5a073695d039b9524 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/My project/Assets/FlowScope/Tests/PlayMode/UI/UIScreenNavigatorTests.cs b/My project/Assets/FlowScope/Tests/PlayMode/UI/UIScreenNavigatorTests.cs new file mode 100644 index 0000000..12059dd --- /dev/null +++ b/My project/Assets/FlowScope/Tests/PlayMode/UI/UIScreenNavigatorTests.cs @@ -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 _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( + new TestViewModel("first"), + CancellationToken.None), + result => panel = result); + + Assert.That(panel, Is.Not.Null); + Assert.That(manager.IsOpen(), 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( + new TestViewModel("first"), + CancellationToken.None)); + yield return UITestAsync.Await( + navigator.PushAsync( + new TestViewModel("second"), + CancellationToken.None)); + + Assert.That(navigator.CanGoBack, Is.True); + navigator.GoBack(); + + Assert.That(manager.IsOpen(), Is.False); + Assert.That(manager.IsOpen(), 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( + new TestViewModel("first"), + CancellationToken.None)); + yield return UITestAsync.Await( + navigator.PushAsync( + new TestViewModel("second"), + CancellationToken.None)); + + navigator.Clear(); + + Assert.That(manager.IsOpen(), Is.False); + Assert.That(manager.IsOpen(), 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( + new TestViewModel("first"), + CancellationToken.None)); + navigator.GoBack(); + + Assert.That(manager.IsOpen(), 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( + new TestViewModel("outside"), + CancellationToken.None)); + yield return UITestAsync.Await( + navigator.PushAsync( + new TestViewModel("inside"), + CancellationToken.None)); + + navigator.Clear(); + + Assert.That(manager.IsOpen(), Is.False); + Assert.That(manager.IsOpen(), Is.True); + } + + private UIManager CreateManager(out UITestResources resources) + { + var canvas = CreateCanvas("popup"); + resources = new UITestResources(); + resources.Register("Custom/Explicit", CreatePrefab("ExplicitPathPrefab")); + resources.Register("UI/Second/Prefab", CreatePrefab("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(); + } + + private GameObject CreatePrefab(string name) + where TPanel : Component + { + var gameObject = new GameObject(name); + _objects.Add(gameObject); + gameObject.AddComponent(); + return gameObject; + } + } +} diff --git a/My project/Assets/FlowScope/Tests/PlayMode/UI/UIScreenNavigatorTests.cs.meta b/My project/Assets/FlowScope/Tests/PlayMode/UI/UIScreenNavigatorTests.cs.meta new file mode 100644 index 0000000..8339d65 --- /dev/null +++ b/My project/Assets/FlowScope/Tests/PlayMode/UI/UIScreenNavigatorTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 09c0ef54950240efb3894c9ff75f4eea +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/My project/Assets/FlowScope/Tests/PlayMode/UI/UITestHelpers.cs b/My project/Assets/FlowScope/Tests/PlayMode/UI/UITestHelpers.cs new file mode 100644 index 0000000..c006d3f --- /dev/null +++ b/My project/Assets/FlowScope/Tests/PlayMode/UI/UITestHelpers.cs @@ -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 _assets = new(); + + public List LoadedKeys { get; } = new(); + public List> Handles { get; } = new(); + + public void Register(string key, GameObject asset) + { + _assets.Add(key, asset); + } + + public Task> LoadAsync(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(key, asset); + Handles.Add(handle); + return Task.FromResult>((IResourceHandle)(object)handle); + } + + public IResourceGroup CreateGroup() + { + throw new NotSupportedException(); + } + } + + internal sealed class UITestResourceHandle : IResourceHandle + 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(Task task, Action onCompleted) + { + while (!task.IsCompleted) + { + yield return null; + } + + if (task.IsFaulted) + { + throw task.Exception.GetBaseException(); + } + + if (task.IsCanceled) + { + throw new OperationCanceledException(); + } + + onCompleted(task.Result); + } + } +} diff --git a/My project/Assets/FlowScope/Tests/PlayMode/UI/UITestHelpers.cs.meta b/My project/Assets/FlowScope/Tests/PlayMode/UI/UITestHelpers.cs.meta new file mode 100644 index 0000000..14eb3a1 --- /dev/null +++ b/My project/Assets/FlowScope/Tests/PlayMode/UI/UITestHelpers.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 69f35d03665e47d3bf7ca6df393f07c2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: