使用ReactiveUI和Avalonia的示例在跨平台.NET Core应用程序中导航并将状态保存到磁盘



现代应用程序的用户界面通常很复杂-通常有必要实现页面导航支持,处理各种输入字段以及基于用户选择的参数显示或隐藏信息。 同时,为了改善UX,应用程序必须在挂起或关闭期间将接口元素的状态保存到磁盘,并在重新启动程序时从磁盘恢复状态。


ReactiveUI MVVM框架建议通过在程序暂停时对表示模型的图形进行序列化来保留应用程序的状态,而对于框架和平台而言,确定暂停时间的机制是不同的。 因此,对于WPF,对于Xamarin.Android- ActivityPaused ,对于Xamarin.iOS- DidEnterBackground ,对于UWP- OnLaunched重载都使用Exit事件。


在本文中,我们将以Avalonia跨平台GUI框架为例 ,考虑使用ReactiveUI通过GUI保存和恢复软件状态,包括路由器状态。 该材料假定读者对CVM语言和.NET平台中的MVVM设计模式反应式编程有基本的了解。 本文中的步骤适用于Windows 10和Ubuntu 18。


项目创建


要尝试实际进行路由,请从Avalonia模板创建一个新的.NET Core项目,安装Avalonia.ReactiveUI程序包-Avalonia和ReactiveUI集成的薄层。 开始之前,请确保已安装.NET Core SDKgit


 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 

确保应用程序启动并显示一个窗口,显示欢迎使用Avalonia!


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



从MyGet连接Avalonia预构建


要连接并使用当GitHub上nuget.config 存储库 master 分支更改时自动发布到MyGet的最新Avalonia构建,我们使用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 

在包含以下内容的.sln文件的文件夹中创建nuget.config文件:


 <?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,或卸载并下载整个解决方案。 我们将使用IDE的NuGet包管理器界面,或使用Windows命令行工具或Linux终端,将Avalonia包更新为所需版本(至少为0.9.1 )。


 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.ViewsApp.xaml.cs Program.csApp.xaml.cs内容-将UseReactiveUI调用应用于UseReactiveUI应用程序构建器,将主视图的初始化移动到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应用程序的页面之间实现导航的主要方法有两种:视图优先和视图模型优先。 视图优先方法涉及以MVVM术语在视图级别控制导航堆栈和页面之间的导航-例如,在UWP或WPF的情况下使用FramePage类,而在使用视图模型优先方法的情况下,导航在表示模型级别实现。 在应用程序中组织路由的ReactiveUI工具专注于使用视图模型优先方法。 ReactiveUI路由包括一个包含路由器状态的IRoutableViewModel实现IRoutableViewModel多个IRoutableViewModel实现以及RoutedViewHost于平台的XAML控件RoutedViewHost




路由器的状态由控制导航堆栈的RoutingState对象表示。 IScreen是导航堆栈的根,在应用程序中可能有多个导航根。 RoutedViewHost监视其相应的RoutingState路由器的状态,通过嵌入XAML控件的相应IRoutableViewModel来响应导航堆栈中的更改。 下面的示例将说明所描述的功能。


将视图模型的状态保存到磁盘


考虑信息搜索屏幕的典型示例。




