Repository navigation
Android: keep recycled rows instead of rebuilding them - #75
Merged
Merged
Conversation
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>
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.
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:RvRecycledViewPoolkeeps at most 5 holders per view type, and its total capacity comes from the liveRecyclerView.ChildCount.Release()d (MAUI views torn down) and rebuilt moments later.NotifyDataSetChanged, for example after pull-to-refresh, RecyclerView detaches every row first.ChildCountis ~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.OnViewRecycled→Unbind()setBindingContext = null, pushing defaults through every binding and its property-changed handlers, thenBindset the next item.Changes
RvRecycledViewPool: per-type capmax(5, visible + 2), withvisibleand the total capacity taken from a high-water mark of the visible row count instead of the liveChildCount.RetainedView.Unbind(clearContext):RvAdapter.OnViewRecycledpassesfalse, so a pooled row keeps its previousBindingContextuntil it's rebound. Every other caller (the stale-position path, iOS, Windows) behaves as before.RetainedView.Bind: only resetsBindingContextto 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.Releasestill 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).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
net10.0-androidandnet10.0, both Release.Bindreset condition: they still clear the context inUnbind(), soBindbehaves as before there.🤖 Generated with Claude Code