Роутинг в кроссплатформенном .NET приложении с сохранением состояния на диск на примере .NET Core, ReactiveUI и Avalonia

в 19:00, , рубрики: .net, avalonia, C#, cross-platform, dotnet, GUI, reactiveui, reactiveX, Программирование, Разработка под Linux, разработка под windows

Роутинг в кроссплатформенном .NET приложении с сохранением состояния на диск на примере .NET Core, ReactiveUI и Avalonia - 1

Пользовательские интерфейсы современных прикладных приложений, как правило, сложны — зачастую необходимо реализовывать поддержку постраничной навигации, обрабатывать разного рода поля ввода, на основе выбранных пользователем параметров отображать или скрывать информацию. При этом, для улучшения UX приложение должно сохранять состояние элементов интерфейса на диск при приостановке или выключении, восстанавливать состояние с диска при повторном запуске программы.

MVVM фреймворк ReactiveUI предлагает сохранять состояние приложения путём сериализации графа моделей представления в момент приостановки программы, при этом механизмы определения момента приостановки различаются для фреймворков и платформ. Так, для WPF используется событие Exit, для Xamarin.Android — ActivityPaused, для Xamarin.iOS — DidEnterBackground, для UWP — перегрузка OnLaunched.

В данном материале рассмотрим использование ReactiveUI для сохранения и восстановления состояния ПО с GUI, включая состояние роутера, на примере кроссплатформенного GUI фреймворка Avalonia. Материал предполагает наличие базовых представлений о шаблоне проектирования MVVM и о реактивном программировании в контексте языка C# и платформы .NET у читателя. Последовательность действий, описанная в статье, применима к ОС Windows 10 и Ubuntu 18.

Создание проекта

Чтобы попробовать роутинг в действии, создадим новый проект .NET Core из шаблона Avalonia, установим пакет Avalonia.ReactiveUI — тонкий слой интеграции Avalonia и ReactiveUI. Убедитесь, что перед началом работы у вас установлены .NET Core SDK и git.

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

Применим вызов UseReactiveUI к билдеру приложения Avalonia, расположенному в Program.cs. Создадим папки Views/ и ViewModels/ в корне проекта, изменим имя класса MainWindow на MainView для удобства, переместим его в каталог Views/, изменив пространства имён соответствующим образом — на ReactiveUI.Samples.Suspension.Views.

class Program
{
    // Код инициализации. Не вызывайте API фреймворка Avalonia и код, 
    // использующий SynchronizationContext, до вызова AppMain: приложение
    // ещё не проинициализировано и что-нибудь может пойти не так.
    public static void Main(string[] a) => BuildAvaloniaApp().Start(AppMain, a);

    // Конфигурация приложения Avalonia. Также используется визуальным 
    // дизайнером, переименовывать или удалять не рекомендуется.
    public static AppBuilder BuildAvaloniaApp()
        => AppBuilder.Configure<App>()
            .UseReactiveUI()
            .UsePlatformDetect()
            .LogToDebug();

    // Точка входа вашего приложения. Здесь вы можете проинициализировать 
    // ваш MVVM фреймворк, DI контейнер и прочие компоненты.
    private static void AppMain(Application app, string[] args)
    {
        app.Run(new Views.MainView());
    }
}

Убедимся, что приложение запускается и показывает окошко с надписью Welcome to Avalonia!

dotnet run --framework netcoreapp2.1

Подключение предварительных сборок Avalonia из MyGet

Для подключения и использования самых новых сборок Avalonia, автоматически публикуемых в MyGet при изменениях ветки master репозитория Avalonia на GitHub, используем файл конфигурации источников пакетов nuget.config. Чтобы IDE и .NET Core CLI увидели nuget.config, необходимо сгенерировать sln файл для созданного выше проекта. Воспользуемся средствами .NET Core CLI:

dotnet new sln
dotnet sln ReactiveUI.Samples.Suspension.sln add ReactiveUI.Samples.Suspension.csproj

Создадим файл nuget.config в папке с .sln-файлом следующего содержания:

<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <packageSources>
    <add key="Avalonia" value="https://www.myget.org/F/avalonia-ci/api/v2" />
  </packageSources>
</configuration>

Может потребоваться перезапуск IDE, либо выгрузка и загрузка решения целиком. Обновим пакеты Avalonia до нужной версии (как минимум 0.8.1-cibuild0002371-beta) с помощью интерфейса пакетного менеджера NuGet вашей IDE, либо с помощью инструментов командной строки Windows или терминала Linux:

