From e13de4faeca87ffd5d680ee11eb5d83bc291685f Mon Sep 17 00:00:00 2001 From: chinboy Date: Sun, 14 Jun 2026 20:16:29 +0800 Subject: [PATCH] feat(GalleryShowCase): implement deferred loading control and diagnostics for showcase items --- .../Controls/GalleryShowCaseRuntimeOptions.cs | 42 +++++++ .../Controls/ShowCaseItem.axaml.cs | 10 ++ .../Controls/ShowCasePanel.axaml.cs | 42 +++++-- .../ViewModels/CaseNavigationViewModel.cs | 3 + .../gallery-showcase-design-pattern.md | 1 + .../Controls/ShowCasePanelStructureTests.cs | 119 +++++++++++++++--- ...CaseNavigationViewModelDiagnosticsTests.cs | 49 ++++++++ 7 files changed, 239 insertions(+), 27 deletions(-) create mode 100644 controlgallery/AtomUIGallery/Controls/GalleryShowCaseRuntimeOptions.cs create mode 100644 tests/AtomUIGallery.Tests/Workspace/CaseNavigationViewModelDiagnosticsTests.cs diff --git a/controlgallery/AtomUIGallery/Controls/GalleryShowCaseRuntimeOptions.cs b/controlgallery/AtomUIGallery/Controls/GalleryShowCaseRuntimeOptions.cs new file mode 100644 index 000000000..9a50c631c --- /dev/null +++ b/controlgallery/AtomUIGallery/Controls/GalleryShowCaseRuntimeOptions.cs @@ -0,0 +1,42 @@ +namespace AtomUIGallery.Controls; + +public static class GalleryShowCaseRuntimeOptions +{ + public const string DisableDeferredLoadingEnvironmentVariable = "ATOMUI_GALLERY_DISABLE_SHOWCASE_DEFERRED"; + + private static bool s_isDeferredLoadingDisabledOverride; + + public static event EventHandler? DeferredLoadingDisabledChanged; + + public static bool IsDeferredLoadingDisabled + { + get => s_isDeferredLoadingDisabledOverride || IsEnvironmentDeferredLoadingDisabled(); + set => SetDeferredLoadingDisabledOverride(value); + } + + public static void ResetDeferredLoadingDisabledOverride() + { + SetDeferredLoadingDisabledOverride(false); + } + + private static void SetDeferredLoadingDisabledOverride(bool value) + { + var oldValue = IsDeferredLoadingDisabled; + s_isDeferredLoadingDisabledOverride = value; + var newValue = IsDeferredLoadingDisabled; + if (oldValue != newValue) + { + DeferredLoadingDisabledChanged?.Invoke(null, EventArgs.Empty); + } + } + + private static bool IsEnvironmentDeferredLoadingDisabled() + { + var value = Environment.GetEnvironmentVariable(DisableDeferredLoadingEnvironmentVariable); + return value is not null && + (value.Equals("1", StringComparison.OrdinalIgnoreCase) || + value.Equals("true", StringComparison.OrdinalIgnoreCase) || + value.Equals("yes", StringComparison.OrdinalIgnoreCase) || + value.Equals("on", StringComparison.OrdinalIgnoreCase)); + } +} diff --git a/controlgallery/AtomUIGallery/Controls/ShowCaseItem.axaml.cs b/controlgallery/AtomUIGallery/Controls/ShowCaseItem.axaml.cs index d9502cd1e..271491606 100644 --- a/controlgallery/AtomUIGallery/Controls/ShowCaseItem.axaml.cs +++ b/controlgallery/AtomUIGallery/Controls/ShowCaseItem.axaml.cs @@ -150,14 +150,24 @@ public class ShowCaseItem : ContentControl change.Property == DeferredContentTemplateProperty || change.Property == IsDeferredContentMaterializedProperty) { + MaterializeDeferredContentIfDiagnosticsDisabled(); UpdateDeferredPlaceholderVisibility(); } } + private void MaterializeDeferredContentIfDiagnosticsDisabled() + { + if (GalleryShowCaseRuntimeOptions.IsDeferredLoadingDisabled) + { + MaterializeDeferredContent(); + } + } + private void UpdateDeferredPlaceholderVisibility() { IsDeferredPlaceholderVisible = IsDeferredContentEnabled && + !GalleryShowCaseRuntimeOptions.IsDeferredLoadingDisabled && DeferredContentTemplate is not null && !IsDeferredContentMaterialized; } diff --git a/controlgallery/AtomUIGallery/Controls/ShowCasePanel.axaml.cs b/controlgallery/AtomUIGallery/Controls/ShowCasePanel.axaml.cs index bf642d0c9..1481b5038 100644 --- a/controlgallery/AtomUIGallery/Controls/ShowCasePanel.axaml.cs +++ b/controlgallery/AtomUIGallery/Controls/ShowCasePanel.axaml.cs @@ -125,7 +125,8 @@ public class ShowCasePanel : TemplatedControl if (_layoutPanel != null) { _nextProgressiveMountIndex = 0; - if (OperatingSystem.IsBrowser()) + if (OperatingSystem.IsBrowser() && + !GalleryShowCaseRuntimeOptions.IsDeferredLoadingDisabled) { _nextProgressiveMountIndex = MountShowCaseItems(0, BrowserInitialShowCaseItemCount); StartProgressiveMountTimer(); @@ -135,7 +136,7 @@ public class ShowCasePanel : TemplatedControl MountShowCaseItems(0, Children.Count); } - if (IsDeferredLoadingEnabled) + if (IsDeferredLoadingEffectivelyEnabled) { MaterializeInitialDeferredContent(); QueueDeferredViewportMaterialization(); @@ -151,12 +152,18 @@ public class ShowCasePanel : TemplatedControl { base.OnAttachedToVisualTree(e); _isAttachedToVisualTree = true; + GalleryShowCaseRuntimeOptions.DeferredLoadingDisabledChanged += HandleDeferredLoadingDisabledChanged; UpdateEffectiveViewportSubscription(); + if (GalleryShowCaseRuntimeOptions.IsDeferredLoadingDisabled) + { + MaterializeAllDeferredContent(); + } } protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) { _isAttachedToVisualTree = false; + GalleryShowCaseRuntimeOptions.DeferredLoadingDisabledChanged -= HandleDeferredLoadingDisabledChanged; UpdateEffectiveViewportSubscription(false); StopProgressiveMountTimer(); _lastEffectiveViewport = null; @@ -170,7 +177,7 @@ public class ShowCasePanel : TemplatedControl if (change.Property == IsDeferredLoadingEnabledProperty) { UpdateEffectiveViewportSubscription(); - if (IsDeferredLoadingEnabled) + if (IsDeferredLoadingEffectivelyEnabled) { MaterializeInitialDeferredContent(); QueueDeferredViewportMaterialization(); @@ -218,7 +225,7 @@ public class ShowCasePanel : TemplatedControl if (Children[index] is ShowCaseItem item) { _layoutPanel.Children.Add(item); - if (!IsDeferredLoadingEnabled) + if (!IsDeferredLoadingEffectivelyEnabled) { item.MaterializeDeferredContent(); } @@ -275,7 +282,7 @@ public class ShowCasePanel : TemplatedControl private void UpdateEffectiveViewportSubscription(bool? shouldSubscribe = null) { - var subscribe = shouldSubscribe ?? (IsDeferredLoadingEnabled && _isAttachedToVisualTree); + var subscribe = shouldSubscribe ?? (IsDeferredLoadingEffectivelyEnabled && _isAttachedToVisualTree); if (subscribe == _isEffectiveViewportSubscribed) { return; @@ -312,7 +319,7 @@ public class ShowCasePanel : TemplatedControl private void MaterializeInitialDeferredContent() { - if (!IsDeferredLoadingEnabled) + if (!IsDeferredLoadingEffectivelyEnabled) { return; } @@ -335,7 +342,7 @@ public class ShowCasePanel : TemplatedControl private void QueueDeferredViewportMaterialization() { - if (!IsDeferredLoadingEnabled || + if (!IsDeferredLoadingEffectivelyEnabled || _deferredViewportMaterializationQueued || !_isAttachedToVisualTree) { @@ -352,7 +359,7 @@ public class ShowCasePanel : TemplatedControl private void MaterializeDeferredContentInViewport() { - if (!IsDeferredLoadingEnabled || + if (!IsDeferredLoadingEffectivelyEnabled || _layoutPanel is null || _lastEffectiveViewport is not { } viewport) { @@ -410,6 +417,25 @@ public class ShowCasePanel : TemplatedControl return viewport.Intersects(itemBounds); } + private bool IsDeferredLoadingEffectivelyEnabled => + IsDeferredLoadingEnabled && !GalleryShowCaseRuntimeOptions.IsDeferredLoadingDisabled; + + private void HandleDeferredLoadingDisabledChanged(object? sender, EventArgs e) + { + UpdateEffectiveViewportSubscription(); + if (IsDeferredLoadingEffectivelyEnabled) + { + MaterializeInitialDeferredContent(); + QueueDeferredViewportMaterialization(); + } + else + { + StopProgressiveMountTimer(); + _nextProgressiveMountIndex = MountShowCaseItems(_nextProgressiveMountIndex, Children.Count); + MaterializeAllDeferredContent(); + } + } + internal virtual void NotifyAboutToActive() { } diff --git a/controlgallery/AtomUIGallery/Workspace/ViewModels/CaseNavigationViewModel.cs b/controlgallery/AtomUIGallery/Workspace/ViewModels/CaseNavigationViewModel.cs index 484ae282b..8b5b7fccf 100644 --- a/controlgallery/AtomUIGallery/Workspace/ViewModels/CaseNavigationViewModel.cs +++ b/controlgallery/AtomUIGallery/Workspace/ViewModels/CaseNavigationViewModel.cs @@ -2,6 +2,7 @@ using System.Reactive; using System.Reactive.Linq; using AtomUI.Controls; using AtomUI.Desktop.Controls; +using AtomUIGallery.Controls; using AtomUIGallery.ShowCases.Alert; using AtomUIGallery.ShowCases.AutoComplete; using AtomUIGallery.ShowCases.Avatar; @@ -234,6 +235,7 @@ public class CaseNavigationViewModel : ReactiveObject, IActivatableViewModel private void DoTestNavigatePages(TimeSpan interval) { + GalleryShowCaseRuntimeOptions.IsDeferredLoadingDisabled = true; _dispatcherTimer.Stop(); _dispatcherTimer.Interval = interval; _dispatcherTimer.Start(); @@ -242,5 +244,6 @@ public class CaseNavigationViewModel : ReactiveObject, IActivatableViewModel private void DoStopTestNavigatePages() { _dispatcherTimer.Stop(); + GalleryShowCaseRuntimeOptions.IsDeferredLoadingDisabled = false; } } diff --git a/docs/gallery/gallery-showcase-design-pattern.md b/docs/gallery/gallery-showcase-design-pattern.md index be008451b..5678fd60b 100644 --- a/docs/gallery/gallery-showcase-design-pattern.md +++ b/docs/gallery/gallery-showcase-design-pattern.md @@ -273,6 +273,7 @@ Panel 层负责控制一批 `ShowCaseItem` 何时 materialize: - `ShowCasePanel` 只允许使用一个 panel 级 `EffectiveViewportChanged` 监听来判断 viewport,不允许给每个 `ShowCaseItem` 单独挂监听。 - 已 materialize 的内容不回收。ShowCase 是文档式页面,不做无限列表虚拟化,避免状态、焦点、Popup/Flyout 生命周期被破坏。 - 旧页面在未迁移前可以保留默认 `IsDeferredLoadingEnabled="False"`;一旦纳入本轮 ShowCase 迁移,或后续新增 ShowCase,就必须开启。 +- 内存泄漏或全量创建压测不能修改单个 ShowCase 的 XAML。使用 `GalleryShowCaseRuntimeOptions` 统一关闭延迟创建:`F5` 启动自动切页压测时会设置 `IsDeferredLoadingDisabled=True`,`F6` 停止压测时恢复运行时开关。命令行全程压测可设置 `ATOMUI_GALLERY_DISABLE_SHOWCASE_DEFERRED=1`,该环境变量优先于 `F6` 的运行时恢复。 Item 层负责真正延迟创建演示控件。所有有演示内容的 `ShowCaseItem` 必须使用下面的写法: diff --git a/tests/AtomUIGallery.Tests/Controls/ShowCasePanelStructureTests.cs b/tests/AtomUIGallery.Tests/Controls/ShowCasePanelStructureTests.cs index c36715c84..8881d785d 100644 --- a/tests/AtomUIGallery.Tests/Controls/ShowCasePanelStructureTests.cs +++ b/tests/AtomUIGallery.Tests/Controls/ShowCasePanelStructureTests.cs @@ -56,32 +56,98 @@ public class ShowCasePanelStructureTests public void ShowCaseItem_Deferred_Content_Template_Builds_Only_When_Materialized() { AvaloniaTestApp.EnsureInitialized(); - var buildCount = 0; - var item = new ShowCaseItem + var oldValue = SetDeferredLoadingEnvironment(null); + GalleryShowCaseRuntimeOptions.ResetDeferredLoadingDisabledOverride(); + try { - IsDeferredContentEnabled = true, - DataContext = "demo data", - DeferredContentTemplate = new FuncDataTemplate((data, _) => + var buildCount = 0; + var item = new ShowCaseItem { - buildCount++; - return new TextBlock + IsDeferredContentEnabled = true, + DataContext = "demo data", + DeferredContentTemplate = new FuncDataTemplate((data, _) => { - Text = data?.ToString() - }; - }, true) - }; + buildCount++; + return new TextBlock + { + Text = data?.ToString() + }; + }, true) + }; - buildCount.ShouldBe(0); - item.IsDeferredContentMaterialized.ShouldBeFalse(); + buildCount.ShouldBe(0); + item.IsDeferredContentMaterialized.ShouldBeFalse(); - item.MaterializeDeferredContent(); + item.MaterializeDeferredContent(); - buildCount.ShouldBe(1); - item.IsDeferredContentMaterialized.ShouldBeTrue(); - item.Content.ShouldBeOfType().Text.ShouldBe("demo data"); + buildCount.ShouldBe(1); + item.IsDeferredContentMaterialized.ShouldBeTrue(); + item.Content.ShouldBeOfType().Text.ShouldBe("demo data"); - item.MaterializeDeferredContent(); - buildCount.ShouldBe(1); + item.MaterializeDeferredContent(); + buildCount.ShouldBe(1); + } + finally + { + SetDeferredLoadingEnvironment(oldValue); + GalleryShowCaseRuntimeOptions.ResetDeferredLoadingDisabledOverride(); + } + } + + [Fact] + public void ShowCaseItem_Materializes_Deferred_Template_Immediately_When_Diagnostics_Disables_Defer() + { + AvaloniaTestApp.EnsureInitialized(); + var oldValue = SetDeferredLoadingEnvironment(null); + GalleryShowCaseRuntimeOptions.ResetDeferredLoadingDisabledOverride(); + try + { + GalleryShowCaseRuntimeOptions.IsDeferredLoadingDisabled = true; + + var buildCount = 0; + var item = new ShowCaseItem + { + IsDeferredContentEnabled = true, + DataContext = "diagnostic data", + DeferredContentTemplate = new FuncDataTemplate((data, _) => + { + buildCount++; + return new TextBlock + { + Text = data?.ToString() + }; + }, true) + }; + + buildCount.ShouldBe(1); + item.IsDeferredContentMaterialized.ShouldBeTrue(); + item.Content.ShouldBeOfType().Text.ShouldBe("diagnostic data"); + } + finally + { + SetDeferredLoadingEnvironment(oldValue); + GalleryShowCaseRuntimeOptions.ResetDeferredLoadingDisabledOverride(); + } + } + + [Fact] + public void ShowCase_Runtime_Options_Do_Not_Let_Runtime_Stop_Override_Environment_Disable() + { + GalleryShowCaseRuntimeOptions.ResetDeferredLoadingDisabledOverride(); + var oldValue = SetDeferredLoadingEnvironment("1"); + try + { + GalleryShowCaseRuntimeOptions.IsDeferredLoadingDisabled.ShouldBeTrue(); + + GalleryShowCaseRuntimeOptions.IsDeferredLoadingDisabled = false; + + GalleryShowCaseRuntimeOptions.IsDeferredLoadingDisabled.ShouldBeTrue(); + } + finally + { + SetDeferredLoadingEnvironment(oldValue); + GalleryShowCaseRuntimeOptions.ResetDeferredLoadingDisabledOverride(); + } } [Fact] @@ -101,12 +167,17 @@ public class ShowCasePanelStructureTests CountOccurrences(panelSource, "EffectiveViewportChanged +=").ShouldBe(1); panelSource.ShouldContain("MaterializeDeferredContentInViewport"); panelSource.ShouldContain("MaterializeAllDeferredContent"); + panelSource.ShouldContain("GalleryShowCaseRuntimeOptions.IsDeferredLoadingDisabled"); + panelSource.ShouldContain("!GalleryShowCaseRuntimeOptions.IsDeferredLoadingDisabled"); + panelSource.ShouldContain("GalleryShowCaseRuntimeOptions.DeferredLoadingDisabledChanged += HandleDeferredLoadingDisabledChanged"); + panelSource.ShouldContain("GalleryShowCaseRuntimeOptions.DeferredLoadingDisabledChanged -= HandleDeferredLoadingDisabledChanged"); itemSource.ShouldContain("IsDeferredContentEnabledProperty"); itemSource.ShouldContain("DeferredContentTemplateProperty"); itemSource.ShouldContain("DeferredContentProperty"); itemSource.ShouldContain("DeferredPlaceholderHeightProperty"); itemSource.ShouldContain("public void MaterializeDeferredContent()"); + itemSource.ShouldContain("GalleryShowCaseRuntimeOptions.IsDeferredLoadingDisabled"); itemTheme.ShouldContain("PART_DeferredPlaceholder"); itemTheme.ShouldContain("DeferredPlaceholderHeight"); itemTheme.ShouldContain("ShowCaseItemTokenResource DeferredPlaceholderHeight"); @@ -153,4 +224,14 @@ public class ShowCasePanelStructureTests startIndex = matchIndex + value.Length; } } + + private static string? SetDeferredLoadingEnvironment(string? value) + { + var oldValue = Environment.GetEnvironmentVariable( + GalleryShowCaseRuntimeOptions.DisableDeferredLoadingEnvironmentVariable); + Environment.SetEnvironmentVariable( + GalleryShowCaseRuntimeOptions.DisableDeferredLoadingEnvironmentVariable, + value); + return oldValue; + } } diff --git a/tests/AtomUIGallery.Tests/Workspace/CaseNavigationViewModelDiagnosticsTests.cs b/tests/AtomUIGallery.Tests/Workspace/CaseNavigationViewModelDiagnosticsTests.cs new file mode 100644 index 000000000..61d75cd12 --- /dev/null +++ b/tests/AtomUIGallery.Tests/Workspace/CaseNavigationViewModelDiagnosticsTests.cs @@ -0,0 +1,49 @@ +using System; +using AtomUIGallery.Controls; +using AtomUIGallery.Workspace.ViewModels; +using ReactiveUI; +using Shouldly; +using Xunit; + +namespace AtomUIGallery.Tests.Workspace; + +public class CaseNavigationViewModelDiagnosticsTests +{ + [Fact] + public void Test_Navigate_Pages_Command_Disables_ShowCase_Deferred_Loading_Until_Stopped() + { + AvaloniaTestApp.EnsureInitialized(); + var oldValue = Environment.GetEnvironmentVariable( + GalleryShowCaseRuntimeOptions.DisableDeferredLoadingEnvironmentVariable); + Environment.SetEnvironmentVariable( + GalleryShowCaseRuntimeOptions.DisableDeferredLoadingEnvironmentVariable, + null); + GalleryShowCaseRuntimeOptions.ResetDeferredLoadingDisabledOverride(); + var viewModel = new CaseNavigationViewModel(new TestScreen()); + + try + { + viewModel.TestNavigatePagesCommand.Execute(TimeSpan.FromMilliseconds(300)) + .Subscribe(); + + GalleryShowCaseRuntimeOptions.IsDeferredLoadingDisabled.ShouldBeTrue(); + + viewModel.StopTestNavigatePagesCommand.Execute() + .Subscribe(); + + GalleryShowCaseRuntimeOptions.IsDeferredLoadingDisabled.ShouldBeFalse(); + } + finally + { + Environment.SetEnvironmentVariable( + GalleryShowCaseRuntimeOptions.DisableDeferredLoadingEnvironmentVariable, + oldValue); + GalleryShowCaseRuntimeOptions.ResetDeferredLoadingDisabledOverride(); + } + } + + private sealed class TestScreen : IScreen + { + public RoutingState Router { get; } = new(); + } +}