Skip to content

Only redraw the list rows that changed on iOS - #152

Merged
simonhamp merged 1 commit into
mainfrom
perf/ios-list-row-identity
Oct 22, 2026
Merged

simonhamp merged 1 commit into
mainfrom
perf/ios-list-row-identity

Conversation

@simonhamp

@simonhamp simonhamp commented Oct 22, 2026 •

Copy link
Copy Markdown
Member

On iOS, a new frame for a long list cost about the same whether one row changed or none of the ones on screen did. SwiftUI's List runs the ForEach's content for every row whenever the list draws, and each row was a chain of modifiers carrying closures (swipe actions, onAppear). Those never compare equal to last frame's, so every row SwiftUI had made got updated again. In dev/null's Log that's every frame PHP sends while you scroll.

Each row's node now goes in an Equatable view built from plain values (the node, the end-reached callback, the list's id). mobile-air's frame diff already hands over the old node object for a subtree that didn't change, so an unchanged row compares equal and SwiftUI skips it. I counted row bodies in a bench: one changed row in a 443-row list now re-evaluates 1 row instead of 443.

What to watch for

Measured

A bench app on an iPhone 16 Pro simulator (iOS 27.0) built from mobile-air's NativeRender and this repo's iOS renderers, with the PHP bridge stubbed. It replays real Log frames, dumped from dev/null's own PHP test harness, through the steps a phone frame takes after the flat buffer is read: fresh node objects, mobile-air's diff against the last tree, then currentTree on main. It reports main-thread busy time in the 0.7 s after each publish, with the list scrolled about 200 rows down. The list has 443 rows, about 290 drawn and the rest #145 placeholders.

dev/null's branch (main + #145 etc.), median ms of 5 runs:

Frame before this PR this PR + NativePHP/mobile-air#575
Same frame again 4.2 4.6 3.4
One row changed 18.8 12.0 9.9
Visible window moved 20 rows 23 to 24 14 to 15 13.5 to 15
Window moved onto rows seen earlier about 21 about 12
Page appended 48 43 17.5

A page append changes the list's child count, and mobile-air's diff gives up on those, so every row arrives as a new object and this PR can't skip any. mobile-air#575 matches children by id instead.

On current main (with mobile-air#575 merged), one changed row goes from 24.9 to 14.4 ms and a page append from 33 to 19.6 ms. Window moves aren't meaningful there, since main has no list_placeholder renderer.

What's left is SwiftUI and UIKit work that still grows with the number of rows: marking the ForEach's items dirty, walking them, and looking up separator settings for each visible cell. With the same Log at 1,062 rows (scrolled to row 400), one changed row went from 25.9 to 15.7 ms and a window move from about 35 to about 20 ms, so the cost still grows with the list, at about half the rate.

During a scroll: the bench can also scroll the Log at a steady 1,500 pt/s while standing in for PHP, publishing the frame RowWindow would send for each visible-range report. Profiled over 14 publishes in a 1,062-row list, SwiftUI's update for each publish went from about 22 ms to about 11.5 ms. Most late frames in that run come from rows being built as they scroll in (about 1.5 s of a 12 s scroll either way), which this PR doesn't touch.

Checked

  • Screenshots before and after are pixel-identical (below the status bar) on the Log fixture, a sectioned list with separators and footers, and a flat list with separators, on both main and dev/null's branch. After the rebase, also with row_color set on a grouped and a plain list.
  • Leading, trailing and legacy delete swipe actions still show, and tapping Archive sent its callback for the right row.
  • The visible-range reports from Report a list's visible rows, and draw placeholders at a row's last height #145 were identical before and after while scrolling the bench.
  • vendor/bin/pest passes.

On Simon's iPhone

A release build of dev/null with this (ported onto #145) and mobile-air#575, traced while flinging the Log, iPhone 16 Pro, iOS 27.2. Looking only at the SwiftUI update each new frame triggers, against the same build without these two, per frame:

without with
Whole update 12.6 ms 9.6 ms
Walking the list's ForEach 3.4 ms 1.7 ms
Running the list's row closure 1.2 ms 0.2 ms

On the phone that update was already small, and row content inside it was 0.1 to 0.3 ms with or without this PR. Most of the main-thread time right after a frame is rows being built as they scroll in. It lands there because the visible-range report that asks for a frame fires as rows come on screen. In the bench, a steady scroll with a new frame every 0.4 s costs the same main-thread time as the same scroll with no frames at all, with or without this PR. Row build cost is what NativePHP/mobile-air#576 goes after.

Android isn't touched.

🤖 Generated with Claude Code

A new frame made SwiftUI's List run every row's content again, so a
frame cost the same whether one row changed or none did. Each row was a
chain of modifiers carrying closures (swipe actions, onAppear), and those
never compare equal to last frame's.

A row's node now goes in an Equatable view built from plain values, so a
row whose node object is the one from the last frame (mobile-air's frame
diff keeps those) is skipped. The insets, background, separators and
swipe actions go on outside it, because the List can't see them inside
an EquatableView. A list without sections also stops wrapping each row
in the section check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@simonhamp
simonhamp force-pushed the perf/ios-list-row-identity branch from c1a2784 to d07c73a Compare October 22, 2026 17:01
@simonhamp
simonhamp marked this pull request as ready for review October 22, 2026 17:08
@simonhamp
simonhamp merged commit 0e6c31a into main Oct 22, 2026
4 checks passed
@simonhamp
simonhamp deleted the perf/ios-list-row-identity branch October 22, 2026 17:09
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