dotnet add package Avalonia.ReactiveUI --version 0.8.1-cibuild0002371-beta
dotnet add package Avalonia.Desktop --version 0.8.1-cibuild0002371-beta
dotnet add package Avalonia --version 0.8.1-cibuild0002371-beta

Потребуется добавить using Avalonia.ReactiveUI в Program.cs. Убедимся, что после обновления пакетов проект запускается и показывает окно приветствия по умолчанию.

dotnet run --framework netcoreapp2.1

Роутинг в кроссплатформенном .NET приложении с сохранением состояния на диск на примере .NET Core, ReactiveUI и Avalonia - 2

Кроссплатформенный роутинг ReactiveUI

Как правило, выделяют два основных подхода к реализации навигации между страницами приложения .NET — view-first и view model-first. View-first подход подразумевает управление стеком навигации и переходами между страницами на уровне Представления в терминологии MVVM — например, при помощи классов Frame и Page в случае UWP или WPF, а при использовании view model-first подхода навигацию реализуют на уровне моделей представления. Инструменты ReactiveUI, организующие роутинг в приложении, ориентированы на использование подхода view model-first. Роутинг ReactiveUI состоит из реализации IScreen, содержащей состояние роутера, нескольких реализаций IRoutableViewModel и платформозависимого элемента управления XAML — RoutedViewHost.

Роутинг в кроссплатформенном .NET приложении с сохранением состояния на диск на примере .NET Core, ReactiveUI и Avalonia - 3

Состояние роутера представлено объектом RoutingState, который управляет стеком навигации. IScreen является корнем стека навигации, при этом корней навигации в приложении может быть несколько. RoutedViewHost наблюдает за состоянием соответствующего ему роутера RoutingState, реагируя на изменения в стеке навигации путём встраивания соответствующего IRoutableViewModel элемента управления XAML. Описанная функциональность будет проиллюстрирована примерами ниже.

Сохранение состояния моделей представления на диск

Рассмотрим типичную модель представления экрана поиска информации в качестве примера.

Роутинг в кроссплатформенном .NET приложении с сохранением состояния на диск на примере .NET Core, ReactiveUI и Avalonia - 4

Мы должны решить, какие элементы модели представления экрана сохранять на диск во время приостановки или выключения приложения, а какие — пересоздавать каждый раз при запуске. Сохранять состояние команд ReactiveUI, реализующих интерфейс ICommand и привязываемых к кнопкам, необходимости нет — ReactiveCommand<TIn, TOut> создаются и инициализируются в конструкторе, при этом состояние индикатора CanExecute зависит от свойств модели представления и пересчитывается при их изменении. Необходимость сохранения результатов поиска — спорный вопрос — зависит от специфики приложения, а вот состояние поля ввода SearchQuery было бы разумно сохранять и восстанавливать!

ViewModels/SearchViewModel.cs

[DataContract]
public class SearchViewModel : ReactiveObject, IRoutableViewModel
{
    private readonly ReactiveCommand<Unit, Unit> _search;
    private string _searchQuery;

    // Получаем реализацию IScreen через конструктор, при получении NULL
    // достаём IScreen из Splat.Locator. Конструктор без параметров
    // необходим для коррекной десериализации модели представления.
    public SearchViewModel(IScreen screen = null) 
    {
        HostScreen = screen ?? Locator.Current.GetService<IScreen>();

        // При каждом изменении свойства SearchQuery проверяем,
        // соблюдены ли условия начала поиска.
        var canSearch = this
            .WhenAnyValue(x => x.SearchQuery)
            .Select(query => !string.IsNullOrWhiteSpace(query));

        // Привязанные к команде кнопки будут выключены, пока
        // условия начала поиска не соблюдены.
        _search = ReactiveCommand.CreateFromTask(
            () => Task.Delay(1000), // эмулируем длительную операцию
            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);
    }
}

Класс модели представления пометим атрибутом [DataContract], а свойства, которые необходимо сериализовать — атрибутами [DataMember]. Этого достаточно в том случае, если используемый сериализатор использует opt-in подход — сохраняет на диск только явно помеченные атрибутами свойства, в случае opt-out подхода необходимо промаркировать атрибутами [IgnoreDataMember] те свойства, сохранять которые на диск не нужно. Дополнительно, реализуем интерфейс IRoutableViewModel в нашей модели представления, чтобы впоследствии она смогла стать частью стека навигации роутера приложения.

