R3

repository·main·Indexed 26 days ago

https://github.com/cysharp/r3

A modern reimplementation of Reactive Extensions (Rx) for .NET, optimized for high-frequency scenarios like games and GUIs. R3 supports .NET Standard 2.0, 2.1, and .NET 6, 7, and 8+. It features a performance-oriented API using abstract classes for Observable<T> and Observer<T>, integrates with .NET 8 TimeProvider, and provides specialized tools like ReactiveProperty, FrameProvider, and ObservableTracker for memory leak debugging.

Tokens
31.9K
Snippets
36
Records
104
Agent score
86%

What's inside R3

  1. Install and Setup R3Extensions.MonoGame

    main

    R3 extensions for the MonoGame engine.

    Installation

    Install via NuGet: PM> Install-Package R3Extensions.MonoGame

    Setup

    1. Reference R3.MonoGame.
    2. Add an instance of ObservableSystemComponent to your Game class.

    Configuration

    ObservableSystemComponent automatically configures MonoGameTimeProvider and MonoGameFrameProvider. It also sets up an UnhandledExceptionHandler. By default, exceptions are sent to System.Diagnostics.Trace. To provide a custom handler:

    var observableSystemComponent = new ObservableSystemComponent(this, ex => Console.WriteLine($"R3 UnhandledException: {ex}");
    Components.Add(observableSystemComponent);
    public class Game1 : Game
    {
        public Game1()
        {
            var observableSystemComponent = new ObservableSystemComponent(this);
            Components.Add(observableSystemComponent);
        }
    }
  2. Use ReactiveProperty for observable models

    main

    A ReactiveProperty<T> is a specialized BehaviorSubject that holds a single value and automatically eliminates duplicate values (it won't notify if the new value is identical to the current one). It is ideal for data binding in UI contexts because its value can be updated via the .Value property.

    Key behaviors:

    • Update value: Use .Value = newValue to update. If the value is the same as the current one, no notification is issued.
    • Force notification: If you need to notify observers even when the value is identical, call .OnNext(value) instead of setting .Value.
    • Read-only access: To follow best practices (like the Android UI Layer pattern), keep a private ReactiveProperty<T> and expose it as a public ReadOnlyReactiveProperty<T>.
    // Reactive Notification Model
    public class Enemy
    {
        public ReactiveProperty<long> CurrentHp { get; private set; }
        public ReactiveProperty<bool> IsDead { get; private set; }
    
        public Enemy(int initialHp)
        {
            CurrentHp = new ReactiveProperty<long>(initialHp);
            IsDead = CurrentHp.Select(x => x <= 0).ToReactiveProperty();
        }
    }
    
    // Usage
    // Update value via .Value
    enemy.CurrentHp.Value -= 99;
    
    // Subscribe to changes
    enemy.CurrentHp.Subscribe(x => Console.WriteLine("HP:" + x));
    
    // Convert to read-only pattern for public APIs
    class NewsViewModel
    {
        ReactiveProperty<NewsUiState> _uiState = new(new NewsUiState());
        public ReadOnlyReactiveProperty<NewsUiState> UiState => _uiState;
    }
  3. Manually inject TimeProvider in Blazor for testing

    main

    You can manually manage the TimeProvider in Blazor by registering a SynchronizationContextTimeProvider as a scoped service. This approach is useful for unit testing (e.g., using bUnit), as it allows you to substitute a FakeTimeProvider easily.

    // use AddScoped instead of AddBlazorR3
    builder.Services.AddScoped<TimeProvider, SynchronizationContextTimeProvider>();
    
    var app = builder.Build();

    In your component:

    public partial class Counter : IDisposable
    {
        int currentCount = 0;
        IDisposable? subscription;
    
        // Inject scoped TimeProvider manually(in bUnit testing, inject FakeTimeProvider)
        [Inject]
        public required TimeProvider TimeProvider { get; init; }
    
        protected override void OnInitialized()
        {
            subscription = Observable.Interval(TimeSpan.FromSeconds(1), TimeProvider)
                .Subscribe(_ =>
                {
                    currentCount++;
                    StateHasChanged();
                });
        }
    
        public void Dispose()
        {
            subscription?.Dispose();
        }
    }
  4. Integrate R3 with Stride using the default FrameProvider

    main

    To enable R3 to work with the Stride engine's frame lifecycle, you must set up a default Frame Dispatcher in the Stride editor:

    1. Reference the R3.Stride package in your project.
    2. Create an empty Entity using the Stride editor.
    3. Add the component R3/R3 Frame Dispatcher to that entity.
    4. Adjust the Stride Frame Provider Component's priority to ensure the subscribed callback executes at the desired point in the frame lifecycle.
  5. Use async/await with R3 Observables

    main

    R3 provides deep integration with async/await.

    Async Methods

    Methods that return a single asynchronous operation (like FirstAsync or LastAsync) return Task<T>. These transform OnErrorResume exceptions into faulted tasks.

    Async Operators

    You can use async functions within several operators to control execution flow. These operators accept an AwaitOperation to define how concurrent asynchronous tasks are handled.

    AwaitOperation Options:

    • Sequential: All values are queued; the next value waits for the current async method to complete.
    • Drop: New values are dropped if an async operation is currently running.
    • Switch: If a new value arrives while an async operation is running, the current one is cancelled and the new one starts.
    • Parallel: All values are sent to the async method immediately (unlimited concurrency).
    • SequentialParallel: All values are sent immediately, but results are queued and passed to the next operator in order.
    • ThrottleFirstLast: Sends the first and last values while the async method is running.

    Key Async Operators:

    • SelectAwait: Transforms values using an async selector.
    • WhereAwait: Filters values using an async predicate.
    • SubscribeAwait: Subscribes using an async onNext handler.
    • Debounce, ThrottleFirst, ThrottleLast, ThrottleFirstLast: Time-based filtering using async samplers.
    • Chunk: Groups elements into chunks using an async window function.
    // Example: Using AwaitOperation.Drop to prevent multiple clicks
    button.OnClickAsObservable()
        .SelectAwait(async (_, ct) =>
        {
            var req = await UnityWebRequest.Get("https://google.com/").SendWebRequest().WithCancellation(ct);
            return req.downloadHandler.text;
        }, AwaitOperation.Drop)
        .SubscribeToText(text);
    
    // Example: Using async Chunk to generate chunks at random intervals
    Observable.Interval(TimeSpan.FromSeconds(1))
        .Index()
        .Chunk(async (_, ct) =>
        {
            await Task.Delay(TimeSpan.FromSeconds(Random.Shared.Next(0, 5)), ct);
        })
        .Subscribe(xs =>
        {
            Console.WriteLine(string.Join(", ", xs));
        });
  6. Install R3 via NuGet

    main

    R3 supports .NET Standard 2.0, .NET Standard 2.1, .NET 6, .NET 7, and .NET 8 or above. You can install it using the dotnet CLI.

    Note: Certain platforms like WPF, Avalonia, Unity, and Godot may require additional installation steps.

    dotnet add package R3
  7. Track subscription leaks with ObservableTracker

    main

    To debug memory leaks and monitor active subscriptions, use ObservableTracker. When enabled, you can iterate through all active tasks to see their type, addition time, and stack trace.

    Note: Tracking is disabled by default.

    // Enable tracking and stack traces
    ObservableTracker.EnableTracking = true;
    ObservableTracker.EnableStackTrace = true;
    
    using var d = Observable.Interval(TimeSpan.FromSeconds(1))
        .Subscribe();
    
    // Inspect active subscriptions
    ObservableTracker.ForEachActiveTask(x =>
    {
        Console.WriteLine(x);
    });
  8. Install and Setup R3 in Godot 4.x

    main

    To use R3 in Godot 4.x, follow these steps:

    1. Install R3 from NuGet.
    2. Download (or clone as a git submodule) the R3 repository.
    3. Move the src/R3.Godot/addons/R3.Godot directory into your Godot project.
    4. Enable the R3.Godot plugin from the Godot Plugins menu.

    Godot Providers

    • GodotTimeProvider.Process / GodotTimeProvider.PhysicsProcess
    • GodotFrameProvider.Process / GodotFrameProvider.PhysicsProcess

    An autoloaded FrameProviderDispatcher sets GodotTimeProvider.Process and GodotFrameProvider.Process as the defaults.

    using Godot;
    using R3;
    using System;
    
    public partial class Node2D : Godot.Node2D
    {
        IDisposable subscription;
    
        public override void _Ready()
        {
            subscription = Observable.EveryUpdate()
                .ThrottleLastFrame(10)
                .Subscribe(x =>
                {
                    GD.Print($"Observable.EveryUpdate: {GodotFrameProvider.Process.GetFrameCount()}");
                });
        }
    
        public override void _ExitTree()
        {
            subscription?.Dispose();
        }
    }
  9. Install R3 in Unity

    main

    To use R3 in Unity, follow these two steps:

    1. Install the core R3 package via NuGet: Open the NuGet Package Manager in your environment, search for "R3", and install it.

      • Note: If you encounter version conflict errors, disable version validation in Unity: Edit -> Project Settings -> Player -> Other Settings -> Configuration -> Uncheck "Assembly Version Validation".
    2. Install the R3.Unity package via Git URL: Add the following URL to your Unity Package Manager to reference the Unity-specific assets: https://github.com/Cysharp/R3.git?path=src/R3.Unity/Assets/R3.Unity

    You can specify a specific version using the # syntax, for example: https://github.com/Cysharp/R3.git?path=src/R3.Unity/Assets/R3.Unity#1.0.0.

    https://github.com/Cysharp/R3.git?path=src/R3.Unity/Assets/R3.Unity
  10. Setup R3 for WPF

    main

    To use R3 with WPF, install the R3Extensions.WPF package.

    To automatically replace the default TimeProvider and FrameProvider with WpfDispatcherTimeProvider and WpfRenderingFrameProvider (which allows time-based operations to run on the UI thread without manual ObserveOn calls), call WpfProviderInitializer.SetDefaultObservableSystem during application startup. You must provide an unhandled exception handler.

    // Install-Package R3Extensions.WPF
    
    public partial class App : Application
    {
        protected override void OnStartup(StartupEventArgs e)
        {
            // Replaces default providers with WPF-optimized ones
            WpfProviderInitializer.SetDefaultObservableSystem(ex => Trace.WriteLine($"R3 UnhandledException:{ex}"));
        }
    }
  11. Use Microsoft.Bcl.TimeProvider for time abstraction

    main

    Use Microsoft.Bcl.TimeProvider to provide time abstraction in applications targeting .NET 7 or earlier, or .NET Framework. For applications targeting .NET 8 and newer, this package is unnecessary as the types are built into the platform.

    Key types include:

    • TimeProvider: The base abstraction for time-related operations.
    • TimeProviderTaskExtensions: Extension methods for tasks involving time.
  12. Use BindableReactiveProperty for XAML Platforms

    main

    For XAML-based platforms (like WPF), BindableReactiveProperty<T> allows you to bind observable properties to views. It implements INotifyPropertyChanged and INotifyDataErrorInfo.

    To use it:

    1. Expose it via new BindableReactiveProperty<T>() or convert an observable using .ToBindableReactiveProperty(initialValue).
    2. In XAML, bind to the .Value property.
    3. Use IReadOnlyBindableReactiveProperty<T> when read-only access is required in bindings.
    public class BasicUsagesViewModel : IDisposable
    {
        public BindableReactiveProperty<string> Input { get; }
        public BindableReactiveProperty<string> Output { get; }
    
        public BasicUsagesViewModel()
        {
            Input = new BindableReactiveProperty<string>("");
            Output = Input.Select(x => x.ToUpper()).ToBindableReactiveProperty("");
        }
    
        public void Dispose()
        {
            Disposable.Dispose(Input, Output);
        }
    }