diff --git a/CHANGELOG.md b/CHANGELOG.md index 18dee5d..de6e743 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/dist/imajviewer_1.3.0_amd64.deb b/dist/imajviewer_1.3.0_amd64.deb new file mode 100644 index 0000000..c2a9bd9 Binary files /dev/null and b/dist/imajviewer_1.3.0_amd64.deb differ diff --git a/docs/rapor-kod-inceleme-performans.md b/docs/rapor-kod-inceleme-performans.md new file mode 100644 index 0000000..45920db --- /dev/null +++ b/docs/rapor-kod-inceleme-performans.md @@ -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ı ~256MB–1.6GB arasında sınırlar: +- Tüm resimler downsample ise: 16 entry × ~16MB ≈ 256MB (tam limit) +- Full-res resimler varsa: 2–3 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 diff --git a/lib/services/image_manager.dart b/lib/services/image_manager.dart index cdd99a1..e9beccb 100644 --- a/lib/services/image_manager.dart +++ b/lib/services/image_manager.dart @@ -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(); } diff --git a/lib/widgets/image_canvas.dart b/lib/widgets/image_canvas.dart index 94121aa..1ffe028 100644 --- a/lib/widgets/image_canvas.dart +++ b/lib/widgets/image_canvas.dart @@ -156,6 +156,7 @@ class _ImageCanvasState extends State { void dispose() { HardwareKeyboard.instance.removeHandler(_onHardwareKey); _renderTick.dispose(); + _imageSizeStream?.removeListener(_imageSizeListener!); super.dispose(); } @@ -200,6 +201,7 @@ class _ImageCanvasState extends State { _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 { ImageProvider? _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 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 { _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 provider = _cachedProvider ?? _cachedFileImage!; return Image( @@ -420,18 +438,6 @@ class _ImageCanvasState extends State { 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; - }, ); } diff --git a/pubspec.yaml b/pubspec.yaml index 6679811..3799ab4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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