Аналогично реализуем модель представления страницы авторизации

ViewModels/LoginViewModel.cs

[DataContract]
public class LoginViewModel : ReactiveObject, IRoutableViewModel
{
    private readonly ReactiveCommand<Unit, Unit> _login;
    private string _password;
    private string _username;

    // Получаем реализацию IScreen через конструктор, при получении NULL
    // достаём IScreen из Splat.Locator. Конструктор без параметров
    // необходим для коррекной десериализации модели представления.
    public LoginViewModel(IScreen screen = null) 
    {
        HostScreen = Locator.Current.GetService<IScreen>();

        // При каждом изменении свойств Username и Password
        // проверяем, можно ли начать процедуру авторизации.
        var canLogin = this
            .WhenAnyValue(
                x => x.Username,
                x => x.Password,
                (user, pass) => !string.IsNullOrWhiteSpace(user) &&
                                !string.IsNullOrWhiteSpace(pass));

        // Привязанные к команде кнопки будут выключены, пока
        // пользовательский ввод не завершён.
        _login = ReactiveCommand.CreateFromTask(
            () => Task.Delay(1000), // эмулируем длительную операцию
            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);
    }

    // Пароль на диск не сохраняем из соображений безопасности!
    public string Password 
    {
        get => _password;
        set => this.RaiseAndSetIfChanged(ref _password, value);
    }
}

Модели представления двух страниц приложения готовы, реализуют интерфейс IRoutableViewModel и могут быть встроены в роутер IScreen. Теперь реализуем непосредственно IScreen. Промаркируем с помощью атрибутов [DataContract], какие свойства модели представления сериализовывать, а какие — игнорировать. Обратите внимание на публичный сеттер свойства, помеченного атрибутом [DataMember], на примере ниже — свойство намеренно открыто для записи для того, чтобы сериализатор мог изменить свежесозданный экземпляр объекта при десериализации модели.

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()
    {
        // Если в данный момент отображается экран авторизации,
        // выключим кнопку, открывающую страницу авторизации.
        var canLogin = this
            .WhenAnyObservable(x => x.Router.CurrentViewModel)
            .Select(current => !(current is LoginViewModel));

        _login = ReactiveCommand.Create(
            () => { Router.Navigate.Execute(new LoginViewModel()); },
            canLogin);

        // Если в данный момент отображается экран поиска,
        // выключим кнопку, открывающую страницу поиска.
        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;
}

В нашем приложении сохранять на диск необходимо только RoutingState, команды по очевидным причинам сохранять на диск не нужно — их состояние целиком зависит от роутера. В сериализованный объект необходимо включать расиширенную информацию о типах, реализующих IRoutableViewModel, чтобы при десериализации стек навигации мог быть восстановлен. Опишем логику модели представления MainViewModel, поместим класс в ViewModels/MainViewModel.cs и в соответствующее пространство имён ReactiveUI.Samples.Suspension.ViewModels.

Роутинг в кроссплатформенном .NET приложении с сохранением состояния на диск на примере .NET Core, ReactiveUI и Avalonia - 5

Роутинг в приложении Avalonia

Логика пользовательского интерфейса на уровне слоёв модели и модели представления демо-приложения реализована и может быть вынесена в отдельную сборку, нацеленную на .NET Standard, поскольку ничего не знает об используемом GUI-фреймворке. Займёмся слоем представления. Слой представления в терминологии MVVM отвечает за отрисовку состояния модели представления на экран, для отрисовки текущего состояния роутера RoutingState используется элемент управления XAML RoutedViewHost, содержащийся в пакете Avalonia.ReactiveUI. Реализуем GUI для SearchViewModel — для этого в директории Views/ создадим два файла: SearchView.xaml и SearchView.xaml.cs.

Описание пользовательского интерфейса с помощью диалекта XAML, используемого в Avalonia, скорее всего покажется знакомым разработчикам на Windows Presentation Foundation, Universal Windows Platform или Xamarin.Forms. В примере выше мы создаём тривиальный интерфейс формы поиска — рисуем текстовое поле для ввода поискового запроса и кнопку, запускающую поиск, при этом привязываем элементы управления к свойствам модели представления SearchViewModel, определённой выше.

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()
    {
        // Вызов WhenActivated используется для выполнения некоторого 
        // кода в момент активации и деактивации модели представления.
        this.WhenActivated((CompositeDisposable disposable) => { });
        AvaloniaXamlLoader.Load(this);
    }
}

