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.
85 lines
3.7 KiB
Dart
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();
|
|
}
|