Browse Source

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.
pull/22069/head
Ronny 1 month ago
committed by GitHub
parent
commit
7c4123f2f3
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 45
      src/Android/Avalonia.Android/AvaloniaAccessHelper.cs

45
src/Android/Avalonia.Android/AvaloniaAccessHelper.cs

@ -49,6 +49,14 @@ namespace Avalonia.Android
private readonly Dictionary<AutomationPeer, HashSet<INodeInfoProvider>> _peerNodeInfoProviders; private readonly Dictionary<AutomationPeer, HashSet<INodeInfoProvider>> _peerNodeInfoProviders;
/// <remarks>
/// Virtual view IDs must be allocated from a monotonic counter rather than derived from
/// the size of <see cref="_peerNodeInfoProviders"/>: 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.
/// </remarks>
private int _nextPeerViewId;
private readonly AvaloniaView _view; private readonly AvaloniaView _view;
public AvaloniaAccessHelper(AvaloniaView view) : base(view) public AvaloniaAccessHelper(AvaloniaView view) : base(view)
@ -85,16 +93,16 @@ namespace Avalonia.Android
} }
else else
{ {
peerViewId = _peerNodeInfoProviders.Count; peerViewId = _nextPeerViewId++;
_peers.Add(peerViewId, peer); _peers.Add(peerViewId, peer);
_peerIds.Add(peer, peerViewId); _peerIds.Add(peer, peerViewId);
nodeInfoProviders = new(); nodeInfoProviders = new();
_peerNodeInfoProviders.Add(peer, nodeInfoProviders); _peerNodeInfoProviders.Add(peer, nodeInfoProviders);
peer.ChildrenChanged += (s, ev) => InvalidateVirtualView(peerViewId, EventHandler childrenChanged = (s, ev) => InvalidateVirtualView(peerViewId,
AccessibilityEventCompat.ContentChangeTypeSubtree); AccessibilityEventCompat.ContentChangeTypeSubtree);
peer.PropertyChanged += (s, ev) => EventHandler<AutomationPropertyChangedEventArgs> propertyChanged = (s, ev) =>
{ {
if (ev.Property == AutomationElementIdentifiers.NameProperty) if (ev.Property == AutomationElementIdentifiers.NameProperty)
{ {
@ -104,13 +112,42 @@ namespace Avalonia.Android
{ {
InvalidateVirtualView(peerViewId, AccessibilityEventCompat.ContentChangeTypeContentDescription); InvalidateVirtualView(peerViewId, AccessibilityEventCompat.ContentChangeTypeContentDescription);
} }
else if (ev.Property == AutomationElementIdentifiers.BoundingRectangleProperty || else if (ev.Property == AutomationElementIdentifiers.BoundingRectangleProperty ||
ev.Property == AutomationElementIdentifiers.ClassNameProperty) ev.Property == AutomationElementIdentifiers.ClassNameProperty)
{ {
InvalidateVirtualView(peerViewId); 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<VisualTreeAttachmentEventArgs>? 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<IExpandCollapseProvider>() is not null) if (peer.GetProvider<IExpandCollapseProvider>() is not null)
nodeInfoProviders.Add(new ExpandCollapseNodeInfoProvider(this, peer, peerViewId)); nodeInfoProviders.Add(new ExpandCollapseNodeInfoProvider(this, peer, peerViewId));
if (peer.GetProvider<IInvokeProvider>() is not null) if (peer.GetProvider<IInvokeProvider>() is not null)

Loading…
Cancel
Save