Skip to content

Android: keep recycled rows instead of rebuilding them - #75

Merged
Redth merged 2 commits into
mainfrom
android/recycler-reuse
Nov 8, 2026
Merged

Redth merged 2 commits into
mainfrom
android/recycler-reuse

Conversation

@Redth

@Redth Redth commented Nov 8, 2026

Copy link
Copy Markdown
Owner

Follow-up to #74. That fixed the window-insets churn; this one stops the Android list from tearing down and rebuilding row views it should reuse.

Problem

Profiling PoolMath's Timeline (NativeAOT, simpleperf) after #74, binding rows was ~12% of UI-thread time while scrolling, and about half of that was building new rows: CreateView → ToPlatform → handlers, plus the GCs those allocations trigger. Two causes:

  1. Recycled-view pool too small, and sized from the wrong count.
    • Per-type cap: RvRecycledViewPool keeps at most 5 holders per view type, and its total capacity comes from the live RecyclerView.ChildCount.
    • Fast flings: a list that's mostly one row type (6–7 visible) hands back more than 5 rows of that type during a fast fling. The extras are Release()d (MAUI views torn down) and rebuilt moments later.
    • Full rebinds: during NotifyDataSetChanged, for example after pull-to-refresh, RecyclerView detaches every row first. ChildCount is ~0 exactly when they're all handed to the pool, so the capacity falls to its minimum and the pool evicts nearly all of them. Every refresh rebuilt every visible row.
  2. Double binding pass on reuse. OnViewRecycled → Unbind() set BindingContext = null, pushing defaults through every binding and its property-changed handlers, then Bind set the next item.

Changes

  • RvRecycledViewPool: per-type cap max(5, visible + 2), with visible and the total capacity taken from a high-water mark of the visible row count instead of the live ChildCount.
  • RetainedView.Unbind(clearContext): RvAdapter.OnViewRecycled passes false, so a pooled row keeps its previous BindingContext until it's rebound. Every other caller (the stale-position path, iOS, Windows) behaves as before.
  • RetainedView.Bind: only resets BindingContext to null first when the same item is bound again, which is still needed to re-apply its bindings. A different item replaces the context directly, so it's one binding pass. Release still clears the context.

Trade-off: a pooled row holds a reference to its last item, and its bindings stay subscribed to it, until the row is rebound or released. The pool is bounded at about a screenful per type.

Results

PoolMath Timeline, Pixel 8 Pro, CoreCLR Release, scripted workloads. Baseline is main with #74 and the app-side insets fix; "after" also includes small app-side binding changes (an in-place weather label, factory DataTemplates).

before after
rows rebuilt, 18 scroll flings 8 0
rows rebuilt, 3 pull-to-refreshes 15 0
worst frames during refresh 248 / 234 / 227 ms 50 / 49 / 44 ms
avg bind, main row type 12 ms 3.9 ms
janky frames (scroll) 3.45% 2.78%
p99 frame (scroll) 42 ms 29 ms

The refresh "before" (248/234/227 ms, 15 rebuilds) is from an intermediate build that already had the larger per-type cap but still sized the pool from the live ChildCount. That's how the high-water mark was found to matter.

Testing

  • Builds: net10.0-android and net10.0, both Release.
  • In the app: via a locally patched package. Scrolling, pull-to-refresh, item taps, and mixed row types (test / chemical / maintenance / note rows) still bind correct data after reuse.
  • Not run: iOS and Windows. Their only change is the shared Bind reset condition: they still clear the context in Unbind(), so Bind behaves as before there.

🤖 Generated with Claude Code

Redth and others added 2 commits November 8, 2026 11:38
Two things made the Android list tear down and rebuild row views it should
have reused:

- RvRecycledViewPool kept at most 5 holders per view type and sized itself from
  the live RecyclerView.ChildCount. A list that's mostly one row type hands back
  more than 5 rows of it during a fast fling, and the extras were released
  (MAUI views torn down) and rebuilt moments later. During NotifyDataSetChanged
  (e.g. after pull-to-refresh) RecyclerView detaches every row first, so
  ChildCount is ~0 exactly when they're all handed to the pool, which then
  evicted nearly all of them. The pool now keeps at least visible+2 per type
  (minimum 5), sized from the most rows seen on screen.
- Recycling a row set its BindingContext to null, pushing defaults through every
  binding, only for Bind to set the next item moments later. Recycled rows now
  keep their previous context until rebound; Bind still resets first when the
  same item is bound again (to re-apply its bindings), and Release / stale
  positions still clear it.

Measured in PoolMath's Timeline (Pixel 8 Pro, CoreCLR Release): rows rebuilt
during 18 scroll flings 8 -> 0; during 3 pull-to-refreshes 15 -> 0, worst frames
248/234/227 ms -> 50/49/44 ms; average test-row bind 12 -> 3.9 ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@Redth
Redth merged commit 7ac6f18 into main Nov 8, 2026
10 checks passed
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