mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-20 14:37:33 +02:00
perf(artwork): reuse decoded covers and preserve in-flight cache writes
This commit is contained in:
1 parent
dde79eeea2
commit
e3126153e6
4 files changed
+245
-10
No files matched your search
@@ -113,17 +113,16 @@ class CoverCacheManager {
|
||||
await instance.emptyCache();
|
||||
} catch (e) {
|
||||
debugPrint('CoverCacheManager: emptyCache failed, fallback to wipe: $e');
|
||||
await _wipeDirectory(cachePath);
|
||||
}
|
||||
|
||||
await _wipeDirectory(cachePath);
|
||||
|
||||
final imageCache = PaintingBinding.instance.imageCache;
|
||||
imageCache.clear();
|
||||
imageCache.clearLiveImages();
|
||||
|
||||
// Pending downloads publish into this manager when their files finish.
|
||||
// Preserve their unindexed files and the single repository writer.
|
||||
instance.store.emptyMemoryCache();
|
||||
_instance = _createManager(cachePath);
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
static Future<CacheStats> getStats() async {
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'dart:ui' as ui;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
import 'package:spotiflac_android/utils/image_cache_utils.dart';
|
||||
|
||||
class CachedCoverImage extends StatelessWidget {
|
||||
static const int _defaultMinCacheExtent = 64;
|
||||
@@ -340,7 +341,9 @@ Future<void> precacheMetadataBackdrop(
|
||||
if (normalized == null || normalized.isEmpty) return;
|
||||
|
||||
final ImageProvider provider;
|
||||
if (normalized.startsWith('http://') || normalized.startsWith('https://')) {
|
||||
final network =
|
||||
normalized.startsWith('http://') || normalized.startsWith('https://');
|
||||
if (network) {
|
||||
provider = cachedCoverImageProvider(normalized);
|
||||
} else if (!normalized.startsWith('content://')) {
|
||||
final filePath = normalized.startsWith('file://')
|
||||
@@ -354,7 +357,7 @@ Future<void> precacheMetadataBackdrop(
|
||||
final extent = metadataBackdropCacheExtent(context);
|
||||
try {
|
||||
await precacheImage(
|
||||
ResizeImage(provider, width: extent, height: extent),
|
||||
ResizeImage(provider, width: extent, height: network ? null : extent),
|
||||
context,
|
||||
onError: (_, _) {},
|
||||
).timeout(const Duration(milliseconds: 200));
|
||||
@@ -368,13 +371,10 @@ void precacheCoverImage(BuildContext context, String? url) {
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
return;
|
||||
}
|
||||
final dpr = MediaQuery.devicePixelRatioOf(context).clamp(1.0, 3.0).toDouble();
|
||||
final targetSize = (360 * dpr).round().clamp(512, 1024).toInt();
|
||||
precacheImage(
|
||||
ResizeImage(
|
||||
cachedCoverImageProvider(url),
|
||||
width: targetSize,
|
||||
height: targetSize,
|
||||
width: coverCacheWidthForViewport(context),
|
||||
),
|
||||
context,
|
||||
);
|
||||
|
||||
@@ -4,11 +4,111 @@ import 'dart:ui' as ui;
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:spotiflac_android/utils/image_cache_utils.dart';
|
||||
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
for (final source in [
|
||||
'network cover',
|
||||
'network backdrop',
|
||||
'local backdrop',
|
||||
]) {
|
||||
testWidgets('metadata $source prewarm reuses the display bitmap', (
|
||||
tester,
|
||||
) async {
|
||||
final cache = PaintingBinding.instance.imageCache;
|
||||
cache.clear();
|
||||
cache.clearLiveImages();
|
||||
late BuildContext context;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: MediaQuery(
|
||||
data: const MediaQueryData(
|
||||
size: Size(393, 852),
|
||||
devicePixelRatio: 2,
|
||||
),
|
||||
child: Builder(
|
||||
builder: (value) {
|
||||
context = value;
|
||||
return const SizedBox();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
final network = source != 'local backdrop';
|
||||
final backdrop = source != 'network cover';
|
||||
const url = 'https://example.invalid/metadata-prewarm.png';
|
||||
final ImageProvider provider = network
|
||||
? cachedCoverImageProvider(url)
|
||||
: FileImage(File('/metadata-prewarm.png'));
|
||||
final width = backdrop
|
||||
? metadataBackdropCacheExtent(context)
|
||||
: coverCacheWidthForViewport(context);
|
||||
final display = ResizeImage(
|
||||
provider,
|
||||
width: width,
|
||||
height: network ? null : width,
|
||||
);
|
||||
final key = await display.obtainKey(ImageConfiguration.empty);
|
||||
final image = await tester.runAsync(() async {
|
||||
final recorder = ui.PictureRecorder();
|
||||
Canvas(recorder).drawColor(Colors.blue, BlendMode.src);
|
||||
final picture = recorder.endRecording();
|
||||
final image = await picture.toImage(1, 1);
|
||||
picture.dispose();
|
||||
return image;
|
||||
});
|
||||
cache.putIfAbsent(
|
||||
key,
|
||||
() => OneFrameImageStreamCompleter(
|
||||
Future.value(ImageInfo(image: image!)),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
try {
|
||||
await tester.runAsync(() async {
|
||||
if (backdrop) {
|
||||
await precacheMetadataBackdrop(
|
||||
context,
|
||||
network ? url : '/metadata-prewarm.png',
|
||||
);
|
||||
} else {
|
||||
precacheCoverImage(context, url);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
}
|
||||
});
|
||||
expect(cache.statusForKey(key).live, isTrue);
|
||||
expect(cache.pendingImageCount, 0);
|
||||
expect(cache.currentSize, 1);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: network
|
||||
? CachedCoverImage(
|
||||
imageUrl: url,
|
||||
memCacheWidth: width,
|
||||
memCacheHeight: backdrop ? width : null,
|
||||
)
|
||||
: Image.file(
|
||||
File('/metadata-prewarm.png'),
|
||||
cacheWidth: width,
|
||||
cacheHeight: width,
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(tester.widget<RawImage>(find.byType(RawImage)).image, isNotNull);
|
||||
expect(cache.currentSize, 1);
|
||||
expect(tester.takeException(), isNull);
|
||||
} finally {
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
cache.clear();
|
||||
cache.clearLiveImages();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (final explicit in [false, true]) {
|
||||
testWidgets(
|
||||
'grid decode follows constraints, explicit override=$explicit',
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
|
||||
class _CacheBinding extends AutomatedTestWidgetsFlutterBinding {
|
||||
@override
|
||||
bool get overrideHttpClient => false;
|
||||
}
|
||||
|
||||
void main() {
|
||||
_CacheBinding();
|
||||
test('clear retains pending covers and one persistent cache index', () async {
|
||||
final directory = await Directory.systemTemp.createTemp('cover-clear-');
|
||||
const channel = MethodChannel('plugins.flutter.io/path_provider');
|
||||
final messenger =
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||
messenger.setMockMethodCallHandler(channel, (_) async => directory.path);
|
||||
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
final release = Completer<void>();
|
||||
final prefixSent = Completer<void>();
|
||||
CacheManager? manager;
|
||||
Future<void>? pendingWork;
|
||||
addTearDown(() async {
|
||||
if (!release.isCompleted) release.complete();
|
||||
await server.close(force: true);
|
||||
await pendingWork?.timeout(
|
||||
const Duration(seconds: 5),
|
||||
onTimeout: () => throw TimeoutException(
|
||||
'Pending HTTP download did not stop during cleanup',
|
||||
),
|
||||
);
|
||||
await manager?.dispose();
|
||||
messenger.setMockMethodCallHandler(channel, null);
|
||||
await directory.delete(recursive: true);
|
||||
});
|
||||
final cover = base64Decode(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl6qVQAAAAASUVORK5CYII=',
|
||||
);
|
||||
final requests = <String, int>{};
|
||||
server.listen((request) async {
|
||||
final path = request.uri.path;
|
||||
requests.update(path, (count) => count + 1, ifAbsent: () => 1);
|
||||
final response = request.response
|
||||
..bufferOutput = false
|
||||
..persistentConnection = false
|
||||
..headers.contentType = ContentType('image', 'png')
|
||||
..headers.set(HttpHeaders.cacheControlHeader, 'max-age=3600')
|
||||
..contentLength = cover.length;
|
||||
if (path == '/pending.png') {
|
||||
response.add(cover.sublist(0, 32));
|
||||
await response.flush();
|
||||
prefixSent.complete();
|
||||
await release.future;
|
||||
response.add(cover.sublist(32));
|
||||
} else {
|
||||
response.add(cover);
|
||||
}
|
||||
await response.close();
|
||||
});
|
||||
await CoverCacheManager.initialize();
|
||||
final original = CoverCacheManager.instance;
|
||||
manager = original;
|
||||
original.store.cleanupRunMinInterval = Duration.zero;
|
||||
const staleKey = 'https://example.invalid/stale-cover.png';
|
||||
final stale = await original.putFile(staleKey, cover, fileExtension: 'png');
|
||||
final base = 'http://${server.address.address}:${server.port}';
|
||||
final pendingUrl = '$base/pending.png';
|
||||
final afterUrl = '$base/after.png';
|
||||
final pending = original.getSingleFile(pendingUrl);
|
||||
pendingWork = pending.then<void>((_) {}, onError: (Object _) {});
|
||||
await prefixSent.future.timeout(
|
||||
const Duration(seconds: 5),
|
||||
onTimeout: () =>
|
||||
throw TimeoutException('Deferred HTTP prefix was not flushed'),
|
||||
);
|
||||
final partial = await _waitForPartialCover(stale);
|
||||
expect(await partial.readAsBytes(), cover.sublist(0, 32));
|
||||
await CoverCacheManager.clearCache();
|
||||
expect(CoverCacheManager.instance, same(original));
|
||||
expect(await stale.exists(), isFalse);
|
||||
expect(await partial.exists(), isTrue);
|
||||
expect(await original.getFileFromCache(staleKey), isNull);
|
||||
final after = await CoverCacheManager.instance
|
||||
.getSingleFile(afterUrl)
|
||||
.timeout(
|
||||
const Duration(seconds: 5),
|
||||
onTimeout: () => throw TimeoutException(
|
||||
'Cover requested after clear did not finish',
|
||||
),
|
||||
);
|
||||
release.complete();
|
||||
final completed = await pending.timeout(
|
||||
const Duration(seconds: 5),
|
||||
onTimeout: () => throw TimeoutException(
|
||||
'Pending streamed cover did not finish after release',
|
||||
),
|
||||
);
|
||||
expect(await completed.readAsBytes(), cover);
|
||||
expect(await after.readAsBytes(), cover);
|
||||
for (final url in [pendingUrl, afterUrl]) {
|
||||
final cached = await CoverCacheManager.instance.getFileFromCache(url);
|
||||
expect(cached, isNotNull);
|
||||
expect(await cached!.file.readAsBytes(), cover);
|
||||
}
|
||||
final repository = original.config.repo as JsonCacheInfoRepository;
|
||||
final rows =
|
||||
jsonDecode(await File(repository.path!).readAsString())
|
||||
as List<dynamic>;
|
||||
expect(rows.map((row) => (row as Map<String, dynamic>)['key']).toSet(), {
|
||||
pendingUrl,
|
||||
afterUrl,
|
||||
});
|
||||
expect(requests, {'/pending.png': 1, '/after.png': 1});
|
||||
});
|
||||
}
|
||||
|
||||
Future<File> _waitForPartialCover(File stale) async {
|
||||
final directory = Directory(stale.parent.path);
|
||||
final deadline = DateTime.now().add(const Duration(seconds: 5));
|
||||
while (DateTime.now().isBefore(deadline)) {
|
||||
await for (final file in directory.list()) {
|
||||
if (file is File && file.path != stale.path && await file.length() > 0) {
|
||||
return file;
|
||||
}
|
||||
}
|
||||
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||
}
|
||||
throw TimeoutException(
|
||||
'Pending cover prefix was not written to its cache file',
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user