You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
There is no shared image cache, no memory budget, and no memory-pressure hook. Each ImageLoader holds exactly one decoded image forever, and dropImage() does not release it.
For a long list this means memory is bounded only by JS GC timing, and scrolling back to a row
that scrolled away re-decodes from scratch.
Related but not the same as #33 (which asks for cache management APIs - getMemoryCacheSize/cleanMemoryCache) and #124 (the useImage() retention and dispose()
race). This one is about there being no cache with an eviction policy to manage in the first
place.
The loader's own cachedResult - the field holding the decoded bitmap - is untouched. iOS is the
same shape; HybridImageLoader.dispose() there does clear cachedResult, but nothing in the
view lifecycle calls it (willHide() calls dropImage, not dispose).
This reads as a contract mismatch with the spec, which describes the ImageLoader variant as the
memory-conscious one:
ImageLoader: Asynchronously loads an image [...] into the view when it becomes visible
(requestImage(…)), and drops it again when the view becomes invisible (dropImage(…)). This
is more efficient and works better for Lists.
dropImage currently makes the view stop showing the image. It does not make the process stop holding it. For a list, holding is the part that matters.
2. There is no cache above that, and no budget
Each createFileImageLoader(path) returns a fresh HybridImageLoader with its own private cachedResult. There is no process-wide store keyed on the path, so:
Two views showing the same file decode it twice and hold two copies.
A loader that goes out of scope takes its bitmap with it, so the decode is not reusable by the
next view that wants the same file.
There is no total budget, no LRU, and nothing that can be trimmed under pressure.
The comparison points both do have one:
Glide sizes an LruResourceCache from MemorySizeCalculator as a fraction of the app heap,
keeps a BitmapPool for reuse, and registers a ComponentCallbacks2 so onTrimMemory shrinks
it.
SDWebImage uses an NSCache-backed memory cache, which the OS evicts under pressure for
free, with maxMemoryCost/maxMemoryCount on top.
react-native-nitro-image has no equivalent of any of that, and no didReceiveMemoryWarning/onTrimMemory handling anywhere in ios/ or android/src.
3. No downsampling either
BitmapFactory.decodeFile is called with no Options, and UIImage(contentsOfFile:) decodes
full size, so the decoded bitmap is always the source's full resolution regardless of how large
the view is. Glide's DownsampleStrategy decodes to the target view size instead. This is a wash
when the view is roughly source-sized, and an N-times multiplier for thumbnails.
The user-visible symptom
Concretely, for a vertical list of full-width photos - the case I hit - with windowSize={5} and removeClippedSubviews:
Scrolling away frees nothing.dropImage fires, the bitmap stays. It is released only once
the row unmounts and the JS ImageLoader object is collected, so the live set is decided by GC
timing rather than by a budget. Peak is probably survivable on a flagship; it is the kind of
thing that stops being survivable on a 3 GB device with other apps resident, which is precisely
the population that is hardest to test.
Scrolling back is worse, and this is the part I am confident about. Because there is no
shared cache, coming back to a row that left the window re-reads and re-decodes the file. loadFromFileAsync is off the UI thread so it is not jank - it is a cell that is briefly blank where expo-image and fast-image render it immediately from the memory cache. On a
list of 1024x1024 photos at 4 MiB decoded each, that is a visible flash of empty cell on every
scroll-back.
Suggested fix
Roughly in order of how much they buy:
Make dropImage() release cachedResult, or gate the retention behind an explicit flag. HybridImageLoader already takes allowCaching: Boolean = true in its constructor but nothing
in HybridImageLoaderFactory ever passes false - surfacing that through createFileImageLoader
and friends would be a small, unblocking first step.
A process-wide LRU keyed on the source, with a byte budget defaulted from available memory,
so releasing on dropImage does not mean re-decoding on scroll-back. This is the change that
turns (1) from a trade into a straight win, and it is what [Feature Request]: Cache management #33's proposed getMemoryCacheSize/cleanMemoryCache would then be managing.
Register for memory pressure - ComponentCallbacks2.onTrimMemory on Android, UIApplication.didReceiveMemoryWarningNotification on iOS - and drop the LRU accordingly. NSCache gives you this for free on iOS if the cache is built on it.
Optional downsampling at decode, e.g. a target size on the loader, so consumers rendering
thumbnails are not forced to hold full-resolution bitmaps.
(1) alone would let a list bound its own memory. (2) is what makes it perform as well as the
libraries people are migrating from.
Context
Found while migrating Foodr off expo-image onto this
library (mrousavy/Foodr#19). Its results screen is a list of generated 1024px dish photos and
keeps a deliberately tight render window specifically because each row holds a decoded bitmap, so
the eviction behaviour is load-bearing there. The migration PR is open and ships, with this
called out as the risk to measure on-device before it merges.
Version
react-native-nitro-image@0.15.2.Summary
There is no shared image cache, no memory budget, and no memory-pressure hook. Each
ImageLoaderholds exactly one decoded image forever, anddropImage()does not release it.For a long list this means memory is bounded only by JS GC timing, and scrolling back to a row
that scrolled away re-decodes from scratch.
Related but not the same as #33 (which asks for cache management APIs -
getMemoryCacheSize/cleanMemoryCache) and #124 (theuseImage()retention anddispose()race). This one is about there being no cache with an eviction policy to manage in the first
place.
1.
dropImage()clears the view, not the memoryHybridImageLoader.ktThe loader's own
cachedResult- the field holding the decoded bitmap - is untouched. iOS is thesame shape;
HybridImageLoader.dispose()there does clearcachedResult, but nothing in theview lifecycle calls it (
willHide()callsdropImage, notdispose).This reads as a contract mismatch with the spec, which describes the
ImageLoadervariant as thememory-conscious one:
dropImagecurrently makes the view stop showing the image. It does not make the process stopholding it. For a list, holding is the part that matters.
2. There is no cache above that, and no budget
Each
createFileImageLoader(path)returns a freshHybridImageLoaderwith its own privatecachedResult. There is no process-wide store keyed on the path, so:next view that wants the same file.
The comparison points both do have one:
LruResourceCachefromMemorySizeCalculatoras a fraction of the app heap,keeps a
BitmapPoolfor reuse, and registers aComponentCallbacks2soonTrimMemoryshrinksit.
NSCache-backed memory cache, which the OS evicts under pressure forfree, with
maxMemoryCost/maxMemoryCounton top.react-native-nitro-imagehas no equivalent of any of that, and nodidReceiveMemoryWarning/onTrimMemoryhandling anywhere inios/orandroid/src.3. No downsampling either
BitmapFactory.decodeFileis called with noOptions, andUIImage(contentsOfFile:)decodesfull size, so the decoded bitmap is always the source's full resolution regardless of how large
the view is. Glide's
DownsampleStrategydecodes to the target view size instead. This is a washwhen the view is roughly source-sized, and an N-times multiplier for thumbnails.
The user-visible symptom
Concretely, for a vertical list of full-width photos - the case I hit - with
windowSize={5}andremoveClippedSubviews:dropImagefires, the bitmap stays. It is released only oncethe row unmounts and the JS
ImageLoaderobject is collected, so the live set is decided by GCtiming rather than by a budget. Peak is probably survivable on a flagship; it is the kind of
thing that stops being survivable on a 3 GB device with other apps resident, which is precisely
the population that is hardest to test.
shared cache, coming back to a row that left the window re-reads and re-decodes the file.
loadFromFileAsyncis off the UI thread so it is not jank - it is a cell that is brieflyblank where
expo-imageandfast-imagerender it immediately from the memory cache. On alist of 1024x1024 photos at 4 MiB decoded each, that is a visible flash of empty cell on every
scroll-back.
Suggested fix
Roughly in order of how much they buy:
dropImage()releasecachedResult, or gate the retention behind an explicit flag.HybridImageLoaderalready takesallowCaching: Boolean = truein its constructor but nothingin
HybridImageLoaderFactoryever passesfalse- surfacing that throughcreateFileImageLoaderand friends would be a small, unblocking first step.
so releasing on
dropImagedoes not mean re-decoding on scroll-back. This is the change thatturns (1) from a trade into a straight win, and it is what [Feature Request]: Cache management #33's proposed
getMemoryCacheSize/cleanMemoryCachewould then be managing.ComponentCallbacks2.onTrimMemoryon Android,UIApplication.didReceiveMemoryWarningNotificationon iOS - and drop the LRU accordingly.NSCachegives you this for free on iOS if the cache is built on it.thumbnails are not forced to hold full-resolution bitmaps.
(1) alone would let a list bound its own memory. (2) is what makes it perform as well as the
libraries people are migrating from.
Context
Found while migrating Foodr off
expo-imageonto thislibrary (mrousavy/Foodr#19). Its results screen is a list of generated 1024px dish photos and
keeps a deliberately tight render window specifically because each row holds a decoded bitmap, so
the eviction behaviour is load-bearing there. The migration PR is open and ships, with this
called out as the risk to measure on-device before it merges.