Знакомым разработчикам на WPF, UWP и XF покажется и code-behind элемента управления SearchView.xaml. Вызов WhenActivated используется для выполнения некоторого кода в момент активации и деактивации представления или модели представления. Если ваше приложение использует hot observables (таймеры, геолокацию, соединение с шиной сообщений), будет разумно присоединить их к CompositeDisposable вызовом DisposeWith, чтобы при откреплении элемента управления XAML и соответствующей ему модели представления от визуального дерева hot observables перестали публиковать новые значения и не произошло утечек памяти.

Аналогично реализуем представление страницы авторизации

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);
    }
}

Отредактируем файлы Views/MainView.xaml и Views/MainView.xaml.cs. Расположим элемент управления XAML RoutedViewHost из пространства имён Avalonia.ReactiveUI на главном экране, привяжем состояние роутера RoutingState к свойству RoutedViewHost.Router. Добавим кнопки для навигации на страницы поиска и авторизации, привяжем их к свойствам ViewModels/MainViewModel, описанной выше.

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>
        <!-- Элемент управления, наблюдающий за RoutingState, 
             встраивающий подходящее View текущей ViewModel  -->
        <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);
    }
}

Простое приложение, демонстрирующее возможности роутинга ReactiveUI и Avalonia, готово. При нажати на кнопки Search и Login вызываются соответствующие команды, создаётся новый экземпляр модели представления и обновляется RoutingState. Элемент управления XAML RoutedViewHost, подписавшийся на изменения RoutingState, пробует достать тип IViewFor<TViewModel>, где TViewModel — тип модели представления, из Locator.Current. Если зарегистрированная реализация IViewFor<TViewModel> найдена, будет создан её новый экземпляр, встроен в RoutedViewHost и отображён в окне приложения Avalonia.

Роутинг в кроссплатформенном .NET приложении с сохранением состояния на диск на примере .NET Core, ReactiveUI и Avalonia - 6

Зарегистрируем необходимые компоненты IViewFor<TViewModel> и IScreen в методе Program.AppMain нашего приложения, используя Locator.CurrentMutable. Регистрация IViewFor<TViewModel> необходима для корректной работы RoutedViewHost, а регистрация IScreen нужна, чтобы при десериализации объекты SearchViewModel и LoginViewModel могли корректно проинициализироваться, используя конструктор без параметров и Locator.Current.

Program.cs

private static void AppMain(Application app, string[] args)
{
    // Регистрируем модели представления.
    Locator.CurrentMutable.RegisterConstant<IScreen>(new MainViewModel());
    Locator.CurrentMutable.Register<IViewFor<SearchViewModel>>(() => new SearchView());
    Locator.CurrentMutable.Register<IViewFor<LoginViewModel>>(() => new LoginView());

    // Получаем корневую модель представления и инициализируем контекст данных.
    app.Run(new MainView { DataContext = Locator.Current.GetService<IScreen>() });
}

Запустим приложение и убедимся, что роутинг работает корректно. Если возникнут какие-либо ошибки в разметке XAML, используемый в Avalonia компилятор XamlIl сообщит нам, где именно, на этапе компиляции. А ещё XamlIl поддерживает отладку XAML прямо в дебаггере IDE!

dotnet run --framework netcoreapp2.1

Роутинг в кроссплатформенном .NET приложении с сохранением состояния на диск на примере .NET Core, ReactiveUI и Avalonia - 7

Сохранение и восстановление состояния приложения целиком

Теперь, когда роутинг настроен и работает, начинается самое интересное — необходимо реализовать сохранение данных на диск при закрытии приложения и чтение данных с диска при его старте, вместе с состоянием роутера. Инициализацией хуков, слушающих события запуска и закрытия приложения, занимается специальный класс AutoSuspendHelper, свой для каждой платформы, которую поддерживает ReactiveUI. Задача разработчика — проинициализировать этот класс в самом начале корня композиции приложения. Также необходимо проинициализировать свойство RxApp.SuspensionHost.CreateNewAppState функцией, которая вернёт состояние приложения по умолчанию, если сохранённое состояние отсутствует или произошла непредвиденная ошибка, или если сохранённый файл повреждён.

