feat(GalleryShowCase): implement deferred loading control and diagnostics for showcase items

This commit is contained in:
chinboy
2026-06-14 20:16:29 +08:00
parent 7ac698ea19
commit e13de4faec
7 changed files with 239 additions and 27 deletions
@@ -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));
}
}
@@ -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;
}
@@ -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()
{
}
@@ -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;
}
}
@@ -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` 必须使用下面的写法:
@@ -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<object?>((data, _) =>
var buildCount = 0;
var item = new ShowCaseItem
{
buildCount++;
return new TextBlock
IsDeferredContentEnabled = true,
DataContext = "demo data",
DeferredContentTemplate = new FuncDataTemplate<object?>((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<TextBlock>().Text.ShouldBe("demo data");
buildCount.ShouldBe(1);
item.IsDeferredContentMaterialized.ShouldBeTrue();
item.Content.ShouldBeOfType<TextBlock>().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<object?>((data, _) =>
{
buildCount++;
return new TextBlock
{
Text = data?.ToString()
};
}, true)
};
buildCount.ShouldBe(1);
item.IsDeferredContentMaterialized.ShouldBeTrue();
item.Content.ShouldBeOfType<TextBlock>().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;
}
}
@@ -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();
}
}