User interfaces of modern enterprise applications are quite complex. You, as a developer, often need to implement in-app navigation, validate user input, show or hide screens based on user preferences. For better UX, your app should be capable of saving state to the disk when the app is suspending and of restoring state when the app is resuming.
ReactiveUI provides facilities allowing you to persist application state by serializing the view model tree when the app is shutting down or suspending. Suspension events vary per platform. ReactiveUI uses the Exit
event for WPF, ActivityPaused
for Xamarin.Android, DidEnterBackground
for Xamarin.iOS, OnLaunched
for UWP.
In this tutorial we are going to build a sample application which demonstrates the use of the ReactiveUI Suspension feature with Avalonia — a cross-platform .NET Core XAML-based GUI framework. You are expected to be familiar with the MVVM pattern and with reactive extensions before reading this note. Steps described in the tutorial should work if you are using Windows 10 or Ubuntu 18 and have .NET Core SDK installed. Let's get started!
Bootstrapping the Project
To see ReactiveUI routing in action, we create a new .NET Core project based on Avalonia application templates. Then we install the Avalonia.ReactiveUI
package. The package provides platform-specific Avalonia lifecycle hooks, routing and activation infrastructure. Remember to install .NET Core and git before executing the commands below.
git clone https://github.com/AvaloniaUI/avalonia-dotnet-templates
git --git-dir ./avalonia-dotnet-templates/.git checkout 9263c6b
dotnet new --install ./avalonia-dotnet-templates
dotnet new avalonia.app -o ReactiveUI.Samples.Suspension
cd ./ReactiveUI.Samples.Suspension
dotnet add package Avalonia.ReactiveUI
dotnet add package Avalonia.Desktop
dotnet add package Avalonia
Let's run the app and ensure it shows a window displaying "Welcome to Avalonia!"
dotnet run --framework netcoreapp2.1
Installing Avalonia Preview Builds from MyGet
Latest Avalonia packages are published to MyGet each time a new commit is pushed to the master
branch of the Avalonia repository on GitHub. To use the latest packages from MyGet in our app, we are going to create a nuget.config
file. But before doing this, we generate an sln
file for the project created earlier, using .NET Core CLI:
dotnet new sln
dotnet sln ReactiveUI.Samples.Suspension.sln add ReactiveUI.Samples.Suspension.csproj
Now we create the nuget.config
file with the following content:
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<add key="AvaloniaCI" value="https://www.myget.org/F/avalonia-ci/api/v2" />
</packageSources>
</configuration>
Usually, a restart is required to force our IDE to detect packages from the newly added MyGet feed, but reloading the solution should help as well. Then, we upgrade Avalonia packages to the most recent version (or at least to 0.8.1-cibuild0003100-beta
) using the NuGet package manager GUI, or .NET Core CLI:
dotnet add package Avalonia.ReactiveUI --version 0.8.1-cibuild0003100-beta
dotnet add package Avalonia.Desktop --version 0.8.1-cibuild0003100-beta
dotnet add package Avalonia --version 0.8.1-cibuild0003100-beta
We create two new folders inside the project root directory, named Views/
and ViewModels/
respectively. Next, we rename the MainWindow
class to MainView
and move it to the Views/
folder. Remember to rename references to the edited class in the corresponding XAML file, otherwise, the project won't compile. Also, remember to change the namespace for MainView
to ReactiveUI.Samples.Suspension.Views
for consistency. Then, we edit two other files, Program.cs
and App.xaml.cs
. We add a call to UseReactiveUI
to the Avalonia app builder, move the app initialization code to the OnFrameworkInitializationCompleted
method to conform Avalonia application lifetime management guidelines:
Program.cs
class Program
{
// The entry point. Things aren't ready yet, so at this point
// you shouldn't use any Avalonia types or anything that
// expects a SynchronizationContext to be ready.
public static void Main(string[] args)
=> BuildAvaloniaApp()
.StartWithClassicDesktopLifetime(args);
// This method is required for IDE previewer infrastructure.
// Don't remove, otherwise, the visual designer will break.
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure<App>()
.UseReactiveUI() // required!
.UsePlatformDetect()
.LogToDebug();
}
App.xaml.cs
public class App : Application
{
public override void Initialize() => AvaloniaXamlLoader.Load(this);
// The entrypoint for your application. Here you initialize your
// MVVM framework, DI container and other components. You can use
// the ApplicationLifetime property here to detect if the app
// is running on a desktop platform or on a mobile platform (WIP).
public override void OnFrameworkInitializationCompleted()
{
new Views.MainView().Show();
base.OnFrameworkInitializationCompleted();
}
}
Before attempting to build the project, we ensure the using Avalonia.ReactiveUI
directive is added to the top of the Program.cs
file. Most likely our IDE has already imported that namespace, but if it didn't, we'll get a compile-time error. Finally, it's time to ensure the app compiles, runs, and shows up a new window:
dotnet run --framework netcoreapp2.1
Cross-platform ReactiveUI Routing
There are two general approaches of organizing in-app navigation in a cross-platform .NET app — view-first and view model-first. The former approach assumes that the View layer manages the navigation stack — for example, using platform-specific Frame and Page classes. With the latter approach, the view model layer takes care of navigation via a platform-agnostic abstraction. ReactiveUI tooling is built keeping the view model-first approach in mind. ReactiveUI routing consists of an IScreen
implementation, which contains the current routing state, several IRoutableViewModel
implementations and a platform-specific XAML control called RoutedViewHost
.
The RoutingState
object encapsulates navigation stack management. IScreen
is the navigation root, but despite the name, it doesn't have to occupy the whole screen. RoutedViewHost
reacts to changes in the bound RoutingState
and embeds the appropriate view for the currently selected IRoutableViewModel
. The described functionality will be illustrated with more comprehensive examples later.
Persisting View Model State
Consider a search screen view model as an example.
We are going to decide, which properties of the view model to save on application shutdown and which ones to recreate. There is no need to save the state of a reactive command which implements the ICommand
interface. ReactiveCommand<TIn, TOut>
class is typically initialized in the constructor, its CanExecute
indicator usually fully depends on view model properties and gets recalculated each time any of those properties change. It's debatable if you were to keep the search results, but saving the search query is a good idea.
ViewModels/SearchViewModel.cs
[DataContract]
public class SearchViewModel : ReactiveObject, IRoutableViewModel
{
private readonly ReactiveCommand<Unit, Unit> _search;
private string _searchQuery;
// We inject the IScreen implementation via the constructor.
// If we receive null, we use Splat.Locator to resolve the
// default implementation. The parameterless constructor is
// required for the deserialization feature to work.
public SearchViewModel(IScreen screen = null)
{
HostScreen = screen ?? Locator.Current.GetService<IScreen>();
// Each time the search query changes, we check if the search
// query is empty. If it is, we disable the command.
var canSearch = this
.WhenAnyValue(x => x.SearchQuery)
.Select(query => !string.IsNullOrWhiteSpace(query));
// Buttons bound to the command will stay disabled
// as long as the command stays disabled.
_search = ReactiveCommand.CreateFromTask(
() => Task.Delay(1000), // emulate a long-running operation
canSearch);
}
public IScreen HostScreen { get; }
public string UrlPathSegment => "/search";
public ICommand Search => _search;
[DataMember]
public string SearchQuery
{
get => _searchQuery;
set => this.RaiseAndSetIfChanged(ref _searchQuery, value);
}
}
We mark the entire view model class with the [DataContract]
attribute, annotate properties we are going to serialize with the [DataMember]
attribute. This is enough if we are going to use opt-in serialization mode. Considering serialization modes, opt-out means that all public fields and properties will be serialized, unless you explicitly ignore them by annotating with the [IgnoreDataMember]
attribute, opt-in means the opposite. Additionally, we implement the IRoutableViewModel
interface in our view model class. This is required while we are going to use the view model as a part of a navigation stack.
ViewModels/LoginViewModel.cs
[DataContract]
public class LoginViewModel : ReactiveObject, IRoutableViewModel
{
private readonly ReactiveCommand<Unit, Unit> _login;
private string _password;
private string _username;
// We inject the IScreen implementation via the constructor.
// If we receive null, we use Splat.Locator to resolve the
// default implementation. The parameterless constructor is
// required for the deserialization feature to work.
public LoginViewModel(IScreen screen = null)
{
HostScreen = screen ?? Locator.Current.GetService<IScreen>();
// When any of the specified properties change,
// we check if user input is valid.
var canLogin = this
.WhenAnyValue(
x => x.Username,
x => x.Password,
(user, pass) => !string.IsNullOrWhiteSpace(user) &&
!string.IsNullOrWhiteSpace(pass));
// Buttons bound to the command will stay disabled
// as long as the command stays disabled.
_login = ReactiveCommand.CreateFromTask(
() => Task.Delay(1000), // emulate a long-running operation
canLogin);
}
public IScreen HostScreen { get; }
public string UrlPathSegment => "/login";
public ICommand Login => _login;
[DataMember]
public string Username
{
get => _username;
set => this.RaiseAndSetIfChanged(ref _username, value);
}
// Note: Saving passwords to disk isn't a good idea.
public string Password
{
get => _password;
set => this.RaiseAndSetIfChanged(ref _password, value);
}
}
The two view models implement the IRoutableViewModel
interface and are ready to be embedded into a navigation screen. Now it's time to implement the IScreen
interface. Again, we use [DataContract]
attributes to indicate which parts to serialize and which ones to ignore. In the example below, the RoutingState
property setter is deliberately declared as public — this allows our serializer to modify the property when it gets deserialized.
ViewModels/MainViewModel.cs
[DataContract]
public class MainViewModel : ReactiveObject, IScreen
{
private readonly ReactiveCommand<Unit, Unit> _search;
private readonly ReactiveCommand<Unit, Unit> _login;
private RoutingState _router = new RoutingState();
public MainViewModel()
{
// If the authorization page is currently shown, then
// we disable the "Open authorization view" button.
var canLogin = this
.WhenAnyObservable(x => x.Router.CurrentViewModel)
.Select(current => !(current is LoginViewModel));
_login = ReactiveCommand.Create(
() => { Router.Navigate.Execute(new LoginViewModel()); },
canLogin);
// If the search screen is currently shown, then we
// disable the "Open search view" button.
var canSearch = this
.WhenAnyObservable(x => x.Router.CurrentViewModel)
.Select(current => !(current is SearchViewModel));
_search = ReactiveCommand.Create(
() => { Router.Navigate.Execute(new SearchViewModel()); },
canSearch);
}
[DataMember]
public RoutingState Router
{
get => _router;
set => this.RaiseAndSetIfChanged(ref _router, value);
}
public ICommand Search => _search;
public ICommand Login => _login;
}
In our main view model, we save only one field to the disk — the one of type RoutingState
. We don't have to save the state of reactive commands, as their availability fully depends on the current state of the router and reactively changes. To be able to restore the router to the exact state it was in, we include extended type information of our IRoutableViewModel
implementations when serializing the router. We will use TypenameHandling.All
setting of Newtonsoft.Json to achieve this later. We put the MainViewModel
into the ViewModels/
folder, adjust the namespace to be ReactiveUI.Samples.Suspension.ViewModels
.
Routing in an Avalonia App
For the moment, we've implemented the presentation model of our application. Later, the view model classes could be extracted into a separate assembly targeting .NET Standard, so the core part of our app could be reused across multiple .NET GUI frameworks. Now it's time to implement the Avalonia-specific GUI part of our app. We create two files in the Views/
folder, named SearchView.xaml
and SearchView.xaml.cs
respectively. These are the two parts of a single search view — the former one is the UI described declaratively in XAML, and the latter one contains C# code-behind. This is essentially the view for the search view model created earlier.
The XAML dialect used in Avalonia should feel immediately familiar for developers coming from WPF, UWP or XF. In the example above we create a simple layout containing a text box and a button which triggers the search. We bind properties and commands from the SearchViewModel
to controls declared in the SearchView
.
Views/SearchView.xaml
<UserControl
xmlns="https://github.com/avaloniaui"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
d:DataContext="{d:DesignInstance viewModels:SearchViewModel}"
xmlns:viewModels="clr-namespace:ReactiveUI.Samples.Suspension.ViewModels"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="ReactiveUI.Samples.Suspension.Views.SearchView"
xmlns:reactiveUi="http://reactiveui.net"
mc:Ignorable="d">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="48" />
<RowDefinition Height="48" />
<RowDefinition Height="48" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="Search view" Margin="5" />
<TextBox Grid.Row="1" Text="{Binding SearchQuery, Mode=TwoWay}" />
<Button Grid.Row="2" Content="Search" Command="{Binding Search}" />
</Grid>
</UserControl>
Views/SearchView.xaml.cs
public sealed class SearchView : ReactiveUserControl<SearchViewModel>
{
public SearchView()
{
// The call to WhenActivated is used to execute a block of code
// when the corresponding view model is activated and deactivated.
this.WhenActivated((CompositeDisposable disposable) => { });
AvaloniaXamlLoader.Load(this);
}
}
WPF and UWP developers may find code-behind for the SearchView.xaml
file familiar as well. A call to WhenActivated
is added to execute view activation logic. The disposable coming as the first argument for WhenActivated
is disposed when the view is deactivated. If your application is using hot observables (e.g. positioning services, timers, event aggregators), it'd be a wise decision to attach the subscriptions to the WhenActivated
composite disposable by adding a DisposeWith
call, so the view will unsubscribe from those hot observables and memory leaks won't take place.
Views/LoginView.xaml
<UserControl
xmlns="https://github.com/avaloniaui"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
d:DataContext="{d:DesignInstance viewModels:LoginViewModel, IsDesignTimeCreatable=True}"
xmlns:viewModels="clr-namespace:ReactiveUI.Samples.Suspension.ViewModels"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="ReactiveUI.Samples.Suspension.Views.LoginView"
xmlns:reactiveUi="http://reactiveui.net"
mc:Ignorable="d">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="48" />
<RowDefinition Height="48" />
<RowDefinition Height="48" />
<RowDefinition Height="48" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="Login view" Margin="5" />
<TextBox Grid.Row="1" Text="{Binding Username, Mode=TwoWay}" />
<TextBox Grid.Row="2" PasswordChar="*"
Text="{Binding Password, Mode=TwoWay}" />
<Button Grid.Row="3" Content="Login" Command="{Binding Login}" />
</Grid>
</UserControl>
Views/LoginView.xaml.cs
public sealed class LoginView : ReactiveUserControl<LoginViewModel>
{
public LoginView()
{
this.WhenActivated(disposables => { });
AvaloniaXamlLoader.Load(this);
}
}
We edit the Views/MainView.xaml
and Views/MainView.xaml.cs
files. We add the RoutedViewHost
control from Avalonia.ReactiveUI
namespace to the main window XAML layout and bind the Router
property of MainViewModel
to the RoutedViewHost.Router
property. We add two buttons, one opens the search page and another one opens the authorization page.
Views/MainView.xaml
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="ReactiveUI.Samples.Suspension.Views.MainView"
xmlns:reactiveUi="http://reactiveui.net"
Title="ReactiveUI.Samples.Suspension">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="48" />
</Grid.RowDefinitions>
<!-- The RoutedViewHost XAML control observes the bound RoutingState.
It subscribes to changes in the navigation stack and embedds
the appropriate view for the currently selected view model. -->
<reactiveUi:RoutedViewHost Grid.Row="0" Router="{Binding Router}">
<reactiveUi:RoutedViewHost.DefaultContent>
<TextBlock Text="Default Content" />
</reactiveUi:RoutedViewHost.DefaultContent>
</reactiveUi:RoutedViewHost>
<Grid Grid.Row="1">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Button Grid.Column="0"
Command="{Binding Search}"
Content="Search" />
<Button Grid.Column="1"
Command="{Binding Login}"
Content="Login" />
<Button Grid.Column="2"
Command="{Binding Router.NavigateBack}"
Content="Back" />
</Grid>
</Grid>
</Window>
Views/MainView.xaml.cs
public sealed class MainView : ReactiveWindow<MainViewModel>
{
public MainView()
{
this.WhenActivated(disposables => { });
AvaloniaXamlLoader.Load(this);
}
}
A simple Avalonia and ReactiveUI routing demo app is ready now. When a user presses the search or login buttons, a command which triggers navigation is invoked and the RoutingState
gets updated. The RoutedViewHost
XAML control observes the routing state, attempts to resolve the appropriate IViewFor<TViewModel>
implementation from Locator.Current
. If an IViewFor<TViewModel>
implementation is registered, then a new instance of the control is created and embedded into the Avalonia window.
We register our IViewFor
and IScreen
implementations in the App.OnFrameworkInitializationCompleted
method, using Locator.CurrentMutable
. Registering IViewFor
implementations is required for RoutedViewHost
control to work. Registering an IScreen
allows our SearchViewModel
and LoginViewModel
to property initialize during deserialization, using the parameterless constructor.
App.xaml.cs
public override void OnFrameworkInitializationCompleted()
{
// Here we register our view models.
Locator.CurrentMutable.RegisterConstant<IScreen>(new MainViewModel());
Locator.CurrentMutable.Register<IViewFor<SearchViewModel>>(() => new SearchView());
Locator.CurrentMutable.Register<IViewFor<LoginViewModel>>(() => new LoginView());
// Here we resolve the root view model and initialize main view data context.
new MainView { DataContext = Locator.Current.GetService<IScreen>() }.Show();
base.OnFrameworkInitializationCompleted();
}
Let's launch our application and ensure routing performs as it should. If something goes wrong with the XAML UI markup, Avalonia XamlIl compiler will notify us about any errors at compile time. Moreover, XamlIl supports debugging XAML!
dotnet run --framework netcoreapp2.1
Saving and Restoring Application State
Now it's time to implement the suspension driver responsible for saving and restoring app state when the app is suspending and resuming. The platform-specific AutoSuspendHelper
class takes care of initializing things, you, as a developer, only need to create an instance of it in the app composition root. Also, you need to initialize the RxApp.SuspensionHost.CreateNewAppState
factory. If the app has no saved data, or if the saved data is corrupt, ReactiveUI invokes that factory method to create a default instance of the application state object.
Then, we invoke the RxApp.SuspensionHost.SetupDefaultSuspendResume
method, and pass a new instance of ISuspensionDriver
to it. Let's implement the ISuspensionDriver
interface using Newtonsoft.Json and classes from the System.IO
namespace.
dotnet add package Newtonsoft.Json
Drivers/NewtonsoftJsonSuspensionDriver.cs
public class NewtonsoftJsonSuspensionDriver : ISuspensionDriver
{
private readonly string _file;
private readonly JsonSerializerSettings _settings = new JsonSerializerSettings
{
TypeNameHandling = TypeNameHandling.All
};
public NewtonsoftJsonSuspensionDriver(string file) => _file = file;
public IObservable<Unit> InvalidateState()
{
if (File.Exists(_file))
File.Delete(_file);
return Observable.Return(Unit.Default);
}
public IObservable<object> LoadState()
{
var lines = File.ReadAllText(_file);
var state = JsonConvert.DeserializeObject<object>(lines, _settings);
return Observable.Return(state);
}
public IObservable<Unit> SaveState(object state)
{
var lines = JsonConvert.SerializeObject(state, _settings);
File.WriteAllText(_file, lines);
return Observable.Return(Unit.Default);
}
}
The described approach has a drawback — some System.IO
classes won't work with Universal Windows Platform. That's rather easy to resolve — all you need to do is to use StorageFile
and StorageFolder
classes instead of File
and Directory
. To read navigation stack from disk, a suspension driver should support deserializing JSON objects into concrete IRoutableViewModel
implementations, that's why we use the TypeNameHandling.All
Newtonsoft.Json serializer setting. We register the suspension driver in the app composition root, in the App.OnFrameworkInitializationCompleted
method:
public override void OnFrameworkInitializationCompleted()
{
// Initialize suspension hooks.
var suspension = new AutoSuspendHelper(ApplicationLifetime);
RxApp.SuspensionHost.CreateNewAppState = () => new MainViewModel();
RxApp.SuspensionHost.SetupDefaultSuspendResume(new NewtonsoftJsonSuspensionDriver("appstate.json"));
suspension.OnFrameworkInitializationCompleted();
// Read main view model state from disk.
var state = RxApp.SuspensionHost.GetAppState<MainViewModel>();
Locator.CurrentMutable.RegisterConstant<IScreen>(state);
// Register views.
Locator.CurrentMutable.Register<IViewFor<SearchViewModel>>(() => new SearchView());
Locator.CurrentMutable.Register<IViewFor<LoginViewModel>>(() => new LoginView());
// Show the main window.
new MainView { DataContext = Locator.Current.GetService<IScreen>() }.Show();
base.OnFrameworkInitializationCompleted();
}
The AutoSuspendHelper
class from the Avalonia.ReactiveUI
package sets up lifecycle hooks for your application, so the ReactiveUI framework will be aware of when to write application state to disk, using the provided ISuspensionDriver
implementation. After we launch our application, the suspension driver will create a new JSON file named appstate.json
. After we make changes in the UI (e.g. type somewhat into the text fields, or click any button) and then close the app, the appstate.json
file will look similar to the following:
Note, that each JSON object in the file contains a $type
key with a fully qualified type name, including namespace.
{
"$type": "ReactiveUI.Samples.Suspension.ViewModels.MainViewModel, ReactiveUI.Samples.Suspension",
"Router": {
"$type": "ReactiveUI.RoutingState, ReactiveUI",
"_navigationStack": {
"$type": "System.Collections.ObjectModel.ObservableCollection`1[[ReactiveUI.IRoutableViewModel, ReactiveUI]], System.ObjectModel",
"$values": [
{
"$type": "ReactiveUI.Samples.Suspension.ViewModels.SearchViewModel, ReactiveUI.Samples.Suspension",
"SearchQuery": "funny cats"
},
{
"$type": "ReactiveUI.Samples.Suspension.ViewModels.LoginViewModel, ReactiveUI.Samples.Suspension",
"Username": "worldbeater"
}
]
}
}
}
If you close the app and then launch it again, you'll see the same content on the screen as you've seen before! The described functionality works on each platform supported by ReactiveUI, including UWP, WPF, Xamarin Forms or Xamarin Native.
Bonus: The ISuspensionDriver
interface can be implemented using Akavache — an asynchronous, persistent key-value store. If you store your data in either the UserAccount
section or the Secure
section, then on iOS and UWP your data will be backed up automatically to the cloud and will be available across all devices on which the app is installed. Also, a BundleSuspensionDriver
exists in the ReactiveUI.AndroidSupport
package. Xamarin.Essentials SecureStorage APIs could be used to store data as well. You can also store your app state on a remote server or in a platform-independent cloud service.
Useful Links
- Source code of the described app is available on GitHub.
- See Avalonia documentation to start learning the framework.
- Read ReactiveUI Handbook to discover more awesome features.