Using ReactiveUI with .Net MAUI to create offline capable apps

Using a Reactive Programming approach

Introduction

Reactive programming is prevalent in native mobile app development. It allows us to write apps declaratively, making the code easier to reason about and understand.

With .Net MAUI, we can use ReactiveUI to create our apps using a reactive programming style.

I have used ReactiveUI with .Net Xamarin and MAUI mobile apps for several years; the framework is both mature and comprehensive. In this article, I present an example of using several ReactiveUI features to solve the issue of providing a great user experience when your app is offline.

Demo App

John O’Reilly’s People In Space Kotlin Multiplatform demo app inspired the creation of a demo app that displays a list of astronauts and shows a detailed view when tapped. You can find it in this GitHub repo.

I’ve used the SpaceX Rest API to provide the data via the Crew endpoint. The app uses Shell navigation and monitors internet connectivity using the Reactive Extensions to convert from .Net Event handling to an Observable event stream using the Observable.FromEventPattern.

I have taken some of the patterns we successfully used in production and created a demo app using .Net MAUI. The libraries used in the demo include:-

The following libraries all fall under the ReactiveUI umbrella:-

  • ReactiveUI – A Reactive framework built on top of Rx
  • Refit – Retrofit inspired automatic Rest library
  • Akavache – Asynchronous key-value store built on SQLite
  • DynamicData – Reactive collections with Rx

Why ReactiveUI?

Mobile applications are inherently reactive, constantly responding to events such as network connections, data flow and requests via REST APIs, user interactions, and more. Managing these events efficiently and effectively is crucial for creating a responsive and robust app. ReactiveUI provides a powerful framework that enables developers to write their applications in a reactive, declarative manner, simplifying the management of asynchronous and event-driven programming.

In native mobile app development, developers can write in a reactive programming style using RxJava and, more recently, Coroutines and Flow on Android. On iOS, developers used RxSwift and, latterly, the Combine framework with SwiftUI.

Reactive programming appears less commonly adopted in the .NET ecosystem because it can have quite a steep learning curve.

ReactiveUI is built on the Rx Extensions and is a mature framework, bringing the benefits of reactive programming to .NET MAUI. By leveraging ReactiveUI, developers can harness the full potential of reactive extensions, making their .NET MAUI applications more maintainable, testable, and responsive to real-time changes.

ReactiveUI is compatible with MAUI, Uno Platform, and Avalonia, enabling it to utilize the patterns and much of the code presented across these .NET platforms.

Design Overview

The app employs the MVVM design pattern within a Shell. Its main page displays a list of astronauts and allows users to navigate to a detail view when an astronaut is tapped.

A repository uses Akavache to cache the data in an SQLite database, which is loaded when the main view is displayed or a pull-to-refresh is initiated.

We monitor the network connection status and notify the user of any changes in the connection status.

Code Walkthrough

The app uses the MVVM design pattern and incorporates ReactiveUI.Fody annotations to reduce boilerplate code.

The ViewModel implements the IActivatableViewModel interface, and the lifetimes of the View and ViewModel are aligned so that resources are disposed of correctly. We use the [ObservableAsProperty] annotation for the IsRefreshing property, which is bound to the IsBusy property of the Repository via a WhenAnyValue. The RefreshView’s IsRefreshing is then bound to the ViewModels IsRefreshing property.

We pass an ISchedulerProvider as a constructor parameter of the ViewModel, which allows us to control the scheduler in testing and perform ‘time travel’ operations.

I updated the code on 27/06/24 to add a CrewModelComparer so the images don’t flash on pull to refresh.

public class MainPageViewModel : ReactiveObject, IActivatableViewModel
{
    private readonly ISchedulerProvider _schedulerProvider;
    private readonly ICrewRepository _crewRepository;
    private readonly INavigationService _navigationService;
    private readonly IUserAlerts _userAlerts;
    
    [Reactive]
    public string PageTitle { get; set; }
    
    [ObservableAsProperty]
    public bool IsRefreshing { get; }
    
    public ReactiveCommand<bool, ICollection?> LoadCommand { get; }
    
    public ReactiveCommand<CrewModel, Unit> NavigateToDetailCommand { get; private set; }
    
