perf(artwork): reuse decoded covers and preserve in-flight cache writes

This commit is contained in:
zarzet committed 2026-10-05 17:53:19 +07:00
1 parent dde79eeea2
commit e3126153e6
4 files changed
+245 -10

No files matched your search

+3 -4
View File
@@ -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 {
+6 -6
View File
@@ -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,
);
+100
View File
@@ -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',
+136
View File
@@ -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',
);
}