fix: image_canvas correctness + RAM fixes (v1.3.0)
- _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:
14
CHANGELOG.md
14
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
|
||||
|
||||
BIN
dist/imajviewer_1.3.0_amd64.deb
vendored
Normal file
BIN
dist/imajviewer_1.3.0_amd64.deb
vendored
Normal file
Binary file not shown.
139
docs/rapor-kod-inceleme-performans.md
Normal file
139
docs/rapor-kod-inceleme-performans.md
Normal 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ı ~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
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user