perf(library): reuse collection lookups across selection rebuilds

This commit is contained in:
zarzet
2026-09-06 02:34:49 +07:00
parent 7b87a8ee15
commit ab21de45c1
4 changed files with 120 additions and 10 deletions
@@ -0,0 +1,25 @@
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/providers/download_history_provider.dart';
import 'package:spotiflac_android/providers/library_collections_provider.dart';
import 'package:spotiflac_android/services/history_database.dart';
/// Collection providers replace their track list when its contents change.
/// Keep derived data across selection/layout rebuilds of the same snapshot.
class LibraryTracksFolderData {
List<CollectionTrackEntry>? _entries;
Set<String> keys = const {};
List<Track> tracks = const [];
HistoryBatchLookupRequest historyRequest = const HistoryBatchLookupRequest(
[],
);
void update(List<CollectionTrackEntry> entries) {
if (identical(entries, _entries)) return;
_entries = entries;
keys = Set.unmodifiable(entries.map((entry) => entry.key));
tracks = List.unmodifiable(entries.map((entry) => entry.track));
historyRequest = HistoryBatchLookupRequest.snapshot(
tracks.map(historyLookupForTrack),
);
}
}
+7 -10
View File
@@ -28,6 +28,7 @@ import 'package:spotiflac_android/utils/nav_bar_inset.dart';
import 'package:spotiflac_android/utils/cover_art_utils.dart';
import 'package:spotiflac_android/screens/collapsing_header_scroll_mixin.dart';
import 'package:spotiflac_android/screens/selection_mode_mixin.dart';
import 'package:spotiflac_android/screens/library_tracks_folder_data.dart';
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
import 'package:spotiflac_android/widgets/selection_action_button.dart';
import 'package:spotiflac_android/widgets/selection_bottom_bar.dart';
@@ -67,6 +68,7 @@ class _LibraryTracksFolderScreenState
SelectionModeMixin<LibraryTracksFolderScreen>,
CollapsingHeaderScrollMixin<LibraryTracksFolderScreen> {
UserPlaylistCollection? playlist;
final _folderData = LibraryTracksFolderData();
@override
void initState() {
@@ -272,7 +274,8 @@ class _LibraryTracksFolderScreenState
break;
}
pruneSelection(entries.map((e) => e.key).toSet());
_folderData.update(entries);
pruneSelection(_folderData.keys);
final title = switch (widget.mode) {
LibraryTracksFolderMode.wishlist => context.l10n.collectionWishlist,
@@ -297,16 +300,10 @@ class _LibraryTracksFolderScreenState
LibraryTracksFolderMode.playlist =>
context.l10n.collectionPlaylistEmptySubtitle,
};
final folderTracks = entries
.map((entry) => entry.track)
.toList(growable: false);
final historyLookups = folderTracks
.map(historyLookupForTrack)
.toList(growable: false);
final folderTracks = _folderData.tracks;
final historyLookups = _folderData.historyRequest.tracks;
final existingHistoryKeys = ref.watch(
downloadHistoryVisibleBatchExistsProvider(
HistoryBatchLookupRequest(historyLookups),
),
downloadHistoryVisibleBatchExistsProvider(_folderData.historyRequest),
);
final bottomPadding = MediaQuery.paddingOf(context).bottom;
+23
View File
@@ -48,6 +48,12 @@ class HistoryBatchLookupRequest {
const HistoryBatchLookupRequest(this.tracks);
/// An immutable request whose hash is computed only once. Use for lists
/// retained across widget builds; the legacy constructor remains available.
factory HistoryBatchLookupRequest.snapshot(
Iterable<HistoryLookupRequest> tracks,
) = _HistoryBatchLookupSnapshot;
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
@@ -65,6 +71,23 @@ class HistoryBatchLookupRequest {
int get hashCode => Object.hashAll(tracks);
}
class _HistoryBatchLookupSnapshot extends HistoryBatchLookupRequest {
_HistoryBatchLookupSnapshot(Iterable<HistoryLookupRequest> tracks)
: super(List.unmodifiable(tracks));
late final int _hash = Object.hashAll(tracks);
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is HistoryBatchLookupRequest &&
hashCode == other.hashCode &&
super == other;
@override
int get hashCode => _hash;
}
class HistoryDatabase {
// The FTS table is a derived, optional index and is initialized lazily after
// the existing schema migration. Keep this contract at v13 because the
+65
View File
@@ -0,0 +1,65 @@
import 'dart:collection';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/providers/library_collections_provider.dart';
import 'package:spotiflac_android/screens/library_tracks_folder_data.dart';
import 'package:spotiflac_android/services/history_database.dart';
import 'package:flutter_test/flutter_test.dart';
class _ReadCountingList<T> extends ListBase<T> {
_ReadCountingList(this.values);
final List<T> values;
int reads = 0;
@override
int get length => values.length;
@override
set length(int value) => throw UnsupportedError('immutable');
@override
T operator [](int index) {
reads++;
return values[index];
}
@override
void operator []=(int index, T value) => throw UnsupportedError('immutable');
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('unchanged collection snapshot does no additional full-list reads', () {
final entries = _ReadCountingList(
List.generate(
4000,
(index) => CollectionTrackEntry(
key: '$index',
addedAt: DateTime.utc(2026),
track: Track(
id: '$index',
name: 'Song $index',
artistName: 'Artist',
albumName: 'Album',
duration: 1,
),
),
),
);
final data = LibraryTracksFolderData()..update(entries);
final request = data.historyRequest;
final reads = entries.reads;
final hash = request.hashCode;
for (var i = 0; i < 100; i++) {
data.update(entries);
expect(identical(data.historyRequest, request), isTrue);
expect(data.historyRequest.hashCode, hash);
}
expect(entries.reads, reads);
expect(data.keys.length, 4000);
expect(() => data.tracks.clear(), throwsUnsupportedError);
expect(() => request.tracks.clear(), throwsUnsupportedError);
expect(request, HistoryBatchLookupRequest(request.tracks));
expect(hash, HistoryBatchLookupRequest(request.tracks).hashCode);
data.update(entries.take(2).toList());
expect(data.keys, {'0', '1'});
expect(data.tracks.length, 2);
expect(identical(data.historyRequest, request), isFalse);
});
}