From dae509078eed7dfb526b21b8d099bb9aecca05dd Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Sun, 6 Sep 2026 02:33:42 +0700 Subject: [PATCH] perf(queue): apply sparse progress updates with chunked snapshots --- lib/providers/download_queue_provider.dart | 6 +- .../download_queue_provider_progress.dart | 20 ++- lib/providers/download_queue_state.dart | 84 +++++++++---- lib/utils/chunked_list.dart | 51 ++++++++ test/download_queue_sparse_update_test.dart | 115 ++++++++++++++++++ 5 files changed, 237 insertions(+), 39 deletions(-) create mode 100644 lib/utils/chunked_list.dart create mode 100644 test/download_queue_sparse_update_test.dart diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index c22dd86c..f0eb2be2 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:math'; import 'dart:convert'; import 'dart:io'; +import 'package:spotiflac_android/utils/chunked_list.dart'; import 'package:flutter/material.dart' show ScaffoldMessenger, SnackBar, SnackBarAction, Text; import 'package:flutter/widgets.dart'; @@ -1085,8 +1086,9 @@ class DownloadQueueNotifier extends Notifier { return; } - final updatedItems = List.from(items); - updatedItems[index] = next; + final updatedItems = ChunkedList.from( + items, + ).updated({index: next}); state = state.copyWith( items: updatedItems, lookup: state.lookup.updatedForIndices( diff --git a/lib/providers/download_queue_provider_progress.dart b/lib/providers/download_queue_provider_progress.dart index 6bb4907f..38c590a8 100644 --- a/lib/providers/download_queue_provider_progress.dart +++ b/lib/providers/download_queue_provider_progress.dart @@ -266,14 +266,12 @@ extension _DownloadQueueProgress on DownloadQueueNotifier { } if (progressUpdates.isNotEmpty) { - var updatedItems = currentItems; - bool changed = false; - final changedIndices = []; + final replacements = {}; for (final entry in progressUpdates.entries) { final index = lookup.indexByItemId[entry.key]; if (index == null) continue; - final current = updatedItems[index]; + final current = currentItems[index]; if (current.status == DownloadStatus.skipped || current.status == DownloadStatus.completed || current.status == DownloadStatus.failed) { @@ -298,22 +296,20 @@ extension _DownloadQueueProgress on DownloadQueueNotifier { current.bytesReceived != next.bytesReceived || current.bytesTotal != next.bytesTotal || current.preparationStage != next.preparationStage) { - if (!changed) { - updatedItems = List.from(updatedItems); - changed = true; - } - updatedItems[index] = next; - changedIndices.add(index); + replacements[index] = next; } } - if (changed) { + if (replacements.isNotEmpty) { + final updatedItems = ChunkedList.from( + currentItems, + ).updated(replacements); state = state.copyWith( items: updatedItems, lookup: state.lookup.updatedForIndices( previousItems: currentItems, nextItems: updatedItems, - changedIndices: changedIndices, + changedIndices: replacements.keys, ), ); } diff --git a/lib/providers/download_queue_state.dart b/lib/providers/download_queue_state.dart index 14b6c51a..4439eb9a 100644 --- a/lib/providers/download_queue_state.dart +++ b/lib/providers/download_queue_state.dart @@ -1,4 +1,41 @@ +import 'dart:collection'; + import 'package:spotiflac_android/models/download_item.dart'; +import 'package:spotiflac_android/utils/chunked_list.dart'; + +/// Shares the stable key-to-position index across progress snapshots. Values +/// come from the current immutable items, so old lookups keep their old values. +class _IndexedQueueMap extends MapBase { + _IndexedQueueMap(this.index, this.items); + final Map index; + final List items; + + @override + DownloadItem? operator [](Object? key) { + final position = index[key]; + return position == null ? null : items[position]; + } + + @override + Iterable get keys => index.keys; + + @override + int get length => index.length; + + @override + bool containsKey(Object? key) => index.containsKey(key); + + @override + void operator []=(String key, DownloadItem value) => + throw UnsupportedError('Immutable queue lookup'); + + @override + void clear() => throw UnsupportedError('Immutable queue lookup'); + + @override + DownloadItem? remove(Object? key) => + throw UnsupportedError('Immutable queue lookup'); +} /// Immutable queue state shared by the notifier and read-only UI consumers. /// @@ -43,7 +80,9 @@ class DownloadQueueState { String? audioQuality, bool? autoFallback, }) { - final resolvedItems = items ?? this.items; + final resolvedItems = items == null + ? this.items + : ChunkedList.from(items); return DownloadQueueState( items: resolvedItems, lookup: @@ -73,8 +112,8 @@ class DownloadQueueState { /// Precomputed queue indexes and counters. /// -/// The lookup can update in O(changed items) when item identities are stable, -/// avoiding full queue scans for every progress event. +/// Stable identity indexes are shared across progress snapshots. Sparse item +/// changes copy only affected storage chunks rather than two entire maps. class DownloadQueueLookup { final Map byTrackId; final Map byItemId; @@ -113,8 +152,7 @@ class DownloadQueueLookup { }); factory DownloadQueueLookup.fromItems(List items) { - final byTrackId = {}; - final byItemId = {}; + final byTrackIndex = {}; final indexByItemId = {}; final itemIds = []; final notCompletedItemIds = []; @@ -125,8 +163,7 @@ class DownloadQueueLookup { var finalizingCount = 0; for (var index = 0; index < items.length; index++) { final item = items[index]; - byTrackId.putIfAbsent(item.track.id, () => item); - byItemId[item.id] = item; + byTrackIndex.putIfAbsent(item.track.id, () => index); indexByItemId[item.id] = index; itemIds.add(item.id); if (item.status != DownloadStatus.completed) { @@ -138,10 +175,12 @@ class DownloadQueueLookup { if (item.status == DownloadStatus.downloading) activeDownloadsCount++; if (item.status == DownloadStatus.finalizing) finalizingCount++; } + final snapshot = ChunkedList.from(items); + final itemIndex = Map.unmodifiable(indexByItemId); return DownloadQueueLookup._( - byTrackId: Map.unmodifiable(byTrackId), - byItemId: Map.unmodifiable(byItemId), - indexByItemId: Map.unmodifiable(indexByItemId), + byTrackId: _IndexedQueueMap(Map.unmodifiable(byTrackIndex), snapshot), + byItemId: _IndexedQueueMap(itemIndex, snapshot), + indexByItemId: itemIndex, itemIds: List.unmodifiable(itemIds), notCompletedItemIds: List.unmodifiable(notCompletedItemIds), queuedCount: queuedCount, @@ -179,7 +218,7 @@ class DownloadQueueLookup { return DownloadQueueLookup.fromItems(nextItems); } - final normalizedChanged = []; + final normalizedChanged = {}; for (final index in changedIndices) { if (index < 0 || index >= nextItems.length) { return DownloadQueueLookup.fromItems(nextItems); @@ -194,8 +233,6 @@ class DownloadQueueLookup { var nextActiveDownloadsCount = activeDownloadsCount; var nextFinalizingCount = finalizingCount; var notCompletedMembershipChanged = false; - Map? nextByItemId; - Map? nextByTrackId; for (final index in normalizedChanged) { final previous = previousItems[index]; @@ -209,12 +246,6 @@ class DownloadQueueLookup { notCompletedMembershipChanged = true; } - nextByItemId ??= Map.from(byItemId); - nextByItemId[next.id] = next; - if (byTrackId[next.track.id]?.id == previous.id) { - nextByTrackId ??= Map.from(byTrackId); - nextByTrackId[next.track.id] = next; - } nextQueuedCount += _deltaForStatus( previous: previous.status, next: next.status, @@ -242,13 +273,16 @@ class DownloadQueueLookup { ); } + if (byTrackId is! _IndexedQueueMap) { + return DownloadQueueLookup.fromItems(nextItems); + } + final snapshot = ChunkedList.from(nextItems); return DownloadQueueLookup._( - byTrackId: nextByTrackId == null - ? byTrackId - : Map.unmodifiable(nextByTrackId), - byItemId: nextByItemId == null - ? byItemId - : Map.unmodifiable(nextByItemId), + byTrackId: _IndexedQueueMap( + (byTrackId as _IndexedQueueMap).index, + snapshot, + ), + byItemId: _IndexedQueueMap(indexByItemId, snapshot), indexByItemId: indexByItemId, itemIds: itemIds, notCompletedItemIds: notCompletedMembershipChanged diff --git a/lib/utils/chunked_list.dart b/lib/utils/chunked_list.dart new file mode 100644 index 00000000..16f3fc7b --- /dev/null +++ b/lib/utils/chunked_list.dart @@ -0,0 +1,51 @@ +import 'dart:collection'; + +/// Immutable indexed storage for frequent sparse updates. Each update copies +/// the chunk directory and only the affected 64-element chunks, without +/// retaining a chain of previous snapshots. +class ChunkedList extends ListBase { + static const _chunkSize = 64; + final List> _chunks; + final int _length; + + ChunkedList._(this._chunks, this._length); + + factory ChunkedList.from(List items) { + if (items is ChunkedList) return items; + return ChunkedList._([ + for (var start = 0; start < items.length; start += _chunkSize) + List.unmodifiable( + items.getRange(start, (start + _chunkSize).clamp(0, items.length)), + ), + ], items.length); + } + + ChunkedList updated(Map changes) { + if (changes.isEmpty) return this; + final chunks = List>.of(_chunks); + final copied = {}; + for (final entry in changes.entries) { + RangeError.checkValidIndex(entry.key, this); + final chunk = entry.key ~/ _chunkSize; + if (copied.add(chunk)) chunks[chunk] = List.of(chunks[chunk]); + chunks[chunk][entry.key % _chunkSize] = entry.value; + } + return ChunkedList._(chunks, length); + } + + @override + int get length => _length; + + @override + set length(int value) => throw UnsupportedError('Immutable list'); + + @override + T operator [](int index) { + RangeError.checkValidIndex(index, this); + return _chunks[index ~/ _chunkSize][index % _chunkSize]; + } + + @override + void operator []=(int index, T value) => + throw UnsupportedError('Immutable list'); +} diff --git a/test/download_queue_sparse_update_test.dart b/test/download_queue_sparse_update_test.dart new file mode 100644 index 00000000..2a5112f7 --- /dev/null +++ b/test/download_queue_sparse_update_test.dart @@ -0,0 +1,115 @@ +import 'dart:collection'; +import 'dart:math'; +import 'package:spotiflac_android/models/download_item.dart'; +import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/providers/download_queue_state.dart'; +import 'package:spotiflac_android/utils/chunked_list.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _CountingList extends ListBase { + _CountingList(this.source); + final List source; + int reads = 0; + @override + int get length => source.length; + @override + set length(int value) => throw UnsupportedError('read only'); + @override + T operator [](int index) { + reads++; + return source[index]; + } + + @override + void operator []=(int index, T value) => throw UnsupportedError('read only'); +} + +DownloadItem _item(int index) => DownloadItem( + id: 'item-$index', + track: Track( + id: 'track-${index ~/ 2}', + name: 'Song', + artistName: 'Artist', + albumName: 'Album', + duration: 1000, + ), + service: 'extension.test', + createdAt: DateTime.utc(2026), + status: DownloadStatus.downloading, +); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + test( + 'sparse updates preserve snapshots and do not revisit the source list', + () { + final source = _CountingList(List.generate(10000, (i) => i)); + final initial = ChunkedList.from(source); + source.reads = 0; + var current = initial; + final expected = List.of(initial); + final random = Random(42); + for (var iteration = 0; iteration < 1000; iteration++) { + final index = random.nextInt(initial.length); + final previous = current; + final oldValue = previous[index]; + current = current.updated({index: -iteration - 1}); + expected[index] = -iteration - 1; + expect(previous[index], oldValue); + } + expect(current, expected); + expect(initial, List.generate(10000, (i) => i)); + expect(source.reads, 0); + expect(() => current[0] = 1, throwsUnsupportedError); + expect(() => current.updated({10000: 1}), throwsRangeError); + }, + ); + + test( + 'queue indexes match a full rebuild through sparse status transitions', + () { + var state = const DownloadQueueState().copyWith( + items: List.generate(130, _item), + ); + final original = state; + final random = Random(17); + for (var tick = 0; tick < 150; tick++) { + final index = random.nextInt(state.items.length); + final old = state; + final next = ChunkedList.from(state.items).updated({ + index: state.items[index].copyWith( + progress: tick / 150, + status: DownloadStatus.values[tick % DownloadStatus.values.length], + ), + }); + state = state.copyWith( + items: next, + lookup: state.lookup.updatedForIndices( + previousItems: state.items, + nextItems: next, + changedIndices: [index, index], + ), + ); + final rebuilt = DownloadQueueLookup.fromItems(next); + expect(state.lookup.byItemId, rebuilt.byItemId); + expect(state.lookup.byTrackId, rebuilt.byTrackId); + expect(state.lookup.queuedCount, rebuilt.queuedCount); + expect(state.lookup.completedCount, rebuilt.completedCount); + expect(state.lookup.failedCount, rebuilt.failedCount); + expect(state.lookup.activeDownloadsCount, rebuilt.activeDownloadsCount); + expect(state.lookup.finalizingCount, rebuilt.finalizingCount); + expect(state.lookup.notCompletedItemIds, rebuilt.notCompletedItemIds); + expect( + old.lookup.byItemId[old.items[index].id], + same(old.items[index]), + ); + } + expect(original.items.every((item) => item.progress == 0), isTrue); + expect( + original.lookup.byItemId.values.every((item) => item.progress == 0), + isTrue, + ); + expect(() => state.lookup.byItemId.clear(), throwsUnsupportedError); + }, + ); +}