From e63aa46458022a4a8974afe1eb1792ec7d1968c3 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Sun, 3 Jul 2022 00:14:04 +0200 Subject: [PATCH] Add a more efficient way to listen to classes changes. Improves the situation in e.g. #8389 drastically. --- src/Avalonia.Base/Controls/Classes.cs | 53 ++++++++++++++++++- .../Controls/IClassesChangedListener.cs | 14 +++++ .../Styling/Activators/StyleClassActivator.cs | 26 ++++----- .../Styling/TypeNameAndClassSelector.cs | 3 +- .../Styling/SelectorTests_Template.cs | 5 +- tests/Avalonia.LeakTests/ControlTests.cs | 2 +- 6 files changed, 80 insertions(+), 23 deletions(-) create mode 100644 src/Avalonia.Base/Controls/IClassesChangedListener.cs diff --git a/src/Avalonia.Base/Controls/Classes.cs b/src/Avalonia.Base/Controls/Classes.cs index 50605661fa..e64209c3cb 100644 --- a/src/Avalonia.Base/Controls/Classes.cs +++ b/src/Avalonia.Base/Controls/Classes.cs @@ -14,6 +14,8 @@ namespace Avalonia.Controls /// public class Classes : AvaloniaList, IPseudoClasses { + private List? _listeners; + /// /// Initializes a new instance of the class. /// @@ -39,6 +41,11 @@ namespace Avalonia.Controls { } + /// + /// Gets the number of listeners subscribed to this collection for unit testing purposes. + /// + internal int ListenerCount => _listeners?.Count ?? 0; + /// /// Parses a classes string. /// @@ -62,6 +69,7 @@ namespace Avalonia.Controls if (!Contains(name)) { base.Add(name); + NotifyChanged(); } } @@ -89,6 +97,7 @@ namespace Avalonia.Controls } base.AddRange(c); + NotifyChanged(); } /// @@ -103,6 +112,8 @@ namespace Avalonia.Controls RemoveAt(i); } } + + NotifyChanged(); } /// @@ -122,6 +133,7 @@ namespace Avalonia.Controls if (!Contains(name)) { base.Insert(index, name); + NotifyChanged(); } } @@ -154,6 +166,7 @@ namespace Avalonia.Controls if (toInsert != null) { base.InsertRange(index, toInsert); + NotifyChanged(); } } @@ -169,7 +182,14 @@ namespace Avalonia.Controls public override bool Remove(string name) { ThrowIfPseudoclass(name, "removed"); - return base.Remove(name); + + if (base.Remove(name)) + { + NotifyChanged(); + return true; + } + + return false; } /// @@ -197,6 +217,7 @@ namespace Avalonia.Controls if (toRemove != null) { base.RemoveAll(toRemove); + NotifyChanged(); } } @@ -214,6 +235,7 @@ namespace Avalonia.Controls var name = this[index]; ThrowIfPseudoclass(name, "removed"); base.RemoveAt(index); + NotifyChanged(); } /// @@ -224,6 +246,7 @@ namespace Avalonia.Controls public override void RemoveRange(int index, int count) { base.RemoveRange(index, count); + NotifyChanged(); } /// @@ -255,6 +278,7 @@ namespace Avalonia.Controls } base.AddRange(source); + NotifyChanged(); } /// @@ -263,13 +287,38 @@ namespace Avalonia.Controls if (!Contains(name)) { base.Add(name); + NotifyChanged(); } } /// bool IPseudoClasses.Remove(string name) { - return base.Remove(name); + if (base.Remove(name)) + { + NotifyChanged(); + return true; + } + + return false; + } + + internal void AddListener(IClassesChangedListener listener) + { + (_listeners ??= new()).Add(listener); + } + + internal void RemoveListener(IClassesChangedListener listener) + { + _listeners?.Remove(listener); + } + + private void NotifyChanged() + { + if (_listeners is null) + return; + foreach (var listener in _listeners) + listener.Changed(); } private void ThrowIfPseudoclass(string name, string operation) diff --git a/src/Avalonia.Base/Controls/IClassesChangedListener.cs b/src/Avalonia.Base/Controls/IClassesChangedListener.cs new file mode 100644 index 0000000000..b4de893c97 --- /dev/null +++ b/src/Avalonia.Base/Controls/IClassesChangedListener.cs @@ -0,0 +1,14 @@ +namespace Avalonia.Controls +{ + /// + /// Internal interface for listening to changes in in a more + /// performant manner than subscribing to CollectionChanged. + /// + internal interface IClassesChangedListener + { + /// + /// Notifies the listener that the collection has changed. + /// + void Changed(); + } +} diff --git a/src/Avalonia.Base/Styling/Activators/StyleClassActivator.cs b/src/Avalonia.Base/Styling/Activators/StyleClassActivator.cs index 98d3f16a0a..3f70ff50b3 100644 --- a/src/Avalonia.Base/Styling/Activators/StyleClassActivator.cs +++ b/src/Avalonia.Base/Styling/Activators/StyleClassActivator.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Collections.Specialized; using Avalonia.Collections; +using Avalonia.Controls; #nullable enable @@ -10,21 +11,17 @@ namespace Avalonia.Styling.Activators /// An which is active when a set of classes match those on a /// control. /// - internal sealed class StyleClassActivator : StyleActivatorBase + internal sealed class StyleClassActivator : StyleActivatorBase, IClassesChangedListener { private readonly IList _match; - private readonly IAvaloniaReadOnlyList _classes; - private NotifyCollectionChangedEventHandler? _classesChangedHandler; + private readonly Classes _classes; - public StyleClassActivator(IAvaloniaReadOnlyList classes, IList match) + public StyleClassActivator(Classes classes, IList match) { _classes = classes; _match = match; } - private NotifyCollectionChangedEventHandler ClassesChangedHandler => - _classesChangedHandler ??= ClassesChanged; - public static bool AreClassesMatching(IReadOnlyList classes, IList toMatch) { int remainingMatches = toMatch.Count; @@ -55,23 +52,20 @@ namespace Avalonia.Styling.Activators return remainingMatches == 0; } - protected override void Initialize() + void IClassesChangedListener.Changed() { PublishNext(IsMatching()); - _classes.CollectionChanged += ClassesChangedHandler; } - protected override void Deinitialize() + protected override void Initialize() { - _classes.CollectionChanged -= ClassesChangedHandler; + PublishNext(IsMatching()); + _classes.AddListener(this); } - private void ClassesChanged(object? sender, NotifyCollectionChangedEventArgs e) + protected override void Deinitialize() { - if (e.Action != NotifyCollectionChangedAction.Move) - { - PublishNext(IsMatching()); - } + _classes.RemoveListener(this); } private bool IsMatching() => AreClassesMatching(_classes, _match); diff --git a/src/Avalonia.Base/Styling/TypeNameAndClassSelector.cs b/src/Avalonia.Base/Styling/TypeNameAndClassSelector.cs index 24d5d6bbbf..d52c8c7d5c 100644 --- a/src/Avalonia.Base/Styling/TypeNameAndClassSelector.cs +++ b/src/Avalonia.Base/Styling/TypeNameAndClassSelector.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Text; +using Avalonia.Controls; using Avalonia.Styling.Activators; #nullable enable @@ -125,7 +126,7 @@ namespace Avalonia.Styling { if (subscribe) { - var observable = new StyleClassActivator(control.Classes, _classes.Value); + var observable = new StyleClassActivator((Classes)control.Classes, _classes.Value); return new SelectorMatch(observable); } diff --git a/tests/Avalonia.Base.UnitTests/Styling/SelectorTests_Template.cs b/tests/Avalonia.Base.UnitTests/Styling/SelectorTests_Template.cs index 8b4c988037..176fa07f19 100644 --- a/tests/Avalonia.Base.UnitTests/Styling/SelectorTests_Template.cs +++ b/tests/Avalonia.Base.UnitTests/Styling/SelectorTests_Template.cs @@ -142,14 +142,13 @@ namespace Avalonia.Base.UnitTests.Styling var border = (Border)target.Object.VisualChildren.Single(); var selector = default(Selector).OfType(templatedControl.Object.GetType()).Class("foo").Template().OfType(); var activator = selector.Match(border).Activator; - var inccDebug = (INotifyCollectionChangedDebug)styleable.Object.Classes; using (activator.Subscribe(_ => { })) { - Assert.Single(inccDebug.GetCollectionChangedSubscribers()); + Assert.Equal(1, ((Classes)styleable.Object.Classes).ListenerCount); } - Assert.Null(inccDebug.GetCollectionChangedSubscribers()); + Assert.Equal(0, ((Classes)styleable.Object.Classes).ListenerCount); } private void BuildVisualTree(Mock templatedControl) where T : class, IVisual diff --git a/tests/Avalonia.LeakTests/ControlTests.cs b/tests/Avalonia.LeakTests/ControlTests.cs index 8c05f2a0a7..8651409af1 100644 --- a/tests/Avalonia.LeakTests/ControlTests.cs +++ b/tests/Avalonia.LeakTests/ControlTests.cs @@ -313,7 +313,7 @@ namespace Avalonia.LeakTests // The TextBox should have subscriptions to its Classes collection from the // default theme. - Assert.NotEmpty(((INotifyCollectionChangedDebug)textBox.Classes).GetCollectionChangedSubscribers()); + Assert.NotEqual(0, textBox.Classes.ListenerCount); // Clear the content and ensure the TextBox is removed. window.Content = null;