perf(queue): apply sparse progress updates with chunked snapshots

This commit is contained in:
zarzet
2026-09-06 02:33:42 +07:00
parent 1d0bb56f9b
commit dae509078e
5 changed files with 237 additions and 39 deletions
+4 -2
View File
@@ -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<DownloadQueueState> {
return;
}
final updatedItems = List<DownloadItem>.from(items);
updatedItems[index] = next;
final updatedItems = ChunkedList<DownloadItem>.from(
items,
).updated({index: next});
state = state.copyWith(
items: updatedItems,
lookup: state.lookup.updatedForIndices(
@@ -266,14 +266,12 @@ extension _DownloadQueueProgress on DownloadQueueNotifier {
}
if (progressUpdates.isNotEmpty) {
var updatedItems = currentItems;
bool changed = false;
final changedIndices = <int>[];
final replacements = <int, DownloadItem>{};
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<DownloadItem>.from(updatedItems);
changed = true;
}
updatedItems[index] = next;
changedIndices.add(index);
replacements[index] = next;
}
}
if (changed) {
if (replacements.isNotEmpty) {
final updatedItems = ChunkedList<DownloadItem>.from(
currentItems,
).updated(replacements);
state = state.copyWith(
items: updatedItems,
lookup: state.lookup.updatedForIndices(
previousItems: currentItems,
nextItems: updatedItems,
changedIndices: changedIndices,
changedIndices: replacements.keys,
),
);
}
+59 -25
View File
@@ -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<String, DownloadItem> {
_IndexedQueueMap(this.index, this.items);
final Map<String, int> index;
final List<DownloadItem> items;
@override
DownloadItem? operator [](Object? key) {
final position = index[key];
return position == null ? null : items[position];
}
@override
Iterable<String> 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<DownloadItem>.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<String, DownloadItem> byTrackId;
final Map<String, DownloadItem> byItemId;
@@ -113,8 +152,7 @@ class DownloadQueueLookup {
});
factory DownloadQueueLookup.fromItems(List<DownloadItem> items) {
final byTrackId = <String, DownloadItem>{};
final byItemId = <String, DownloadItem>{};
final byTrackIndex = <String, int>{};
final indexByItemId = <String, int>{};
final itemIds = <String>[];
final notCompletedItemIds = <String>[];
@@ -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<DownloadItem>.from(items);
final itemIndex = Map<String, int>.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 = <int>[];
final normalizedChanged = <int>{};
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<String, DownloadItem>? nextByItemId;
Map<String, DownloadItem>? nextByTrackId;
for (final index in normalizedChanged) {
final previous = previousItems[index];
@@ -209,12 +246,6 @@ class DownloadQueueLookup {
notCompletedMembershipChanged = true;
}
nextByItemId ??= Map<String, DownloadItem>.from(byItemId);
nextByItemId[next.id] = next;
if (byTrackId[next.track.id]?.id == previous.id) {
nextByTrackId ??= Map<String, DownloadItem>.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<DownloadItem>.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
+51
View File
@@ -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<T> extends ListBase<T> {
static const _chunkSize = 64;
final List<List<T>> _chunks;
final int _length;
ChunkedList._(this._chunks, this._length);
factory ChunkedList.from(List<T> items) {
if (items is ChunkedList<T>) return items;
return ChunkedList._([
for (var start = 0; start < items.length; start += _chunkSize)
List<T>.unmodifiable(
items.getRange(start, (start + _chunkSize).clamp(0, items.length)),
),
], items.length);
}
ChunkedList<T> updated(Map<int, T> changes) {
if (changes.isEmpty) return this;
final chunks = List<List<T>>.of(_chunks);
final copied = <int>{};
for (final entry in changes.entries) {
RangeError.checkValidIndex(entry.key, this);
final chunk = entry.key ~/ _chunkSize;
if (copied.add(chunk)) chunks[chunk] = List<T>.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');
}
+115
View File
@@ -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<T> extends ListBase<T> {
_CountingList(this.source);
final List<T> 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<int>.from(source);
source.reads = 0;
var current = initial;
final expected = List<int>.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<DownloadItem>.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);
},
);
}