Пользовательские интерфейсы современных прикладных приложений, как правило, сложны — зачастую необходимо реализовывать поддержку постраничной навигации, обрабатывать разного рода поля ввода, на основе выбранных пользователем параметров отображать или скрывать информацию. При этом, для улучшения 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. Убедимся, что приложение запускается и показывает окошко с надписью Welcome to Avalonia! 
 Для подключения и использования самых новых сборок Avalonia, автоматически публикуемых в MyGet при изменениях ветки master репозитория Avalonia на GitHub, используем файл конфигурации источников пакетов nuget.config. Чтобы IDE и .NET Core CLI увидели nuget.config, необходимо сгенерировать sln файл для созданного выше проекта. Воспользуемся средствами .NET Core CLI: Создадим файл nuget.config в папке с .sln-файлом следующего содержания: Может потребоваться перезапуск IDE, либо выгрузка и загрузка решения целиком. Обновим пакеты Avalonia до нужной версии (как минимум 0.9.1) с помощью интерфейса пакетного менеджера NuGet вашей IDE, либо с помощью инструментов командной строки Windows или терминала Linux: Теперь файл проекта ReactiveUI.Samples.Suspension.csproj выглядит следующим образом: Создадим папки Views/ и ViewModels/ в корне проекта, изменим имя класса MainWindow на MainView для удобства, переместим его в каталог Views/, изменив пространства имён соответствующим образом — на ReactiveUI.Samples.Suspension.Views. Отредактируем содержимое файлов Program.cs и App.xaml.cs — применим вызов UseReactiveUI к билдеру приложения Avalonia, переместим инициализацию главного представления в OnFrameworkInitializationCompleted, чтобы соответствовать рекомендациям по управлению жизненным циклом приложения: Program.cs App.xaml.cs Потребуется добавить using Avalonia.ReactiveUI в Program.cs. Убедимся, что после обновления пакетов проект запускается и показывает окно приветствия по умолчанию.  
 Как правило, выделяют два основных подхода к реализации навигации между страницами приложения .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 Класс модели представления пометим атрибутом , а свойства, которые необходимо сериализовать — атрибутами . Этого достаточно в том случае, если используемый сериализатор использует opt-in подход — сохраняет на диск только явно помеченные атрибутами свойства, в случае opt-out подхода необходимо промаркировать атрибутами  те свойства, сохранять которые на диск не нужно. Дополнительно, реализуем интерфейс IRoutableViewModel в нашей модели представления, чтобы впоследствии она смогла стать частью стека навигации роутера приложения. ViewModels/LoginViewModel.cs Модели представления двух страниц приложения готовы, реализуют интерфейс IRoutableViewModel и могут быть встроены в роутер IScreen. Теперь реализуем непосредственно IScreen. Промаркируем с помощью атрибутов , какие свойства модели представления сериализовывать, а какие — игнорировать. Обратите внимание на публичный сеттер свойства, помеченного атрибутом , на примере ниже — свойство намеренно открыто для записи для того, чтобы сериализатор мог изменить свежесозданный экземпляр объекта при десериализации модели.  ViewModels/MainViewModel.cs В нашем приложении сохранять на диск необходимо только RoutingState, команды по очевидным причинам сохранять на диск не нужно — их состояние целиком зависит от роутера. В сериализованный объект необходимо включать расширенную информацию о типах, реализующих IRoutableViewModel, чтобы при десериализации стек навигации мог быть восстановлен. Опишем логику модели представления MainViewModel, поместим класс в ViewModels/MainViewModel.cs и в соответствующее пространство имён ReactiveUI.Samples.Suspension.ViewModels. 
 Логика пользовательского интерфейса на уровне слоёв модели и модели представления демо-приложения реализована и может быть вынесена в отдельную сборку, нацеленную на .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 Знакомым разработчикам на WPF, UWP и XF покажется и code-behind элемента управления SearchView.xaml. Вызов WhenActivated используется для выполнения некоторого кода в момент активации и деактивации представления или модели представления. Если ваше приложение использует hot observables (таймеры, геолокацию, соединение с шиной сообщений), будет разумно присоединить их к CompositeDisposable вызовом DisposeWith, чтобы при откреплении элемента управления XAML и соответствующей ему модели представления от визуального дерева hot observables перестали публиковать новые значения и не произошло утечек памяти. Views/LoginView.xaml Views/LoginView.xaml.cs Отредактируем файлы Views/MainView.xaml и Views/MainView.xaml.cs. Расположим элемент управления XAML RoutedViewHost из пространства имён Avalonia.ReactiveUI на главном экране, привяжем состояние роутера RoutingState к свойству RoutedViewHost.Router. Добавим кнопки для навигации на страницы поиска и авторизации, привяжем их к свойствам ViewModels/MainViewModel, описанной выше. Views/MainView.xaml Views/MainView.xaml.cs Простое приложение, демонстрирующее возможности роутинга 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 Запустим приложение и убедимся, что роутинг работает корректно. Если возникнут какие-либо ошибки в разметке XAML, используемый в Avalonia компилятор XamlIl сообщит нам, где именно, на этапе компиляции. А ещё XamlIl поддерживает отладку XAML прямо в дебаггере IDE! 
 Теперь, когда роутинг настроен и работает, начинается самое интересное — необходимо реализовать сохранение данных на диск при закрытии приложения и чтение данных с диска при его старте, вместе с состоянием роутера. Инициализацией хуков, слушающих события запуска и закрытия приложения, занимается специальный класс AutoSuspendHelper, свой для каждой платформы, которую поддерживает ReactiveUI. Задача разработчика — проинициализировать этот класс в самом начале корня композиции приложения. Также необходимо проинициализировать свойство RxApp.SuspensionHost.CreateNewAppState функцией, которая вернёт состояние приложения по умолчанию, если сохранённое состояние отсутствует или произошла непредвиденная ошибка, или если сохранённый файл повреждён.  Далее необходимо вызвать метод RxApp.SuspensionHost.SetupDefaultSuspendResume, передав ему реализацию ISuspensionDriver — драйвера, занимающегося записью и чтением объекта состояния. Для реализации ISuspensionDriver используем библиотеку Newtonsoft.Json и пространство имён System.IO для работы с файловой системой. Для этого установим пакет Newtonsoft.Json: Drivers/NewtonsoftJsonSuspensionDriver.cs У данного подхода есть минусы — System.IO не работает с Universal Winows Platform, но это легко исправить — достаточно вместо File и Directory использовать StorageFile и StorageFolder. Чтобы прочитать стек навигации с диска, драйвер должен поддерживать десериализацию конкретного типа в интерфейс IRoutableViewModel, с Newtonsoft.Json этого можно достичь с включённой настройкой TypeNameHandling.All у сериализатора. Зарегистрируем драйвер в корне композиции приложения Avalonia — в App.OnFrameworkInitializationCompleted: Вспомогательный объект AutoSuspendHelper из пакета Avalonia.ReactiveUI проинициализирует события приостановки для используемого вашим приложением IApplicationLifetime — таким образом, при завершении работы приложения или при его остановке реализованный выше ISuspensionDriver сохранит состояние на диск. Приведённая выше реализация ISuspensionDriver при первом запуске и выключении приложения создаст файл состояния с именем appstate.json следующего вида в рабочей директории: Обратите внимание — в каждый объект включено поле $type, содержащее информацию о полном имени типа и полном имени сборки, в которой находится тип. Если вы откроете, например, страницу поиска, впечатаете текст в поля ввода, закроете и запустите приложение снова, вы увидите в точности тот экран, который видели при выключении программы! Стоит отметить, что данная функциональность будет работать с любой платформой, поддерживаемой ReactiveUI — как с UWP и WPF, так и с Xamarin.Forms.  
 Бонус: ISuspensionDriver может быть реализован с помощью Akavache — при хранении состояния в секции UserAccount и Secure в случае iOS и UWP данные будут загружены в облако и синхронизированы со всеми устройствами, на которых установлено приложение, а для Android существует реализация BundleSuspensionDriver в пакете ReactiveUI.AndroidSupport. Также при желании можно записывать JSON файлы в Xamarin.Essentials SecureStorage. Отметим, что хранить состояние приложения можно и на собственном удалённом сервере — всё в руках разработчика! Кроссплатформенное ПО, анализ данных