Skip to content

Load local images off the main thread on iOS - #149

Merged
simonhamp merged 3 commits into
mainfrom
perf/ios-image-cache-off-main
Oct 22, 2026
Merged

simonhamp merged 3 commits into
mainfrom
perf/ios-image-cache-off-main

Conversation

@simonhamp

@simonhamp simonhamp commented Oct 21, 2026 •

Copy link
Copy Markdown
Member

On iOS, a local image did its disk work on the main thread every time SwiftUI evaluated its view. NativeUIImageCache stat-ed the file to build its cache key (modification date and size), and on a miss UIImage(contentsOfFile:) read the file, with the decode following at first draw. A list of local images pays that per visible row, per evaluation. A phone trace of dev/null's Log scrolling (avatars and commit images downloaded to files) spent 0.78 s of 75 s in NativeUIImageCache.image(atPath:) on the main thread: 0.49 s in the stat calls and 0.28 s in UIImage(contentsOfFile:).

Android doesn't have this problem. Coil 3.1 keys a file by its path (addLastModifiedToFileCacheKey is off by default), reads and decodes on an IO dispatcher at the view's size, and draws a memory-cache hit straight away. This brings iOS in line. It also takes in #151, which was stacked on this PR and merged into its branch.

The change

  • NativeUIImageCache.image(atPath:) is now a memory lookup by path. It never touches the disk.
  • NativeUIImageCache.load(atPath:) checks the file's modification date and size, then reads and decodes it in a background task. Images showing the same file share one load. A load is cancelled when every image waiting on it has gone, so a fast fling doesn't queue decodes for rows already off screen.
  • A new NativeUILocalImage view draws a cached image straight away. Otherwise it draws a placeholder and starts the load. <native:image> and the list item avatar and thumbnail use it. The image element's placeholder is the Color.clear it already drew for a missing file, so it fills the element's frame. List item placeholders are unchanged.
  • Images decode at the size they're drawn. NativeUILocalImage measures its laid-out size with onGeometryChange and asks for an image that fills that many pixels. Measuring doesn't change layout: before the image arrives it measures the placeholder, which fills the element's frame. The file is still read with UIImage(contentsOfFile:), so UIKit keeps choosing the file (including a name@3x.jpg beside name.jpg), scale and orientation. When the frame is smaller than the file, it decodes with preparingThumbnail(of:), which handles orientation. Otherwise it does a full decode with preparingForDisplay(). The thumbnail keeps the full file's size in points, so an image laid out at its natural size or aspect ratio comes out the same size.
  • There's one cache entry per path. A file drawn at two sizes keeps the bigger decode, which serves the smaller frame too. When a frame grows, the image on screen keeps drawing until a bigger decode replaces it.
  • The decode size fills the frame, for fit images as well as fill ones. A contained image whose frame is a different shape from the image can decode bigger than it's drawn, but never smaller, so a flexible fit image doesn't get a blurry first frame and a second decode.

Numbers

Main thread

iPhone 17 Pro simulator (iOS 26.2), a 300-row native:list of local JPEGs: 460px avatars shared between rows, 2400x1600 photos in aspect-[3/2] columns with fit=2, and 120x80 fit=1 images. Cold launch, then the same 30 scripted flings, with Time Profiler attached to the app:

Main thread samples (1 ms) Before After
Total 4,095 3,856
In NativeUIImageCache 258 9
Stat / file attributes 155 0
UIImage(contentsOfFile:) 128 0
JPEG decode 69 1

The 9 left are dictionary and NSCache lookups and starting the task. All 125 contentsOfFile samples in the after trace are on background threads.

On Simon's iPhone 16 Pro, scrolling dev/null's Log (release builds, Time Profiler, about 60 s each, without the display-size decoding):

Main thread time Before After
Total 21.6 s 16.4 s
In NativeUIImageCache 777 ms 17 ms
Of that, file stat calls 623 ms 0

All 450 attributesOfItem samples in the second trace are on background threads. 9 of the 17 ms left are UIImage deallocs when a full-size decoded image is released on the main thread, which the display-size decoding makes smaller. The scrolling in the two runs wasn't identical, so compare the image rows, not the totals.

Decoding at display size

Same simulator setup. Before is the off-main loading on its own, after adds the display-size decoding:

Before After
Decode samples, all threads (Time Profiler, 2 runs) 2,320 / 2,387 1,269 / 1,233
Photo decodes during the flings 113 at 3.84 MP 77 at 0.61 MP
Avatar decodes during the flings 326 81
Footprint after launch / 20 flings down / 10 up (2 runs) 307-312 / 437-467 / 507-514 MB 259-263 / 290-295 / 292-294 MB
CG Image memory at the end 217-224 MB no CG Image regions listed
Placeholder on screen, 8 fast flings down (3 runs) 1.8-2.1 s 1.1-1.4 s
Placeholder on screen, 8 fast flings back up (3 runs) 2.2-2.3 s 1.5-1.8 s

Each photo decode is slower on the simulator, though: the median went from 15.4 ms (full size) to 23.8 ms (954x636), and in a standalone test the thumbnail decode costs about 20 ms against 12 ms for a full decode of the same file. Total decode time still halves because there are far fewer decodes: the cache now holds about 26 of these photos instead of 4, so avatars and recently seen photos stop being thrown out. A phone has a hardware JPEG decoder, so the per-decode numbers could look different there.

Checked on the simulator

  • Images appear. EXIF orientation survives the background decode, full size and thumbnail: a photo stored as portrait pixels with orientation 6 draws upright and landscape.
  • Nothing moves when an image arrives. These are two consecutive frames from a screen recording of a fast fling just after a cold launch. Two photos and an avatar arrive and every row stays where it was.

Image

  • Layout doesn't change with the display-size decoding. The accessibility frames of every element on the first screen are identical without it (left) and with it (right):

Image

  • Downsampled photos are as sharp as before. They're drawn at one pixel per screen pixel instead of being scaled down by the GPU. Full decode on the left, display-size decode on the right, 2x zoom:

Image

  • A growing frame gets a bigger decode. On a test row whose image goes from 60x40 to 300x200 pt every few seconds, the new size starts a 900x600 decode within a millisecond of the layout change, and it lands about 24 ms later. Shrinking back reuses the bigger decode. The grown image is as sharp as the full decode. Left is a full decode, middle is the grown decode, right is what the 60x40 decode would look like stretched:

Image

  • A big photo is never in memory at full size. Decoding an 8000x6000 JPEG for a small frame raised peak memory by 9 MB, against 432 MB for the full decode.
  • Overwriting a file in place shows the new image when its row next comes on screen.

For review

  • Behaviour change: a file overwritten in place while its image is on screen now shows its new bytes the next time the image appears, not on the next redraw. The redraw check was the stat this PR removes. Android doesn't pick that change up at all until Coil's memory cache drops the old image.
  • A missing file is retried each time its image appears, not on each redraw.
  • A file that's drawn small first and big later is decoded twice. The second decode replaces the first in the cache.
  • Without the display-size decoding, CG Image memory went well past the cache's 64 MB limit (217-224 MB). So decoded images are also kept alive outside the cache, probably by each row's held image or its layer. Smaller decodes shrink that a lot, but they don't put a limit on it.
  • main is merged in. The only conflict was feature: leadingImageFit on list items #116's contentMode for list item images, which now goes through NativeUILocalImage like the rest.
  • Not from this PR, but found while testing it: a fit=1 image with no size of its own inside an aspect-* column made its native:list row as tall as the image's natural size. That's in mobile-air's flex layout, and Size a flex row from its flexed children's real height mobile-air#571 fixes it.
  • I tested on top of combined/dev-null-app, using a local clone of dev-null-app with a test screen. Nothing from the test app is in this PR.
  • swiftc -typecheck -wmo against mobile-air main's NativeRender gives the same baseline errors as mobile-ui main (core types outside that folder) and nothing new, with planted errors to prove the new code was checked. With -strict-concurrency=complete the only warning in the file is the existing cache static. 370 PHP tests pass.

🤖 Generated with Claude Code

NativeUIImageCache stat-ed the file on every body evaluation to build its
key, and UIImage(contentsOfFile:) read and decoded the file on the main
thread. A trace of a list of local images scrolling on a phone spent 0.78 s
of 75 s there.

The cache is now keyed by path, and the main thread only looks in memory.
NativeUILocalImage shows a cached image straight away, otherwise a
placeholder in the image's frame while the file is checked, read and decoded
in the background. Rows showing the same file share one load, and a load is
cancelled when every image waiting on it has gone off screen. Each appearance
re-checks the file's modification date and size in the background, so a file
overwritten in place still shows its new bytes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
simonhamp added a commit that referenced this pull request Oct 22, 2026
NativeUIImageCache stat-ed the file on every body evaluation to build its
key, and UIImage(contentsOfFile:) read and decoded the file on the main
thread. A trace of a list of local images scrolling on a phone spent 0.78 s
of 75 s there.

The cache is now keyed by path, and the main thread only looks in memory.
NativeUILocalImage shows a cached image straight away, otherwise a
placeholder in the image's frame while the file is checked, read and decoded
in the background. Rows showing the same file share one load, and a load is
cancelled when every image waiting on it has gone off screen. Each appearance
re-checks the file's modification date and size in the background, so a file
overwritten in place still shows its new bytes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 7304631)
simonhamp and others added 2 commits October 22, 2026 19:16
NativeUILocalImage decoded every file at full size, so a 2400x1600 photo
in a 318x212 pt frame took 15 MB, and the 64 MB cache held about four of
them. Coil on Android decodes at the view's size.

The view now measures its laid-out size with onGeometryChange, which
changes nothing about layout, and asks the cache for an image big enough
to fill that many pixels. The cache reads the file with
UIImage(contentsOfFile:), so UIKit still picks the @3x sibling, the scale
and the orientation, then decodes with preparingThumbnail(of:) when the
frame is smaller than the file. The thumbnail keeps the full file's size
in points, so an image laid out at its natural size or ratio doesn't move.

There's still one cache entry per path. A file drawn at two sizes keeps
the bigger decode and serves both. When a frame grows, the image already
on screen keeps drawing until a bigger decode replaces it.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…off-main

# Conflicts:
#	resources/ios/NativeUIImageSource.swift
@simonhamp
simonhamp marked this pull request as ready for review October 22, 2026 18:22
@simonhamp
simonhamp merged commit 2fcfbfd into main Oct 22, 2026
4 checks passed
@simonhamp
simonhamp deleted the perf/ios-image-cache-off-main branch October 22, 2026 18:23
simonhamp added a commit that referenced this pull request Oct 22, 2026
Brings in #149 (with #151), #152 and the final #145, plus everything else
merged to main since #144. The four conflicts were all in files where
combined's own changes (#145, #149, custom icons, the pager header and the
sticky header) are now on main in their final form, so main's version wins.
Combined still carries text-outline (#142) and the Android sheet handle on
top of main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant