Pull to refresh

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

Reading time18 min
Views21K



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


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


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


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


Чтобы попробовать роутинг в действии, создадим новый проект .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
dotnet add package Avalonia.Desktop
dotnet add package Avalonia

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


# Use .NET Core version which you have installed.
# It can be netcoreapp2.0, netcoreapp2.1 and so on.
dotnet run --framework netcoreapp3.0



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


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


dotnet new sln # Ctrl+C
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="AvaloniaCI" value="https://www.myget.org/F/avalonia-ci/api/v2" />
  </packageSources>
</configuration>

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


dotnet add package Avalonia.ReactiveUI --version 0.9.1
dotnet add package Avalonia.Desktop --version 0.9.1
dotnet add package Avalonia --version 0.9.1
cat ReactiveUI.Samples.Suspension.csproj

Теперь файл проекта ReactiveUI.Samples.Suspension.csproj выглядит следующим образом:


<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>WinExe</OutputType>
    <TargetFramework>netcoreapp3.0</TargetFramework>
  </PropertyGroup>
  <ItemGroup>
    <Compile Update="**\*.xaml.cs">
      <DependentUpon>%(Filename)</DependentUpon>
    </Compile>
    <AvaloniaResource Include="**\*.xaml">
      <SubType>Designer</SubType>
    </AvaloniaResource>
  </ItemGroup>
  <ItemGroup>
    <PackageReference Include="Avalonia" Version="0.9.1" />
    <PackageReference Include="Avalonia.Desktop" Version="0.9.1" />
    <PackageReference Include="Avalonia.ReactiveUI" Version="0.9.1" />
  </ItemGroup>
</Project>

Создадим папки Views/ и ViewModels/ в корне проекта, изменим имя класса MainWindow на MainView для удобства, переместим его в каталог Views/, изменив пространства имён соответствующим образом — на ReactiveUI.Samples.Suspension.Views. Отредактируем содержимое файлов Program.cs и App.xaml.cs — применим вызов UseReactiveUI к билдеру приложения Avalonia, переместим инициализацию главного представления в OnFrameworkInitializationCompleted, чтобы соответствовать рекомендациям по управлению жизненным циклом приложения:


Program.cs


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

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

App.xaml.cs


public class App : Application
{
    public override void Initialize() => AvaloniaXamlLoader.Load(this);

    // Точка входа вашего приложения. Здесь вы можете проинициализировать 
    // ваш MVVM фреймворк, DI контейнер и прочие компоненты. В этом методе 
    // можно обращаться к свойству ApplicationLifetime, на основе которого
    // инициализировать мобильные или десктопные представления.
    public override void OnFrameworkInitializationCompleted()
    {
        new Views.MainView().Show();
        base.OnFrameworkInitializationCompleted();
    }
}

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


# Use .NET Core version which you have installed.
# It can be netcoreapp2.0, netcoreapp2.1 and so on.
dotnet run --framework netcoreapp3.0



Кроссплатформенный роутинг 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.




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


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


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




Мы должны решить, какие элементы модели представления экрана сохранять на диск во время приостановки или выключения приложения, а какие — пересоздавать каждый раз при запуске. Сохранять состояние команд 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 = screen ?? 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.




Роутинг в приложении 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.




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


App.xaml.cs


public override void OnFrameworkInitializationCompleted()
{
    // Регистрируем модели представления.
    Locator.CurrentMutable.RegisterConstant<IScreen>(new MainViewModel());
    Locator.CurrentMutable.Register<IViewFor<SearchViewModel>>(() => new SearchView());
    Locator.CurrentMutable.Register<IViewFor<LoginViewModel>>(() => new LoginView());

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

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


dotnet run --framework netcoreapp3.0



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


Теперь, когда роутинг настроен и работает, начинается самое интересное — необходимо реализовать сохранение данных на диск при закрытии приложения и чтение данных с диска при его старте, вместе с состоянием роутера. Инициализацией хуков, слушающих события запуска и закрытия приложения, занимается специальный класс 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 — в App.OnFrameworkInitializationCompleted:


public override void OnFrameworkInitializationCompleted()
{
    // Инициализируем хуки приостановки. 
    var suspension = new AutoSuspendHelper(ApplicationLifetime);
    RxApp.SuspensionHost.CreateNewAppState = () => new MainViewModel();
    RxApp.SuspensionHost.SetupDefaultSuspendResume(new NewtonsoftJsonSuspensionDriver("appstate.json"));
    suspension.OnFrameworkInitializationCompleted();

    // Регистрируем сервисы, читаем с диска корневую модель представления.
    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());

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

Вспомогательный объект AutoSuspendHelper из пакета Avalonia.ReactiveUI проинициализирует события приостановки для используемого вашим приложением IApplicationLifetime — таким образом, при завершении работы приложения или при его остановке реализованный выше ISuspensionDriver сохранит состояние на диск. Приведённая выше реализация 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"
        }
      ]
    }
  }
}

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




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


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


Tags:
Hubs:
Total votes 31: ↑30 and ↓1+29
Comments0

Articles