Files
plezy/lib/utils/semantics_tree_gate.dart
T
edde746 7e1f18f93f perf(android): skip the semantics tree when no enabled accessibility service can read it
Android enables Flutter semantics for any bound accessibility service, so a
TV running the Projectivy Launcher (whose service only wants foreground-app
events) paid for a full semantics tree on every frame that touched a node:
on an Android 14 box, 2 ms per frame, ~20 ms on each spotlight swap, and a
third of the navigation-time GC churn.

The Android side now reports whether any enabled service can consume the
tree (touch exploration, an accessibility tool, or spoken/braille/audible/
visual feedback; an empty list means UiAutomation, which reads it too), and
AssistiveTechnologyService closes a SemanticsTreeGate on the app binding
when none can. The gate sits under SemanticsBinding.semanticsEnabled, so the
pipeline owner drops the semantics owner exactly as if the platform had
turned accessibility off; explicit ensureSemantics clients (the debug handle
for Maestro) always win. Re-evaluated on platform toggles, service-list
changes (API 33+) and resume.

Cold navigation on the box with Projectivy's service bound, 30 D-pad steps
over three hubs: SEMANTICS phase 700-770 ms -> 0, UI frame max 31-37 ms ->
25 ms, GC 3.0 s -> 2.2-2.6 s.
2026-09-02 22:38:11 +02:00

85 lines
3.7 KiB
Dart

import 'package:flutter/foundation.dart';
import 'package:flutter/semantics.dart';
/// Lets the app decline to build a semantics tree while the platform has
/// accessibility switched on but nothing is going to read the tree.
///
/// Android turns Flutter semantics on whenever *any* accessibility service is
/// bound, and on TV that is routinely a utility with no interest in app
/// content — a launcher's "current app" hook, a key remapper. The framework
/// then compiles and ships a full semantics tree every frame that touches a
/// node: measured on an Android 14 TV box with the Projectivy Launcher service
/// bound, 2 ms of every frame and ~20 ms on each spotlight swap, plus the
/// allocation churn behind it. A screen reader must see everything, so the
/// decision is made per enabled service by `AssistiveTechnologyService`; this
/// mixin is only the switch.
///
/// The switch sits under [SemanticsBinding.semanticsEnabled], the single value
/// the pipeline owner and the engine handshake consult, so turning it off tears
/// the semantics owner down exactly as if the platform had disabled
/// accessibility: no tree is compiled, no update is sent, and the engine is
/// told through `setSemanticsTreeEnabled` to drop its copy. Defaults to wanted.
///
/// An explicit [ensureSemantics] client (the debug-build handle kept for UI
/// automation) always wins: the gate only ever suppresses the platform's own
/// request. Disposing such a client is not observable here, so a closed gate
/// takes effect again at the next platform or gate change rather than at once.
mixin SemanticsTreeGate on SemanticsBinding {
final _GateNotifier _gate = _GateNotifier();
/// Whether a platform request for semantics should produce a tree.
bool get semanticsTreeWanted => _gate.wanted;
set semanticsTreeWanted(bool value) {
if (_gate.wanted == value) return;
_gate.wanted = value;
_gate.notify();
}
/// Whether the platform (or an explicit [ensureSemantics] client) asked for
/// semantics, before this gate is applied.
bool get platformSemanticsRequested => super.semanticsEnabled;
/// The platform holds at most one handle; anything beyond it is an explicit
/// client that must keep receiving a tree.
bool get _hasExplicitClient => debugOutstandingSemanticsHandles > (platformDispatcher.semanticsEnabled ? 1 : 0);
@override
bool get semanticsEnabled => super.semanticsEnabled && (_gate.wanted || _hasExplicitClient);
@override
SemanticsHandle ensureSemantics() {
final handle = super.ensureSemantics();
// A new explicit client may have just re-enabled a gated tree; the base
// notifier only fires on the false→true edge, which the platform's own
// handle has already consumed.
if (!_gate.wanted && _hasExplicitClient) _gate.notify();
return handle;
}
@override
void addSemanticsEnabledListener(VoidCallback listener) {
super.addSemanticsEnabledListener(listener);
_gate.addListener(listener);
}
@override
void removeSemanticsEnabledListener(VoidCallback listener) {
super.removeSemanticsEnabledListener(listener);
_gate.removeListener(listener);
}
/// Listens to [platformSemanticsRequested] only; unlike
/// [addSemanticsEnabledListener] it does not fire when the gate flips, so the
/// service driving the gate can react to platform changes without re-entering
/// its own decision.
void addPlatformSemanticsListener(VoidCallback listener) => super.addSemanticsEnabledListener(listener);
void removePlatformSemanticsListener(VoidCallback listener) => super.removeSemanticsEnabledListener(listener);
}
class _GateNotifier extends ChangeNotifier {
bool wanted = true;
void notify() => notifyListeners();
}