perf(search): reduce list rendering and provider rebuilds

This commit is contained in:
zarzet
2026-09-26 23:41:47 +07:00
parent e0093841bc
commit abfd8df5fb
6 changed files with 223 additions and 50 deletions
+26 -22
View File
@@ -765,22 +765,25 @@ class _HomeTabState extends ConsumerState<HomeTab>
),
);
final hasExploreContent = ref.watch(
exploreProvider.select((s) => s.sections.isNotEmpty),
);
final exploreLoading = ref.watch(
exploreProvider.select((s) => s.isLoading),
);
final hasHomeFeedExtension = ref.watch(
extensionProvider.select(
(s) => s.extensions.any((e) => e.enabled && e.hasHomeFeed),
),
);
final homeFeedDisabled = ref.watch(
settingsProvider.select(
(s) => s.homeFeedProvider == AppSettings.homeFeedProviderOff,
),
);
final hasExploreContent =
_showsHome &&
ref.watch(exploreProvider.select((s) => s.sections.isNotEmpty));
final exploreLoading =
_showsHome && ref.watch(exploreProvider.select((s) => s.isLoading));
final hasHomeFeedExtension =
_showsHome &&
ref.watch(
extensionProvider.select(
(s) => s.extensions.any((e) => e.enabled && e.hasHomeFeed),
),
);
final homeFeedDisabled =
_showsHome &&
ref.watch(
settingsProvider.select(
(s) => s.homeFeedProvider == AppSettings.homeFeedProviderOff,
),
);
final colorScheme = Theme.of(context).colorScheme;
final searchText = _showsSearch ? _urlController.text.trim() : '';
@@ -804,9 +807,11 @@ class _HomeTabState extends ConsumerState<HomeTab>
_showsSearch &&
ref.watch(trackProvider.select((s) => s.isShowingRecentAccess));
final screenHeight = MediaQuery.sizeOf(context).height;
final hasHistoryItems = ref.watch(
_homeHistoryPreviewProvider.select((items) => items.isNotEmpty),
);
final hasHistoryItems =
_showsHome &&
ref.watch(
_homeHistoryPreviewProvider.select((items) => items.isNotEmpty),
);
final recentModeRequested =
widget.mode == HomeTabMode.search ||
@@ -982,9 +987,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
final recentAccessView = ref.watch(
recentAccessViewProvider,
);
return SliverToBoxAdapter(
child: _buildRecentAccess(recentAccessView, colorScheme),
);
return _buildRecentAccess(recentAccessView, colorScheme);
},
),
@@ -1113,6 +1116,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
return SliverMainAxisGroup(
slivers: _buildSearchResults(
resultsRef: ref,
tracks: tracks,
isLoading: isLoading,
error: error,
+32 -9
View File
@@ -2,12 +2,43 @@ part of 'home_tab.dart';
extension _HomeTabRecentUI on _HomeTabState {
Widget _buildRecentAccess(_RecentAccessView view, ColorScheme colorScheme) {
return SliverMainAxisGroup(
slivers: [
SliverToBoxAdapter(child: _buildRecentAccessHeader(view, colorScheme)),
if (view.uniqueItems.isNotEmpty)
SliverPadding(
padding:
const EdgeInsets.fromLTRB(16, 0, 16, 8) +
EdgeInsets.symmetric(horizontal: wideListInset(context)),
sliver: SliverList.builder(
itemCount: view.uniqueItems.length,
itemBuilder: (context, index) {
final item = view.uniqueItems[index];
return KeyedSubtree(
key: ValueKey('${item.type.name}:${item.id}'),
child: _buildRecentAccessItem(
item,
colorScheme,
view.downloadFilePathByRecentKey,
),
);
},
),
),
],
);
}
Widget _buildRecentAccessHeader(
_RecentAccessView view,
ColorScheme colorScheme,
) {
final uniqueItems = view.uniqueItems;
final hasHiddenDownloads = view.hasHiddenDownloads;
return Padding(
padding:
const EdgeInsets.fromLTRB(16, 8, 16, 8) +
EdgeInsets.fromLTRB(16, 8, 16, uniqueItems.isEmpty ? 8 : 0) +
EdgeInsets.symmetric(horizontal: wideListInset(context)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -99,14 +130,6 @@ extension _HomeTabRecentUI on _HomeTabState {
],
),
),
)
else
...uniqueItems.map(
(item) => _buildRecentAccessItem(
item,
colorScheme,
view.downloadFilePathByRecentKey,
),
),
],
),
+3 -1
View File
@@ -299,6 +299,7 @@ extension _HomeTabSearchResultsUI on _HomeTabState {
}
List<Widget> _buildSearchResults({
required WidgetRef resultsRef,
required List<Track> tracks,
required bool isLoading,
required String? error,
@@ -418,7 +419,7 @@ extension _HomeTabSearchResultsUI on _HomeTabState {
if (sortedTracks.isNotEmpty) {
_historySnapshot.update(sortedTracks);
final historyLookups = _historySnapshot.lookups;
final existingHistoryKeys = ref.watch(
final existingHistoryKeys = resultsRef.watch(
downloadHistoryVisibleBatchExistsProvider(_historySnapshot.request),
);
slivers.addAll(
@@ -540,6 +541,7 @@ extension _HomeTabSearchResultsUI on _HomeTabState {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: MornyeGlassPanel.overlay(
blurEnabled: false,
radius: 24,
firstInGroup: isFirst,
lastInGroup: isLast,
+2 -3
View File
@@ -219,9 +219,9 @@ class _TrackItemWithStatus extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
final queueItem = ref.watch(
final isQueued = ref.watch(
downloadQueueLookupProvider.select(
(lookup) => lookup.byTrackId[track.id],
(lookup) => lookup.byTrackId.containsKey(track.id),
),
);
@@ -249,7 +249,6 @@ class _TrackItemWithStatus extends ConsumerWidget {
thumbHeight = thumbSize.$2;
}
final isQueued = queueItem != null;
final hasCover = track.coverUrl?.isNotEmpty == true;
return Column(
+6
View File
@@ -131,6 +131,7 @@ class MornyeGlassPanel extends ConsumerWidget {
this.strongTint = false,
this.tintOpacity,
this.liquidGlass = false,
this.blurEnabled = true,
});
/// Shared glass material for confirmation dialogs and floating sheets.
@@ -142,6 +143,7 @@ class MornyeGlassPanel extends ConsumerWidget {
this.lastInGroup = true,
this.tintOpacity = 0.78,
this.liquidGlass = false,
this.blurEnabled = true,
}) : strongTint = false;
final Widget child;
@@ -152,9 +154,13 @@ class MornyeGlassPanel extends ConsumerWidget {
final double? tintOpacity;
final bool liquidGlass;
/// Disable backdrop sampling for surfaces that scroll over a plain page.
final bool blurEnabled;
@override
Widget build(BuildContext context, WidgetRef ref) {
final blur =
blurEnabled &&
!MediaQuery.highContrastOf(context) &&
(!ref.watch(lowEndDeviceProvider) ||
ref.watch(backdropBlurEnabledProvider));
+154 -15
View File
@@ -4,14 +4,17 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/l10n/app_localizations.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/providers/download_history_provider.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/explore_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/music_player_provider.dart';
import 'package:spotiflac_android/providers/recent_access_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/track_provider.dart';
import 'package:spotiflac_android/screens/home_tab.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/widgets/app_search_field.dart';
import 'package:spotiflac_android/widgets/mornye_chrome.dart';
void main() {
testWidgets(
@@ -124,6 +127,128 @@ void main() {
expect(find.text('Found artist'), findsOneWidget);
expect(tester.takeException(), isNull);
});
for (final (brightness, itemType) in [
for (final brightness in Brightness.values)
for (final itemType in ['artist', 'track']) (brightness, itemType),
]) {
testWidgets(
'Search keeps field glass and scrolls $itemType results without blur in $brightness',
(tester) async {
tester.view.physicalSize = const Size(430, 650);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final search = _Search(resultCount: 120, itemType: itemType);
await tester.pumpWidget(
ProviderScope(
overrides: [
settingsProvider.overrideWith(_Settings.new),
extensionProvider.overrideWith(_Extensions.new),
exploreProvider.overrideWith(_Explore.new),
downloadHistoryProvider.overrideWith(_History.new),
downloadHistoryBatchExistsProvider.overrideWith(
(ref, request) async => <String>{},
),
downloadQueueLookupProvider.overrideWith(
(ref) => DownloadQueueState().lookup,
),
currentMediaItemProvider.overrideWith(
(ref) => Stream.value(null),
),
recentAccessProvider.overrideWith(_Recent.new),
trackProvider.overrideWith(() => search),
],
child: MaterialApp(
theme: MornyeTheme.build(brightness),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const HomeTab(mode: HomeTabMode.search),
),
),
);
await tester.pumpAndSettle();
expect(
find.descendant(
of: find.byType(AppSearchField),
matching: find.byType(BackdropFilter),
),
findsOneWidget,
);
await tester.enterText(find.byType(TextField), 'Example');
await tester.testTextInput.receiveAction(TextInputAction.search);
await tester.pumpAndSettle();
expect(find.text('Found $itemType 0'), findsOneWidget);
expect(find.text('Found $itemType 119'), findsNothing);
expect(
find.descendant(
of: find.ancestor(
of: find.text('Found $itemType 0'),
matching: find.byType(MornyeGlassPanel),
),
matching: find.byType(BackdropFilter),
),
findsNothing,
);
await tester.scrollUntilVisible(
find.text('Found $itemType 119'),
500,
scrollable: find.byType(Scrollable).first,
);
await tester.pumpAndSettle();
expect(find.text('Found $itemType 119').hitTestable(), findsOneWidget);
expect(search._requests, 1);
expect(
find.descendant(
of: find.ancestor(
of: find.text('Found $itemType 119'),
matching: find.byType(MornyeGlassPanel),
),
matching: find.byType(BackdropFilter),
),
findsNothing,
);
expect(tester.takeException(), isNull);
},
);
}
testWidgets('Search builds recent items as they enter the viewport', (
tester,
) async {
tester.view.physicalSize = const Size(430, 600);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(
ProviderScope(
overrides: [
settingsProvider.overrideWith(_Settings.new),
extensionProvider.overrideWith(_Extensions.new),
exploreProvider.overrideWith(_Explore.new),
downloadHistoryProvider.overrideWith(_History.new),
recentAccessProvider.overrideWith(() => _Recent(itemCount: 10)),
trackProvider.overrideWith(_Search.new),
],
child: MaterialApp(
theme: MornyeTheme.build(Brightness.dark),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const HomeTab(mode: HomeTabMode.search),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Recent artist 0'), findsOneWidget);
expect(find.text('Recent artist 9'), findsNothing);
await tester.scrollUntilVisible(
find.text('Recent artist 9'),
300,
scrollable: find.byType(Scrollable).first,
);
await tester.pumpAndSettle();
expect(find.text('Recent artist 9').hitTestable(), findsOneWidget);
expect(tester.takeException(), isNull);
});
}
class _Tabs extends StatefulWidget {
@@ -211,25 +336,38 @@ class _History extends DownloadHistoryNotifier {
}
class _Recent extends RecentAccessNotifier {
_Recent({this.itemCount = 1});
final int itemCount;
@override
RecentAccessState build() => RecentAccessState(
isLoaded: true,
items: [
RecentAccessItem(
id: 'recent',
name: 'Recently visited artist',
items: List.generate(
itemCount,
(index) => RecentAccessItem(
id: 'recent-$index',
name: itemCount == 1
? 'Recently visited artist'
: 'Recent artist $index',
type: RecentAccessType.artist,
accessedAt: DateTime(2026),
accessedAt: DateTime(2026, 1, itemCount - index),
providerId: 'example',
),
],
),
);
}
class _Search extends TrackNotifier {
_Search({bool failFirst = false}) : _failFirst = failFirst;
_Search({
bool failFirst = false,
this.resultCount = 1,
this.itemType = 'artist',
}) : _failFirst = failFirst;
final bool _failFirst;
final int resultCount;
final String itemType;
int _requests = 0;
@override
@@ -248,20 +386,21 @@ class _Search extends TrackNotifier {
);
return;
}
state = const TrackState(
state = TrackState(
hasSearchText: true,
searchExtensionId: 'example',
tracks: [
Track(
id: 'artist',
name: 'Found artist',
tracks: List.generate(
resultCount,
(index) => Track(
id: '$itemType-$index',
name: resultCount == 1 ? 'Found $itemType' : 'Found $itemType $index',
artistName: '',
albumName: '',
duration: 0,
itemType: 'artist',
itemType: itemType,
source: 'example',
),
],
),
);
}
}