Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,32 @@ var removed = await _cache.RemoveAsync<MyModel>(cacheKey: "my-key");
await _cache.ClearCacheAsync("group"); // null clears the default group
```

### Keeping Recycled List Rows Bound (MAUI)

A `CollectionView` reuses its row views. By default StellarUI tears a row's bindings down
when the row is recycled and builds them again when it is reused. A list can opt in to
keeping a number of recycled rows bound instead, which removes that work while scrolling:

```csharp
var list = new CollectionView().RecycledItemViewLimit(5);
```

- The default is `0`: nothing is kept, and a list that never calls the method is unaffected.
- `5` matches the number of views an Android `RecyclerView` pool holds per view type. If you
have resized that pool, pass the same number here.
- Kept rows are released when the list leaves its window, or when the limit is lowered.
- Set the limit before the list shows its rows. A row learns whether its list keeps rows
when it is bound, so a row that was already bound when the limit was raised is torn down
once more and kept from the next time it is bound.
Comment on lines +526 to +528
- Only rows whose view maps a data model onto its own view model are kept
(`GridBase<TViewModel, TDataModel>` and the other two-parameter base classes). A row that
is handed a view model per item is torn down as before.

A kept row does **not** run `Bind`, `Initialized`, `Activated` or `Deactivated` again when it
is given its next item; it only receives `Detached` and `Attached`, and its view model's
item changes. Opt in only for rows that take everything they show from bindings to the view
model. A row that reads its item imperatively inside `Bind` would show the previous item.

### Hot Reload

StellarUI has built-in support for hot reload during development:
Expand Down
90 changes: 90 additions & 0 deletions Stellar.Maui.UnitTests/ItemsViewRecyclingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,96 @@ public void ListThatHasNotOptedIn_TearsEveryViewDownAsBefore()
Assert.Equal(0, source.HandlerCount);
}

[Fact]
public void ListThatHasNotOptedIn_IsNotWatched()
{
var source = new LongLivedSource();
var list = new ListHarness(recycledItemViewLimit: null);
var cells = BindCells(list, source, 2);

foreach (var cell in cells)
{
list.Recycle(cell);
}

Assert.Null(ItemsHostWatcher.Find(list.List));
}

[Fact]
public void RecycledItemViewLimit_OfZero_OnAListThatHasNotOptedIn_DoesNotWatchIt()
{
var list = new ListHarness(recycledItemViewLimit: null);

list.List.RecycledItemViewLimit(0);

Assert.Null(ItemsHostWatcher.Find(list.List));
}

[Fact]
public void RecycledItemViewLimit_SetAfterViewsAreBound_KeepsThemOnceTheyHaveBeenBoundAgain()
{
// A view learns whether its list keeps recycled views when it is bound, not when it
// is recycled: by then MAUI has already taken its parent away. So a view bound
// before the list opted in is torn down once more, and kept from its next bind on.
var source = new LongLivedSource();
var list = new ListHarness(recycledItemViewLimit: null);
var cells = BindCells(list, source, 2);

list.List.RecycledItemViewLimit(Cap);

foreach (var cell in cells)
{
list.Recycle(cell);
}

Assert.All(cells, static cell => Assert.False(cell.ViewManager.ControlsBound));

foreach (var cell in cells)
{
list.Bind(cell, new TestItem("Rebound", 1));
list.Recycle(cell);
}

Assert.All(cells, static cell => Assert.True(cell.ViewManager.ControlsBound));
Assert.Equal(2, source.HandlerCount);
}

[Fact]
public void RecycledItemViewLimit_OfZero_OnAListThatHasNotOptedIn_AllocatesNothing()
{
// Warmed up on a list of its own so that neither JIT nor static state is counted.
new ListHarness(recycledItemViewLimit: null).List.RecycledItemViewLimit(0);
var list = new ListHarness(recycledItemViewLimit: null);

var before = GC.GetAllocatedBytesForCurrentThread();
list.List.RecycledItemViewLimit(0);
var allocated = GC.GetAllocatedBytesForCurrentThread() - before;

Assert.Equal(0, allocated);
}

[Theory]
[InlineData(null)]
[InlineData(Cap)]
public void ItemView_RemembersItsListWithoutAllocating(int? recycledItemViewLimit)
{
// A view that is not a Stellar view is only ever tracked, so this is the cost of
// the tracking alone. The first manager warms up whatever is created once.
var list = new ListHarness(recycledItemViewLimit);
var view = new ReactiveUI.Reactive.Maui.ReactiveContentView<TestCellViewModel>();
list.List.AddLogicalChild(view);
using var first = new MauiViewManager<TestCellViewModel>();
first.PropertyChanged(view, nameof(Element.Parent));
using var manager = new MauiViewManager<TestCellViewModel>();

var before = GC.GetAllocatedBytesForCurrentThread();
manager.PropertyChanged(view, nameof(Element.Parent));
manager.PropertyChanged(view, nameof(VisualElement.Window));
var allocated = GC.GetAllocatedBytesForCurrentThread() - before;

Assert.Equal(0, allocated);
}

[Fact]
public void RecycledItemViewLimit_Lowered_ReleasesTheViewsAlreadyKept()
{
Expand Down
14 changes: 3 additions & 11 deletions Stellar.Maui.UnitTests/MauiTestBase.cs
Original file line number Diff line number Diff line change
@@ -1,19 +1,11 @@
using ReactiveUI.Reactive.Builder;

namespace Stellar.Maui.UnitTests;

public abstract class MauiTestBase
public abstract class MauiTestBase : StellarTestBase
{
// RecyclerView.RecycledViewPool.DEFAULT_MAX_SCRAP, which the view manager mirrors.
// The limit ListHarness opts its list in with, which is
// RecyclerView.RecycledViewPool.DEFAULT_MAX_SCRAP.
protected const int Cap = 5;

static MauiTestBase()
{
// ReactiveUI throws on first use unless it has been built. This is the same
// registration UseStellarComponents performs, without a MauiAppBuilder.
RxAppBuilder.CreateReactiveUIBuilder().WithMaui().BuildApp();
}

public static TheoryData<CellKind> CellKinds =>
new()
{
Expand Down
3 changes: 3 additions & 0 deletions Stellar.Maui.UnitTests/StellarTestBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ namespace Stellar.Maui.UnitTests;

public abstract class StellarTestBase
{
// ReactiveUI throws on first use unless it has been built. Every test class goes
// through the registration an app performs, so the result cannot depend on which
// class happens to run first.
static StellarTestBase()
{
MauiApp.CreateBuilder(useDefaults: false).UseStellarComponents();
Expand Down
14 changes: 13 additions & 1 deletion Stellar.Maui/Extensions/ItemsViewExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ public static class ItemsViewExtensions
/// <remarks>
/// A kept view does not run Bind, Initialized, Activated or Deactivated again when it
/// is given its next row; only its view model's item changes. Opt in only for item
/// views that take everything they show from bindings to the view model.
/// views that take everything they show from bindings to the view model. Set the limit
/// before the list shows its rows: a view learns whether its list keeps views when it
/// is bound, so one bound before the limit was raised is torn down once more and kept
/// from the next time it is bound.
Comment on lines +18 to +21
/// </remarks>
/// <typeparam name="TItemsView">The type of the list.</typeparam>
/// <param name="itemsView">The list.</param>
Expand All @@ -27,6 +30,15 @@ public static TItemsView RecycledItemViewLimit<TItemsView>(this TItemsView items
ArgumentNullException.ThrowIfNull(itemsView);
ArgumentOutOfRangeException.ThrowIfNegative(limit);

// Zero is what a list that never opted in already does, so it must not be what
// gives the list a watcher.
if (limit == 0)
{
ItemsHostWatcher.Find(itemsView)?.Limit = 0;

return itemsView;
}

ItemsHostWatcher.For(itemsView).Limit = limit;

return itemsView;
Expand Down
56 changes: 50 additions & 6 deletions Stellar.Maui/ItemsHostWatcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,25 +9,44 @@ namespace Stellar.Maui;
/// </summary>
internal sealed class ItemsHostWatcher
{
/// <summary>
/// Stands in for the watcher of a list that has not opted in, so that an item view can
/// remember it belongs to such a list without a reference to the list. It keeps
/// nothing and refuses every view.
/// </summary>
public static readonly ItemsHostWatcher None = new();

private static readonly ConditionalWeakTable<ItemsView, ItemsHostWatcher> Watchers = new();

// Zero until a list opts in. Read on every bind of every item view, so a list in an
// app that never opts in does not pay for a table lookup.
private static int _watched;

private readonly Lock _gate = new();

private readonly List<WeakReference<IParkedItemView>> _parked = [];

private readonly WeakReference<ItemsView>? _host;

// Zero until the list opts in through RecycledItemViewLimit.
private int _limit;

private ItemsHostWatcher()
{
}

private ItemsHostWatcher(ItemsView host)
{
_host = new WeakReference<ItemsView>(host);

host.PropertyChanged += this.OnHostPropertyChanged;
}

public static ItemsHostWatcher For(ItemsView host) =>
Watchers.GetValue(host, static x => new ItemsHostWatcher(x));
Watchers.GetValue(host, static x => Create(x));

public static ItemsHostWatcher? Find(ItemsView host) =>
Watchers.TryGetValue(host, out var watcher) ? watcher : null;
Volatile.Read(ref _watched) != 0 && Watchers.TryGetValue(host, out var watcher) ? watcher : null;

public int Limit
{
Expand All @@ -47,8 +66,24 @@ public int Limit
}
}

public bool TryPark(WeakReference<IParkedItemView> view)
public bool IsFor(ItemsView host) =>
_host is not null && _host.TryGetTarget(out var target) && ReferenceEquals(target, host);

/// <summary>
/// Takes a slot for a view if the list is in its window and has one free. The weak
/// reference the slot is held through is created only then, and is handed back so the
/// same one can give the slot up again or take another later.
/// </summary>
/// <param name="view">The view asking for a slot.</param>
/// <param name="slot">The weak reference the view was given before, if any; set when a slot is taken.</param>
/// <returns>True if the view now holds a slot.</returns>
public bool TryPark(IParkedItemView view, ref WeakReference<IParkedItemView>? slot)
{
if (_host is null || !_host.TryGetTarget(out var host) || host.Window is null)
{
return false;
}

lock (_gate)
{
for (var i = _parked.Count - 1; i >= 0; i--)
Expand All @@ -64,17 +99,19 @@ public bool TryPark(WeakReference<IParkedItemView> view)
return false;
}

_parked.Add(view);
slot ??= new WeakReference<IParkedItemView>(view);

_parked.Add(slot);

return true;
}
}

public void Unpark(WeakReference<IParkedItemView> view)
public void Unpark(WeakReference<IParkedItemView> slot)
{
lock (_gate)
{
_parked.Remove(view);
_parked.Remove(slot);
}
}

Expand Down Expand Up @@ -102,6 +139,13 @@ public void ReleaseParked()
}
}

private static ItemsHostWatcher Create(ItemsView host)
{
Interlocked.Increment(ref _watched);

return new ItemsHostWatcher(host);
}

private void OnHostPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(VisualElement.Window) && sender is ItemsView { Window: null })
Expand Down
Loading
Loading