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.
63 lines
1.8 KiB
Dart
63 lines
1.8 KiB
Dart
import 'package:flutter/widgets.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:plezy/widgets/card_inflation_budget.dart';
|
|
|
|
class _UpgradeHost extends StatefulWidget {
|
|
const _UpgradeHost();
|
|
|
|
@override
|
|
State<_UpgradeHost> createState() => _UpgradeHostState();
|
|
}
|
|
|
|
class _UpgradeHostState extends State<_UpgradeHost> with SkeletonUpgradeScheduler {
|
|
int builds = 0;
|
|
int pendingSkeletons = 2;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
builds++;
|
|
if (pendingSkeletons > 0) {
|
|
pendingSkeletons--;
|
|
scheduleSkeletonUpgrade();
|
|
}
|
|
return const SizedBox();
|
|
}
|
|
}
|
|
|
|
void main() {
|
|
setUp(CardInflationBudget.reset);
|
|
|
|
testWidgets('budget grants maxPerFrame slots and resets on the next frame', (tester) async {
|
|
await tester.pumpWidget(const SizedBox());
|
|
|
|
for (var i = 0; i < CardInflationBudget.maxPerFrame; i++) {
|
|
expect(CardInflationBudget.tryTake(), isTrue);
|
|
}
|
|
expect(CardInflationBudget.tryTake(), isFalse);
|
|
|
|
// In production tryTake only runs during builds, where a frame is in
|
|
// flight; here the takes happened between frames, so schedule one for
|
|
// the post-frame reset to ride on.
|
|
tester.binding.scheduleFrame();
|
|
await tester.pump();
|
|
|
|
expect(CardInflationBudget.tryTake(), isTrue);
|
|
});
|
|
|
|
testWidgets('skeleton upgrade chain re-arms per frame and stops when drained', (tester) async {
|
|
await tester.pumpWidget(const _UpgradeHost());
|
|
final state = tester.state<_UpgradeHostState>(find.byType(_UpgradeHost));
|
|
expect(state.builds, 1);
|
|
|
|
// Each pump runs the post-frame setState, upgrading one pending skeleton.
|
|
await tester.pump();
|
|
expect(state.builds, 2);
|
|
await tester.pump();
|
|
expect(state.builds, 3);
|
|
|
|
// Drained: no reschedule, no further rebuilds.
|
|
await tester.pump();
|
|
expect(state.builds, 3);
|
|
});
|
|
}
|