perf(tv): shape spotlight text once per swap instead of twice

Every D-pad step on the TV home ends with a spotlight swap, and on a
low-end box that frame ran over budget: 12 % of UI-thread CPU during
navigation was paragraph layout, most of it shaping strings a second time
to measure them before the render tree shaped them for real.

- FittingTitleText no longer lays the title out to learn whether it fits:
  Text wraps and ellipsizes at maxLines, so the box can only overflow
  vertically, and one cached one-glyph line height settles that. Only a
  box shorter than maxLines lines still searches, now bracketed by the
  proportional shrink of the base layout and stopped at a 0.25 px
  tolerance instead of a fixed 12 bisections.
- FittedMetadataLine and inlineRatingBadgeWidth measure through a small
  bounded cache keyed by text, style, scaler and direction, so separators,
  type labels, certifications, runtimes and years measure once per
  session.
- MediaHub.isContinueWatchingHub/usesContinueWatchingAction memoize their
  regex tokenisation per key; every card build and step re-asked them.

Cold navigation on the Android 14 box (30 steps over three hubs): build
312-316 ms -> 284-285 ms, layout 530-540 ms -> 515 ms, UI frame p90
7.5-7.9 ms -> 6.8-7.2 ms.
This commit is contained in:
edde746
2026-09-02 22:38:11 +02:00
parent 7e1f18f93f
commit c79ff68a7b
6 changed files with 201 additions and 54 deletions
+13 -6
View File
@@ -75,24 +75,31 @@ class MediaHub {
}
}
bool _isContinueWatchingKey(String rawKey) {
// Per-key memo: every card build and D-pad step re-asks these, and the
// answer needs two regex passes over a key. The set of distinct hub keys a
// session sees is small, so the maps stay small too.
final Map<String, bool> _continueWatchingKeys = <String, bool>{};
final Map<String, bool> _continueWatchingActionKeys = <String, bool>{};
final RegExp _hubKeySeparators = RegExp(r'[^a-z0-9]+');
bool _isContinueWatchingKey(String rawKey) => _continueWatchingKeys.putIfAbsent(rawKey, () {
final compactKey = _compactHubKey(rawKey);
if (compactKey == 'continuewatching') return true;
final tokens = _hubKeyTokens(rawKey);
return tokens.contains('inprogress') || _hasTailToken(tokens, 'continue');
}
});
bool _usesContinueWatchingActionKey(String rawKey) {
bool _usesContinueWatchingActionKey(String rawKey) => _continueWatchingActionKeys.putIfAbsent(rawKey, () {
final tokens = _hubKeyTokens(rawKey);
return _hasTailToken(tokens, 'nextup') || tokens.contains('ondeck');
}
});
List<String> _hubKeyTokens(String rawKey) {
return rawKey.toLowerCase().split(RegExp(r'[^a-z0-9]+')).where((part) => part.isNotEmpty).toList(growable: false);
return rawKey.toLowerCase().split(_hubKeySeparators).where((part) => part.isNotEmpty).toList(growable: false);
}
String _compactHubKey(String rawKey) => rawKey.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]+'), '');
String _compactHubKey(String rawKey) => rawKey.toLowerCase().replaceAll(_hubKeySeparators, '');
bool _hasTailToken(List<String> tokens, String token) => tokens.isNotEmpty && tokens.last == token;
+41
View File
@@ -0,0 +1,41 @@
import 'dart:collection';
import 'package:flutter/painting.dart';
typedef _Key = (String, TextStyle, TextScaler, TextDirection);
final LinkedHashMap<_Key, Size> _sizes = LinkedHashMap<_Key, Size>();
const int _maxEntries = 512;
/// Size of [text] laid out on one unconstrained line, memoized per
/// (text, style, scaler, direction).
///
/// Metadata strips measure every field before building it so they can shed
/// parts that will not fit (#1893), and fitted titles need a line height to
/// know whether a box can overflow at all; each such measurement shapes a
/// string the render tree is about to shape again. Shaping dominates the TV
/// spotlight's info swap on low-end boxes, and the measured strings repeat
/// heavily — separators, "Movie", "TV-14", years, runtimes, "88%" — so a
/// small cache turns the measuring pass into hash lookups. Bounded and
/// insertion-ordered: the oldest entry goes first.
Size cachedSingleLineTextSize(
String text, {
required TextStyle style,
required TextScaler textScaler,
required TextDirection textDirection,
}) {
final key = (text, style, textScaler, textDirection);
final cached = _sizes[key];
if (cached != null) return cached;
final painter = TextPainter(
text: TextSpan(text: text, style: style),
textDirection: textDirection,
textScaler: textScaler,
maxLines: 1,
)..layout();
final size = painter.size;
painter.dispose();
if (_sizes.length >= _maxEntries) _sizes.remove(_sizes.keys.first);
_sizes[key] = size;
return size;
}
+7 -11
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import '../media/media_rating.dart';
import '../utils/text_measure_cache.dart';
import 'media_rating_badge.dart';
/// One slot on a [FittedMetadataLine].
@@ -77,17 +78,12 @@ class FittedMetadataLine extends StatelessWidget {
final badgeGap = ratingSpacing ?? 4;
final entryGap = ratingEntrySpacing ?? 10;
double textWidth(String text) {
final painter = TextPainter(
text: TextSpan(text: text, style: effectiveStyle),
textDirection: textDirection,
textScaler: textScaler,
maxLines: 1,
)..layout();
final width = painter.width;
painter.dispose();
return width;
}
double textWidth(String text) => cachedSingleLineTextSize(
text,
style: effectiveStyle,
textScaler: textScaler,
textDirection: textDirection,
).width;
// Units are the droppable atoms: a text part is one unit, a ratings
// slot is one unit per badge.
+63 -29
View File
@@ -1,5 +1,8 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../utils/platform_detector.dart';
import '../utils/text_measure_cache.dart';
class FittingTitleText extends StatelessWidget {
final String text;
@@ -57,6 +60,22 @@ class FittingTitleText extends StatelessWidget {
);
}
/// Sizes that differ by less than this are indistinguishable on screen, so
/// the bisection stops there instead of running a fixed iteration count.
static const double _fontSizeTolerance = 0.25;
/// The largest font size, down to [minFontSize], at which [text] fits the
/// box.
///
/// The box can only overflow vertically: [Text] wraps and ellipsizes at
/// [maxLines], so a title fits whenever [maxLines] lines of the base style
/// do. That is settled from one cached one-glyph line height, without
/// shaping the title — which the render tree is about to do anyway and
/// which is the single most expensive thing a TV spotlight swap does on a
/// low-end box. Only a box shorter than [maxLines] lines needs the search,
/// and it is bracketed: layout height scales with the font size at a fixed
/// line count, so the proportional shrink of the base layout is a fitting
/// lower bound and the bisection only refines between it and the base size.
double _fitFontSize({
required String text,
required TextStyle style,
@@ -68,37 +87,52 @@ class FittingTitleText extends StatelessWidget {
final baseFontSize = style.fontSize ?? 14;
if (baseFontSize <= minFontSize) return baseFontSize;
bool fits(double fontSize) {
final painter = TextPainter(
text: TextSpan(
text: text,
style: style.copyWith(fontSize: fontSize),
),
maxLines: maxLines,
ellipsis: overflow == TextOverflow.ellipsis ? '\u2026' : null,
textDirection: textDirection,
textScaler: textScaler,
textAlign: textAlign ?? TextAlign.start,
)..layout(maxWidth: maxWidth);
final result = painter.height <= maxHeight + 0.1 && painter.width <= maxWidth + 0.1;
painter.dispose();
return result;
}
final lineHeight = cachedSingleLineTextSize(
'X',
style: style,
textScaler: textScaler,
textDirection: textDirection,
).height;
if (maxLines * lineHeight <= maxHeight + 0.1) return baseFontSize;
if (fits(baseFontSize)) return baseFontSize;
if (!fits(minFontSize)) return minFontSize;
var low = minFontSize;
var high = baseFontSize;
for (var i = 0; i < 12; i++) {
final mid = (low + high) / 2;
if (fits(mid)) {
low = mid;
} else {
high = mid;
final painter = TextPainter(
maxLines: maxLines,
ellipsis: overflow == TextOverflow.ellipsis ? '\u2026' : null,
textDirection: textDirection,
textScaler: textScaler,
textAlign: textAlign ?? TextAlign.start,
);
try {
bool fits(double fontSize) {
painter
..text = TextSpan(
text: text,
style: style.copyWith(fontSize: fontSize),
)
..layout(maxWidth: maxWidth);
return painter.height <= maxHeight + 0.1 && painter.width <= maxWidth + 0.1;
}
if (fits(baseFontSize)) return baseFontSize;
final shrink = math.min(maxHeight / painter.height, maxWidth / painter.width);
var low = math.max(minFontSize, baseFontSize * shrink);
if (low <= minFontSize || !fits(low)) {
if (!fits(minFontSize)) return minFontSize;
low = minFontSize;
}
var high = baseFontSize;
while (high - low > _fontSizeTolerance) {
final mid = (low + high) / 2;
if (fits(mid)) {
low = mid;
} else {
high = mid;
}
}
return low;
} finally {
painter.dispose();
}
return low;
}
}
+7 -8
View File
@@ -6,6 +6,7 @@ import '../media/media_item.dart';
import '../media/media_rating.dart';
import '../utils/formatters.dart';
import '../utils/rating_utils.dart';
import '../utils/text_measure_cache.dart';
import 'app_icon.dart';
/// Every attributed score for [item], falling back to [fallbackItem] as a
@@ -57,15 +58,13 @@ double inlineRatingBadgeWidth(
}) {
final size = iconSize ?? textStyle.fontSize ?? 13;
final info = ratingInfoForSource(rating.source, rating.value);
final painter = TextPainter(
text: TextSpan(text: mediaRatingLabel(rating), style: textStyle),
textDirection: textDirection,
final labelWidth = cachedSingleLineTextSize(
mediaRatingLabel(rating),
style: textStyle,
textScaler: textScaler,
maxLines: 1,
)..layout();
final width = size * (info?.iconAspect ?? 1) + (spacing ?? 4) + painter.width;
painter.dispose();
return width;
textDirection: textDirection,
).width;
return size * (info?.iconAspect ?? 1) + (spacing ?? 4) + labelWidth;
}
/// The bare badge row for an explicit list of scores, for single-line
+70
View File
@@ -0,0 +1,70 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/widgets/fitting_title_text.dart';
const _style = TextStyle(fontSize: 40, letterSpacing: 0, wordSpacing: 0);
Future<double> _fittedSize(WidgetTester tester, String text, {required double width, required double height}) async {
await tester.pumpWidget(
MaterialApp(
home: Center(
child: SizedBox(
width: width,
height: height,
child: FittingTitleText(text, style: _style),
),
),
),
);
return tester.widget<Text>(find.text(text)).style!.fontSize!;
}
/// Whether [text] at [fontSize], wrapped to two lines like the widget does,
/// fits [height] — the widget's own definition of fitting.
bool _fits(String text, double fontSize, {required double width, required double height}) {
final painter = TextPainter(
text: TextSpan(
text: text,
style: _style.copyWith(fontSize: fontSize),
),
maxLines: 2,
ellipsis: '\u2026',
textDirection: TextDirection.ltr,
)..layout(maxWidth: width);
final fits = painter.height <= height + 0.1;
painter.dispose();
return fits;
}
void main() {
testWidgets('keeps the base size when two lines fit the box', (tester) async {
expect(await _fittedSize(tester, 'ABCDE', width: 400, height: 100), 40);
// Width never shrinks the title: a too-long line is ellipsized instead.
expect(await _fittedSize(tester, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', width: 50, height: 100), 40);
});
testWidgets('a box shorter than two lines gets the largest size that still fits', (tester) async {
for (final (text, width, height) in [
('ABCDE FGHIJ', 50.0, 8.0), // one line beats two: 11 chars on one line at ~4.5 > two lines at 4
('ABCDE FGHIJ', 50.0, 20.0), // two lines at ~10, bounded by the line width
('The Lord of the Rings: The Return of the King', 300.0, 60.0),
]) {
final size = await _fittedSize(tester, text, width: width, height: height);
expect(size, lessThan(40), reason: '$text in ${width}x$height');
expect(
_fits(text, size, width: width, height: height),
isTrue,
reason: '$text at $size must fit',
);
expect(
_fits(text, size + 0.5, width: width, height: height),
isFalse,
reason: '$text at $size is not maximal',
);
}
});
testWidgets('never goes below the minimum size', (tester) async {
expect(await _fittedSize(tester, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', width: 5, height: 0.5), 1);
});
}