Skip to content

dropImage() does not release the decoded image, and there is no shared cache, budget or memory-pressure hook #176

Description

@mrousavy

Version

react-native-nitro-image@0.15.2.

Summary

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.

1. dropImage() clears the view, not the memory

HybridImageLoader.kt

override fun dropImage(forView: HybridNitroImageViewSpec) {
    val view = forView as? HybridImageView ?: return
    uiScope.launch { view.imageView.setImageDrawable(null) }
}

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:

  1. 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.
  2. 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.
  3. 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.
  4. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions