Repository navigation
Load local images off the main thread on iOS - #149
Merged
Merged
Conversation
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>
This was referenced Oct 21, 2026
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)
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
marked this pull request as ready for review
October 22, 2026 18:22
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
On iOS, a local image did its disk work on the main thread every time SwiftUI evaluated its view.
NativeUIImageCachestat-ed the file to build its cache key (modification date and size), and on a missUIImage(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 inNativeUIImageCache.image(atPath:)on the main thread: 0.49 s in the stat calls and 0.28 s inUIImage(contentsOfFile:).Android doesn't have this problem. Coil 3.1 keys a file by its path (
addLastModifiedToFileCacheKeyis 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.NativeUILocalImageview 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 theColor.clearit already drew for a missing file, so it fills the element's frame. List item placeholders are unchanged.NativeUILocalImagemeasures its laid-out size withonGeometryChangeand 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 withUIImage(contentsOfFile:), so UIKit keeps choosing the file (including aname@3x.jpgbesidename.jpg), scale and orientation. When the frame is smaller than the file, it decodes withpreparingThumbnail(of:), which handles orientation. Otherwise it does a full decode withpreparingForDisplay(). 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.fitimages as well asfillones. A contained image whose frame is a different shape from the image can decode bigger than it's drawn, but never smaller, so a flexiblefitimage 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:listof local JPEGs: 460px avatars shared between rows, 2400x1600 photos inaspect-[3/2]columns withfit=2, and 120x80fit=1images. Cold launch, then the same 30 scripted flings, with Time Profiler attached to the app:NativeUIImageCacheUIImage(contentsOfFile:)The 9 left are dictionary and NSCache lookups and starting the task. All 125
contentsOfFilesamples 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):
NativeUIImageCacheAll 450
attributesOfItemsamples in the second trace are on background threads. 9 of the 17 ms left areUIImagedeallocs 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:
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
For review
mainis merged in. The only conflict was feature: leadingImageFit on list items #116'scontentModefor list item images, which now goes throughNativeUILocalImagelike the rest.fit=1image with no size of its own inside anaspect-*column made itsnative:listrow 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.swiftc -typecheck -wmoagainst mobile-air main's NativeRender gives the same baseline errors as mobile-uimain(core types outside that folder) and nothing new, with planted errors to prove the new code was checked. With-strict-concurrency=completethe only warning in the file is the existingcachestatic. 370 PHP tests pass.🤖 Generated with Claude Code