mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-13 05:19:04 +02:00
perf(queue): apply sparse progress updates with chunked snapshots
This commit is contained in:
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user