diff --git a/app/lib/domain/entities/equatable_image.dart b/app/lib/domain/entities/equatable_image.dart index ccb698f7..0cfe0564 100644 --- a/app/lib/domain/entities/equatable_image.dart +++ b/app/lib/domain/entities/equatable_image.dart @@ -20,10 +20,28 @@ import 'dart:ui'; class EquatableImage { - final Image value; + Image? _value; final int _imageHash; + bool _isDisposed = false; - EquatableImage(this.value, {required int hash}) : _imageHash = hash; + EquatableImage(Image value, {required int hash}) + : _value = value, + _imageHash = hash; + + /// The underlying ui.Image. Returns null if disposed. + Image? get value => _isDisposed ? null : _value; + + /// Whether this image has been disposed. + bool get isDisposed => _isDisposed; + + /// Disposes the underlying ui.Image to free GPU memory. + /// This is safe to call multiple times. + void dispose() { + if (_isDisposed) return; + _isDisposed = true; + _value?.dispose(); + _value = null; + } @override int get hashCode => _imageHash.hashCode; diff --git a/app/lib/domain/services/generic_website.dart b/app/lib/domain/services/generic_website.dart index b1b6377f..9b0fc3fb 100644 --- a/app/lib/domain/services/generic_website.dart +++ b/app/lib/domain/services/generic_website.dart @@ -86,11 +86,14 @@ class GenericWebsiteService extends _$GenericWebsiteService { //Global icon cache late CacheRepository _cacheRepository; //Local decoded icon cache - final LRUCache _browserIconCache; + late final LRUCache _browserIconCache; GenericWebsiteService() : _iconsService = GeckoIconService(), - _browserIconCache = LRUCache(50); + _browserIconCache = LRUCache( + 50, + onEvict: (icon) => icon.image.dispose(), + ); @override void build() { diff --git a/app/lib/domain/services/generic_website.g.dart b/app/lib/domain/services/generic_website.g.dart index a3795c4b..2080c90f 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'44d6e4cc161b1200070d99e29c1db04cd18ca091'; + r'32a561820bda432f3c17e05b9da9c85871efffb4'; 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 5e83cda8..7024709b 100644 --- a/app/lib/features/geckoview/domain/providers/tab_state.dart +++ b/app/lib/features/geckoview/domain/providers/tab_state.dart @@ -45,6 +45,24 @@ part 'tab_state.g.dart'; @Riverpod(keepAlive: true) class TabStates extends _$TabStates { + /// Disposes images from a TabState to free GPU memory. + void _disposeTabImages(TabState tab) { + tab.icon?.dispose(); + tab.thumbnail?.dispose(); + } + + /// Updates state while disposing images from removed tabs. + void _updateState(Map newState) { + // Find and dispose images from tabs that are being removed + for (final tabId in state.keys) { + if (!newState.containsKey(tabId)) { + _disposeTabImages(state[tabId]!); + } + } + + state = newState; + } + Future _onTabContentStateChange(TabContentState contentState) async { final current = await patchedState(contentState.id); @@ -71,7 +89,7 @@ class TabStates extends _$TabStates { isLoading: contentState.isLoading, ); - state = {...state}..[contentState.id] = newState; + _updateState({...state}..[contentState.id] = newState); if (newState.isFinishedLoading) { ref @@ -104,6 +122,9 @@ 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); @@ -113,6 +134,9 @@ class TabStates extends _$TabStates { 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); @@ -244,6 +268,12 @@ class TabStates extends _$TabStates { ); ref.onDispose(() async { + // Dispose all images from all tabs + for (final tab in state.values) { + _disposeTabImages(tab); + } + + // Cancel all stream subscriptions for (final sub in subscriptions) { await sub.cancel(); } 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 faf74838..d7605363 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'cd73ea6ea479f2708a2c9e5ca86054c9d2464116'; +String _$tabStatesHash() => r'f2e6920017b3f46944d85254c09c54779eb89eb3'; 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 ebd66a0e..f2a537df 100644 --- a/app/lib/features/geckoview/domain/providers/web_extensions_state.dart +++ b/app/lib/features/geckoview/domain/providers/web_extensions_state.dart @@ -61,6 +61,8 @@ class WebExtensionsState extends _$WebExtensionsState { } else { if (state.containsKey(extensionId)) { state = {...state}..remove(extensionId); + // Dispose the cached image when extension is removed + _imageCache.remove(extensionId)?.dispose(); } } } @@ -71,13 +73,14 @@ class WebExtensionsState extends _$WebExtensionsState { final image = await tryDecodeImage(bytes); if (image != null) { - if (_imageCache[extensionId] != image) { - _imageCache[extensionId] = image; + // Dispose old image before replacing + _imageCache[extensionId]?.dispose(); - if (state.containsKey(extensionId)) { - state = {...state} - ..[extensionId] = state[extensionId]!.copyWith.icon(image); - } + _imageCache[extensionId] = image; + + if (state.containsKey(extensionId)) { + state = {...state} + ..[extensionId] = state[extensionId]!.copyWith.icon(image); } } } @@ -106,6 +109,13 @@ 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 for (final sub in subscriptions) { await sub.cancel(); } 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 54deab61..74302527 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'6fb44ab543250f2b2554e410ee749fdd035cd4e7'; + r'd7147f419889386f8cf526aed3318839ac22d893'; final class WebExtensionsStateFamily extends $Family with diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_preview.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_preview.dart index c259852c..d34a29ca 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_preview.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_preview.dart @@ -205,7 +205,7 @@ class GridTabPreview extends HookConsumerWidget { ], ), const SizedBox(height: 6), - if (tabState.thumbnail != null) + if (tabState.thumbnail?.value != null) Expanded( child: ClipRRect( borderRadius: const BorderRadius.only( @@ -284,7 +284,7 @@ class ListTabPreview extends HookConsumerWidget { onTap: onTap, onLongPress: onLongPress, contentPadding: const EdgeInsets.only(left: 4), - leading: (tabState.thumbnail != null) + leading: (tabState.thumbnail?.value != null) ? RepaintBoundary( child: RawImage( image: tabState.thumbnail!.value, diff --git a/app/lib/features/geckoview/utils/image_helper.dart b/app/lib/features/geckoview/utils/image_helper.dart index 1fae7e8f..74c4d0a7 100644 --- a/app/lib/features/geckoview/utils/image_helper.dart +++ b/app/lib/features/geckoview/utils/image_helper.dart @@ -24,7 +24,10 @@ import 'package:fast_equatable/hash.dart'; import 'package:weblibre/domain/entities/equatable_image.dart'; import 'package:weblibre/utils/lru_cache.dart'; -final _cache = LRUCache(100); +final _cache = LRUCache( + 100, + onEvict: (image) => image.dispose(), +); Future tryDecodeImage( Uint8List bytes, { @@ -35,7 +38,7 @@ Future tryDecodeImage( final digest = secureHash(bytes); final cached = _cache.get(digest); - if (cached != null) { + if (cached != null && !cached.isDisposed) { return cached; } @@ -50,7 +53,7 @@ Future tryDecodeImage( final frameInfo = await codec.getNextFrame(); final image = EquatableImage(frameInfo.image, hash: digest); - if (image.value.width > 0) { + if (image.value != null && image.value!.width > 0) { _cache.set(digest, image); return image; } diff --git a/app/lib/utils/lru_cache.dart b/app/lib/utils/lru_cache.dart index c5479e89..9331a60e 100644 --- a/app/lib/utils/lru_cache.dart +++ b/app/lib/utils/lru_cache.dart @@ -22,13 +22,16 @@ import 'dart:collection'; class LRUCache { int _capacity; final LinkedHashMap _cache; + final void Function(V)? _onEvict; LRUCache( this._capacity, { bool Function(K, K)? equals, int Function(K)? hashCode, bool Function(dynamic)? isValidKey, - }) : _cache = LinkedHashMap( + void Function(V)? onEvict, + }) : _onEvict = onEvict, + _cache = LinkedHashMap( equals: equals, hashCode: hashCode, isValidKey: isValidKey, @@ -36,7 +39,12 @@ class LRUCache { void resize(int capacity) { if (_capacity > capacity) { - _cache.keys.take(_capacity - capacity).forEach(_cache.remove); + _cache.keys.take(_capacity - capacity).forEach((key) { + final evicted = _cache.remove(key); + if (evicted != null) { + _onEvict?.call(evicted); + } + }); } _capacity = capacity; @@ -58,14 +66,39 @@ class LRUCache { } V set(K key, V value) { + V? evicted; + if (_cache.containsKey(key)) { - _cache.remove(key); // Remove the existing item before updating. + evicted = _cache.remove(key); // Remove the existing item before updating. } else if (_cache.length == _capacity) { - _cache.remove( + evicted = _cache.remove( _cache.keys.first, ); // Explicitly remove the least recently used item if at capacity. } + if (evicted != null) { + _onEvict?.call(evicted); + } + return _cache[key] = value; // Inserting or updating the item. } + + /// Clears all entries from the cache, calling onEvict for each entry. + void clear() { + if (_onEvict != null) { + for (final value in _cache.values) { + _onEvict(value); + } + } + _cache.clear(); + } + + /// Removes an entry by key, calling onEvict if it existed. + V? remove(K key) { + final value = _cache.remove(key); + if (value != null) { + _onEvict?.call(value); + } + return value; + } }