    private ReadOnlyObservableCollection _crew;

    public ReadOnlyObservableCollection Crew
    {
        get => _crew;
        set => this.RaiseAndSetIfChanged(ref _crew, value);
    }

    private static readonly Func<CrewModel, string> KeySelector = crew => crew.Id;
    private readonly SourceCache<CrewModel, string> _crewCache = new(KeySelector);
        
    private static readonly CrewModelComparer CrewComparer = new();
    
    public ViewModelActivator Activator { get; } = new();
    
    public MainPageViewModel(ISchedulerProvider schedulerProvider,
        ICrewRepository crewRepository,
        INavigationService navigationService,
        IUserAlerts userAlerts)
    {
        _schedulerProvider = schedulerProvider;
        _crewRepository = crewRepository;
        _navigationService = navigationService;
        _userAlerts = userAlerts;
        
        PageTitle = "People In Space MAUI";
        
        var crewSort = SortExpressionComparer
            .Ascending(c => c.Name);

        var crewSubscription = _crewCache.Connect()
            .Sort(crewSort)
            .Bind(out _crew)
            .ObserveOn(_schedulerProvider.MainThread)        
            .DisposeMany()                              
            .Subscribe();
        
        LoadCommand = ReactiveCommand.CreateFromObservable<bool, ICollection?>(
            forceRefresh =>  _crewRepository.GetCrew(forceRefresh),
            this.WhenAnyValue(x => x.IsRefreshing).Select(x => !x), 
            outputScheduler: _schedulerProvider.MainThread); 
        LoadCommand.ThrownExceptions.Subscribe(Crew_OnError);
        LoadCommand.Subscribe(Crew_OnNext);
        
        NavigateToDetailCommand = ReactiveCommand.Create(NavigateToDetail);
        
        this.WhenActivated(disposables =>
        {
            this.WhenAnyValue(x => x._crewRepository.IsBusy)
                .ObserveOn(_schedulerProvider.MainThread)
                .ToPropertyEx(this, x => x.IsRefreshing, scheduler: _schedulerProvider.MainThread)
                .DisposeWith(disposables);
            
            disposables.Add(crewSubscription);
        });
    }
    
    private void Crew_OnNext(ICollection? crew)
    {
        try
        {
            if (crew != null) UpdateCrew(crew);
        }
        catch (Exception exception)
        {
            Crew_OnError(exception);
        }
    }
    
    private void UpdateCrew(ICollection crew)
    {
        _crewCache.Edit(innerCache =>
        {
            innerCache.AddOrUpdate(crew, CrewComparer);
        });
    }

    private void Crew_OnError(Exception e)
    {
        _userAlerts.ShowToast(e.Message).FireAndForgetSafeAsync();
    }
    
    private void NavigateToDetail(CrewModel crewMember)
    {
        var name = Uri.EscapeDataString(crewMember.Name);
        var image = Uri.EscapeDataString(crewMember.Image.ToString());
        var wikipedia = Uri.EscapeDataString(crewMember.Wikipedia.ToString());
        
        var route = $"{Routes.DetailPage}?name={name}&image={image}&wikipedia={wikipedia}";
        _navigationService.NavigateAsync(route);
    }
}

The ViewModel surfaces errors which may occur when obtaining the data via a Toast.

private void Crew_OnError(Exception e)
{
    _userAlerts.ShowToast(e.Message).FireAndForgetSafeAsync();
}

The CrewModel looks like this:

public partial class CrewModel
{
    [JsonProperty("name")]
    public string Name { get; set; }

    [JsonProperty("agency")]
    public string Agency { get; set; }

    [JsonProperty("image")]
    public Uri Image { get; set; }

    [JsonProperty("wikipedia")]
    public Uri Wikipedia { get; set; }

    [JsonProperty("launches")]
    public string[] Launches { get; set; }

    [JsonProperty("status")]
    public Status Status { get; set; }

    [JsonProperty("id")]
    public string Id { get; set; }
}

public enum Status { Active, Inactive, Retired, Unknown };

public partial class CrewModel
{
    public static CrewModel[] FromJson(string json) => JsonConvert.DeserializeObject<CrewModel[]>(json, Converter.Settings);
}

