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)