Profile traces showed 100-370ms UI-thread frames while scrolling the library screen, dominated by rebuilding and inflating media cards. - Gate per-card focus/pointer chrome on input mode: FocusableWrapper skips the scale/border wrappers and creates its AnimationController lazily outside keyboard mode, and ClickableCursor plus the card tap region only build MouseRegion/InkWell machinery on desktop - TV and touch use a bare GestureDetector. Hub cards also drop their outer gesture wrapper outside keyboard mode; the card's own tap region always won the gesture arena anyway. - Memoize sliver children (SliverChildMemo): browse/collections grids and hub rows return identical widget instances for unchanged items, so delegate swaps from pagination, watch-state, and deletion setStates no longer rebuild every realized card inside layout. The browse tab prunes the memo in lockstep with focus-node eviction so a cached card can never resurrect a disposed FocusNode. - Budget fresh inflation (CardInflationBudget): while a scrollable is moving in pointer/touch mode at most one new card inflates per frame, the rest render as SkeletonMediaCard and upgrade on following frames. Hub rows also stop pre-inflating 250px of off-screen cards on entry. Device traces: worst frame 373ms -> 103ms, per-card build 3.6ms -> 2.4ms median; remaining row-entry work is spread across frames.
77 lines
2.9 KiB
Dart
77 lines
2.9 KiB
Dart
import 'package:flutter/scheduler.dart';
|
|
import 'package:flutter/widgets.dart';
|
|
|
|
/// Global per-frame budget for inflating fresh media cards while scrolling.
|
|
///
|
|
/// Inflating a card (build + first layout + first paint) costs ~8ms on
|
|
/// low-end hardware, and a grid row entering the viewport inflates a whole
|
|
/// row of them in one frame — a guaranteed dropped frame. Callers ask
|
|
/// [tryTake] for a slot before inflating a *new* card during an active
|
|
/// scroll; when the budget is spent they render a [SkeletonMediaCard]
|
|
/// instead and upgrade it on a following frame (see
|
|
/// [SkeletonUpgradeScheduler]).
|
|
///
|
|
/// The budget is global, not per-list, so several hub rows entering in the
|
|
/// same frame share one cap instead of multiplying it. Cards that are
|
|
/// already built (memo hits) never consume a slot.
|
|
abstract final class CardInflationBudget {
|
|
/// One fresh card per frame: a card costs ~8ms and an upgrade frame also
|
|
/// pays the delegate walk, so two would already blow a 60Hz budget on the
|
|
/// devices this exists for. Typical fling entry rate on a 3-column grid is
|
|
/// under one card per frame, so the backlog stays near zero.
|
|
static const int maxPerFrame = 1;
|
|
|
|
static int _taken = 0;
|
|
static bool _resetScheduled = false;
|
|
|
|
/// Claims an inflation slot for the current frame. Returns false when the
|
|
/// frame's budget is already spent.
|
|
static bool tryTake() {
|
|
if (_taken >= maxPerFrame) return false;
|
|
_taken++;
|
|
if (!_resetScheduled) {
|
|
_resetScheduled = true;
|
|
SchedulerBinding.instance.addPostFrameCallback((_) {
|
|
_resetScheduled = false;
|
|
_taken = 0;
|
|
});
|
|
}
|
|
return true;
|
|
}
|
|
|
|
@visibleForTesting
|
|
static void reset() {
|
|
_taken = 0;
|
|
_resetScheduled = false;
|
|
}
|
|
|
|
/// Whether an enclosing scrollable is actively scrolling — the condition
|
|
/// under which fresh inflations should be budgeted. Checks the nearest
|
|
/// scrollable and the nearest vertical one: a card in a horizontal hub row
|
|
/// enters either because its own row scrolls or because the vertical list
|
|
/// carrying the row does.
|
|
static bool isScrollingContext(BuildContext context) {
|
|
if (Scrollable.maybeOf(context)?.position.isScrollingNotifier.value ?? false) {
|
|
return true;
|
|
}
|
|
return Scrollable.maybeOf(context, axis: Axis.vertical)?.position.isScrollingNotifier.value ?? false;
|
|
}
|
|
}
|
|
|
|
/// Re-arms a post-frame rebuild while budgeted skeletons are pending, so
|
|
/// every skeleton is upgraded to its real card within a frame or two of the
|
|
/// budget freeing up. The chain stops by itself: a build that emits no
|
|
/// skeleton schedules nothing.
|
|
mixin SkeletonUpgradeScheduler<T extends StatefulWidget> on State<T> {
|
|
bool _skeletonUpgradeScheduled = false;
|
|
|
|
void scheduleSkeletonUpgrade() {
|
|
if (_skeletonUpgradeScheduled) return;
|
|
_skeletonUpgradeScheduled = true;
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
_skeletonUpgradeScheduled = false;
|
|
if (mounted) setState(() {});
|
|
});
|
|
}
|
|
}
|