我们必须决定在应用程序暂停或关闭期间将屏幕表示模型的哪些元素保存到磁盘,以及在每次启动时重新创建。 无需保存实现ICommand接口并附加到按钮的ReactiveUI命令的状态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]具有[DataMember] 。 如果所使用的序列化程序使用“选择加入”方法就足够了-它仅将使用属性明确标记的属性保存到磁盘;对于选择“退出”方法,则需要使用[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视图MainViewModel的逻辑,将类放在ViewModels/MainViewModel.cs和相应的ReactiveUI.Samples.Suspension.ViewModels命名空间中。




在Avalonia应用中路由


在模型层和演示应用程序的表示模型的层级上的用户界面逻辑已实现,并且可以移动到针对.NET Standard的单独程序集中,因为它对所使用的GUI框架一无所知。 让我们看一下表示层。 在MVVM术语中,表示层负责在屏幕上呈现表示模型的状态,要呈现RoutingState路由器的当前状态,请使用RoutedViewHost包中包含的XAML RoutedViewHost控件。 我们为SearchViewModel实现GUI-为此,在Views/目录中创建两个文件: SearchView.xamlSearchView.xaml.cs


Windows Presentation Foundation,Universal Windows Platform或Xamarin.Forms上的开发人员可能对使用Avalonia中使用的XAML方言的用户界面进行了描述。 在上面的示例中,我们为搜索表单创建了一个简单的界面-我们绘制一个文本框以输入搜索查询和一个按钮来启动搜索,同时将控件绑定到上面定义的SearchViewModel模型的属性。


视图/ 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> 

视图/ SearchView.xaml.cs


 public sealed class SearchView : ReactiveUserControl<SearchViewModel> { public SearchView() { //  WhenActivated     //        . this.WhenActivated((CompositeDisposable disposable) => { }); AvaloniaXamlLoader.Load(this); } } 

熟悉的WPF,UWP和XF SearchView.xaml也将看到SearchView.xaml控件的代码。 在激活和停用视图或表示模型时, WhenActivated调用用于执行一些代码。 如果您的应用程序使用了热的可观察对象 (计时器,地理位置,到消息总线的连接),则最好CompositeDisposable调用DisposeWith将它们附加到CompositeDisposable这样当您从可视树中DisposeWith XAML控件及其对应的视图模型时, 热的可观察对象将停止发布新值,并且不会泄漏记忆。


同样,我们实现授权页面的表示

视图/ 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> 

视图/ LoginView.xaml.cs


 public sealed class LoginView : ReactiveUserControl<LoginViewModel> { public LoginView() { this.WhenActivated(disposables => { }); AvaloniaXamlLoader.Load(this); } } 

编辑Views/MainView.xamlViews/MainView.xaml.csRoutedViewHost来自RoutedViewHost命名空间RoutedViewHost XAML RoutedViewHost主屏幕上,并将RoutingState路由器的状态分配给RoutingState属性。 添加按钮以导航到搜索和授权页面,将它们绑定到上述的ViewModels/MainViewModel属性。


视图/ 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> 

视图/ MainView.xaml.cs


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

一个演示ReactiveUIAvalonia路由功能的简单应用程序已准备就绪。 单击“ Search和“ Login按钮后,将调用相应的命令,创建视图模型的新实例,并更新RoutingState 。 订阅对RoutedViewHost的更改的XAML控件RoutedViewHost尝试获取IViewFor<TViewModel> ,其中TViewModelLocator.Current的视图模型类型。 如果找到IViewFor<TViewModel>的注册实现, IViewFor<TViewModel>创建一个新实例,将其内置到RoutedViewHost并显示在RoutedViewHost应用程序窗口中。




我们使用Locator.CurrentMutable在应用程序的App.OnFrameworkInitializationCompleted方法中注册必要的组件IViewFor<TViewModel>IScreenIViewFor<TViewModel>对于RoutedViewHost IScreen必需的,而IScreen注册IScreen必需的,以便在反序列化时,可以使用不带参数和Locator.Current的构造函数正确初始化SearchViewModelLoginViewModel


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中使用XamlIIl编译器将在编译阶段告诉我们确切的位置。 XamlIl还直接在IDE调试器中支持XAML调试


 dotnet run --framework netcoreapp3.0 



保存和还原整个应用程序状态


既然路由已经配置并且可以正常工作,最有趣的部分就开始了-您需要在关闭应用程序时实现将数据保存到磁盘,并在启动时从磁盘读取数据以及路由器的状态。 侦听应用程序启动和关闭事件的挂钩的初始化由特殊的AutoSuspendHelper类处理,该类AutoSuspendHelperReactiveUI支持的每个平台。 开发人员的任务是在应用程序组合根的最开始处初始化此类。 还需要RxApp.SuspensionHost.CreateNewAppState函数初始化RxApp.SuspensionHost.CreateNewAppState属性RxApp.SuspensionHost.CreateNewAppState如果没有保存状态或发生意外错误,或者保存的文件已损坏,则该函数将返回应用程序的默认状态。


接下来,您需要调用RxApp.SuspensionHost.SetupDefaultSuspendResume方法,并将其传递给RxApp.SuspensionHost.SetupDefaultSuspendResume的实现, ISuspensionDriver是读写状态对象的驱动程序。 为了实现ISuspensionDriverISuspensionDriver使用Newtonsoft.Json库和System.IO命名空间来处理文件系统。 为此,请安装Newtonsoft.Json软件包:


 dotnet add package Newtonsoft.Json 

驱动程序/ 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 AkavacheUserAccount Secure iOS UWP , , Android BundleSuspensionDriver ReactiveUI.AndroidSupport . JSON Xamarin.Essentials SecureStorage . , — !



Source: https://habr.com/ru/post/zh-CN457164/


All Articles