internal static class Converter
{
    public static readonly JsonSerializerSettings Settings = new JsonSerializerSettings
    {
        MetadataPropertyHandling = MetadataPropertyHandling.Ignore,
        DateParseHandling = DateParseHandling.None,
        Converters =
        {
            StatusConverter.Singleton,
            new IsoDateTimeConverter { DateTimeStyles = DateTimeStyles.AssumeUniversal }
        },
    };
}

internal class StatusConverter : JsonConverter
{
    public override bool CanConvert(Type t) => t == typeof(Status) || t == typeof(Status?);

    public override object ReadJson(JsonReader reader, Type t, object existingValue, JsonSerializer serializer)
    {
        if (reader.TokenType == JsonToken.Null) return null;
        var value = serializer.Deserialize(reader);
        if (value == "active")
        {
            return Status.Active;
        }
        throw new Exception("Cannot unmarshal type Status");
    }

    public override void WriteJson(JsonWriter writer, object untypedValue, JsonSerializer serializer)
    {
        if (untypedValue == null)
        {
            serializer.Serialize(writer, null);
            return;
        }
        var value = (Status)untypedValue;
        if (value == Status.Active)
        {
            serializer.Serialize(writer, "active");
            return;
        }
        throw new Exception("Cannot marshal type Status");
    }

    public static readonly StatusConverter Singleton = new StatusConverter();
}

The CrewModelComparer is used by the DynamicData SourceCache to determine if the item should be added or updated:

public class CrewModelComparer : IEqualityComparer
{
    public bool Equals(CrewModel? x, CrewModel? y)
    {
        if (x == null && y == null) return true;
        if (x == null || y == null) return false;

        return x.Id == y.Id && 
               x.Name == y.Name && 
               x.Agency == y.Agency && 
               x.Image == y.Image && 
               x.Wikipedia == y.Wikipedia &&
               x.Launches.SequenceEqual(y.Launches) &&
               x.Status == y.Status;
    }

    public int GetHashCode(CrewModel? obj)
    {
        if (obj == null) return 0;

        var hashCode = obj.Id.GetHashCode();
        hashCode = (hashCode * 397) ^ obj.Name.GetHashCode();
        hashCode = (hashCode * 397) ^ obj.Agency.GetHashCode();
        hashCode = (hashCode * 397) ^ obj.Image.GetHashCode();
        hashCode = (hashCode * 397) ^ obj.Wikipedia.GetHashCode();
        hashCode = (hashCode * 397) ^ obj.Launches.GetHashCode();
        hashCode = (hashCode * 397) ^ obj.Status.GetHashCode();

        return hashCode;
    }
}

The Repository uses Refit to generate a type-safe Rest client automatically and obtains data from the API. Akavache caches the API results, with the cache lifetime currently set to one day. The Repository feeds the API result into an Observable Stream, which updates the ViewModel using a DynamicData SourceCache supporting a ReadOnlyObservableCollection.

If we set the force-refresh flag to ‘False’, the Repository will return the cached data for subsequent requests until it expires. The force-refresh flag is controlled via a CommandParameter in the RefreshView Command binding, so this can be set to ‘True’ such that every pull-to-refresh triggers a call to the API.

Akavache provides other functions, such as GetandFetchLatest, which we could use if we always wanted to read from the cache initially and then call the API. If we make this change and breakpoint on the UpdateCrew function in the MainPageViewModel we will see this hit twice when the page loads, the first dataset obtained from the cache, the second from the API.

