diff --git a/src/Avalonia.Base/Visual.cs b/src/Avalonia.Base/Visual.cs index 4fd21f02f9..bdf8723b81 100644 --- a/src/Avalonia.Base/Visual.cs +++ b/src/Avalonia.Base/Visual.cs @@ -376,7 +376,9 @@ namespace Avalonia if (e.OldValue is IAffectsRender oldValue) { if (sender._affectsRenderWeakSubscriber != null) + { InvalidatedWeakEvent.Unsubscribe(oldValue, sender._affectsRenderWeakSubscriber); + } } if (e.NewValue is IAffectsRender newValue) diff --git a/src/Avalonia.Controls/Control.cs b/src/Avalonia.Controls/Control.cs index 16d4ef5c15..083182a370 100644 --- a/src/Avalonia.Controls/Control.cs +++ b/src/Avalonia.Controls/Control.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.ComponentModel; using Avalonia.Automation.Peers; using Avalonia.Controls.Documents; @@ -10,6 +11,7 @@ using Avalonia.Interactivity; using Avalonia.Media; using Avalonia.Rendering; using Avalonia.Styling; +using Avalonia.Threading; using Avalonia.VisualTree; namespace Avalonia.Controls @@ -53,21 +55,57 @@ namespace Avalonia.Controls /// Event raised when an element wishes to be scrolled into view. /// public static readonly RoutedEvent RequestBringIntoViewEvent = - RoutedEvent.Register("RequestBringIntoView", RoutingStrategies.Bubble); + RoutedEvent.Register( + "RequestBringIntoView", + RoutingStrategies.Bubble); /// /// Provides event data for the event. /// public static readonly RoutedEvent ContextRequestedEvent = - RoutedEvent.Register(nameof(ContextRequested), + RoutedEvent.Register( + nameof(ContextRequested), RoutingStrategies.Tunnel | RoutingStrategies.Bubble); + /// + /// Defines the event. + /// + public static readonly RoutedEvent LoadedEvent = + RoutedEvent.Register( + nameof(Loaded), + RoutingStrategies.Direct); + + /// + /// Defines the event. + /// + public static readonly RoutedEvent UnloadedEvent = + RoutedEvent.Register( + nameof(Unloaded), + RoutingStrategies.Direct); + /// /// Defines the property. /// public static readonly AttachedProperty FlowDirectionProperty = - AvaloniaProperty.RegisterAttached(nameof(FlowDirection), inherits: true); - + AvaloniaProperty.RegisterAttached( + nameof(FlowDirection), + inherits: true); + + // Note the following: + // _loadedQueue : + // Is the queue where any control will be added to indicate that its loaded + // event should be scheduled and called later. + // _loadedProcessingQueue : + // Contains a copied snapshot of the _loadedQueue at the time when processing + // starts and individual events are being fired. This was needed to avoid + // exceptions if new controls were added in the Loaded event itself. + + private static bool _isLoadedProcessing = false; + private static readonly HashSet _loadedQueue = new HashSet(); + private static readonly HashSet _loadedProcessingQueue = new HashSet(); + + private bool _isAttachedToVisualTree = false; + private bool _isLoaded = false; private DataTemplates? _dataTemplates; private IControl? _focusAdorner; private AutomationPeer? _automationPeer; @@ -108,6 +146,15 @@ namespace Avalonia.Controls set => SetValue(ContextFlyoutProperty, value); } + /// + /// Gets a value indicating whether the control is fully constructed in the visual tree + /// and both layout and render are complete. + /// + /// + /// This is set to true while raising the event. + /// + public bool IsLoaded => _isLoaded; + /// /// Gets or sets a user-defined object attached to the control. /// @@ -135,6 +182,35 @@ namespace Avalonia.Controls remove => RemoveHandler(ContextRequestedEvent, value); } + /// + /// Occurs when the control has been fully constructed in the visual tree and both + /// layout and render are complete. + /// + /// + /// This event is guaranteed to occur after the control template is applied and references + /// to objects created after the template is applied are available. This makes it different + /// from OnAttachedToVisualTree which doesn't have these references. This event occurs at the + /// latest possible time in the control creation life-cycle. + /// + public event EventHandler? Loaded + { + add => AddHandler(LoadedEvent, value); + remove => RemoveHandler(LoadedEvent, value); + } + + /// + /// Occurs when the control is removed from the visual tree. + /// + /// + /// This is API symmetrical with and exists for compatibility with other + /// XAML frameworks; however, it behaves the same as OnDetachedFromVisualTree. + /// + public event EventHandler? Unloaded + { + add => AddHandler(UnloadedEvent, value); + remove => RemoveHandler(UnloadedEvent, value); + } + public new IControl? Parent => (IControl?)base.Parent; /// @@ -215,18 +291,124 @@ namespace Avalonia.Controls /// The control that receives the focus adorner. protected virtual IControl? GetTemplateFocusTarget() => this; + private static Action loadedProcessingAction = () => + { + // Copy the loaded queue for processing + // There was a possibility of the "Collection was modified; enumeration operation may not execute." + // exception when only a single hash set was used. This could happen when new controls are added + // within the Loaded callback/event itself. To fix this, two hash sets are used and while one is + // being processed the other accepts adding new controls to process next. + _loadedProcessingQueue.Clear(); + foreach (Control control in _loadedQueue) + { + _loadedProcessingQueue.Add(control); + } + _loadedQueue.Clear(); + + foreach (Control control in _loadedProcessingQueue) + { + control.OnLoadedCore(); + } + + _loadedProcessingQueue.Clear(); + _isLoadedProcessing = false; + + // Restart if any controls were added to the queue while processing + if (_loadedQueue.Count > 0) + { + _isLoadedProcessing = true; + Dispatcher.UIThread.Post(loadedProcessingAction!, DispatcherPriority.Loaded); + } + }; + + /// + /// Schedules to be called for this control. + /// For performance, it will be queued with other controls. + /// + internal void ScheduleOnLoadedCore() + { + if (_isLoaded == false) + { + bool isAdded = _loadedQueue.Add(this); + + if (isAdded && + _isLoadedProcessing == false) + { + _isLoadedProcessing = true; + Dispatcher.UIThread.Post(loadedProcessingAction!, DispatcherPriority.Loaded); + } + } + } + + /// + /// Invoked as the first step of marking the control as loaded and raising the + /// event. + /// + internal void OnLoadedCore() + { + if (_isLoaded == false && + _isAttachedToVisualTree) + { + _isLoaded = true; + OnLoaded(); + } + } + + /// + /// Invoked as the first step of marking the control as unloaded and raising the + /// event. + /// + internal void OnUnloadedCore() + { + if (_isLoaded) + { + // Remove from the loaded event queue here as a failsafe in case the control + // is detached before the dispatcher runs the Loaded jobs. + _loadedQueue.Remove(this); + + _isLoaded = false; + OnUnloaded(); + } + } + + /// + /// Invoked just before the event. + /// + protected virtual void OnLoaded() + { + var eventArgs = new RoutedEventArgs(LoadedEvent); + eventArgs.Source = null; + RaiseEvent(eventArgs); + } + + /// + /// Invoked just before the event. + /// + protected virtual void OnUnloaded() + { + var eventArgs = new RoutedEventArgs(UnloadedEvent); + eventArgs.Source = null; + RaiseEvent(eventArgs); + } + /// protected sealed override void OnAttachedToVisualTreeCore(VisualTreeAttachmentEventArgs e) { base.OnAttachedToVisualTreeCore(e); + _isAttachedToVisualTree = true; InitializeIfNeeded(); + + ScheduleOnLoadedCore(); } /// protected sealed override void OnDetachedFromVisualTreeCore(VisualTreeAttachmentEventArgs e) { base.OnDetachedFromVisualTreeCore(e); + _isAttachedToVisualTree = false; + + OnUnloadedCore(); } /// @@ -324,7 +506,9 @@ namespace Avalonia.Controls var keymap = AvaloniaLocator.Current.GetService()?.OpenContextMenu; if (keymap is null) + { return; + } var matches = false; diff --git a/src/Avalonia.Controls/WindowBase.cs b/src/Avalonia.Controls/WindowBase.cs index 12ba143c8a..5d3e51b394 100644 --- a/src/Avalonia.Controls/WindowBase.cs +++ b/src/Avalonia.Controls/WindowBase.cs @@ -169,7 +169,6 @@ namespace Avalonia.Controls } } - [Obsolete("No longer used. Has no effect.")] protected IDisposable BeginAutoSizing() => Disposable.Empty; @@ -186,6 +185,26 @@ namespace Avalonia.Controls } } + /// + protected override void OnClosed(EventArgs e) + { + // Window must manually raise Loaded/Unloaded events as it is a visual root and + // does not raise OnAttachedToVisualTreeCore/OnDetachedFromVisualTreeCore events + OnUnloadedCore(); + + base.OnClosed(e); + } + + /// + protected override void OnOpened(EventArgs e) + { + // Window must manually raise Loaded/Unloaded events as it is a visual root and + // does not raise OnAttachedToVisualTreeCore/OnDetachedFromVisualTreeCore events + ScheduleOnLoadedCore(); + + base.OnOpened(e); + } + protected override void HandleClosed() { _ignoreVisibilityChange = true; diff --git a/tests/Avalonia.Benchmarks/Layout/ControlsBenchmark.cs b/tests/Avalonia.Benchmarks/Layout/ControlsBenchmark.cs index 3493dd0f53..51b52d6130 100644 --- a/tests/Avalonia.Benchmarks/Layout/ControlsBenchmark.cs +++ b/tests/Avalonia.Benchmarks/Layout/ControlsBenchmark.cs @@ -1,6 +1,7 @@ using System; using System.Runtime.CompilerServices; using Avalonia.Controls; +using Avalonia.Threading; using Avalonia.UnitTests; using BenchmarkDotNet.Attributes; @@ -37,6 +38,21 @@ namespace Avalonia.Benchmarks.Layout _root.Child = calendar; _root.LayoutManager.ExecuteLayoutPass(); + Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded); + } + + [Benchmark] + [MethodImpl(MethodImplOptions.NoInlining)] + public void CreateCalendarWithLoaded() + { + using var subscription = Control.LoadedEvent.AddClassHandler((c, s) => { }); + + var calendar = new Calendar(); + + _root.Child = calendar; + + _root.LayoutManager.ExecuteLayoutPass(); + Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded); } [Benchmark] @@ -48,6 +64,7 @@ namespace Avalonia.Benchmarks.Layout _root.Child = button; _root.LayoutManager.ExecuteLayoutPass(); + Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded); } [Benchmark] @@ -59,6 +76,7 @@ namespace Avalonia.Benchmarks.Layout _root.Child = textBox; _root.LayoutManager.ExecuteLayoutPass(); + Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded); } public void Dispose() diff --git a/tests/Avalonia.Controls.UnitTests/Utils/HotKeyManagerTests.cs b/tests/Avalonia.Controls.UnitTests/Utils/HotKeyManagerTests.cs index 8d9a4aa599..e4d177f7ca 100644 --- a/tests/Avalonia.Controls.UnitTests/Utils/HotKeyManagerTests.cs +++ b/tests/Avalonia.Controls.UnitTests/Utils/HotKeyManagerTests.cs @@ -12,6 +12,7 @@ using Moq; using Xunit; using Avalonia.Input.Raw; using Factory = System.Func, Avalonia.Controls.Window, Avalonia.AvaloniaObject>; +using Avalonia.Threading; namespace Avalonia.Controls.UnitTests.Utils { @@ -60,115 +61,6 @@ namespace Avalonia.Controls.UnitTests.Utils } } - [Fact] - public void HotKeyManager_Should_Release_Reference_When_Control_Detached() - { - using (AvaloniaLocator.EnterScope()) - { - var styler = new Mock(); - - AvaloniaLocator.CurrentMutable - .Bind().ToConstant(new WindowingPlatformMock()) - .Bind().ToConstant(styler.Object); - - var gesture1 = new KeyGesture(Key.A, KeyModifiers.Control); - - WeakReference reference = null; - - var tl = new Window(); - - new Action(() => - { - var button = new Button(); - reference = new WeakReference(button, true); - tl.Content = button; - tl.Template = CreateWindowTemplate(); - tl.ApplyTemplate(); - tl.Presenter.ApplyTemplate(); - HotKeyManager.SetHotKey(button, gesture1); - - // Detach the button from the logical tree, so there is no reference to it - tl.Content = null; - tl.ApplyTemplate(); - })(); - - - // The button should be collected since it's detached from the listbox - GC.Collect(); - GC.WaitForPendingFinalizers(); - GC.Collect(); - GC.WaitForPendingFinalizers(); - - Assert.Null(reference?.Target); - } - } - - [Fact] - public void HotKeyManager_Should_Release_Reference_When_Control_In_Item_Template_Detached() - { - using (UnitTestApplication.Start(TestServices.StyledWindow)) - { - var styler = new Mock(); - - AvaloniaLocator.CurrentMutable - .Bind().ToConstant(new WindowingPlatformMock()) - .Bind().ToConstant(styler.Object); - - var gesture1 = new KeyGesture(Key.A, KeyModifiers.Control); - - var weakReferences = new List(); - var tl = new Window { SizeToContent = SizeToContent.WidthAndHeight, IsVisible = true }; - var lm = tl.LayoutManager; - - var keyGestures = new AvaloniaList { gesture1 }; - var listBox = new ListBox - { - Width = 100, - Height = 100, - VirtualizationMode = ItemVirtualizationMode.None, - // Create a button with binding to the KeyGesture in the template and add it to references list - ItemTemplate = new FuncDataTemplate(typeof(KeyGesture), (o, scope) => - { - var keyGesture = o as KeyGesture; - var button = new Button - { - DataContext = keyGesture, [!Button.HotKeyProperty] = new Binding("") - }; - weakReferences.Add(new WeakReference(button, true)); - return button; - }) - }; - // Add the listbox and render it - tl.Content = listBox; - lm.ExecuteInitialLayoutPass(); - listBox.Items = keyGestures; - lm.ExecuteLayoutPass(); - - // Let the button detach when clearing the source items - keyGestures.Clear(); - lm.ExecuteLayoutPass(); - - // Add it again to double check,and render - keyGestures.Add(gesture1); - lm.ExecuteLayoutPass(); - - keyGestures.Clear(); - lm.ExecuteLayoutPass(); - - // The button should be collected since it's detached from the listbox - GC.Collect(); - GC.WaitForPendingFinalizers(); - GC.Collect(); - GC.WaitForPendingFinalizers(); - - Assert.True(weakReferences.Count > 0); - foreach (var weakReference in weakReferences) - { - Assert.Null(weakReference.Target); - } - } - } - [Theory] [MemberData(nameof(ElementsFactory), parameters: true)] public void HotKeyManager_Should_Use_CommandParameter(string factoryName, Factory factory) diff --git a/tests/Avalonia.LeakTests/ControlTests.cs b/tests/Avalonia.LeakTests/ControlTests.cs index bb520c16aa..8c05f2a0a7 100644 --- a/tests/Avalonia.LeakTests/ControlTests.cs +++ b/tests/Avalonia.LeakTests/ControlTests.cs @@ -3,7 +3,10 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; using System.Reactive.Disposables; + +using Avalonia.Collections; using Avalonia.Controls; +using Avalonia.Controls.Presenters; using Avalonia.Controls.Shapes; using Avalonia.Controls.Templates; using Avalonia.Data; @@ -67,6 +70,9 @@ namespace Avalonia.LeakTests var result = run(); + // Process all Loaded events to free control reference(s) + Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded); + dotMemory.Check(memory => Assert.Equal(0, memory.GetObjects(where => where.Type.Is()).ObjectsCount)); } @@ -100,6 +106,9 @@ namespace Avalonia.LeakTests var result = run(); + // Process all Loaded events to free control reference(s) + Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded); + dotMemory.Check(memory => Assert.Equal(0, memory.GetObjects(where => where.Type.Is()).ObjectsCount)); } @@ -141,6 +150,9 @@ namespace Avalonia.LeakTests var result = run(); + // Process all Loaded events to free control reference(s) + Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded); + dotMemory.Check(memory => Assert.Equal(0, memory.GetObjects(where => where.Type.Is()).ObjectsCount)); } @@ -179,6 +191,9 @@ namespace Avalonia.LeakTests var result = run(); + // Process all Loaded events to free control reference(s) + Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded); + dotMemory.Check(memory => Assert.Equal(0, memory.GetObjects(where => where.Type.Is()).ObjectsCount)); dotMemory.Check(memory => @@ -216,6 +231,9 @@ namespace Avalonia.LeakTests var result = run(); + // Process all Loaded events to free control reference(s) + Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded); + dotMemory.Check(memory => Assert.Equal(0, memory.GetObjects(where => where.Type.Is()).ObjectsCount)); } @@ -261,6 +279,9 @@ namespace Avalonia.LeakTests var result = run(); + // Process all Loaded events to free control reference(s) + Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded); + dotMemory.Check(memory => Assert.Equal(0, memory.GetObjects(where => where.Type.Is()).ObjectsCount)); dotMemory.Check(memory => @@ -351,6 +372,9 @@ namespace Avalonia.LeakTests var result = run(); + // Process all Loaded events to free control reference(s) + Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded); + dotMemory.Check(memory => Assert.Equal(0, memory.GetObjects(where => where.Type.Is()).ObjectsCount)); } @@ -384,6 +408,9 @@ namespace Avalonia.LeakTests var result = run(); + // Process all Loaded events to free control reference(s) + Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded); + dotMemory.Check(memory => Assert.Equal(0, memory.GetObjects(where => where.Type.Is()).ObjectsCount)); } @@ -421,6 +448,9 @@ namespace Avalonia.LeakTests var result = run(); + // Process all Loaded events to free control reference(s) + Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded); + dotMemory.Check(memory => Assert.Equal(0, memory.GetObjects(where => where.Type.Is()).ObjectsCount)); } @@ -496,6 +526,9 @@ namespace Avalonia.LeakTests var result = run(); + // Process all Loaded events to free control reference(s) + Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded); + dotMemory.Check(memory => Assert.Equal(0, memory.GetObjects(where => where.Type.Is()).ObjectsCount)); } @@ -536,9 +569,12 @@ namespace Avalonia.LeakTests initialMenuCount = memory.GetObjects(where => where.Type.Is()).ObjectsCount; initialMenuItemCount = memory.GetObjects(where => where.Type.Is()).ObjectsCount; }); - + AttachShowAndDetachContextMenu(window); + // Process all Loaded events to free control reference(s) + Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded); + Mock.Get(window.PlatformImpl).Invocations.Clear(); dotMemory.Check(memory => Assert.Equal(initialMenuCount, memory.GetObjects(where => where.Type.Is()).ObjectsCount)); @@ -580,10 +616,13 @@ namespace Avalonia.LeakTests initialMenuCount = memory.GetObjects(where => where.Type.Is()).ObjectsCount; initialMenuItemCount = memory.GetObjects(where => where.Type.Is()).ObjectsCount; }); - + BuildAndShowContextMenu(window); BuildAndShowContextMenu(window); + // Process all Loaded events to free control reference(s) + Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded); + Mock.Get(window.PlatformImpl).Invocations.Clear(); dotMemory.Check(memory => Assert.Equal(initialMenuCount, memory.GetObjects(where => where.Type.Is()).ObjectsCount)); @@ -623,6 +662,9 @@ namespace Avalonia.LeakTests var result = run(); + // Process all Loaded events to free control reference(s) + Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded); + dotMemory.Check(memory => Assert.Equal(0, memory.GetObjects(where => where.Type.Is()).ObjectsCount)); @@ -657,6 +699,9 @@ namespace Avalonia.LeakTests var result = run(); + // Process all Loaded events to free control reference(s) + Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded); + dotMemory.Check(memory => Assert.Equal(0, memory.GetObjects(where => where.Type.Is()).ObjectsCount)); } @@ -725,14 +770,128 @@ namespace Avalonia.LeakTests Assert.Empty(lb.ItemContainerGenerator.Containers); + // Process all Loaded events to free control reference(s) + Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded); + dotMemory.Check(memory => Assert.Equal(0, memory.GetObjects(where => where.Type.Is()).ObjectsCount)); } } + [Fact] + public void HotKeyManager_Should_Release_Reference_When_Control_Detached() + { + using (Start()) + { + Func run = () => + { + var gesture1 = new KeyGesture(Key.A, KeyModifiers.Control); + var tl = new Window + { + Content = new ItemsRepeater(), + }; + + tl.Show(); + + var button = new Button(); + tl.Content = button; + tl.Template = CreateWindowTemplate(); + tl.ApplyTemplate(); + tl.Presenter.ApplyTemplate(); + HotKeyManager.SetHotKey(button, gesture1); + + // Detach the button from the logical tree, so there is no reference to it + tl.Content = null; + tl.ApplyTemplate(); + + return tl; + }; + + var result = run(); + + // Process all Loaded events to free control reference(s) + Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded); + + dotMemory.Check(memory => + Assert.Equal(0, memory.GetObjects(where => where.Type.Is