perf: async startup cache sweep, memoize chat name color

the orphaned-cache-file sweep ran a synchronous recursive listSync on
the UI isolate at startup against a cache that can hold 10k files; use
the async listing instead. memoize adjustChatNameColor keyed by (color,
background, target contrast) since chat repeats the same handful of
palette colors thousands of times and low-contrast ones pay a 16-step
search per call.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Tommy Chow 2026-07-03 14:45:38 -04:00
parent 979f46edfe
commit 16f2722a5b
3 changed files with 61 additions and 1 deletions

View file

@ -23,7 +23,10 @@ class CustomCacheManager {
return;
}
final allFiles = cacheDir.listSync(recursive: true).toList();
// Async listing — the cache can hold up to maxNrOfCacheObjects (10k)
// files, and a synchronous recursive listing that large would block the
// UI isolate during startup.
final allFiles = await cacheDir.list(recursive: true).toList();
await _repo.open();
final cachedObjects = await _repo.getAllObjects();

View file

@ -24,6 +24,12 @@ double _contrastRatio(Color a, Color b) {
return (hi + 0.05) / (lo + 0.05);
}
/// Memoized results for [adjustChatNameColor]. Chat renders the same handful
/// of Twitch palette colors thousands of times per session, and every call
/// costs luminance math (plus a 16-step search for low-contrast colors) — the
/// answer only depends on this key, so compute it once per combination.
final _adjustedNameColorCache = <(int, int, double), Color>{};
/// Adjusts [color] just enough to reach [targetContrast] on the app's background.
/// Defaults to Theme.of(context).scaffoldBackgroundColor; if transparent, falls
/// back to theme.colorScheme.surface.
@ -44,6 +50,18 @@ Color adjustChatNameColor(
bg = theme.colorScheme.surface;
}
final cacheKey = (color.toARGB32(), bg.toARGB32(), targetContrast);
final cached = _adjustedNameColorCache[cacheKey];
if (cached != null) return cached;
final adjusted = _adjustNameColorUncached(color, bg, targetContrast);
// Custom hex name colors are unbounded in theory; keep the cache small.
if (_adjustedNameColorCache.length >= 1024) _adjustedNameColorCache.clear();
_adjustedNameColorCache[cacheKey] = adjusted;
return adjusted;
}
Color _adjustNameColorUncached(Color color, Color bg, double targetContrast) {
if (_contrastRatio(color, bg) >= targetContrast) return color;
final hsl0 = HSLColor.fromColor(color);

View file

@ -198,6 +198,45 @@ void main() {
expect(adjustedColor, isNot(const Color(0xFF00FF00)));
});
testWidgets('memoized results are stable and keyed by background',
(tester) async {
late Color first;
late Color second;
late Color onLightBackground;
const darkBackground = Color(0xFF121212);
const lowContrastColor = Color(0xFF1F1F1F); // Near-black on dark bg
await tester.pumpWidget(
buildTestWidget(
scaffoldBackground: darkBackground,
child: Builder(
builder: (context) {
// Repeated calls with identical inputs must return the same
// adjusted color (second call is served from the memo cache).
first = adjustChatNameColor(context, lowContrastColor);
second = adjustChatNameColor(context, lowContrastColor);
// A different background must not be served the dark-background
// cache entry: near-black already passes on white, so it should
// come back unchanged.
onLightBackground = adjustChatNameColor(
context,
lowContrastColor,
background: const Color(0xFFFFFFFF),
);
return const SizedBox();
},
),
),
);
expect(second, first);
expect(
first.computeLuminance(),
greaterThan(lowContrastColor.computeLuminance()),
);
expect(onLightBackground, lowContrastColor);
});
testWidgets('handles transparent scaffold background', (tester) async {
late Color adjustedColor;
const darkColor = Color(0xFF000000);