fix: image_canvas correctness + RAM fixes (v1.3.0)
Some checks failed
Build & Release / build-linux (push) Has been cancelled
Build & Release / build-windows (push) Has been cancelled
Build & Release / create-release (push) Has been cancelled

- _imageSize reset on image switch (wrong swap threshold on nav)
- downsample caps both dims (tall images no longer full-res decode)
- single _readImageSize instead of leaking loadingBuilder listeners
- evict old downsample provider on full-res swap
- remove imageCache.clear() that dropped live image (gallery flash)
This commit is contained in:
Alhan
2026-08-14 21:54:34 +03:00
parent addece7ade
commit 72c35a8a26
6 changed files with 168 additions and 21 deletions

View File

@@ -4,22 +4,26 @@ All notable changes to imajViewer are documented in this file.
## [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
## [1.3.0] - 2026-08-14
### Fixed
- RAM leak on image switch — old providers now evicted + `ImageCache.evict()`
- `_imageSize` now resets on image switch (wrong swap threshold on navigation)
- Downsample now caps both dimensions, not just width (tall images no longer full-res decode)
- Image size listener leak in `loadingBuilder` — replaced with single `_readImageSize`
- Old downsample provider now evicted on full-res swap
- Removed `imageCache.clear()` that also dropped the live image (gallery flash)
## [1.2.0] - 2026-08-14
### 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
- Internationalization: Turkish + English, auto-detected from system locale
- Zoom decodes at original resolution while zooming in (crisp details)
- Max zoom increased to 20x
### Fixed
- RAM leak on image switch — old providers now evicted + `ImageCache.evict()`
- Black screen flash during zoom (gapless playback)
### Changed

