perf(theme): bound cover palette decoding and coalesce requests

This commit is contained in:
zarzet
2026-09-17 02:59:14 +07:00
parent 96b037bb34
commit 7064f1a53a
2 changed files with 73 additions and 3 deletions
+23 -3
View File
@@ -14,6 +14,7 @@ class CoverPalette {
/// URL+brightness. Bounded because a long library-browsing session would
/// otherwise keep every visited album's scheme alive.
static final Map<String, ColorScheme> _cache = <String, ColorScheme>{};
static final Map<String, Future<ColorScheme?>> _pending = {};
static final List<String> _cacheOrder = <String>[];
static const int _maxEntries = 32;
@@ -50,11 +51,23 @@ class CoverPalette {
String source,
Brightness brightness, {
String? cacheKey,
}) async {
}) {
final key = cacheKey ?? cacheKeyFor(source, brightness);
final cached = _cache[key];
if (cached != null) return cached;
if (cached != null) return Future.value(cached);
return _pending.putIfAbsent(
key,
() => _resolve(source, brightness, key).whenComplete(() {
_pending.remove(key);
}),
);
}
static Future<ColorScheme?> _resolve(
String source,
Brightness brightness,
String key,
) async {
final ImageProvider provider;
if (_isNetworkSource(source)) {
provider = cachedCoverImageProvider(source);
@@ -66,7 +79,14 @@ class CoverPalette {
try {
final scheme = await ColorScheme.fromImageProvider(
provider: provider,
// Palette extraction only samples a small image. Bound decoding too,
// instead of decoding the original before Flutter downsamples it.
provider: ResizeImage(
provider,
width: 112,
height: 112,
policy: ResizeImagePolicy.fit,
),
brightness: brightness,
);
_cache[key] = scheme;
+50
View File
@@ -0,0 +1,50 @@
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/theme/cover_palette.dart';
void main() {
testWidgets('palette requests share a small aspect-preserving decode', (
tester,
) async {
await tester.runAsync(() async {
final directory = await Directory.systemTemp.createTemp('palette-test-');
final cache = PaintingBinding.instance.imageCache;
cache.clear();
cache.clearLiveImages();
try {
final recorder = ui.PictureRecorder();
Canvas(recorder).drawRect(
const Rect.fromLTWH(0, 0, 512, 256),
Paint()..color = Colors.blue,
);
final picture = recorder.endRecording();
final image = await picture.toImage(512, 256);
final bytes = await image.toByteData(format: ui.ImageByteFormat.png);
image.dispose();
picture.dispose();
final file = File('${directory.path}/cover.png');
await file.writeAsBytes(bytes!.buffer.asUint8List());
final first = CoverPalette.resolve(file.path, Brightness.light);
final second = CoverPalette.resolve(file.path, Brightness.light);
expect(identical(first, second), isTrue);
final scheme = await first;
expect(scheme, isNotNull);
// The original is 512 x 256. Its cached decode fits inside 112 x 112
// while preserving the 2:1 aspect ratio, instead of retaining 512 KiB.
expect(cache.currentSizeBytes, 112 * 56 * 4);
expect(
await CoverPalette.resolve(file.path, Brightness.light),
same(scheme),
);
} finally {
cache.clear();
cache.clearLiveImages();
await directory.delete(recursive: true);
}
});
});
}