public class CrewRepository(
    ISchedulerProvider schedulerProvider,
    ISpaceXApi spaceXApi,
    IBlobCache cache)
    : ReactiveObject, ICrewRepository
{
    private const string CrewCacheKey = "crew_cache_key";
    private readonly TimeSpan _cacheLifetime = TimeSpan.FromDays(1);

    [Reactive]
    public bool IsBusy { get; set; }

    public IObservable<ICollection?> GetCrew(bool forceRefresh = false)
    {
        return Observable.Defer(() =>
        {
            IsBusy = true;
            if (forceRefresh)
            {
                return FetchAndCacheCrew();
            }

            DateTimeOffset? expiration = DateTimeOffset.Now + _cacheLifetime;
            return cache.GetOrFetchObject(CrewCacheKey,
                    fetchFunc: FetchAndCacheCrew,
                    absoluteExpiration: expiration)
                .Do(_ => IsBusy = false);
        }).SubscribeOn(schedulerProvider.ThreadPool);
    }

    private IObservable<ICollection> FetchAndCacheCrew()
    {
        return Observable.Create<ICollection>(async observer =>
        {
            try
            {
                var crewJson = await spaceXApi.GetAllCrew().ConfigureAwait(false);
                var crew = CrewModel.FromJson(crewJson).ToList();
                await cache.InsertObject(CrewCacheKey, crew, DateTimeOffset.Now + _cacheLifetime);
                observer.OnNext(crew);
                observer.OnCompleted();
            }
            catch (Exception ex)
            {
                observer.OnError(ex);
            }
            finally
            {
                IsBusy = false;
            }
        }).SubscribeOn(schedulerProvider.ThreadPool);
    }
}

The code-behind for the Main Page executes the ViewModels Load Command when the page is shown; we pass false as the command parameter so the data is fetched from the cache if valid. Please note the WhenActivated block is required, even though it is empty. Without this, the equivalent WhenActivated in the ViewModel won’t be triggered, and the IsRefreshing OAPH won’t be set, and as a result, the RefreshView’s spinner will spin forever after a pull to refresh.

public partial class MainPage : ReactiveUI.Maui.ReactiveContentPage
{
    public MainPage(MainPageViewModel viewModel)
    {
        BindingContext = viewModel;
        ViewModel = viewModel;
        
        InitializeComponent();
        
        this.WhenActivated(disposables =>
        {
        });
    }

    protected override void OnAppearing()
    {
        ViewModel?.LoadCommand.Execute(false).Subscribe();
        
        base.OnAppearing();
    }
}

We monitor the network connection status and use an ‘Observable. FromEventPattern’ to stream the events into an Observable. We then monitor this Observable and show a Snackbar on the UI when the network connectivity changes.

public interface INetworkStatusObserver: IDisposable
{
    void Start();
    IObservable ConnectivityNotifications { get; }
}

public class NetworkStatusObserver(IConnectivity connectivity) : INetworkStatusObserver
{
    private readonly Subject _connectivityNotifications = new();
    private IDisposable? _subscription;

    public IObservable ConnectivityNotifications => _connectivityNotifications;

    public void Start()
    {
        _subscription = Observable.FromEventPattern<EventHandler, ConnectivityChangedEventArgs>(
                handler => connectivity.ConnectivityChanged += handler,
                handler => connectivity.ConnectivityChanged -= handler)
            .Select(eventPattern => eventPattern.EventArgs.NetworkAccess)
            .Subscribe(_connectivityNotifications);
    }
    
    public void Dispose()
    {
        _subscription?.Dispose();
        _connectivityNotifications.Dispose();
    }
}

public partial class App : Application
{
    private readonly INetworkStatusObserver _networkStatusObserver;
    private readonly IUserAlerts _userAlerts;
    private readonly IConnectivity _connectivity;

    private readonly TimeSpan _snackbarDuration = TimeSpan.FromSeconds(3);
    
    private NetworkAccess? _currentNetworkAccess;
    
    public App(IServiceProvider serviceProvider)
    {
        InitializeComponent();
        
        _networkStatusObserver = serviceProvider.GetRequiredService();
        _userAlerts = serviceProvider.GetRequiredService();
        _connectivity = serviceProvider.GetRequiredService();
        
        _networkStatusObserver.Start();
        
        _networkStatusObserver.ConnectivityNotifications.Subscribe(networkAccess =>
        {
            if (_currentNetworkAccess == networkAccess) return;
            _currentNetworkAccess = networkAccess;
                
            _userAlerts.ShowSnackbar(networkAccess != NetworkAccess.Internet
                    ? "Internet access has been lost."
                    : "Internet access has been restored.", 
                _snackbarDuration).FireAndForgetSafeAsync();
        });
        
        MainPage = new AppShell();
    }

