Saving Routing State to the Disk in a Cross-Platform .NET Core GUI App with ReactiveUI and Avalonia

Original author: @worldbeater
  • Translation
  • Tutorial

image


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

image


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

image


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.


Implementation details for login view model

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.


Implementation details for login view

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 the UWP framework, UWP apps run in a sandbox and do things differently. That's rather easy to resolve — all you need to do is to use StorageFile and StorageFolder classes instead of File and Directory when targeting UWP. 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:


appstate.json

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.


image


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.



Share post
AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 0

Only users with full accounts can post comments. Log in, please.