From 7c4123f2f3cfadeeec04b5658fac8140b96278de Mon Sep 17 00:00:00 2001 From: Ronny Date: Mon, 24 Aug 2026 13:32:54 +0000 Subject: [PATCH] Android: unregister automation peers when their control leaves the visual tree (#22024) AvaloniaAccessHelper keeps every automation peer it has ever handed to Android in three dictionaries (_peers, _peerIds, _peerNodeInfoProviders) and never removes any of them - the file contains no Remove or Clear call. Each ControlAutomationPeer holds a strong reference to its Owner, so once accessibility has explored a control, that control (and the visual tree hanging off it) is pinned for the lifetime of the AvaloniaView. This is invisible in an app with a static UI, but it is unbounded in one that rebuilds its visual tree: on a digital-signage device rebuilding its screen every 20-40 s, this retained one full dead screen per rebuild - about 2.5 MB of live managed heap each time, measured after a forced gen2 collection, growing until the low-memory killer stepped in. Removing the stale entries brought the same bench from unbounded growth to a flat plateau over 28 consecutive rebuilds. The registration is now dropped when the peer's control is detached from the visual tree, and the two peer event handlers are unsubscribed at the same time (they were never removed either). Virtual view IDs are now allocated from a monotonic counter instead of being derived from _peerNodeInfoProviders.Count. That was safe only while nothing was ever removed: with removal, Count can fall back onto an ID that is still in use and the next registration would throw from Dictionary.Add inside an accessibility callback. The root peer (ID 0) is deliberately left registered: GetVirtualViewAt and GetVisibleVirtualViews index _peers[0] directly, so removing it would throw. It is a single entry, owned by the view, and dies with the helper. --- .../Avalonia.Android/AvaloniaAccessHelper.cs | 45 +++++++++++++++++-- 1 file changed, 41 insertions(+), 4 deletions(-) diff --git a/src/Android/Avalonia.Android/AvaloniaAccessHelper.cs b/src/Android/Avalonia.Android/AvaloniaAccessHelper.cs index 2943e13a21..0bec287fed 100644 --- a/src/Android/Avalonia.Android/AvaloniaAccessHelper.cs +++ b/src/Android/Avalonia.Android/AvaloniaAccessHelper.cs @@ -49,6 +49,14 @@ namespace Avalonia.Android private readonly Dictionary> _peerNodeInfoProviders; + /// + /// Virtual view IDs must be allocated from a monotonic counter rather than derived from + /// the size of : entries are now removed when their + /// owner leaves the visual tree, so the dictionary's count no longer grows monotonically + /// and reusing it would hand out an ID that is still in use. + /// + private int _nextPeerViewId; + private readonly AvaloniaView _view; public AvaloniaAccessHelper(AvaloniaView view) : base(view) @@ -85,16 +93,16 @@ namespace Avalonia.Android } else { - peerViewId = _peerNodeInfoProviders.Count; + peerViewId = _nextPeerViewId++; _peers.Add(peerViewId, peer); _peerIds.Add(peer, peerViewId); nodeInfoProviders = new(); _peerNodeInfoProviders.Add(peer, nodeInfoProviders); - peer.ChildrenChanged += (s, ev) => InvalidateVirtualView(peerViewId, + EventHandler childrenChanged = (s, ev) => InvalidateVirtualView(peerViewId, AccessibilityEventCompat.ContentChangeTypeSubtree); - peer.PropertyChanged += (s, ev) => + EventHandler propertyChanged = (s, ev) => { if (ev.Property == AutomationElementIdentifiers.NameProperty) { @@ -104,13 +112,42 @@ namespace Avalonia.Android { InvalidateVirtualView(peerViewId, AccessibilityEventCompat.ContentChangeTypeContentDescription); } - else if (ev.Property == AutomationElementIdentifiers.BoundingRectangleProperty || + else if (ev.Property == AutomationElementIdentifiers.BoundingRectangleProperty || ev.Property == AutomationElementIdentifiers.ClassNameProperty) { InvalidateVirtualView(peerViewId); } }; + peer.ChildrenChanged += childrenChanged; + peer.PropertyChanged += propertyChanged; + + // Drop the registration once the peer's control leaves the visual tree, otherwise + // every control ever explored by accessibility is kept alive for the lifetime of + // the view: the peer holds a strong reference to its Owner, and these three + // dictionaries were never pruned. On a long-running app that rebuilds its UI (for + // instance digital signage swapping screens), this retains each dead visual tree in + // full — measured at ~2.5 MB per rebuild on a real device. + // The root peer (ID 0) is deliberately never unregistered: GetVirtualViewAt and + // GetVisibleVirtualViews index _peers[0] directly, so removing it would throw. + // It is a single entry owned by the view itself, and dies with the helper. + if (peerViewId != 0 && peer is ControlAutomationPeer controlPeer) + { + EventHandler? detachedFromVisualTree = null; + detachedFromVisualTree = (s, ev) => + { + controlPeer.Owner.DetachedFromVisualTree -= detachedFromVisualTree; + peer.ChildrenChanged -= childrenChanged; + peer.PropertyChanged -= propertyChanged; + + _peers.Remove(peerViewId); + _peerIds.Remove(peer); + _peerNodeInfoProviders.Remove(peer); + }; + + controlPeer.Owner.DetachedFromVisualTree += detachedFromVisualTree; + } + if (peer.GetProvider() is not null) nodeInfoProviders.Add(new ExpandCollapseNodeInfoProvider(this, peer, peerViewId)); if (peer.GetProvider() is not null)