    private void CheckInitialNetworkStatus()
    {
        _currentNetworkAccess = _connectivity.NetworkAccess;
        if (_currentNetworkAccess != NetworkAccess.Internet)
        {
            _userAlerts.ShowSnackbar("No internet access.", _snackbarDuration)
                .FireAndForgetSafeAsync();
        }
    }
    
    protected override void OnStart()
    {
        CheckInitialNetworkStatus();
    }

    protected override void CleanUp()
    {
        _networkStatusObserver.Dispose();
    }
}

The Main Page view is a ReactiveContentPage and has a CollectionView hosted by a RefreshView, this snippet shows the CommandParameter, which we can change to observe the effects of bypassing the cache on pull-to-refresh:

<reactiveMaui:ReactiveContentPage 
    x:TypeArguments="viewModels:MainPageViewModel"
    xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
    xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
    xmlns:reactiveMaui="clr-namespace:ReactiveUI.Maui;assembly=ReactiveUI.Maui"
    xmlns:viewModels="clr-namespace:PeopleInSpaceMaui.ViewModels"
    xmlns:models="clr-namespace:PeopleInSpaceMaui.Models"
    xmlns:sys="clr-namespace:System;assembly=mscorlib"
    x:DataType="viewModels:MainPageViewModel"
    x:Class="PeopleInSpaceMaui.Views.MainPage"
    x:Name="MainPageView"
    Title="{Binding PageTitle}">
<RefreshView IsRefreshing="{Binding Path=IsRefreshing}"
             Command="{Binding LoadCommand}">
        <RefreshView.CommandParameter>
            <sys:Boolean>True</sys:Boolean>
        </RefreshView.CommandParameter>
        <ScrollView>
            <CollectionView ItemsSource="{Binding Crew}"
                            SelectionMode="None"
                            EmptyView="Please pull to refresh the view"
                            Margin="10">

The MauiProgram contains a handler for unhandled Rx Exceptions in addition to setup code for Akavache and Refit. Note also the use of the [autoRoutes] annotation to generate a Route for each Page via the ejp.RouteGenerator plugin. The code-behind for the AppShell registers a route for all of the Pages.

[AutoRoutes("Page")]
public static class MauiProgram
{
    public static MauiApp CreateMauiApp()
    {
        Akavache.Registrations.Start("PeopleInSpace");

        RxApp.DefaultExceptionHandler = new AnonymousObserver(ex =>
        {
            App.Current.MainPage.DisplayAlert("Error", ex.Message, "OK");
        });
        
        var builder = MauiApp.CreateBuilder();
        builder
            .UseMauiApp()
            .UseMauiCommunityToolkit()
            .ConfigureFonts(fonts =>
            {
                fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
                fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
            });
        
        builder.Services.AddSingleton(BlobCache.LocalMachine);
        
        builder.Services.AddTransient();
        builder.Services.AddScoped();
        builder.Services.AddTransient();
        builder.Services.AddScoped();
        builder.Services.AddSingleton<ICrewRepository, CrewRepository>();
        builder.Services.AddSingleton<ISchedulerProvider, SchedulerProvider>();
        builder.Services.AddSingleton<INavigationService, NavigationService>();
        builder.Services.AddSingleton<IUserAlerts, UserAlerts>();
        builder.Services.AddRefitClient().ConfigureHttpClient(c => c.BaseAddress = new Uri("https://api.spacexdata.com/v4"));
        builder.Services.AddSingleton(provider => Connectivity.Current);
        builder.Services.AddSingleton<INetworkStatusObserver, NetworkStatusObserver>();
        
#if DEBUG
        builder.Logging.AddDebug();
#endif

        return builder.Build();
    }
}

public partial class AppShell : Shell
{
    public AppShell()
    {
        InitializeComponent();
        
        foreach (var route in Routes.RouteTypeMap)
        {
            Routing.RegisterRoute(route.Key, route.Value);
        }

    }
}
}

Summary

I hope sharing this example pattern was helpful to you.

I have used this pattern in production apps over the last few years and found it really helped provide a quality, robust user experience.

If you are interested in discussing how we could help with .Net, Kotlin or KMP development, you can reach me, Richard Woollcott, at [email protected]