feat: downsampling + full-res swap + RAM leak fix
- Büyük resimler ilk yüklemede max 2048px'e downsampling (RAM ~1.6MB) - Zoom 1.5x stretch'i geçince full-res'e swap (orijinal çözünürlük) - Resim geçişinde eski provider evict + ImageCache.evict() (RAM leak) - Tiled render kodu tamamen kaldırıldı
This commit is contained in:
10
CHANGELOG.md
10
CHANGELOG.md
@@ -4,6 +4,16 @@ All notable changes to imajViewer are documented in this file.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Downsampling: large images decode at max 2048px on first load (RAM ~1.6MB)
|
||||||
|
- Full-res swap: zooming in past 1.5x stretch swaps to original resolution
|
||||||
|
- Image size tracking via `loadingBuilder` for swap threshold
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- RAM leak on image switch — old providers now evicted + `ImageCache.evict()`
|
||||||
|
|
||||||
|
## [1.2.0] - 2026-08-14
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- Internationalization: Turkish + English, auto-detected from system locale
|
- Internationalization: Turkish + English, auto-detected from system locale
|
||||||
- Zoom decodes at original resolution while zooming in (crisp details)
|
- Zoom decodes at original resolution while zooming in (crisp details)
|
||||||
|
|||||||
58
docs/rapor-downsampling-swap.md
Normal file
58
docs/rapor-downsampling-swap.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# Durum Raporu: Downsampling + Full-Res Swap + RAM Leak Fix
|
||||||
|
|
||||||
|
Tarih: 2026-08-14
|
||||||
|
Branch: `main`
|
||||||
|
|
||||||
|
## Yapılan Değişiklikler
|
||||||
|
|
||||||
|
Tek dosya: `lib/widgets/image_canvas.dart`
|
||||||
|
|
||||||
|
### 1. Downsampling (ilk yüklemede RAM tasarrufu)
|
||||||
|
|
||||||
|
- `_downsampleTarget = 2048` sabiti eklendi.
|
||||||
|
- `_buildImage()` artık ilk yüklemede `ResizeImage.resizeIfNeeded(2048, null, ...)`
|
||||||
|
kullanıyor. Büyük resimler (ör. 12000px) 2048px'e küçültülerek decode ediliyor.
|
||||||
|
- Önceki davranış: resim her zaman tam çözünürlükte decode ediliyordu
|
||||||
|
(`cacheWidth = longest * _sc * 2`), bu da dev resimlerde RAM'i dolduruyordu.
|
||||||
|
|
||||||
|
### 2. Full-Res Swap (zoom'da kalite)
|
||||||
|
|
||||||
|
- `_checkFullResSwap()`: downsampled resmin viewport'a oranı (`stretch`) 1.5x'i
|
||||||
|
geçince `_usingFullRes = true` yapılıyor ve doğrudan `FileImage` (tam çözünürlük)
|
||||||
|
kullanılıyor.
|
||||||
|
- `_handleScroll()` içinde `_sc` güncellendikten sonra çağrılıyor.
|
||||||
|
- Sonuç: küçük görünümde 2048px (RAM ~1.6MB), zoom ile yakınlaşınca orijinal.
|
||||||
|
|
||||||
|
### 3. RAM Leak Fix (resim geçişlerinde sızıntı)
|
||||||
|
|
||||||
|
- `didUpdateWidget()` içinde `old.filePath != widget.filePath` kontrolü:
|
||||||
|
- `_cachedFileImage?.evict()`, `_cachedProvider?.evict()`
|
||||||
|
- `PaintingBinding.instance.imageCache.evict(FileImage(File(old.filePath)))`
|
||||||
|
- Tüm cache alanları `null`'a çekiliyor.
|
||||||
|
- Önceki davranış: her yüklenen resim Flutter'ın global `ImageCache`'inde
|
||||||
|
birikiyor, RAM sürekli artıyordu.
|
||||||
|
|
||||||
|
### 4. Görüntü boyutu takibi
|
||||||
|
|
||||||
|
- `_imageSize`, `loadingBuilder` + `ImageStreamListener` ile decode edilen
|
||||||
|
gerçek piksel boyutundan dolduruluyor. `_checkFullResSwap()` bu değeri
|
||||||
|
stretch hesabında kullanıyor.
|
||||||
|
|
||||||
|
## Temizlik
|
||||||
|
|
||||||
|
- Tiled render kodunun tamamı kaldırıldı (Flutter codec API region decode
|
||||||
|
desteklemiyor).
|
||||||
|
- `dart:ui` ve `dart:typed_data` import'ları gereksiz kalınca silindi.
|
||||||
|
- Kullanılmayan `_uiImage`, `_fullResImage`, `_fullResLoading` alanları kaldırıldı.
|
||||||
|
|
||||||
|
## Doğrulama
|
||||||
|
|
||||||
|
- `flutter analyze --no-pub`: 0 error.
|
||||||
|
- `flutter build linux --debug`: başarılı.
|
||||||
|
- Kullanıcı testi: onaylandı ("herşey tamam gözüküyor").
|
||||||
|
|
||||||
|
## Açık Konular / Sonraki Adımlar
|
||||||
|
|
||||||
|
- `_downsampleTarget` ve swap threshold (1.5x) değerleri tune edilebilir.
|
||||||
|
- LOD (progressive loading) seviyeleri — şimdilik tek kademe (2048 → full) yeterli.
|
||||||
|
- Android/iOS hedeflerinde `ResizeImage` davranışı ayrıca test edilmeli.
|
||||||
101
docs/yonerge-downsampling-swap.md
Normal file
101
docs/yonerge-downsampling-swap.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# Yönerge: Downsampling + Full-Res Swap + RAM Leak Fix
|
||||||
|
|
||||||
|
## Amaç
|
||||||
|
Yüksek çözünürlüklü görsellerde RAM tüketimini azaltmak ve resim geçişlerinde bellek sızıntısını gidermek.
|
||||||
|
|
||||||
|
## Dosya
|
||||||
|
`lib/widgets/image_canvas.dart`
|
||||||
|
|
||||||
|
## 1. State değişkenleri ekle
|
||||||
|
|
||||||
|
`_ImageCanvasState` sınıfına aşağıdaki değişkenleri ekle:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
ui.Image? _uiImage;
|
||||||
|
ui.Image? _fullResImage;
|
||||||
|
bool _fullResLoading = false;
|
||||||
|
bool _usingFullRes = false;
|
||||||
|
static const int _downsampleTarget = 2048;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. `_loadUiImage()` — Downsample decode
|
||||||
|
|
||||||
|
Mevcut `_loadUiImage()` metodunu aşağıdaki gibi değiştir:
|
||||||
|
|
||||||
|
- `ui.instantiateImageCodec()` çağrısına `targetWidth: _downsampleTarget, targetHeight: _downsampleTarget` parametrelerini ekle
|
||||||
|
- Decode edilen resmin boyutunu `_uiImage` ve `_tiledImageSize` olarak kaydet
|
||||||
|
- Eğer decode boyutu target'tan büyükse → `_loadFullResImage(bytes)` başlat (arka plan)
|
||||||
|
- Eğer decode boyutu target'tan küçükse → resim zaten küçük, `_usingFullRes = true` yap
|
||||||
|
|
||||||
|
## 3. `_loadFullResImage()` — Arka plan full-res yükleme
|
||||||
|
|
||||||
|
Yeni metot:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
Future<void> _loadFullResImage(Uint8List bytes) async {
|
||||||
|
if (_fullResLoading) return;
|
||||||
|
_fullResLoading = true;
|
||||||
|
try {
|
||||||
|
final codec = await ui.instantiateImageCodec(bytes);
|
||||||
|
final frame = await codec.getNextFrame();
|
||||||
|
if (mounted && !_usingFullRes) {
|
||||||
|
setState(() => _fullResImage = frame.image);
|
||||||
|
} else {
|
||||||
|
frame.image.dispose();
|
||||||
|
}
|
||||||
|
} catch (e) { /* log */ }
|
||||||
|
finally { _fullResLoading = false; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. `_checkFullResSwap()` — Zoom threshold'unda swap
|
||||||
|
|
||||||
|
Yeni metot. `_handleScroll()` içinde `_sc` güncellendikten sonra çağrılır.
|
||||||
|
|
||||||
|
- Downsample resmin ne kadar stretch edildiğini hesapla (`stretchX`, `stretchY`)
|
||||||
|
- `maxStretch >= 1.5` ise:
|
||||||
|
- `_uiImage?.dispose()`
|
||||||
|
- `_uiImage = _fullResImage`
|
||||||
|
- `_fullResImage = null`
|
||||||
|
- `_tiledImageSize` güncelle
|
||||||
|
- `_usingFullRes = true`
|
||||||
|
|
||||||
|
## 5. `_handleScroll()` — Swap kontrolü ekle
|
||||||
|
|
||||||
|
`_sc = newSc;` satırından sonra `_checkFullResSwap()` çağrısı ekle.
|
||||||
|
|
||||||
|
## 6. RAM Leak Fix — `didUpdateWidget`
|
||||||
|
|
||||||
|
Resim değiştiğinde eski tüm referansları temizle:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
_uiImage?.dispose();
|
||||||
|
_fullResImage?.dispose();
|
||||||
|
_cachedFileImage?.evict();
|
||||||
|
_cachedProvider?.evict();
|
||||||
|
_cachedFileImage = null;
|
||||||
|
_cachedProvider = null;
|
||||||
|
_cachedPath = null;
|
||||||
|
_cachedCacheWidth = null;
|
||||||
|
PaintingBinding.instance.imageCache.evict(FileImage(File(old.filePath)));
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. `_resetView()` — Tüm state sıfırla
|
||||||
|
|
||||||
|
`_fullResImage`, `_fullResLoading`, `_usingFullRes` sıfırlansın.
|
||||||
|
|
||||||
|
## 8. `dispose()` — Her iki resmi de dispose et
|
||||||
|
|
||||||
|
```dart
|
||||||
|
_uiImage?.dispose();
|
||||||
|
_fullResImage?.dispose();
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. Import
|
||||||
|
|
||||||
|
`dart:typed_data` import'u ekle (`Uint8List` için).
|
||||||
|
|
||||||
|
## Sonuç
|
||||||
|
- İlk yükleme: max 2048px downsample (RAM ~1.6MB)
|
||||||
|
- Zoom ile yakınlaşınca: full-res swap (~512MB+ büyük resimlerde)
|
||||||
|
- Resim geçişinde: eski tüm cache dispose + evict
|
||||||
@@ -39,6 +39,11 @@ class _ImageCanvasState extends State<ImageCanvas> {
|
|||||||
bool _ctrlPressed = false;
|
bool _ctrlPressed = false;
|
||||||
bool _shiftPressed = false;
|
bool _shiftPressed = false;
|
||||||
|
|
||||||
|
// ── Downsampling + swap ──
|
||||||
|
bool _usingFullRes = false;
|
||||||
|
Size _imageSize = Size.zero;
|
||||||
|
static const int _downsampleTarget = 2048;
|
||||||
|
|
||||||
// ── Kare hızına eşitlenmiş render tick ──
|
// ── Kare hızına eşitlenmiş render tick ──
|
||||||
final ValueNotifier<int> _renderTick = ValueNotifier(0);
|
final ValueNotifier<int> _renderTick = ValueNotifier(0);
|
||||||
bool _frameScheduled = false;
|
bool _frameScheduled = false;
|
||||||
@@ -179,12 +184,22 @@ class _ImageCanvasState extends State<ImageCanvas> {
|
|||||||
@override
|
@override
|
||||||
void didUpdateWidget(ImageCanvas old) {
|
void didUpdateWidget(ImageCanvas old) {
|
||||||
super.didUpdateWidget(old);
|
super.didUpdateWidget(old);
|
||||||
if (old.filePath != widget.filePath) _resetView();
|
if (old.filePath != widget.filePath) {
|
||||||
|
_cachedFileImage?.evict();
|
||||||
|
_cachedProvider?.evict();
|
||||||
|
_cachedFileImage = null;
|
||||||
|
_cachedProvider = null;
|
||||||
|
_cachedPath = null;
|
||||||
|
_cachedCacheWidth = null;
|
||||||
|
PaintingBinding.instance.imageCache.evict(FileImage(File(old.filePath)));
|
||||||
|
_resetView();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _resetView() {
|
void _resetView() {
|
||||||
_tx = 0; _ty = 0; _sc = 1.0; _isFilled = true;
|
_tx = 0; _ty = 0; _sc = 1.0; _isFilled = true;
|
||||||
_angle = 0; _contrast = 1.0; _saturation = 1.0; _brightness = 0;
|
_angle = 0; _contrast = 1.0; _saturation = 1.0; _brightness = 0;
|
||||||
|
_usingFullRes = false;
|
||||||
_markRenderDirty();
|
_markRenderDirty();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,6 +231,7 @@ class _ImageCanvasState extends State<ImageCanvas> {
|
|||||||
_ty -= dh / 2;
|
_ty -= dh / 2;
|
||||||
}
|
}
|
||||||
_sc = newSc;
|
_sc = newSc;
|
||||||
|
_checkFullResSwap();
|
||||||
_clamp();
|
_clamp();
|
||||||
_markRenderDirty();
|
_markRenderDirty();
|
||||||
}
|
}
|
||||||
@@ -372,16 +388,29 @@ class _ImageCanvasState extends State<ImageCanvas> {
|
|||||||
String? _cachedPath;
|
String? _cachedPath;
|
||||||
int? _cachedCacheWidth;
|
int? _cachedCacheWidth;
|
||||||
|
|
||||||
|
void _checkFullResSwap() {
|
||||||
|
if (_usingFullRes || _imageSize.isEmpty) return;
|
||||||
|
final double stretchX = _vpSize.width * _sc / _imageSize.width;
|
||||||
|
final double stretchY = _vpSize.height * _sc / _imageSize.height;
|
||||||
|
if (math.max(stretchX, stretchY) >= 1.5) {
|
||||||
|
setState(() {
|
||||||
|
_usingFullRes = true;
|
||||||
|
_cachedProvider = _cachedFileImage;
|
||||||
|
_cachedCacheWidth = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildImage() {
|
Widget _buildImage() {
|
||||||
final longest = _vpSize.longestSide;
|
final needFullRes = _usingFullRes;
|
||||||
final cacheWidth = longest > 0 ? (longest * _sc * 2).round() : null;
|
final effectiveWidth = needFullRes ? null : _downsampleTarget;
|
||||||
if (_cachedPath != widget.filePath || _cachedCacheWidth != cacheWidth) {
|
if (_cachedPath != widget.filePath || _cachedCacheWidth != effectiveWidth) {
|
||||||
_cachedPath = widget.filePath;
|
_cachedPath = widget.filePath;
|
||||||
_cachedCacheWidth = cacheWidth;
|
_cachedCacheWidth = effectiveWidth;
|
||||||
_cachedFileImage = FileImage(File(widget.filePath));
|
_cachedFileImage = FileImage(File(widget.filePath));
|
||||||
_cachedProvider = cacheWidth != null
|
_cachedProvider = effectiveWidth != null
|
||||||
? ResizeImage.resizeIfNeeded(cacheWidth, null, _cachedFileImage!)
|
? ResizeImage.resizeIfNeeded(effectiveWidth, null, _cachedFileImage!)
|
||||||
: null;
|
: _cachedFileImage;
|
||||||
}
|
}
|
||||||
final ImageProvider<Object> provider = _cachedProvider ?? _cachedFileImage!;
|
final ImageProvider<Object> provider = _cachedProvider ?? _cachedFileImage!;
|
||||||
return Image(
|
return Image(
|
||||||
@@ -391,6 +420,18 @@ class _ImageCanvasState extends State<ImageCanvas> {
|
|||||||
filterQuality: FilterQuality.high,
|
filterQuality: FilterQuality.high,
|
||||||
gaplessPlayback: true,
|
gaplessPlayback: true,
|
||||||
errorBuilder: (_, _, _) => _errorWidget(),
|
errorBuilder: (_, _, _) => _errorWidget(),
|
||||||
|
loadingBuilder: (context, child, loadingProgress) {
|
||||||
|
if (loadingProgress == null && _imageSize.isEmpty) {
|
||||||
|
provider.resolve(const ImageConfiguration()).addListener(
|
||||||
|
ImageStreamListener((info, _) {
|
||||||
|
if (_imageSize.isEmpty) {
|
||||||
|
_imageSize = Size(info.image.width.toDouble(), info.image.height.toDouble());
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return child;
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
name: imajviewer
|
name: imajviewer
|
||||||
description: "Ultra-lightweight image viewer for Linux and Windows."
|
description: "Ultra-lightweight image viewer for Linux and Windows."
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 1.1.0+3
|
version: 1.2.0+4
|
||||||
|
|
||||||
msix_config:
|
msix_config:
|
||||||
display_name: ImajViewer
|
display_name: ImajViewer
|
||||||
|
|||||||
Reference in New Issue
Block a user