-
Notifications
You must be signed in to change notification settings - Fork 0
Getting Started
Rambla targets current .NET (the repo builds on .NET 10). The core is
netstandard2.0, so it works broadly; UI integration ships as adapters.
- 1. Reference the packages
- 2. Install a scheduler (WPF)
- 3. Declare state with
[State] - 4. A complete WPF example
- 5. Drive it from a background feed
- 6. Batch related writes
- 7. Publish a whole snapshot
- 8. Derived values
- 9. Collections
- 10. Use it without a UI (and in tests)
- 11. Metrics
- 12. Tuning the refresh rate
-
Rambla— the framework-agnostic core (includes the[State]source generator). -
Rambla.Wpf— the WPF dispatcher adapter.
(Packages are not yet published to NuGet; for now reference the projects or build from source. See the Roadmap.)
At application startup, on the UI thread, install the dispatcher scheduler so
background writes marshal to the UI automatically. Do this once in App:
// App.xaml.cs
using System.Windows;
using Rambla.Wpf;
public partial class App : Application
{
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
// Every RamblaState created without an explicit scheduler now flushes
// onto this dispatcher, at Background priority (so high-frequency state
// never starves input or rendering).
DispatcherStateScheduler.InstallAsDefault();
}
}Without an adapter, the default is a synchronous ImmediateStateScheduler — great
for tests and non-UI code, but on a background thread it raises PropertyChanged
off the UI thread, so always install a UI scheduler in a real app.
Inherit RamblaState and annotate backing fields. The generator emits the
observable property and strips a leading underscore (_bid → Bid). The class
must be partial.
using Rambla;
public partial class MarketViewModel : RamblaState
{
[State] private decimal _bid;
[State] private decimal _ask;
[State] private decimal _last;
[State] private decimal _pnl;
}That generates, for each field:
public decimal Bid
{
get => _bid;
set => SetField(ref _bid, value);
}ViewModel (uses the ambient scheduler installed in step 2):
using Rambla;
public partial class TickerViewModel : RamblaState
{
[State] private string _symbol = "BTC-USD";
[State] private decimal _price;
[State] private decimal _change;
}Window XAML — bind exactly as you would to any INotifyPropertyChanged:
<StackPanel>
<TextBlock Text="{Binding Symbol}" FontWeight="Bold" />
<TextBlock Text="{Binding Price, StringFormat=N2}" FontSize="28" />
<TextBlock Text="{Binding Change, StringFormat='{}{0:+0.00;-0.00}%'}" />
</StackPanel>Code-behind — set the DataContext and start feeding:
public partial class MainWindow : Window
{
private readonly TickerViewModel _vm = new(); // uses the installed dispatcher scheduler
public MainWindow()
{
InitializeComponent();
DataContext = _vm;
_ = FeedAsync(); // fire-and-forget background feed
}
private async Task FeedAsync()
{
var rng = new Random();
while (true)
{
// These writes happen on a background thread — no Dispatcher.Invoke:
_vm.Price = 60_000m + rng.Next(-500, 500);
_vm.Change = (decimal)(rng.NextDouble() * 4 - 2);
await Task.Delay(1); // ~1000 updates/sec; Rambla coalesces to the UI
}
}
}The feed writes ~1000 times/sec from a worker; Rambla coalesces those into a few
UI notifications per second. No Dispatcher.Invoke, no OnPropertyChanged.
The point of Rambla is that the producer doesn't care about the UI thread:
async Task RunFeedAsync(TickerViewModel vm, IAsyncEnumerable<Quote> stream, CancellationToken ct)
{
await foreach (Quote q in stream.WithCancellation(ct))
{
vm.Price = q.Price; // any thread; Rambla marshals + coalesces
vm.Change = q.Change;
}
}If a value ticks many times before the next frame, the UI only ever sees the latest — that's coalescing, and it's the whole point (see Philosophy).
Group writes so the UI is notified once, and never mid-update — it won't
render a new Bid beside a stale Ask:
using (vm.BeginUpdate())
{
vm.Bid = quote.Bid;
vm.Ask = quote.Ask;
vm.Last = quote.Last;
}
// or the lambda form:
vm.Update(() =>
{
vm.Bid = quote.Bid;
vm.Ask = quote.Ask;
});This is notification coherence, not cross-thread state atomicity — see Core Semantics.
A clean pattern for feed-driven views is a single Apply that batches everything:
public partial class MarketViewModel : RamblaState
{
[State] private decimal _bid;
[State] private decimal _ask;
[State] private decimal _last;
[State] private decimal _volume;
public void Apply(Quote q)
{
using (BeginUpdate())
{
Bid = q.Bid;
Ask = q.Ask;
Last = q.Last;
Volume = q.Volume;
}
}
}
// producer:
vm.Apply(quote); // one coherent flush per quote, from any threadV1 has no [DependsOn] yet, so a derived value is just another [State] field you
set in the same batch as its inputs:
public partial class MarketViewModel : RamblaState
{
[State] private decimal _bid;
[State] private decimal _ask;
[State] private decimal _spread;
public void Apply(decimal bid, decimal ask)
{
using (BeginUpdate())
{
Bid = bid;
Ask = ask;
Spread = ask - bid; // computed once, notified in the same flush
}
}
}Bind {Binding Spread} normally. (Automatic derived notification is on the
Roadmap.)
ObservableCollection<T> is not thread-safe and isn't built for high-frequency
change bursts. For now:
- Build/replace the list off-thread, then assign or add on the UI thread.
- Keep individual row objects as
RamblaStateso their fields update at high frequency while the collection itself changes rarely (this is what the market dashboard sample does).
A purpose-built RamblaList<T> (batched changes, efficient diffing) is Phase 2 —
see the Roadmap.
Pass a scheduler explicitly. ImmediateStateScheduler flushes synchronously, which
makes tests deterministic:
using Rambla.Scheduling;
var vm = new MarketViewModel(ImmediateStateScheduler.Instance);
var changed = new List<string>();
((INotifyPropertyChanged)vm).PropertyChanged += (_, e) => changed.Add(e.PropertyName!);
vm.Bid = 1.23m;
vm.Bid = 1.24m; // coalescing still applies within a flush
// With the immediate scheduler each set flushes inline, so:
Assert.Contains(nameof(vm.Bid), changed);For coalescing assertions, inject a manual scheduler that queues flushes and drains
them on demand (see the repo's ManualStateScheduler in the tests).
Turn on lifetime counters to see how much work coalescing saved:
// per instance:
var vm = new MarketViewModel(scheduler, collectMetrics: true);
// or globally:
RamblaOptions.Default.CollectMetrics = true;
StateMetrics m = vm.Metrics;
Console.WriteLine($"{m.Mutations} mutations → {m.Notifications} notifications " +
$"({m.CoalescingRatio:P1} coalesced)");Metrics are off by default so the hot path stays allocation- and contention-free.
The shipped DispatcherStateScheduler posts flushes at Background priority, so
it already yields to input and rendering and coalesces naturally under load.
For a hard cap (e.g. "at most 60 UI updates/sec regardless of load"), the market
dashboard sample includes a ThrottledDispatcherScheduler you can copy — it drives
flushes from a DispatcherTimer at a fixed rate. Use the sample's latency probe to
find the knee for your workload (usually ~60 Hz); see Benchmarks.
// RamblaOptions.Default.MaxRefreshRate is reserved for the built-in coalescing
// scheduler (Roadmap Phase 1); today it documents intent.
RamblaOptions.Default.MaxRefreshRate = 60;Rambla — state for real-time .NET desktop applications · MIT · github.com/nicoseijas/Rambla · This wiki follows ASD-STE100.
Use Rambla
Reference