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

tulvgli1xkcq1jqef9m91o_caxe.png

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



  
    
  

Может потребоваться перезапуск 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

bn847bgygv5j5awbdb_0pvipoyq.png


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

4s7a6_gsiixrbgx3h5kf7leezcw.png

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


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

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

kitko4krrwapat8y9_bm0kcobky.png

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

ViewModels/SearchViewModel.cs

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

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

        // При каждом изменении свойства 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 _login;
    private string _password;
    private string _username;

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

        // При каждом изменении свойств 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 _search;
    private readonly ReactiveCommand _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.

d7l0zmx2opzysobg-rkqvhexpxk.png


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


    
        
            
            
            
            
        
        
        
        

Views/SearchView.xaml.cs

public sealed class SearchView : ReactiveUserControl
{
    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


    
        
            
            
            
            
            
        
        
        
        
        

Views/LoginView.xaml.cs

public sealed class LoginView : ReactiveUserControl
{
    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


    
        
            
            
        
        
        
            
                
            
        
        
            
                
                
                
            
            

Views/MainView.xaml.cs

public sealed class MainView : ReactiveWindow
{
    public MainView()
    {
        this.WhenActivated(disposables => { });
        AvaloniaXamlLoader.Load(this);
    }
}

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

yso4dgybdbqulwbb7dumlbktp5w.png

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

Program.cs

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

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

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

dotnet run --framework netcoreapp2.1

c2pp88h397pwscpwn-i8vnke6sw.gif


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

Теперь, когда роутинг настроен и работает, начинается самое интересное — необходимо реализовать сохранение данных на диск при закрытии приложения и чтение данных с диска при его старте, вместе с состоянием роутера. Инициализацией хуков, слушающих события запуска и закрытия приложения, занимается специальный класс 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 InvalidateState()
    {
        if (File.Exists(_file)) 
            File.Delete(_file);
        return Observable.Return(Unit.Default);
    }

    public IObservable LoadState()
    {
        var lines = File.ReadAllText(_file);
        var state = JsonConvert.DeserializeObject(lines, _settings);
        return Observable.Return(state);
    }

    public IObservable 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();
    Locator.CurrentMutable.RegisterConstant(state);
    Locator.CurrentMutable.Register>(() => new SearchView());
    Locator.CurrentMutable.Register>(() => new LoginView());

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

Приведённая выше реализация 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();
        RxApp.SuspensionHost.IsLaunchingNew = Observable.Return(Unit.Default);

        var exiting = new Subject();
        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();
        RxApp.SuspensionHost.ShouldInvalidateState = errored;
        AppDomain.CurrentDomain.UnhandledException += (o, e) => 
                errored.OnNext(Unit.Default);
    }
}

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

yfigp0_ob3ah_cofj-3tdazxrgc.png

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


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


© Habrahabr.ru