BIN
dist/imajviewer_1.3.0_amd64.deb vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,139 @@
# Kod İnceleme + Performans Analizi
Tarih: 2026-08-14
Kapsam: Tüm `lib/` (13 dosya, 1799 satır)
Bağlam: Downsampling + full-res swap + RAM leak fix (v1.2.0) sonrası genel inceleme
---
## 1. Tespit Edilen Hatalar
### HATA-1 (Önemli): `_imageSize` resim değişiminde sıfırlanmıyor
`lib/widgets/image_canvas.dart`
`_imageSize`, `loadingBuilder` içinde `_imageSize.isEmpty` koşuluyla **yalnızca bir kez**
dolduruluyor. Ama `_resetView()` ve `didUpdateWidget()` bu alanı **sıfırlamıyor**.
Sonuç: ikinci resme geçince `_imageSize` eski resmin boyutunu tutar, yeni resmin
boyutu hiç okunmaz. `_checkFullResSwap()` eski boyutla stretch hesabı yapar →
swap yanlış zamanda tetiklenir (ya hiç, ya da gereksiz erken).
Örnek: 2000px bir resimden 12000px panoramaya geçince, stretch hesabı hâlâ 2000px
üzerinden yapılır; swap ya geç tetiklenir ya da downsample 2048px'te takılı kalır
(zoom'da kalite düşer). Ters yönde ise gereksiz erken full-res yükleme olur.
Düzeltme: `_resetView()` içine `_imageSize = Size.zero;` ekle.
### HATA-2 (Önemli): Downsample yalnızca genişliğe uygulanıyor
`_buildImage()` içinde `ResizeImage.resizeIfNeeded(2048, null, ...)` çağrısı
`cacheHeight = null` geçiyor. Bu, **yalnızca genişlik** kısıtı demek.
Sonuç: Uzun dikey resimler (ör. 800×20000 ekran görüntüsü) genişliği 2048'in
altında olduğu için **hiç küçültülmez**, tam çözünürlükte (20000px) decode edilir.
Tek resim yüzlerce MB RAM yer — downsampling'in amacı boşa çıkar. Aynı sorun
panoramik 20000×800 resimlerde genişlik kısıtıyla zaten yakalanır, ama dikeyde kaçar.
Düzeltme: `ResizeImage.resizeIfNeeded(2048, 2048, ...)` (iki boyut da sınırla).
Flutter codec aspect oranını koruyarak sığdırır (fit).
### HATA-3 (Performans): `loadingBuilder` içinde listener sızıntısı
`loadingBuilder` her frame'de çağrılabilir. `_imageSize.isEmpty` olduğu sürece her
çağrıda `provider.resolve()` + `addListener()` yapılıyor; listener **hiç
remove edilmiyor**. `_imageSize` bir kez dolunca duruyor ama dolana kadar (ya da
HATA-1 yüzünden hiç dolmazsa) her rebuild'de yeni listener birikir.
Ayrıca `resolve()` ikinci bir decode stream'i açar — `Image` widget'ı zaten kendi
decode'ini yaparken boyut öğrenmek için ikinci kez çözmek gereksiz iş.
Düzeltme: `_imageSize`'i `loadingBuilder` yerine `initState`/`didUpdateWidget`'ta
tek sefer `resolve()` ile oku ve `ImageStreamListener`'ı `ImageStream.removeListener`
ile kaldır. Ya da `Image` widget'ının `ImageInfo`'sini `imageStream` üzerinden
tek listener ile dinle.
### HATA-4 (Küçük): Full-res swap'ta eski downsample provider evict edilmiyor
`_checkFullResSwap()` `_cachedProvider = _cachedFileImage` yapıyor ama eski
`ResizeImage` provider'ı `ImageCache`'ten evict etmiyor. Downsamlı `ui.Image`
(~16MB) cache'te gereksiz kalır. `maximumSizeBytes = 256MB` altında LRU temizler
ama tamamen gereksiz bir tutma.
### HATA-5 (Yorum yanlışlığı): `imageCache.clear()` canlı görseli de siler
`image_manager.dart:73``PaintingBinding.instance.imageCache.clear()` tüm cache'i
boşaltır, **ekrandaki mevcut görsel de dahil**. Üstündeki yorum "canlı görsel korunur"
diyor ama bu doğru değil. `clear()` o an render edilen image'ı da atar → galeri
kurulumunda kısa bir flaş/boşluk riski.
Düzeltme: Yalnızca eski klasöre ait dosyaları `evict` et (tek tek) ya da bu satırı
kaldırıp `didUpdateWidget` evict mantığına bırak.
---
## 2. RAM Modeli (mevcut davranış)
- Downsample 2048px → ~16MB/resim (RGBA 2048×2048×4)
- Full-res swap sonrası → 100MB+ (büyük resimlerde)
- `ImageCache`: `maximumSize = 16` entry, `maximumSizeBytes = 256MB`
**Kullanıcının "50 resim = 5GB" endişesi gerçekleşmez.** `maximumSizeBytes = 256MB`
LRU evict ile toplamı ~256MB1.6GB arasında sınırlar:
- Tüm resimler downsample ise: 16 entry × ~16MB ≈ 256MB (tam limit)
- Full-res resimler varsa: 23 tane tutulur, gerisi LRU ile atılır
Yani ölçülen "her resim ~100MB, artmıyor" davranışı doğru ve beklenen.
**Kalan risk:** `maximumSize = 16` entry limiti, `maximumSizeBytes`'tan bağımsız
işler. Full-res ağırlıklı kullanımda 16 × 100MB = 1.6GB'a kadar tutulabilir.
`maximumSizeBytes`'ı düşürmek (ör. 128MB) ya da entry limitini küçültmek bu tavanı
düşürür.
---
## 3. Performans İyileştirme Önerileri
### ÖN-1: `dir.listSync()` → async/isolate
`image_manager.dart:43` — klasör `listSync()` ile senkron taranıyor. Büyük klasörde
(10k+ dosya) UI thread bloklanır, pencere donar. `dir.list()` (async) ya da
`Isolate.run` ile arka planda tarama önerilir. Sonuç `notifyListeners` ile gelir.
### ÖN-2: `_printImage` tam dosyayı belleğe alıyor
`viewer_screen.dart:173``File(path).readAsBytes()` tüm dosyayı RAM'e yükler.
12000px resimde print sırasında +200MB spike. Nadir işlem ama downsample edilmiş
kopya üzerinden print etmek düşünülebilir.
### ÖN-3: `_buildImage` içindeki `_imageKey` + `ResizeImage` memo cache
HATA-1 çözülünce `_imageSize` doğru çalışır; ayrıca `_checkFullResSwap`'ın
`setState` çağrısı scroll handler içinde — bu zaten `_markRenderDirty` ile çift
rebuild üretebilir. `_checkFullResSwap`'ın `setState` yerine `_markRenderDirty`
kullanması yeterli (ValueListenableBuilder zaten tick'i dinliyor).
### ÖN-4: Warmup katmanı her açılışta
`_startWarmup` shader derlemesini her resim açılışında yapıyor; uygulama ömründe
bir kez yapılması yeterli olabilir (global static flag). Küçük kazanç.
---
## 4. Doğrulama Matrisi
| Madde | Durum |
|---|---|
| `flutter analyze` | 0 error (mevcut info/deprecated uyarıları hariç) |
| Release build | ✓ |
| RAM stabilitesi (kullanıcı testi) | ✓ "her resim ~100MB, artmıyor" |
| HATA-1 (`_imageSize` reset) | ✅ düzeltildi |
| HATA-2 (dikey downsample) | ✅ düzeltildi |
| HATA-3 (listener sızıntısı) | ✅ düzeltildi |
| HATA-4 (eski provider evict) | ✅ düzeltildi |
| HATA-5 (`clear()` canlı görsel) | ✅ düzeltildi |
---
## 5. Öncelik Sırası
1. **HATA-1 + HATA-2** — doğruluk + RAM (uzun dikey resimler hâlâ patlayabilir)
2. **HATA-3** — listener sızıntısı (uzun oturumlarda)
3. **ÖN-1** — büyük klasörde UI donması
4. HATA-4, HATA-5 — kozmetik/ince ayar

View File

@@ -69,8 +69,6 @@ class ImageManager extends ChangeNotifier {
}
if (_currentIndex < 0) _currentIndex = 0;
// Eski klasörün decode edilmiş görsellerini bırak; canlı görsel korunur
PaintingBinding.instance.imageCache.clear();
notifyListeners();
}

View File

@@ -156,6 +156,7 @@ class _ImageCanvasState extends State<ImageCanvas> {
void dispose() {
HardwareKeyboard.instance.removeHandler(_onHardwareKey);
_renderTick.dispose();
_imageSizeStream?.removeListener(_imageSizeListener!);
super.dispose();
}
@@ -200,6 +201,7 @@ class _ImageCanvasState extends State<ImageCanvas> {
_tx = 0; _ty = 0; _sc = 1.0; _isFilled = true;
_angle = 0; _contrast = 1.0; _saturation = 1.0; _brightness = 0;
_usingFullRes = false;
_imageSize = Size.zero;
_markRenderDirty();
}
@@ -387,12 +389,27 @@ class _ImageCanvasState extends State<ImageCanvas> {
ImageProvider<Object>? _cachedProvider;
String? _cachedPath;
int? _cachedCacheWidth;
ImageStream? _imageSizeStream;
ImageStreamListener? _imageSizeListener;
/// Provider değiştiğinde boyutu tek sefer oku (listener sızıntısı olmadan).
void _readImageSize(ImageProvider<Object> provider) {
_imageSizeStream?.removeListener(_imageSizeListener!);
_imageSizeStream = provider.resolve(const ImageConfiguration());
_imageSizeListener = ImageStreamListener((info, _) {
if (mounted) {
_imageSize = Size(info.image.width.toDouble(), info.image.height.toDouble());
}
});
_imageSizeStream!.addListener(_imageSizeListener!);
}
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) {
_cachedProvider?.evict(); // eski downsample provider'ı cache'ten at
setState(() {
_usingFullRes = true;
_cachedProvider = _cachedFileImage;
@@ -409,8 +426,9 @@ class _ImageCanvasState extends State<ImageCanvas> {
_cachedCacheWidth = effectiveWidth;
_cachedFileImage = FileImage(File(widget.filePath));
_cachedProvider = effectiveWidth != null
? ResizeImage.resizeIfNeeded(effectiveWidth, null, _cachedFileImage!)
? ResizeImage.resizeIfNeeded(effectiveWidth, effectiveWidth, _cachedFileImage!)
: _cachedFileImage;
_readImageSize(_cachedProvider!);
}
final ImageProvider<Object> provider = _cachedProvider ?? _cachedFileImage!;
return Image(
@@ -420,18 +438,6 @@ class _ImageCanvasState extends State<ImageCanvas> {
filterQuality: FilterQuality.high,
gaplessPlayback: true,
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;
},
);
}

View File

@@ -1,7 +1,7 @@
name: imajviewer
description: "Ultra-lightweight image viewer for Linux and Windows."
publish_to: 'none'
version: 1.2.0+4
version: 1.3.0+5
msix_config:
display_name: ImajViewer