mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-22 07:18:38 +02:00
perf(bridge): bound metadata cache and persisted JSON bytes
This commit is contained in:
1 parent
a57b4504de
commit
0419b4485e
3 files changed
+258
-26
No files matched your search
@@ -22,6 +22,29 @@ bool isForegroundServiceStartNotAllowed(Object error) {
|
||||
Object? _decodeJsonInBackground(String json) => jsonDecode(json);
|
||||
String _encodeJsonInBackground(Object? value) => jsonEncode(value);
|
||||
|
||||
String _encodeBoundedLookupCacheInBackground(Map<String, dynamic> entries) {
|
||||
final result = StringBuffer('{');
|
||||
var bytes = 2; // Opening and closing braces.
|
||||
var count = 0;
|
||||
for (final entry in entries.entries) {
|
||||
final fragment =
|
||||
'${_encodeJsonInBackground(entry.key)}:${_encodeJsonInBackground(entry.value)}';
|
||||
final entryBytes = utf8.encode(fragment).length;
|
||||
final separatorBytes = count == 0 ? 0 : 1;
|
||||
if (entryBytes > PlatformBridge._persistentCacheMaxEntryBytes ||
|
||||
bytes + separatorBytes + entryBytes >
|
||||
PlatformBridge._persistentCacheMaxBytes) {
|
||||
continue;
|
||||
}
|
||||
if (count > 0) result.write(',');
|
||||
result.write(fragment);
|
||||
bytes += separatorBytes + entryBytes;
|
||||
count++;
|
||||
}
|
||||
result.write('}');
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
Object? _decodeJsonFileInBackground(String path) {
|
||||
final contents = File(path).readAsStringSync();
|
||||
return contents.isEmpty ? null : jsonDecode(contents);
|
||||
@@ -142,8 +165,13 @@ bool shouldResetRestoredInstallation({
|
||||
class _BridgeCacheEntry {
|
||||
final Map<String, dynamic> value;
|
||||
final DateTime expiresAt;
|
||||
final int bytes;
|
||||
|
||||
const _BridgeCacheEntry({required this.value, required this.expiresAt});
|
||||
const _BridgeCacheEntry({
|
||||
required this.value,
|
||||
required this.expiresAt,
|
||||
required this.bytes,
|
||||
});
|
||||
|
||||
bool get isExpired => DateTime.now().isAfter(expiresAt);
|
||||
}
|
||||
@@ -151,8 +179,13 @@ class _BridgeCacheEntry {
|
||||
class _BridgeListCacheEntry {
|
||||
final List<Map<String, dynamic>> value;
|
||||
final DateTime expiresAt;
|
||||
final int bytes;
|
||||
|
||||
const _BridgeListCacheEntry({required this.value, required this.expiresAt});
|
||||
const _BridgeListCacheEntry({
|
||||
required this.value,
|
||||
required this.expiresAt,
|
||||
required this.bytes,
|
||||
});
|
||||
|
||||
bool get isExpired => DateTime.now().isAfter(expiresAt);
|
||||
}
|
||||
@@ -190,6 +223,12 @@ class PlatformBridge {
|
||||
static const _urlHandleCacheTtl = Duration(minutes: 5);
|
||||
static const _customSearchCacheTtl = Duration(minutes: 2);
|
||||
static const _bridgeCacheMaxEntries = 256;
|
||||
// Bound retained JSON containers as well as strings. Entry counts alone do
|
||||
// not bound album/playlist responses, which can contain thousands of tracks.
|
||||
static const _bridgeCacheMaxEntryBytes = 4 * 1024 * 1024;
|
||||
static const _bridgeCacheMaxBytes = 8 * 1024 * 1024;
|
||||
static const _persistentCacheMaxEntryBytes = 1024 * 1024;
|
||||
static const _persistentCacheMaxBytes = 2 * 1024 * 1024;
|
||||
static const _lookupCachePersistDebounce = Duration(milliseconds: 500);
|
||||
static const _metadataPersistentCacheKey = 'bridge_metadata_lookup_cache_v1';
|
||||
static const _downloadProgressEvents = EventChannel(
|
||||
@@ -397,15 +436,7 @@ class PlatformBridge {
|
||||
Duration ttl,
|
||||
String persistentCacheKey,
|
||||
) {
|
||||
_pruneExpiredBridgeCache(cache);
|
||||
while (cache.length >= _bridgeCacheMaxEntries && cache.isNotEmpty) {
|
||||
cache.remove(cache.keys.first);
|
||||
}
|
||||
cache[key] = _BridgeCacheEntry(
|
||||
// Loader results stay private; every caller receives its own deep copy.
|
||||
value: value,
|
||||
expiresAt: DateTime.now().add(ttl),
|
||||
);
|
||||
_putMemoryCachedMap(cache, key, value, ttl);
|
||||
_scheduleLookupCachePersist(
|
||||
cache,
|
||||
persistentCacheKey,
|
||||
@@ -413,6 +444,37 @@ class PlatformBridge {
|
||||
);
|
||||
}
|
||||
|
||||
/// Conservative retained-size estimate, with early exit for oversized JSON.
|
||||
/// Avoids allocating another encoded response just to decide cache admission.
|
||||
static int _payloadBytes(Object? value) {
|
||||
var bytes = 0;
|
||||
void visit(Object? item) {
|
||||
if (bytes > _bridgeCacheMaxEntryBytes) return;
|
||||
if (item is String) {
|
||||
bytes += 32 + item.length * 2;
|
||||
} else if (item is Map) {
|
||||
bytes += 64;
|
||||
for (final entry in item.entries) {
|
||||
bytes += 48;
|
||||
visit(entry.key);
|
||||
visit(entry.value);
|
||||
if (bytes > _bridgeCacheMaxEntryBytes) break;
|
||||
}
|
||||
} else if (item is List) {
|
||||
bytes += 32 + item.length * 8;
|
||||
for (final entry in item) {
|
||||
visit(entry);
|
||||
if (bytes > _bridgeCacheMaxEntryBytes) break;
|
||||
}
|
||||
} else {
|
||||
bytes += 16;
|
||||
}
|
||||
}
|
||||
|
||||
visit(value);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
static void _pruneExpiredBridgeCache(Map<String, _BridgeCacheEntry> cache) {
|
||||
if (cache.isEmpty) return;
|
||||
final now = DateTime.now();
|
||||
@@ -495,6 +557,10 @@ class PlatformBridge {
|
||||
) async {
|
||||
final raw = prefs.getString(prefsKey);
|
||||
if (raw == null || raw.isEmpty) return;
|
||||
if (raw.length > _persistentCacheMaxBytes) {
|
||||
await prefs.remove(prefsKey);
|
||||
return;
|
||||
}
|
||||
|
||||
final decoded = await _decodeJsonStringAsync(raw);
|
||||
if (generation != _lookupCacheGeneration) return;
|
||||
@@ -514,9 +580,17 @@ class PlatformBridge {
|
||||
final expiresAt = DateTime.fromMillisecondsSinceEpoch(expiresAtMs);
|
||||
if (!expiresAt.isAfter(now)) continue;
|
||||
|
||||
final map = Map<String, dynamic>.from(value);
|
||||
final bytes = _payloadBytes(map) + key.length * 2;
|
||||
if (bytes > _bridgeCacheMaxEntryBytes) continue;
|
||||
if (target.values.fold<int>(0, (sum, item) => sum + item.bytes) + bytes >
|
||||
_bridgeCacheMaxBytes) {
|
||||
break;
|
||||
}
|
||||
target[key] = _BridgeCacheEntry(
|
||||
value: Map<String, dynamic>.from(value),
|
||||
value: map,
|
||||
expiresAt: expiresAt,
|
||||
bytes: bytes,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -528,16 +602,25 @@ class PlatformBridge {
|
||||
) async {
|
||||
try {
|
||||
_pruneExpiredBridgeCache(cache);
|
||||
final data = <String, dynamic>{
|
||||
for (final entry in cache.entries)
|
||||
entry.key: {
|
||||
'expires_at': entry.value.expiresAt.millisecondsSinceEpoch,
|
||||
'value': entry.value.value,
|
||||
},
|
||||
};
|
||||
final encoded = data.length >= 32
|
||||
? await compute(_encodeJsonInBackground, data)
|
||||
: jsonEncode(data);
|
||||
final data = <String, dynamic>{};
|
||||
var bytes = 64;
|
||||
for (final entry in cache.entries.toList().reversed) {
|
||||
final cost = entry.value.bytes + 128;
|
||||
if (cost > _persistentCacheMaxEntryBytes ||
|
||||
bytes + cost > _persistentCacheMaxBytes) {
|
||||
continue;
|
||||
}
|
||||
bytes += cost;
|
||||
data[entry.key] = {
|
||||
'expires_at': entry.value.expiresAt.millisecondsSinceEpoch,
|
||||
'value': entry.value.value,
|
||||
};
|
||||
}
|
||||
// Escaping and multibyte UTF-8 can outgrow the retained-size estimate.
|
||||
// Enforce both encoded limits in the isolate, without UI-side encoding.
|
||||
final encoded = bytes >= _backgroundJsonDecodeThresholdBytes
|
||||
? await compute(_encodeBoundedLookupCacheInBackground, data)
|
||||
: _encodeBoundedLookupCacheInBackground(data);
|
||||
if (generation != _lookupCacheGeneration) return;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (generation != _lookupCacheGeneration) return;
|
||||
@@ -2054,12 +2137,19 @@ class PlatformBridge {
|
||||
Duration ttl,
|
||||
) {
|
||||
_pruneExpiredBridgeCache(cache);
|
||||
while (cache.length >= _bridgeCacheMaxEntries && cache.isNotEmpty) {
|
||||
cache.remove(cache.keys.first);
|
||||
cache.remove(key);
|
||||
final bytes = _payloadBytes(value) + key.length * 2;
|
||||
if (bytes > _bridgeCacheMaxEntryBytes) return;
|
||||
var retained = cache.values.fold<int>(0, (sum, item) => sum + item.bytes);
|
||||
while (cache.isNotEmpty &&
|
||||
(cache.length >= _bridgeCacheMaxEntries ||
|
||||
retained + bytes > _bridgeCacheMaxBytes)) {
|
||||
retained -= cache.remove(cache.keys.first)!.bytes;
|
||||
}
|
||||
cache[key] = _BridgeCacheEntry(
|
||||
value: value,
|
||||
expiresAt: DateTime.now().add(ttl),
|
||||
bytes: bytes,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2084,12 +2174,19 @@ class PlatformBridge {
|
||||
Duration ttl,
|
||||
) {
|
||||
_pruneExpiredBridgeListCache(cache);
|
||||
while (cache.length >= _bridgeCacheMaxEntries && cache.isNotEmpty) {
|
||||
cache.remove(cache.keys.first);
|
||||
cache.remove(key);
|
||||
final bytes = _payloadBytes(value) + key.length * 2;
|
||||
if (bytes > _bridgeCacheMaxEntryBytes) return;
|
||||
var retained = cache.values.fold<int>(0, (sum, item) => sum + item.bytes);
|
||||
while (cache.isNotEmpty &&
|
||||
(cache.length >= _bridgeCacheMaxEntries ||
|
||||
retained + bytes > _bridgeCacheMaxBytes)) {
|
||||
retained -= cache.remove(cache.keys.first)!.bytes;
|
||||
}
|
||||
cache[key] = _BridgeListCacheEntry(
|
||||
value: value,
|
||||
expiresAt: DateTime.now().add(ttl),
|
||||
bytes: bytes,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,29 @@ void main() {
|
||||
|
||||
tearDown(() => messenger.setMockMethodCallHandler(channel, null));
|
||||
|
||||
test(
|
||||
'oversized custom-search lists are displayed without accumulating',
|
||||
() async {
|
||||
final rows = [
|
||||
{'id': 'large', 'name': 'x' * (3 * 1024 * 1024)},
|
||||
];
|
||||
final payload = jsonEncode(rows);
|
||||
var calls = 0;
|
||||
messenger.setMockMethodCallHandler(channel, (_) async {
|
||||
calls++;
|
||||
return payload;
|
||||
});
|
||||
for (var index = 0; index < 2; index++) {
|
||||
final result = await PlatformBridge.customSearchWithExtension(
|
||||
'oversized-search-test',
|
||||
'large',
|
||||
);
|
||||
expect(result, rows);
|
||||
}
|
||||
expect(calls, 2);
|
||||
},
|
||||
);
|
||||
|
||||
for (final format in ['small-json', 'large-json', 'native-list']) {
|
||||
test('custom search decodes and caches $format results', () async {
|
||||
final rows = List.generate(
|
||||
|
||||
@@ -41,6 +41,118 @@ void main() {
|
||||
Future<Map<String, dynamic>> load() =>
|
||||
PlatformBridge.getProviderMetadata('example', 'album', 'collection');
|
||||
|
||||
test(
|
||||
'oversized responses are returned and coalesced but not retained',
|
||||
() async {
|
||||
final payload = jsonEncode({'text': 'x' * (3 * 1024 * 1024)});
|
||||
var calls = 0;
|
||||
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||
calls++;
|
||||
return payload;
|
||||
});
|
||||
final results = await Future.wait([load(), load()]);
|
||||
expect(calls, 1);
|
||||
expect((results.first['text'] as String).length, 3 * 1024 * 1024);
|
||||
results.first['text'] = 'changed';
|
||||
expect(results.last['text'], isNot('changed'));
|
||||
expect((await load())['text'], isNot('changed'));
|
||||
expect(calls, 2);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 550));
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final stored = prefs.getString('bridge_metadata_lookup_cache_v1');
|
||||
expect(stored == null || jsonDecode(stored) is Map, true);
|
||||
expect(stored ?? '', isNot(contains('collection')));
|
||||
},
|
||||
);
|
||||
|
||||
test('byte budget evicts old collections before the count limit', () async {
|
||||
var calls = 0;
|
||||
final payload = jsonEncode({'text': 'x' * (700 * 1024)});
|
||||
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||
calls++;
|
||||
return payload;
|
||||
});
|
||||
Future<Map<String, dynamic>> collectionAt(int index) =>
|
||||
PlatformBridge.getProviderMetadata('example', 'album', 'album-$index');
|
||||
for (var index = 0; index < 8; index++) {
|
||||
await collectionAt(index);
|
||||
}
|
||||
expect(calls, 8);
|
||||
await collectionAt(7);
|
||||
expect(calls, 8);
|
||||
await collectionAt(0);
|
||||
expect(calls, 9);
|
||||
});
|
||||
|
||||
test(
|
||||
'persistence keeps small metadata and skips a large collection',
|
||||
() async {
|
||||
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||
final id = (call.arguments as Map)['resource_id'];
|
||||
return jsonEncode({
|
||||
'text': id == 'large' ? 'x' * (600 * 1024) : 'small',
|
||||
});
|
||||
});
|
||||
await PlatformBridge.getProviderMetadata('example', 'album', 'large');
|
||||
await PlatformBridge.getProviderMetadata('example', 'track', 'small');
|
||||
await Future<void>.delayed(const Duration(milliseconds: 650));
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final stored = prefs.getString('bridge_metadata_lookup_cache_v1')!;
|
||||
expect(utf8.encode(stored).length, lessThanOrEqualTo(2 * 1024 * 1024));
|
||||
expect(jsonDecode(stored), contains('example:track:small'));
|
||||
expect(jsonDecode(stored), isNot(contains('example:album:large')));
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'persistence skips entries oversized after JSON escaping or UTF-8',
|
||||
() async {
|
||||
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||
final id = (call.arguments as Map)['resource_id'];
|
||||
return jsonEncode({
|
||||
'text': switch (id) {
|
||||
'escaped' => '\u0000' * 250000,
|
||||
'multibyte' => '音' * 380000,
|
||||
_ => 'small',
|
||||
},
|
||||
});
|
||||
});
|
||||
for (final id in ['escaped', 'multibyte', 'small']) {
|
||||
await PlatformBridge.getProviderMetadata('example', 'album', id);
|
||||
}
|
||||
await Future<void>.delayed(const Duration(milliseconds: 750));
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final stored =
|
||||
jsonDecode(prefs.getString('bridge_metadata_lookup_cache_v1')!)
|
||||
as Map;
|
||||
expect(stored.keys, ['example:album:small']);
|
||||
},
|
||||
);
|
||||
|
||||
test('persistence bounds the total encoded multibyte payload', () async {
|
||||
messenger.setMockMethodCallHandler(
|
||||
channel,
|
||||
(_) async => jsonEncode({'text': '音' * 250000}),
|
||||
);
|
||||
for (var index = 0; index < 3; index++) {
|
||||
await PlatformBridge.getProviderMetadata('example', 'album', '$index');
|
||||
}
|
||||
await Future<void>.delayed(const Duration(milliseconds: 750));
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getString('bridge_metadata_lookup_cache_v1')!;
|
||||
expect(utf8.encode(raw).length, lessThanOrEqualTo(2 * 1024 * 1024));
|
||||
final stored = jsonDecode(raw) as Map;
|
||||
expect(stored.keys, ['example:album:2', 'example:album:1']);
|
||||
for (final entry in stored.entries) {
|
||||
expect(
|
||||
utf8
|
||||
.encode('${jsonEncode(entry.key)}:${jsonEncode(entry.value)}')
|
||||
.length,
|
||||
lessThanOrEqualTo(1024 * 1024),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
void mutate(Map<String, dynamic> value) {
|
||||
(value['album_info'] as Map<String, dynamic>)['name'] = 'Changed';
|
||||
final first = (value['track_list'] as List).first as Map<String, dynamic>;
|
||||
|
||||
Reference in new issue
Block a user