From 057d14b6d9fd3e0664d0a1303c366ac2685ad743 Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Wed, 14 Jan 2026 05:21:48 +0100 Subject: [PATCH] improve image caching and disposal --- app/lib/domain/services/generic_website.dart | 4 ++- .../domain/services/generic_website.g.dart | 2 +- .../geckoview/domain/providers/tab_state.dart | 16 +++++------ .../domain/providers/tab_state.g.dart | 2 +- .../providers/web_extensions_state.dart | 22 +++++++++------ .../providers/web_extensions_state.g.dart | 2 +- .../widgets/extension_badge_icon.dart | 6 ++++- .../widgets/menu_item_buttons.dart | 27 ++++++++++++------- .../presentation/widgets/tab_icon.dart | 6 +++-- .../search_modules/history_suggestions.dart | 4 +-- .../widgets/search_modules/tab_search.dart | 6 +++-- .../geckoview/utils/image_helper.dart | 9 ++++++- 12 files changed, 69 insertions(+), 37 deletions(-) diff --git a/app/lib/domain/services/generic_website.dart b/app/lib/domain/services/generic_website.dart index a9c33ac6..4ffa1e40 100644 --- a/app/lib/domain/services/generic_website.dart +++ b/app/lib/domain/services/generic_website.dart @@ -294,8 +294,10 @@ class GenericWebsiteService extends _$GenericWebsiteService { Future getCachedIcon(Uri url) async { if (url.scheme.startsWith('https') || url.scheme.startsWith('http')) { final cachedBrowserIcon = _browserIconCache.get(url.origin); - if (cachedBrowserIcon != null) { + if (cachedBrowserIcon?.image.value != null) { return cachedBrowserIcon; + } else if (cachedBrowserIcon != null) { + _browserIconCache.remove(url.origin); } final cachedIcon = await _cacheRepository.getCachedIcon(url.origin); diff --git a/app/lib/domain/services/generic_website.g.dart b/app/lib/domain/services/generic_website.g.dart index 2080c90f..4e77b868 100644 --- a/app/lib/domain/services/generic_website.g.dart +++ b/app/lib/domain/services/generic_website.g.dart @@ -42,7 +42,7 @@ final class GenericWebsiteServiceProvider } String _$genericWebsiteServiceHash() => - r'32a561820bda432f3c17e05b9da9c85871efffb4'; + r'80b0f7113368bdd6fbe43fc27d2ab7332fe4b692'; abstract class _$GenericWebsiteService extends $Notifier { void build(); diff --git a/app/lib/features/geckoview/domain/providers/tab_state.dart b/app/lib/features/geckoview/domain/providers/tab_state.dart index 7024709b..f1adeeca 100644 --- a/app/lib/features/geckoview/domain/providers/tab_state.dart +++ b/app/lib/features/geckoview/domain/providers/tab_state.dart @@ -122,24 +122,24 @@ class TabStates extends _$TabStates { Future _onIconChange(IconChangeEvent event) async { final IconChangeEvent(:tabId, :bytes) = event; - // Dispose old icon before replacing - state[tabId]?.icon?.dispose(); - final image = await bytes.mapNotNull((bytes) => tryDecodeImage(bytes)); - final current = state[tabId] ?? TabState.$default(tabId); + + // Dispose old icon only after successfully creating new one + current.icon?.dispose(); + state = {...state}..[tabId] = current.copyWith.icon(image); } Future _onThumbnailChange(ThumbnailEvent event) async { final ThumbnailEvent(:tabId, :bytes) = event; - // Dispose old thumbnail before replacing - state[tabId]?.thumbnail?.dispose(); - final image = await bytes.mapNotNull((bytes) => tryDecodeImage(bytes)); - final current = state[tabId] ?? TabState.$default(tabId); + + // Dispose old thumbnail only after successfully creating new one + current.thumbnail?.dispose(); + state = {...state}..[tabId] = current.copyWith.thumbnail(image); } diff --git a/app/lib/features/geckoview/domain/providers/tab_state.g.dart b/app/lib/features/geckoview/domain/providers/tab_state.g.dart index d7605363..ca3ddb1c 100644 --- a/app/lib/features/geckoview/domain/providers/tab_state.g.dart +++ b/app/lib/features/geckoview/domain/providers/tab_state.g.dart @@ -41,7 +41,7 @@ final class TabStatesProvider } } -String _$tabStatesHash() => r'f2e6920017b3f46944d85254c09c54779eb89eb3'; +String _$tabStatesHash() => r'67b497304b4137ce0115a66f06f3622c86e5727d'; abstract class _$TabStates extends $Notifier> { Map build(); diff --git a/app/lib/features/geckoview/domain/providers/web_extensions_state.dart b/app/lib/features/geckoview/domain/providers/web_extensions_state.dart index f2a537df..305eedcf 100644 --- a/app/lib/features/geckoview/domain/providers/web_extensions_state.dart +++ b/app/lib/features/geckoview/domain/providers/web_extensions_state.dart @@ -27,22 +27,31 @@ import 'package:weblibre/domain/entities/equatable_image.dart'; import 'package:weblibre/features/geckoview/domain/entities/states/web_extension.dart'; import 'package:weblibre/features/geckoview/domain/providers.dart'; import 'package:weblibre/features/geckoview/utils/image_helper.dart'; +import 'package:weblibre/utils/lru_cache.dart'; part 'web_extensions_state.g.dart'; @Riverpod(keepAlive: true) class WebExtensionsState extends _$WebExtensionsState { - final _imageCache = {}; + late final LRUCache _imageCache; + + WebExtensionsState() + : _imageCache = LRUCache(50, onEvict: (image) => image.dispose()); void _onExtensionUpdate(ExtensionDataEvent event) { final ExtensionDataEvent(:extensionId, :data) = event; if (data != null) { + final cachedIcon = _imageCache.get(extensionId); + if (cachedIcon != null && cachedIcon.value == null) { + _imageCache.remove(extensionId); + } + final current = state[extensionId] ?? WebExtensionState( extensionId: extensionId, - icon: _imageCache[extensionId], + icon: _imageCache.get(extensionId), enabled: false, ); @@ -73,10 +82,10 @@ class WebExtensionsState extends _$WebExtensionsState { final image = await tryDecodeImage(bytes); if (image != null) { - // Dispose old image before replacing - _imageCache[extensionId]?.dispose(); + // Dispose old image only after successfully creating new one + _imageCache.get(extensionId)?.dispose(); - _imageCache[extensionId] = image; + _imageCache.set(extensionId, image); if (state.containsKey(extensionId)) { state = {...state} @@ -110,9 +119,6 @@ class WebExtensionsState extends _$WebExtensionsState { ref.onDispose(() async { // Dispose all cached images - for (final image in _imageCache.values) { - image.dispose(); - } _imageCache.clear(); // Cancel all stream subscriptions diff --git a/app/lib/features/geckoview/domain/providers/web_extensions_state.g.dart b/app/lib/features/geckoview/domain/providers/web_extensions_state.g.dart index 74302527..4086cea5 100644 --- a/app/lib/features/geckoview/domain/providers/web_extensions_state.g.dart +++ b/app/lib/features/geckoview/domain/providers/web_extensions_state.g.dart @@ -62,7 +62,7 @@ final class WebExtensionsStateProvider } String _$webExtensionsStateHash() => - r'd7147f419889386f8cf526aed3318839ac22d893'; + r'2664a50bb776197010e9da9b4e3a9379c849ba75'; final class WebExtensionsStateFamily extends $Family with diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/extension_badge_icon.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/extension_badge_icon.dart index e4f8a839..e4fcafba 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/extension_badge_icon.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/extension_badge_icon.dart @@ -36,7 +36,11 @@ class ExtensionBadgeIcon extends StatelessWidget { textColor: state.badgeTextColor, backgroundColor: state.badgeBackgroundColor, child: RepaintBoundary( - child: RawImage(image: state.icon?.value, width: 24, height: 24), + child: + state.icon?.value.mapNotNull( + (image) => RawImage(image: image, width: 24, height: 24), + ) ?? + const SizedBox(width: 24, height: 24), ), ); } diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/menu_item_buttons.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/menu_item_buttons.dart index 174f77d0..d1c633aa 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/menu_item_buttons.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/menu_item_buttons.dart @@ -174,17 +174,26 @@ class ShareScreenshotMenuItemButton extends HookConsumerWidget { if (screenshot != null) { ui.decodeImageFromList(screenshot, (result) async { - final png = await result.toByteData(format: ui.ImageByteFormat.png); - - if (png != null) { - final file = XFile.fromData( - png.buffer.asUint8List(), - mimeType: 'image/png', + try { + final png = await result.toByteData( + format: ui.ImageByteFormat.png, ); - await SharePlus.instance.share( - ShareParams(files: [file], subject: tabState.titleOrAuthority), - ); + if (png != null) { + final file = XFile.fromData( + png.buffer.asUint8List(), + mimeType: 'image/png', + ); + + await SharePlus.instance.share( + ShareParams( + files: [file], + subject: tabState.titleOrAuthority, + ), + ); + } + } finally { + result.dispose(); } }); } diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_icon.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_icon.dart index 13a9ad8e..b99a13f7 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_icon.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_icon.dart @@ -36,8 +36,10 @@ class TabIcon extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final icon = useCachedFuture(() async { - if (tabState.icon != null) { - return tabState.icon!.value; + final faviconIcon = tabState.icon?.value; + + if (faviconIcon != null) { + return faviconIcon; } final icon = await ref diff --git a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/history_suggestions.dart b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/history_suggestions.dart index e4a9a3c8..8a73b0fd 100644 --- a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/history_suggestions.dart +++ b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/history_suggestions.dart @@ -97,8 +97,8 @@ class HistorySuggestions extends HookConsumerWidget { leading: RepaintBoundary( child: icon.data?.value.mapNotNull( - (favicon) => RawImage( - image: favicon, + (image) => RawImage( + image: image, height: 24, width: 24, ), diff --git a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/tab_search.dart b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/tab_search.dart index 6759eab7..dd2c0228 100644 --- a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/tab_search.dart +++ b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/tab_search.dart @@ -184,8 +184,10 @@ class TabSearch extends HookConsumerWidget { leading: RepaintBoundary( child: result.icon.mapNotNull( - (icon) => - RawImage(image: icon.value, height: 24, width: 24), + (icon) => icon.value.mapNotNull( + (image) => + RawImage(image: image, height: 24, width: 24), + ), ) ?? UrlIcon([result.url], iconSize: 24), ), diff --git a/app/lib/features/geckoview/utils/image_helper.dart b/app/lib/features/geckoview/utils/image_helper.dart index 74c4d0a7..7f7ab191 100644 --- a/app/lib/features/geckoview/utils/image_helper.dart +++ b/app/lib/features/geckoview/utils/image_helper.dart @@ -29,6 +29,11 @@ final _cache = LRUCache( onEvict: (image) => image.dispose(), ); +/// Clears the global image cache, disposing all cached images. +void clearImageCache() { + _cache.clear(); +} + Future tryDecodeImage( Uint8List bytes, { int? targetWidth, @@ -38,8 +43,10 @@ Future tryDecodeImage( final digest = secureHash(bytes); final cached = _cache.get(digest); - if (cached != null && !cached.isDisposed) { + if (cached?.value != null) { return cached; + } else if (cached != null) { + _cache.remove(digest); } try {