Далее необходимо вызвать метод RxApp.SuspensionHost.SetupDefaultSuspendResume, передав ему реализацию ISuspensionDriver — драйвера, занимающегося записью и чтением объекта состояния. Для реализации ISuspensionDriver используем библиотеку Newtonsoft.Json и пространство имён System.IO для работы с файловой системой. Для этого установим пакет Newtonsoft.Json:

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);
    }
}

У данного подхода есть минусы — System.IO не работает с Universal Winows Platform, но это легко исправить — достаточно вместо File и Directory использовать StorageFile и StorageFolder. Чтобы прочитать стек навигации с диска, драйвер должен поддерживать десериализацию конкретного типа в интерфейс IRoutableViewModel, с Newtonsoft.Json этого можно достичь с включённой настройкой TypeNameHandling.All у сериализатора. Зарегистрируем драйвер в корне композиции приложения Avalonia — в AppMain:

private static void AppMain(Application app, string[] args)
{
    // Инициализируем хуки приостановки. 
    var suspension = new AutoSuspendHelper(app);
    RxApp.SuspensionHost.CreateNewAppState = () => new MainViewModel();
    RxApp.SuspensionHost.SetupDefaultSuspendResume(new NewtonsoftJsonSuspensionDriver("appstate.json"));

    // Регистрируем сервисы, читаем с диска корневую модель представления.
    var state = RxApp.SuspensionHost.GetAppState<MainViewModel>();
    Locator.CurrentMutable.RegisterConstant<IScreen>(state);
    Locator.CurrentMutable.Register<IViewFor<SearchViewModel>>(() => new SearchView());
    Locator.CurrentMutable.Register<IViewFor<LoginViewModel>>(() => new LoginView());

    // Запускаем программу.
    app.Run(new MainView { DataContext = Locator.Current.GetService<IScreen>() });
}

Приведённая выше реализация ISuspensionDriver при первом запуске и выключении приложения создаст файл состояния с именем appstate.json следующего вида в рабочей директории:

appstate.json

Обратите внимание — в каждый объект включено поле $type, содержащее информацию о полном имени типа и полном имени сборки, в которой находится тип.

{
  "$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"
        }
      ]
    }
  }
}

Заметим, что на момент написания статьи в пакет Avalonia.ReactiveUI не включена реализация AutoSuspendHelper (#2672), поэтому сконструируем вспомогательный класс самостоятельно — это довольно просто, однако стоит учитывать, что в дальнейшем API Avalonia изменится с целью поддержки событий приостановки мобильных приложений.

public sealed class AutoSuspendHelper : IEnableLogger
{
    public AutoSuspendHelper(Application app)
    {
        RxApp.SuspensionHost.IsResuming = Observable.Never<Unit>();
        RxApp.SuspensionHost.IsLaunchingNew = Observable.Return(Unit.Default);

        var exiting = new Subject<IDisposable>();
        app.OnExit += (o, e) =>
        {
            // Блокировка необходима, чтобы предотвратить преждевременное
            // завершение работы нашего приложения.
            var manual = new ManualResetEvent(false);
            exiting.OnNext(Disposable.Create(() => manual.Set()));
            manual.WaitOne();
        };
        RxApp.SuspensionHost.ShouldPersistState = exiting;

        var errored = new Subject<Unit>();
        RxApp.SuspensionHost.ShouldInvalidateState = errored;
        AppDomain.CurrentDomain.UnhandledException += (o, e) => 
                errored.OnNext(Unit.Default);
    }
}

Если вы откроете, например, страницу поиска, впечатаете текст в поля ввода, закроете и запустите приложение снова, вы увидите в точности тот экран, который видели при выключении программы! Стоит отметить, что данная функциональность будет работать с любой платформой, поддерживаемой ReactiveUI — как с UWP и WPF, так и с Xamarin.Forms.

Роутинг в кроссплатформенном .NET приложении с сохранением состояния на диск на примере .NET Core, ReactiveUI и Avalonia - 8

Бонус: ISuspensionDriver может быть реализован с помощью Akavache — при хранении состояния в секции UserAccount и Secure в случае iOS и UWP данные будут загружены в облако и синхронизированы со всеми устройствами, на которых установлено приложение, а для Android существует реализация BundleSuspensionDriver в пакете ReactiveUI.AndroidSupport. Также при желании можно записывать JSON файлы в Xamarin.Essentials SecureStorage. Отметим, что хранить состояние приложения можно и на собственном удалённом сервере — всё в руках разработчика!

Полезные ссылки

Автор: Artyom Gorchakov

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js