perf(cache): enforce cover byte budget during foreground use

This commit is contained in:
zarzet
2026-09-06 02:33:43 +07:00
parent dae509078e
commit 3368cf8c5d
5 changed files with 214 additions and 46 deletions
+3
View File
@@ -251,6 +251,7 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization>
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
CoverCacheManager.stopMaintenance();
_localLibraryEnabledSub?.close();
_downloadHistoryWarmupTimer?.cancel();
_localLibraryWarmupTimer?.cancel();
@@ -260,6 +261,7 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization>
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
CoverCacheManager.scheduleMaintenance();
_maybeAutoScanLocalLibrary();
if (ref.exists(localLibraryProvider)) {
unawaited(
@@ -274,6 +276,7 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization>
.resumePendingDownloadsOnForeground();
}
} else if (state == AppLifecycleState.paused) {
CoverCacheManager.stopMaintenance();
// Last reliable moment before the OS may kill the process: make sure
// any debounced download-queue persistence reaches disk.
if (ref.exists(downloadQueueProvider)) {
+37 -46
View File
@@ -5,6 +5,8 @@ import 'package:flutter/painting.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as p;
import 'package:spotiflac_android/utils/cache_byte_budget.dart';
import 'package:spotiflac_android/utils/periodic_async_task.dart';
/// Persistent cache manager for album/track cover images.
///
@@ -17,13 +19,30 @@ class CoverCacheManager {
static const Duration _maxCacheAge = Duration(days: 365);
// flutter_cache_manager only caps object count, not bytes; hi-res covers
// run 300KB-1.5MB each, so 1000 objects can mean hundreds of MB on a
// storage-starved device. Sweep oldest files past the byte cap on init.
// storage-starved device. Sweep periodically while the app is foregrounded.
static const int _maxCacheBytes = 150 << 20;
static const int _sweepTargetBytes = 120 << 20;
static CacheManager? _instance;
static bool _initialized = false;
static bool _maintenanceScheduled = false;
static Future<void> _cacheWork = Future<void>.value();
static final _maintenance = PeriodicAsyncTask(
interval: const Duration(minutes: 15),
run: () => _serializeCacheWork(() async {
final cachePath = _cachePath;
if (cachePath == null) return;
final deleted = await trimCacheToByteBudget(
Directory(cachePath),
maxBytes: _maxCacheBytes,
targetBytes: _sweepTargetBytes,
);
if (deleted > 0) {
debugPrint('CoverCacheManager: Trimmed $deleted cached covers');
}
}),
onError: (error, _) =>
debugPrint('CoverCacheManager: Sweep failed: $error'),
);
static String? _cachePath;
static CacheManager get instance {
@@ -58,57 +77,29 @@ class CoverCacheManager {
}
}
/// Runs byte-cap maintenance after startup work has settled. Calling this
/// repeatedly is cheap; only the first call schedules a sweep.
/// Runs after startup and periodically thereafter. The next sweep is
/// scheduled after completion, so even slow storage cannot overlap sweeps.
static void scheduleMaintenance({
Duration delay = const Duration(seconds: 20),
}) {
if (_maintenanceScheduled) return;
_maintenanceScheduled = true;
unawaited(
Future<void>.delayed(delay).then((_) async {
final cachePath = _cachePath;
if (cachePath != null) await _sweepOverByteCap(cachePath);
}),
);
_maintenance.start(delay: delay);
}
/// Deletes oldest cover files until the cache is back under
/// [_sweepTargetBytes]. Stale JSON repo entries self-heal: a missing file
/// is a cache miss and gets re-downloaded on demand.
static Future<void> _sweepOverByteCap(String cachePath) async {
try {
final dir = Directory(cachePath);
if (!await dir.exists()) return;
final files = <File>[];
var totalSize = 0;
await for (final entity in dir.list(recursive: true)) {
if (entity is File && !entity.path.endsWith('.json')) {
files.add(entity);
totalSize += await entity.length();
}
}
if (totalSize <= _maxCacheBytes) return;
final stats = <File, FileStat>{
for (final file in files) file: await file.stat(),
};
files.sort((a, b) => stats[a]!.modified.compareTo(stats[b]!.modified));
for (final file in files) {
if (totalSize <= _sweepTargetBytes) break;
try {
totalSize -= stats[file]!.size;
await file.delete();
} catch (_) {}
}
debugPrint('CoverCacheManager: Swept cover cache over byte cap');
} catch (e) {
debugPrint('CoverCacheManager: Byte-cap sweep failed: $e');
}
static void stopMaintenance() {
_maintenance.stop();
}
static Future<void> clearCache() async {
static Future<void> _serializeCacheWork(Future<void> Function() operation) {
final next = _cacheWork.then((_) => operation());
_cacheWork = next.catchError((Object error) {
debugPrint('CoverCacheManager: Cache maintenance failed: $error');
});
return next;
}
static Future<void> clearCache() => _serializeCacheWork(_clearCache);
static Future<void> _clearCache() async {
if (!_initialized || _instance == null || _cachePath == null) {
await initialize();
}
+41
View File
@@ -0,0 +1,41 @@
import 'dart:io';
/// Trims old cached payloads to a byte budget. Repository metadata, symlinks,
/// and recently written files (possibly active downloads) are left alone.
Future<int> trimCacheToByteBudget(
Directory directory, {
required int maxBytes,
required int targetBytes,
Duration minimumAge = const Duration(minutes: 1),
}) async {
assert(targetBytes >= 0 && targetBytes <= maxBytes);
if (!await directory.exists()) return 0;
final files = <(File, FileStat)>[];
var total = 0;
await for (final entry in directory.list(
recursive: true,
followLinks: false,
)) {
if (entry is! File || entry.path.endsWith('.json')) continue;
final stat = await entry.stat();
if (stat.type != FileSystemEntityType.file) continue;
files.add((entry, stat));
total += stat.size;
}
if (total <= maxBytes) return 0;
files.sort((a, b) => a.$2.modified.compareTo(b.$2.modified));
final cutoff = DateTime.now().subtract(minimumAge);
var deleted = 0;
for (final (file, stat) in files) {
if (total <= targetBytes) break;
if (stat.modified.isAfter(cutoff)) continue;
try {
await file.delete();
total -= stat.size;
deleted++;
} on FileSystemException {
// A cache download/eviction may have changed the directory since listing.
}
}
return deleted;
}
+48
View File
@@ -0,0 +1,48 @@
import 'dart:async';
/// Foreground maintenance that waits between completed runs and can be paused
/// safely even while a run is awaiting I/O.
class PeriodicAsyncTask {
PeriodicAsyncTask({required this.interval, required this.run, this.onError});
final Duration interval;
final Future<void> Function() run;
final void Function(Object, StackTrace)? onError;
Timer? _timer;
bool _enabled = false;
int _generation = 0;
Future<void> _pending = Future<void>.value();
void start({required Duration delay}) {
if (_enabled) return;
_enabled = true;
_schedule(delay, _generation);
}
void stop() {
_enabled = false;
_generation++;
_timer?.cancel();
_timer = null;
}
void _schedule(Duration delay, int generation) {
_timer = Timer(delay, () async {
final operation = _pending.then((_) async {
if (!_enabled || generation != _generation) return;
try {
await run();
} catch (error, stack) {
onError?.call(error, stack);
}
});
_pending = operation;
try {
await operation;
} finally {
if (_enabled && generation == _generation) {
_schedule(interval, generation);
}
}
});
}
}
+85
View File
@@ -0,0 +1,85 @@
import 'dart:async';
import 'dart:io';
import 'package:spotiflac_android/utils/cache_byte_budget.dart';
import 'package:spotiflac_android/utils/periodic_async_task.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
testWidgets(
'maintenance repeats without overlapping and survives pause/resume and failure',
(tester) async {
final pending = <Completer<void>>[];
var errors = 0;
final task = PeriodicAsyncTask(
interval: const Duration(minutes: 15),
run: () {
final done = Completer<void>();
pending.add(done);
return done.future;
},
onError: (_, _) => errors++,
);
addTearDown(task.stop);
task.start(delay: const Duration(seconds: 20));
task.start(delay: Duration.zero);
await tester.pump(const Duration(seconds: 19));
expect(pending, isEmpty);
await tester.pump(const Duration(seconds: 1));
expect(pending, hasLength(1));
await tester.pump(const Duration(minutes: 30));
expect(pending, hasLength(1));
task.stop();
task.start(delay: Duration.zero);
await tester.pump(const Duration(milliseconds: 1));
expect(pending, hasLength(1));
pending[0].complete();
await tester.pump();
expect(pending, hasLength(2));
pending[1].completeError(StateError('temporary I/O error'));
await tester.pump();
expect(errors, 1);
await tester.pump(const Duration(minutes: 15));
expect(pending, hasLength(3));
task.stop();
pending[2].complete();
await tester.pump(const Duration(hours: 1));
expect(pending, hasLength(3));
},
);
test(
'cache budget trims oldest payloads while retaining metadata and new files',
() async {
final directory = await Directory.systemTemp.createTemp(
'cover-budget-test-',
);
addTearDown(() => directory.delete(recursive: true));
Future<File> file(String name, int minutesOld) async {
final file = File('${directory.path}/$name');
await file.writeAsBytes(List.filled(100, 0));
await file.setLastModified(
DateTime.now().subtract(Duration(minutes: minutesOld)),
);
return file;
}
final oldest = await file('old-cover', 60);
final newer = await file('newer-cover', 10);
final active = await file('active-cover', 0);
final metadata = await file('cache.json', 120);
expect(
await trimCacheToByteBudget(directory, maxBytes: 350, targetBytes: 150),
0,
);
expect(
await trimCacheToByteBudget(directory, maxBytes: 250, targetBytes: 150),
2,
);
expect(await oldest.exists(), isFalse);
expect(await newer.exists(), isFalse);
expect(await active.exists(), isTrue);
expect(await metadata.exists(), isTrue);
},
);
}