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:
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user