Skip to content
Merged
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
53 changes: 53 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ Platform-specific packages provide DependencyProperty observation and other plat
| `WhenChanging` | Observe property changes (before value changes, requires `INotifyPropertyChanging`) |
| `WhenAnyValue` | ReactiveUI compatibility shim -- same semantics as `WhenChanged` |
| `WhenAny` | Multi-property observation with selector |
| `WhenAnyDynamic` | Observe a chain given as an `Expression` built at run time (reflection, not AOT-safe) |
| `WhenAnyObservable` | Observe and switch between observable properties |
| `BindOneWay` | One-way binding from source to target |
| `BindTwoWay` | Two-way binding between source and target |
Expand Down Expand Up @@ -178,6 +179,28 @@ IObservable<string> fullName = vm.WhenChanged(
IObservable<string> nameChanging = vm.WhenChanging(x => x.Name);
```

### Observing a Chain Named at Run Time

`WhenAnyDynamic` takes the chain as a `System.Linq.Expressions.Expression` the caller built, rather than as a
lambda the compiler could read. Arities 1 to 12 are available, each with and without the distinct gate.

```csharp
// The chain is a value, so it can be assembled from a property name, a configuration entry, or a caller.
Expression chain = ((Expression<Func<MyViewModel, string>>)(x => x.Address.City)).Body;

IObservable<string?> cityObs = vm.WhenAnyDynamic(chain, static c => (string?)c.Value);

IObservable<string> fullName = vm.WhenAnyDynamic(
firstNameChain,
lastNameChain,
static (first, last) => $"{first.Value} {last.Value}");
```

There is nothing for a generator to resolve in an expression built at run time, so the chain is walked by
reflection. Every overload carries `[RequiresUnreferencedCode]`, which makes this the one part of the observation
surface that is not trimming- or AOT-safe and reports each call site in a `PublishAot` build. Where the chain is
known at compile time, `WhenChanged` and `WhenAny` observe the same thing with no reflection.

### One-Way Binding

```csharp
Expand Down Expand Up @@ -413,6 +436,36 @@ subscription rather than re-projecting the two observed sides:
| Two-Way Binding (.NET 10.0) | 8.1x faster | 8.5x less |
| First Binding (.NET 10.0) | 4.1x faster | 9.6x less |

#### Chains Named at Run Time (WhenAnyDynamic)

| Method | Runtime | Mean | Allocated |
|-------------------|-----------|-------:|----------:|
| Single Chain | .NET 10.0 | 278 us | 102.7 KB |
| Two Chains | .NET 10.0 | 595 us | 190.2 KB |
| Deep Chain | .NET 10.0 | 354 us | 103.3 KB |
| First Observation | .NET 10.0 | 7.3 us | 1.1 KB |
| Single Chain | .NET 8.0 | 347 us | 102.7 KB |
| Two Chains | .NET 8.0 | 691 us | 190.1 KB |
| Deep Chain | .NET 8.0 | 419 us | 103.3 KB |
| First Observation | .NET 8.0 | 7.9 us | 1.1 KB |

This is the one part of the surface where the two engines are level. Both walk the chain by reflection and
allocate the same objects doing it, so the numbers land on top of each other; the gain is in resolving the chain
at compile time instead:

| Single chain, .NET 10.0 | Mean | Allocated |
|-----------------------------|-------:|----------:|
| Generated (`WhenChanged`) | 175 us | 63.4 KB |
| `WhenAnyDynamic` | 278 us | 102.7 KB |
| ReactiveUI `WhenAnyDynamic` | 299 us | 102.6 KB |

Most of the gap to the generated path is the `IObservedChange` handed to the selector - 40 bytes per
notification, which the signature has to produce, against a generated observation that emits the value itself.

Two chains fire twice the notifications of one, so their 190 KB is 95 bytes per notification against a single
chain's 103: combining a higher arity costs one subscription, not a per-notification charge. Every arity observes
each of its chains through the same walk and differs only in how many it combines.

## Diagnostics

The separate analyzer package reports the following diagnostics:
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ namespace ReactiveUI.Binding;
/// over these generic stubs due to having fewer optional parameters (or matching type specificity).
/// Supports 1-16 property selectors for observation APIs (WhenChanged, WhenChanging, WhenAnyValue).
/// </summary>
[ExcludeFromCodeCoverage]
public static partial class ReactiveUIBindingExtensions
{
/// <summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved.
// ReactiveUI Association Incorporated licenses this file to you under the MIT license.
// See the LICENSE file in the project root for full license information.

#if NET8_0_OR_GREATER
using System.Diagnostics.CodeAnalysis;
#endif
using System.Linq.Expressions;
using System.Runtime.CompilerServices;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Diagnosers;
using BenchmarkDotNet.Jobs;
using ReactiveUI;
using ReactiveUI.Builder;
using BenchmarkVm = ReactiveUI.Binding.Benchmarks.BenchmarkViewModel;

namespace RxUiDynamicChain;

/// <summary>
/// The same dynamic-chain scenarios as <c>WhenAnyDynamicBenchmark</c>, run against ReactiveUI's own engine so
/// the two are read side by side.
/// </summary>
/// <remarks>
/// Declared outside the <c>ReactiveUI.Binding</c> namespace on purpose. Extension lookup walks the enclosing
/// namespaces from the inside out and stops at the first level offering a candidate, so a class nested under
/// <c>ReactiveUI.Binding</c> reaches this library's overloads and never consults ReactiveUI's. The view model
/// arrives through a type alias rather than an import for the same reason: importing its namespace would put
/// both libraries' overloads at the outermost level and make every call ambiguous.
/// </remarks>
[SimpleJob(RuntimeMoniker.Net80)]
[SimpleJob(RuntimeMoniker.Net10_0)]
[MemoryDiagnoser]
[EventPipeProfiler(EventPipeProfile.GcVerbose)]
[MarkdownExporterAttribute.GitHub]
#if NET8_0_OR_GREATER
[RequiresUnreferencedCode("Evaluates expression-based member chains via reflection; members may be trimmed.")]
#endif
public class RxUiDynamicChainBaseline
{
/// <summary>Represents the number of property change events to be triggered during the benchmark tests.</summary>
private const int PropertyChangeCount = 1_000;

/// <summary>Names the chain reaching one property.</summary>
private static readonly Expression NameChain = Chain(x => x.Name);

/// <summary>Names the chain reaching a second property, so an arity above one has something to combine.</summary>
private static readonly Expression AgeChain = Chain(x => x.Age);

/// <summary>Names the chain reaching through an intermediate.</summary>
private static readonly Expression ChildValueChain = Chain(x => x.Child.Value);

/// <summary>Reads one observed change.</summary>
/// <remarks>
/// Typed rather than inferred so the binding is pinned: the parameter names ReactiveUI's
/// <see cref="IObservedChange{TSender, TValue}"/>, so a call that resolved to this library's overload of
/// the same name would not compile rather than quietly benchmarking the wrong engine.
/// </remarks>
private static readonly Func<IObservedChange<BenchmarkVm?, object?>, object?> ReadOne =
static c1 => c1.Value;

/// <summary>Reads whichever of two observed changes carries a value.</summary>
private static readonly Func<IObservedChange<BenchmarkVm?, object?>, IObservedChange<BenchmarkVm?, object?>, object?> ReadEither =
static (c1, c2) => c1.Value ?? c2.Value;

/// <summary>The view model instance used for observation benchmarks.</summary>
private BenchmarkVm _vm = null!;

/// <summary>Registers the observation plugins ReactiveUI resolves each link through.</summary>
[GlobalSetup]
public void Register()
{
var builder = RxAppBuilder.CreateReactiveUIBuilder();
_ = builder.WithCoreServices();
_ = builder.BuildApp();
}

/// <summary>Sets up a fresh view model before each benchmark iteration.</summary>
[IterationSetup]
public void Setup() =>
_vm = new() { Name = "Initial", Age = 0, Child = new() { Value = "ChildInitial" } };

/// <summary>One chain: subscribe, fire N changes, dispose.</summary>
[Benchmark(Description = "Single Chain")]
public void SingleChain()
{
object? last = null;
using var sub = _vm.WhenAnyDynamic(NameChain, ReadOne)
.Subscribe(v => last = v);

for (var i = 0; i < PropertyChangeCount; i++)
{
_vm.Name = $"Name_{i}";
}
}

/// <summary>Two chains combined: subscribe, fire N changes on each, dispose.</summary>
[Benchmark(Description = "Two Chains")]
public void TwoChains()
{
object? last = null;
using var sub = _vm.WhenAnyDynamic(NameChain, AgeChain, ReadEither)
.Subscribe(v => last = v);

for (var i = 0; i < PropertyChangeCount; i++)
{
_vm.Name = $"Name_{i}";
_vm.Age = i;
}
}

/// <summary>A chain through an intermediate: subscribe, fire N changes on the leaf, dispose.</summary>
[Benchmark(Description = "Deep Chain")]
public void DeepChain()
{
object? last = null;
using var sub = _vm.WhenAnyDynamic(ChildValueChain, ReadOne)
.Subscribe(v => last = v);

for (var i = 0; i < PropertyChangeCount; i++)
{
_vm.Child.Value = $"Value_{i}";
}
}

/// <summary>Cold start: subscribe, read the initial value, dispose. No property changes.</summary>
/// <returns>The observed value.</returns>
[Benchmark(Description = "First Observation")]
public object? FirstObservation()
{
object? result = null;
using var sub = _vm.WhenAnyDynamic(NameChain, ReadOne)
.Subscribe(v => result = v);
return result;
}

/// <summary>Names a property chain the way a caller building one at run time hands it over.</summary>
/// <typeparam name="TValue">The type the chain ends at.</typeparam>
/// <param name="property">The chain to name.</param>
/// <returns>The expression body, which is what these overloads take.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static Expression Chain<TValue>(Expression<Func<BenchmarkVm, TValue>> property) =>
property.Body;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved.
// ReactiveUI Association Incorporated licenses this file to you under the MIT license.
// See the LICENSE file in the project root for full license information.

#if NET8_0_OR_GREATER
using System.Diagnostics.CodeAnalysis;
#endif
using System.Linq.Expressions;
using System.Runtime.CompilerServices;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Diagnosers;
using BenchmarkDotNet.Jobs;
using ReactiveUI.Binding.Builder;

namespace ReactiveUI.Binding.Benchmarks;

/// <summary>
/// Reflection-walked observation benchmarks, and what the generated observation of the same chain costs.
/// The pair is the choice a caller actually makes: a chain named by an expression built at run time cannot
/// be resolved at compile time, and this says what that buys and what it costs.
/// </summary>
/// <remarks>
/// No NativeAOT job: these overloads walk members by reflection and say so with
/// <c>RequiresUnreferencedCode</c>, so an ahead-of-time published run is not a
/// configuration they support.
/// </remarks>
[SimpleJob(RuntimeMoniker.Net80)]
[SimpleJob(RuntimeMoniker.Net10_0)]
[MemoryDiagnoser]
[EventPipeProfiler(EventPipeProfile.GcVerbose)]
[MarkdownExporterAttribute.GitHub]
#if NET8_0_OR_GREATER
[RequiresUnreferencedCode("Evaluates expression-based member chains via reflection; members may be trimmed.")]
#endif
public class WhenAnyDynamicBenchmark
{
/// <summary>Represents the number of property change events to be triggered during the benchmark tests.</summary>
private const int PropertyChangeCount = 1_000;

/// <summary>Names the chain reaching one property.</summary>
private static readonly Expression NameChain = Chain(x => x.Name);

/// <summary>Names the chain reaching a second property, so an arity above one has something to combine.</summary>
private static readonly Expression AgeChain = Chain(x => x.Age);

/// <summary>Names the chain reaching through an intermediate.</summary>
private static readonly Expression ChildValueChain = Chain(x => x.Child.Value);

/// <summary>The view model instance used for observation benchmarks.</summary>
private BenchmarkViewModel _vm = null!;

/// <summary>Registers the observation plugins the reflection walk resolves each link through.</summary>
[GlobalSetup]
public void Register()
{
var builder = RxBindingBuilder.CreateReactiveUIBindingBuilder();
_ = builder.WithCoreServices();
_ = builder.BuildApp();
}

/// <summary>Sets up a fresh view model before each benchmark iteration.</summary>
[IterationSetup]
public void Setup() =>
_vm = new() { Name = "Initial", Age = 0, Child = new() { Value = "ChildInitial" } };

/// <summary>One chain: subscribe, fire N changes, dispose.</summary>
[Benchmark(Description = "Single Chain")]
public void SingleChain()
{
object? last = null;
using var sub = _vm.WhenAnyDynamic(NameChain, static c1 => c1.Value)
.Subscribe(v => last = v);

for (var i = 0; i < PropertyChangeCount; i++)
{
_vm.Name = $"Name_{i}";
}
}

/// <summary>Two chains combined: subscribe, fire N changes on each, dispose.</summary>
[Benchmark(Description = "Two Chains")]
public void TwoChains()
{
object? last = null;
using var sub = _vm.WhenAnyDynamic(NameChain, AgeChain, static (c1, c2) => c1.Value ?? c2.Value)
.Subscribe(v => last = v);

for (var i = 0; i < PropertyChangeCount; i++)
{
_vm.Name = $"Name_{i}";
_vm.Age = i;
}
}

/// <summary>A chain through an intermediate: subscribe, fire N changes on the leaf, dispose.</summary>
[Benchmark(Description = "Deep Chain")]
public void DeepChain()
{
object? last = null;
using var sub = _vm.WhenAnyDynamic(ChildValueChain, static c1 => c1.Value)
.Subscribe(v => last = v);

for (var i = 0; i < PropertyChangeCount; i++)
{
_vm.Child.Value = $"Value_{i}";
}
}

/// <summary>Cold start: subscribe, read the initial value, dispose. No property changes.</summary>
/// <returns>The observed value.</returns>
[Benchmark(Description = "First Observation")]
public object? FirstObservation()
{
object? result = null;
using var sub = _vm.WhenAnyDynamic(NameChain, static c1 => c1.Value)
.Subscribe(v => result = v);
return result;
}

/// <summary>The same single chain resolved at compile time, which is what the reflection walk is weighed against.</summary>
[Benchmark(Description = "Single Chain (generated)", Baseline = true)]
public void SingleChainGenerated()
{
var last = string.Empty;
using var sub = _vm.WhenChanged(x => x.Name)
.Subscribe(v => last = v);

for (var i = 0; i < PropertyChangeCount; i++)
{
_vm.Name = $"Name_{i}";
}
}

/// <summary>Names a property chain the way a caller building one at run time hands it over.</summary>
/// <typeparam name="TValue">The type the chain ends at.</typeparam>
/// <param name="property">The chain to name.</param>
/// <returns>The expression body, which is what these overloads take.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static Expression Chain<TValue>(Expression<Func<BenchmarkViewModel, TValue>> property) =>
property.Body;
}
Loading
Loading