dispose images correctly

This commit is contained in:
Fabian Freund
2026-01-10 06:28:26 +01:00
parent 9a04b2d89a
commit f84b8838e1
10 changed files with 120 additions and 23 deletions
+20 -2
View File
@@ -20,10 +20,28 @@
import 'dart:ui'; import 'dart:ui';
class EquatableImage { class EquatableImage {
final Image value; Image? _value;
final int _imageHash; 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 @override
int get hashCode => _imageHash.hashCode; int get hashCode => _imageHash.hashCode;
+5 -2
View File
@@ -86,11 +86,14 @@ class GenericWebsiteService extends _$GenericWebsiteService {
//Global icon cache //Global icon cache
late CacheRepository _cacheRepository; late CacheRepository _cacheRepository;
//Local decoded icon cache //Local decoded icon cache
final LRUCache<String, BrowserIcon> _browserIconCache; late final LRUCache<String, BrowserIcon> _browserIconCache;
GenericWebsiteService() GenericWebsiteService()
: _iconsService = GeckoIconService(), : _iconsService = GeckoIconService(),
_browserIconCache = LRUCache(50); _browserIconCache = LRUCache(
50,
onEvict: (icon) => icon.image.dispose(),
);
@override @override
void build() { void build() {
@@ -42,7 +42,7 @@ final class GenericWebsiteServiceProvider
} }
String _$genericWebsiteServiceHash() => String _$genericWebsiteServiceHash() =>
r'44d6e4cc161b1200070d99e29c1db04cd18ca091'; r'32a561820bda432f3c17e05b9da9c85871efffb4';
abstract class _$GenericWebsiteService extends $Notifier<void> { abstract class _$GenericWebsiteService extends $Notifier<void> {
void build(); void build();
@@ -45,6 +45,24 @@ part 'tab_state.g.dart';
@Riverpod(keepAlive: true) @Riverpod(keepAlive: true)
class TabStates extends _$TabStates { 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<String, TabState> 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<void> _onTabContentStateChange(TabContentState contentState) async { Future<void> _onTabContentStateChange(TabContentState contentState) async {
final current = await patchedState(contentState.id); final current = await patchedState(contentState.id);
@@ -71,7 +89,7 @@ class TabStates extends _$TabStates {
isLoading: contentState.isLoading, isLoading: contentState.isLoading,
); );
state = {...state}..[contentState.id] = newState; _updateState({...state}..[contentState.id] = newState);
if (newState.isFinishedLoading) { if (newState.isFinishedLoading) {
ref ref
@@ -104,6 +122,9 @@ class TabStates extends _$TabStates {
Future<void> _onIconChange(IconChangeEvent event) async { Future<void> _onIconChange(IconChangeEvent event) async {
final IconChangeEvent(:tabId, :bytes) = event; final IconChangeEvent(:tabId, :bytes) = event;
// Dispose old icon before replacing
state[tabId]?.icon?.dispose();
final image = await bytes.mapNotNull((bytes) => tryDecodeImage(bytes)); final image = await bytes.mapNotNull((bytes) => tryDecodeImage(bytes));
final current = state[tabId] ?? TabState.$default(tabId); final current = state[tabId] ?? TabState.$default(tabId);
@@ -113,6 +134,9 @@ class TabStates extends _$TabStates {
Future<void> _onThumbnailChange(ThumbnailEvent event) async { Future<void> _onThumbnailChange(ThumbnailEvent event) async {
final ThumbnailEvent(:tabId, :bytes) = event; final ThumbnailEvent(:tabId, :bytes) = event;
// Dispose old thumbnail before replacing
state[tabId]?.thumbnail?.dispose();
final image = await bytes.mapNotNull((bytes) => tryDecodeImage(bytes)); final image = await bytes.mapNotNull((bytes) => tryDecodeImage(bytes));
final current = state[tabId] ?? TabState.$default(tabId); final current = state[tabId] ?? TabState.$default(tabId);
@@ -244,6 +268,12 @@ class TabStates extends _$TabStates {
); );
ref.onDispose(() async { 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) { for (final sub in subscriptions) {
await sub.cancel(); await sub.cancel();
} }
@@ -41,7 +41,7 @@ final class TabStatesProvider
} }
} }
String _$tabStatesHash() => r'cd73ea6ea479f2708a2c9e5ca86054c9d2464116'; String _$tabStatesHash() => r'f2e6920017b3f46944d85254c09c54779eb89eb3';
abstract class _$TabStates extends $Notifier<Map<String, TabState>> { abstract class _$TabStates extends $Notifier<Map<String, TabState>> {
Map<String, TabState> build(); Map<String, TabState> build();
@@ -61,6 +61,8 @@ class WebExtensionsState extends _$WebExtensionsState {
} else { } else {
if (state.containsKey(extensionId)) { if (state.containsKey(extensionId)) {
state = {...state}..remove(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); final image = await tryDecodeImage(bytes);
if (image != null) { if (image != null) {
if (_imageCache[extensionId] != image) { // Dispose old image before replacing
_imageCache[extensionId] = image; _imageCache[extensionId]?.dispose();
if (state.containsKey(extensionId)) { _imageCache[extensionId] = image;
state = {...state}
..[extensionId] = state[extensionId]!.copyWith.icon(image); if (state.containsKey(extensionId)) {
} state = {...state}
..[extensionId] = state[extensionId]!.copyWith.icon(image);
} }
} }
} }
@@ -106,6 +109,13 @@ class WebExtensionsState extends _$WebExtensionsState {
}; };
ref.onDispose(() async { 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) { for (final sub in subscriptions) {
await sub.cancel(); await sub.cancel();
} }
@@ -62,7 +62,7 @@ final class WebExtensionsStateProvider
} }
String _$webExtensionsStateHash() => String _$webExtensionsStateHash() =>
r'6fb44ab543250f2b2554e410ee749fdd035cd4e7'; r'd7147f419889386f8cf526aed3318839ac22d893';
final class WebExtensionsStateFamily extends $Family final class WebExtensionsStateFamily extends $Family
with with
@@ -205,7 +205,7 @@ class GridTabPreview extends HookConsumerWidget {
], ],
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
if (tabState.thumbnail != null) if (tabState.thumbnail?.value != null)
Expanded( Expanded(
child: ClipRRect( child: ClipRRect(
borderRadius: const BorderRadius.only( borderRadius: const BorderRadius.only(
@@ -284,7 +284,7 @@ class ListTabPreview extends HookConsumerWidget {
onTap: onTap, onTap: onTap,
onLongPress: onLongPress, onLongPress: onLongPress,
contentPadding: const EdgeInsets.only(left: 4), contentPadding: const EdgeInsets.only(left: 4),
leading: (tabState.thumbnail != null) leading: (tabState.thumbnail?.value != null)
? RepaintBoundary( ? RepaintBoundary(
child: RawImage( child: RawImage(
image: tabState.thumbnail!.value, image: tabState.thumbnail!.value,
@@ -24,7 +24,10 @@ import 'package:fast_equatable/hash.dart';
import 'package:weblibre/domain/entities/equatable_image.dart'; import 'package:weblibre/domain/entities/equatable_image.dart';
import 'package:weblibre/utils/lru_cache.dart'; import 'package:weblibre/utils/lru_cache.dart';
final _cache = LRUCache<int, EquatableImage>(100); final _cache = LRUCache<int, EquatableImage>(
100,
onEvict: (image) => image.dispose(),
);
Future<EquatableImage?> tryDecodeImage( Future<EquatableImage?> tryDecodeImage(
Uint8List bytes, { Uint8List bytes, {
@@ -35,7 +38,7 @@ Future<EquatableImage?> tryDecodeImage(
final digest = secureHash(bytes); final digest = secureHash(bytes);
final cached = _cache.get(digest); final cached = _cache.get(digest);
if (cached != null) { if (cached != null && !cached.isDisposed) {
return cached; return cached;
} }
@@ -50,7 +53,7 @@ Future<EquatableImage?> tryDecodeImage(
final frameInfo = await codec.getNextFrame(); final frameInfo = await codec.getNextFrame();
final image = EquatableImage(frameInfo.image, hash: digest); final image = EquatableImage(frameInfo.image, hash: digest);
if (image.value.width > 0) { if (image.value != null && image.value!.width > 0) {
_cache.set(digest, image); _cache.set(digest, image);
return image; return image;
} }
+37 -4
View File
@@ -22,13 +22,16 @@ import 'dart:collection';
class LRUCache<K, V> { class LRUCache<K, V> {
int _capacity; int _capacity;
final LinkedHashMap<K, V> _cache; final LinkedHashMap<K, V> _cache;
final void Function(V)? _onEvict;
LRUCache( LRUCache(
this._capacity, { this._capacity, {
bool Function(K, K)? equals, bool Function(K, K)? equals,
int Function(K)? hashCode, int Function(K)? hashCode,
bool Function(dynamic)? isValidKey, bool Function(dynamic)? isValidKey,
}) : _cache = LinkedHashMap<K, V>( void Function(V)? onEvict,
}) : _onEvict = onEvict,
_cache = LinkedHashMap<K, V>(
equals: equals, equals: equals,
hashCode: hashCode, hashCode: hashCode,
isValidKey: isValidKey, isValidKey: isValidKey,
@@ -36,7 +39,12 @@ class LRUCache<K, V> {
void resize(int capacity) { void resize(int capacity) {
if (_capacity > 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; _capacity = capacity;
@@ -58,14 +66,39 @@ class LRUCache<K, V> {
} }
V set(K key, V value) { V set(K key, V value) {
V? evicted;
if (_cache.containsKey(key)) { 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) { } else if (_cache.length == _capacity) {
_cache.remove( evicted = _cache.remove(
_cache.keys.first, _cache.keys.first,
); // Explicitly remove the least recently used item if at capacity. ); // 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. 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;
}
} }