mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-17 23:32:19 +02:00
perf(cache): enforce cover byte budget during foreground use
This commit is contained in:
@@ -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)) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user