What was at stake

Small thumbnails were keeping full-size images in memory.

GoodBarber builds thousands of iOS apps from one engine. Their lists, galleries, and search results all load images through one shared image loader: an in-house copy of an older version of the open-source SDWebImage library.

That loader decoded every image at its full size, whatever size it was shown at. It then kept the decoded copy in a memory cache with no size limit, emptied only when iOS warns the app that memory is low. On the Search screen, testers saw memory climb steadily as they searched, and never level off.

My responsibility — the change, from diagnosis to the first screen. I traced the growth to the size at which images were decoded, added display-sized decoding to the shared loader, built a small test app to measure it, and moved the article results in Search to it.

Why a small image can cost a lot

An image file is compressed, on the server and on disk. To draw it, iOS first decodes it into raw pixels, and the loader stores four bytes per pixel. So the memory an image takes depends on its pixel dimensions, not on the size of its file or of the view that shows it.

Two squares drawn to scale. Decoded at full size, a 750 by 750 pixel image takes 2.25 MB of memory. A 60-point thumbnail on a 3× screen only needs 180 by 180 pixels, about 130 KB: seventeen times less.

A 750 × 750 pixel image, for example, takes 2.25 MB once decoded. Shown as a 60-point thumbnail on a 3× screen, it only needs 180 × 180 pixels: about 130 KB. Before this change, the loader decoded and cached the full-size version, whatever the thumbnail.

Tell the loader the size

The fix starts with the screen, because only the screen knows how big an image will be. A screen can now send that size, in pixels, with its request. Search reads the thumbnail’s size on screen and multiplies it by the screen scale.

Work out the size to ask for
read the thumbnail's size on screen, in points
if it has no size yet:
ask for the full image, as before
otherwise:
multiply it by the screen scale to get pixels
ask for the image at that size

A screen that sends no size gets exactly the old behavior. That is what kept the change safe: every existing screen works untouched, and screens can move over one at a time.

Decode straight to that size

ImageIO, Apple’s image decoding framework, can build a small copy directly from the compressed file, without first building the full-size image in memory. You give it the longest side you need. It keeps the proportions and applies the photo’s orientation.

Decode an image for a given size
if no size was asked for:
decode at full size, as before
if the image is animated:
leave it as it is
if the image already fits in the size:
decode at full size, as before
ask ImageIO for a copy whose longest side fits the size,
keeping the proportions and the photo's orientation
if ImageIO cannot read the file:
decode at full size, as before

Each fallback is the old behavior, never a missing image. The approach follows the one newer versions of SDWebImage use.

One cache entry per size

The same image can appear at two sizes: a small thumbnail in a list, a large picture at the top of an article. They cannot share one cache entry. A small copy stretched over a large picture looks blurry, and a large copy in a list brings the memory problem back. So the cache key is the image’s address plus the size asked for.

Memory holds the small decoded copy. The disk holds the original file, under the same key. When iOS frees the memory copy, the next request decodes the file on disk again, at the same size, without downloading it.

One image request in five steps: the screen works out the size in pixels, the view's size times the screen scale; the loader looks up the image address plus that size, in memory first, and shows the small copy right away if it is there; otherwise the original file comes from the disk or is downloaded; ImageIO decodes it to fit the size; the small copy is kept in memory, the original file on disk, and the image is shown. A screen that gives no size keeps the old full-size path at every step.

Measuring it

To measure the change on its own, I built a small test app: a list of 292 remote photos shown as 180-point thumbnails, loaded 24 rows at a time as you scroll. A button switches between full-size and display-sized decoding. Each switch empties the memory cache and starts the list again from the top.

I scrolled the list for about twenty seconds in each mode while Instruments, Apple’s profiling tool, recorded every memory allocation:

Full sizeDisplay size
Memory still in use at the end481.69 MiB154.41 MiB
Of which, image pixels (“CG raster data”)435.84 MiB129.53 MiB
Memory allocated over the whole run3.92 GiB3.89 GiB

Memory still in use at the end was 68% lower. The total allocated barely moved: the app does about the same work, but keeps far less of it.

Instruments screenshot, Allocations, full-size decoding: a memory graph over 20.2 seconds, and a statistics table showing 481.69 MiB persistent for All Heap and Anonymous VM, of which 435.84 MiB is CG raster data, and 3.92 GiB allocated in total.

Full-size decoding: 481.69 MiB still in use at the end of the run.

Instruments screenshot, Allocations, display-sized decoding: a memory graph over 20.6 seconds, and a statistics table showing 154.41 MiB persistent for All Heap and Anonymous VM, of which 129.53 MiB is CG raster data, and 3.89 GiB allocated in total.

Display-sized decoding: 154.41 MiB. Instruments scales each graph to its own run, so compare the numbers in the tables, not the heights of the graphs.

This is one run per mode, in a test app. It shows the size of the effect, not what every GoodBarber screen will gain.

When things do not go to plan

What happensWhat the loader does
A screen checks the cache without giving the sizeIt finds nothing: it must ask with the size it loads with
Code edits the image as it arrivesThe edit runs on the full image; the result is then redrawn at the smaller size
A list row is reused before its image arrivesThe old request is cancelled and its image never appears, as before

Trade-offs

  • The screen must know the size. A screen that sends none keeps the old cost. So far only the article results in Search send one; the other kinds of search results, and every other screen, still decode at full size.
  • Checked by measurement, not by automated tests. The library has no tests of its own; the Instruments runs above are the check.

History

  1. Rewritten in plain language.
  2. Published