
As interfaces de usuário de aplicativos corporativos modernos são bastante complexas. Você, como desenvolvedor, geralmente precisa implementar a navegação no aplicativo, validar a entrada do usuário, mostrar ou ocultar telas com base nas preferências do usuário. Para um melhor UX, seu aplicativo deve ser capaz de salvar o estado no disco quando o aplicativo estiver em suspensão e de restaurar o estado quando o aplicativo for reiniciado.
O ReactiveUI fornece recursos que permitem persistir o estado do aplicativo serializando a árvore do modelo de exibição quando o aplicativo está sendo encerrado ou suspenso. Os eventos de suspensão variam de acordo com a plataforma. O ReactiveUI usa o evento Exit
para WPF, ActivityPaused
para Xamarin.Android, DidEnterBackground
para Xamarin.iOS, OnLaunched
para UWP.
Neste tutorial, criaremos um aplicativo de exemplo que demonstra o uso do recurso de suspensão do ReactiveUI com o Avalonia - uma estrutura de GUI baseada em XAML do .NET Core de plataforma cruzada. Você deve estar familiarizado com o padrão MVVM e com extensões reativas antes de ler esta nota. As etapas descritas no tutorial devem funcionar se você estiver usando o Windows 10 ou Ubuntu 18 e tiver o .NET Core SDK instalado. Vamos começar!
Inicializando o projeto
Para ver o roteamento do ReactiveUI em ação, criamos um novo projeto .NET Core baseado nos modelos de aplicativo Avalonia. Em seguida, instalamos o pacote Avalonia.ReactiveUI
. O pacote fornece ganchos de ciclo de vida Avalonia específicos da plataforma, infraestrutura de roteamento e ativação . Lembre-se de instalar o .NET Core e o git antes de executar os comandos abaixo.
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
Vamos executar o aplicativo e garantir que ele mostre uma janela exibindo "Bem-vindo ao Avalonia!"
# Use .NET Core version which you have installed. # It can be netcoreapp2.0, netcoreapp2.1 and so on. dotnet run --framework netcoreapp3.0

Instalando o Avalonia Preview Builds a partir do MyGet
Os pacotes mais recentes do Avalonia são publicados no MyGet toda vez que um novo commit é enviado à ramificação master
do repositório do Avalonia no GitHub. Para usar os pacotes mais recentes do MyGet em nosso aplicativo, vamos criar um arquivo nuget.config
. Porém, antes de fazer isso, geramos um arquivo sln
para o projeto criado anteriormente, usando a CLI do .NET Core :
dotnet new sln # Ctrl+C dotnet sln ReactiveUI.Samples.Suspension.sln add ReactiveUI.Samples.Suspension.csproj
Agora, criamos o arquivo nuget.config
com o seguinte conteúdo:
<?xml version="1.0" encoding="utf-8"?> <configuration> <packageSources> <add key="AvaloniaCI" value="https://www.myget.org/F/avalonia-ci/api/v2" /> </packageSources> </configuration>
Geralmente, é necessária uma reinicialização para forçar nosso IDE a detectar pacotes do feed MyGet recém-adicionado, mas recarregar a solução também deve ajudar. Em seguida, atualizamos os pacotes Avalonia para a versão mais recente (ou pelo menos para 0.9.1
) usando a GUI do gerenciador de pacotes NuGet ou a CLI do .NET Core:
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
O arquivo ReactiveUI.Samples.Suspension.csproj
deve se parecer com o seguinte agora:
<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>
Criamos duas novas pastas dentro do diretório raiz do projeto, denominadas Views/
e ViewModels/
respectivamente. Em seguida, renomeamos a classe MainWindow
para MainView
e a movemos para a pasta Views/
. Lembre-se de renomear referências para a classe editada no arquivo XAML correspondente, caso contrário, o projeto não será compilado. Além disso, lembre-se de alterar o espaço para nome do MainView
para ReactiveUI.Samples.Suspension.Views
para obter consistência. Em seguida, editamos outros dois arquivos, Program.cs
e App.xaml.cs
Adicionamos uma chamada ao UseReactiveUI
ao criador de aplicativos Avalonia, movemos o código de inicialização do aplicativo para o método OnFrameworkInitializationCompleted
para conformar as diretrizes de gerenciamento da vida útil do aplicativo Avalonia:
Program.cs
class Program {
App.xaml.cs
public class App : Application { public override void Initialize() => AvaloniaXamlLoader.Load(this);
Antes de tentar criar o projeto, garantimos que a diretiva using Avalonia.ReactiveUI
seja adicionada à parte superior do arquivo Program.cs
. Provavelmente, nosso IDE já importou esse espaço para nome, mas, se não, receberemos um erro em tempo de compilação. Finalmente, é hora de garantir que o aplicativo compile, execute e mostre uma nova janela:
dotnet run --framework netcoreapp2.1

Há duas abordagens gerais para organizar a navegação no aplicativo em um aplicativo .NET de plataforma cruzada - exibir primeiro e exibir modelo primeiro. A abordagem anterior pressupõe que a camada View gerencia a pilha de navegação - por exemplo, usando as classes Frame e Page específicas da plataforma. Com a última abordagem, a camada do modelo de visualização cuida da navegação por meio de uma abstração independente da plataforma. As ferramentas do ReactiveUI são criadas tendo em mente a abordagem de visualização do modelo primeiro. O roteamento ReactiveUI consiste em uma implementação IScreen
, que contém o estado de roteamento atual, várias implementações IRoutableViewModel
e um controle XAML específico da plataforma chamado RoutedViewHost
.

O objeto RoutingState
encapsula o gerenciamento de pilha de navegação. IScreen
é a raiz da navegação, mas, apesar do nome, não precisa ocupar a tela inteira. RoutedViewHost
reage às alterações no RoutingState
ligado e incorpora a exibição apropriada para o IRoutableViewModel
atualmente selecionado. A funcionalidade descrita será ilustrada com exemplos mais abrangentes posteriormente.
Estado do modelo de vista persistente
Considere um modelo de visualização da tela de pesquisa como exemplo.

Vamos decidir quais propriedades do modelo de visualização salvar no encerramento do aplicativo e quais recriar. Não há necessidade de salvar o estado de um comando reativo que implementa a interface ICommand
. ReactiveCommand<TIn, TOut>
classe ReactiveCommand<TIn, TOut>
geralmente é inicializada no construtor, seu indicador CanExecute
geralmente depende totalmente das propriedades do modelo de exibição e é recalculado toda vez que alguma dessas propriedades é alterada. É discutível se você manter os resultados da pesquisa, mas salvar a consulta de pesquisa é uma boa ideia.
ViewModels / SearchViewModel.cs
[DataContract] public class SearchViewModel : ReactiveObject, IRoutableViewModel { private readonly ReactiveCommand<Unit, Unit> _search; private string _searchQuery;
Marcamos toda a classe do modelo de exibição com o atributo [DataContract]
, anotamos as propriedades que vamos serializar com o atributo [DataMember]
. Isso é suficiente se quisermos usar o modo de serialização opcional. Considerando os modos de serialização, opt-out significa que todos os campos e propriedades públicos serão serializados, a menos que você os ignore explicitamente anotando com o atributo [IgnoreDataMember]
, opt-in significa o contrário. Além disso, implementamos a interface IRoutableViewModel
em nossa classe de modelo de exibição. Isso é necessário enquanto vamos usar o modelo de exibição como parte de uma pilha de navegação.
Detalhes de implementação para o modelo de visualização de loginViewModels / LoginViewModel.cs
[DataContract] public class LoginViewModel : ReactiveObject, IRoutableViewModel { private readonly ReactiveCommand<Unit, Unit> _login; private string _password; private string _username;
Os dois modelos de visualização implementam a interface IRoutableViewModel
e estão prontos para serem incorporados em uma tela de navegação. Agora é hora de implementar a interface IScreen
. Novamente, usamos os atributos [DataContract]
para indicar quais partes serializar e quais ignorar. No exemplo abaixo, o RoutingState
propriedade RoutingState
é deliberadamente declarado como público - isso permite que nosso serializador modifique a propriedade quando ela for desserializada.
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() {
Em nosso modelo de visualização principal, salvamos apenas um campo no disco - o do tipo RoutingState
. Não precisamos salvar o estado dos comandos reativos, pois sua disponibilidade depende totalmente do estado atual do roteador e muda reativamente. Para poder restaurar o roteador para o estado exato em que estava, incluímos informações de tipo estendidas de nossas implementações IRoutableViewModel
ao serializar o roteador. Usaremos TypenameHandling.All
configuração de Newtonsoft.Json para conseguir isso mais tarde. Colocamos o MainViewModel
na pasta ViewModels/
, ajustamos o espaço para nome como ReactiveUI.Samples.Suspension.ViewModels
.

Roteamento em um aplicativo Avalonia
No momento, implementamos o modelo de apresentação de nossa aplicação. Posteriormente, as classes de modelo de exibição podem ser extraídas em um assembly separado direcionado ao .NET Standard , para que a parte principal do nosso aplicativo possa ser reutilizada em várias estruturas da GUI do .NET. Agora é hora de implementar a parte GUI específica para Avalonia do nosso aplicativo. Criamos dois arquivos na pasta Views/
, denominados SearchView.xaml
e SearchView.xaml.cs
respectivamente. Essas são as duas partes de uma única exibição de pesquisa - a primeira é a interface do usuário descrita declarativamente em XAML e a segunda contém o C # code-behind. Essa é essencialmente a visualização do modelo de visualização de pesquisa criado anteriormente.
O dialeto XAML usado em Avalonia deve parecer imediatamente familiar para desenvolvedores provenientes de WPF, UWP ou XF. No exemplo acima, criamos um layout simples que contém uma caixa de texto e um botão que aciona a pesquisa. Vinculamos propriedades e comandos do SearchViewModel
aos controles declarados no SearchView
.
Visualizações / 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>
Visualizações / SearchView.xaml.cs
public sealed class SearchView : ReactiveUserControl<SearchViewModel> { public SearchView() {
Os desenvolvedores de WPF e UWP também podem encontrar o code-behind do arquivo SearchView.xaml
. Uma chamada para WhenActivated
é adicionada para executar a lógica de ativação da visualização. A entrada descartável como o primeiro argumento para WhenActivated
é descartada quando a exibição é desativada. Se o seu aplicativo estiver usando observáveis quentes (por exemplo, serviços de posicionamento, temporizadores, agregadores de eventos), seria uma decisão sábia anexar as assinaturas ao descartável composto WhenActivated
adicionando uma chamada DisposeWith
, para que a visualização cancele a inscrição desses observáveis quentes e vazamentos de memória não ocorrerão.
Detalhes de implementação para a visualização de loginViews / 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>
Visualizações / LoginView.xaml.cs
public sealed class LoginView : ReactiveUserControl<LoginViewModel> { public LoginView() { this.WhenActivated(disposables => { }); AvaloniaXamlLoader.Load(this); } }
Views/MainView.xaml.cs
arquivos Views/MainView.xaml
e Views/MainView.xaml.cs
. Adicionamos o controle RoutedViewHost
do espaço de nomes Avalonia.ReactiveUI
ao layout XAML da janela principal e vinculamos a propriedade Router
de MainViewModel
à propriedade RoutedViewHost.Router
. Adicionamos dois botões, um abre a página de pesquisa e outro abre a página de autorização.
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> <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>
Visualizações / MainView.xaml.cs
public sealed class MainView : ReactiveWindow<MainViewModel> { public MainView() { this.WhenActivated(disposables => { }); AvaloniaXamlLoader.Load(this); } }
Um simples aplicativo de demonstração de roteamento Avalonia e ReactiveUI está pronto agora. Quando um usuário pressiona os botões de pesquisa ou login, um comando que aciona a navegação é chamado e o RoutingState
é atualizado. O controle RoutedViewHost
XAML observa o estado de roteamento, tenta resolver a IViewFor<TViewModel>
apropriada de IViewFor<TViewModel>
partir de Locator.Current
. Se uma IViewFor<TViewModel>
for registrada, uma nova instância do controle será criada e incorporada à janela Avalonia.

Registramos nossas implementações IViewFor
e IViewFor
no método App.OnFrameworkInitializationCompleted
, usando Locator.CurrentMutable
. É necessário registrar IViewFor
implementações IViewFor
para que o controle RoutedViewHost
funcione. O registro de um IScreen
permite que nosso SearchViewModel
e LoginViewModel
sejam inicializados durante a desserialização, usando o construtor sem parâmetros.
App.xaml.cs
public override void OnFrameworkInitializationCompleted() {
Vamos iniciar nosso aplicativo e garantir que o roteamento funcione como deveria. Se algo der errado com a marcação da interface do usuário XAML, o compilador Avalonia XamlIl nos notificará sobre quaisquer erros no momento da compilação. Além disso, o XamlIl suporta a depuração de XAML !
dotnet run --framework netcoreapp3.0

Salvando e restaurando o estado do aplicativo
Agora é hora de implementar o driver de suspensão responsável por salvar e restaurar o estado do aplicativo quando o aplicativo estiver sendo suspenso e reiniciado. A classe AutoSuspendHelper
específica da plataforma cuida da inicialização, você, como desenvolvedor, só precisa criar uma instância na raiz de composição do aplicativo. Além disso, você precisa inicializar a fábrica RxApp.SuspensionHost.CreateNewAppState
. Se o aplicativo não tiver dados salvos ou se os dados salvos estiverem corrompidos, o ReactiveUI chama esse método de fábrica para criar uma instância padrão do objeto de estado do aplicativo.
Em seguida, invocamos o método RxApp.SuspensionHost.SetupDefaultSuspendResume
e passamos uma nova instância do ISuspensionDriver
para ele. Vamos implementar a interface ISuspensionDriver
usando Newtonsoft.Json e classes no espaço para nome System.IO
.
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); } }
A abordagem descrita tem uma desvantagem - algumas classes System.IO
não funcionam com a estrutura UWP, os aplicativos UWP são executados em uma sandbox e fazem as coisas de maneira diferente. Isso é bastante fácil de resolver - tudo o que você precisa fazer é usar as classes StorageFolder
e StorageFolder
vez do File
and Directory
ao direcionar-se à UWP. Para ler a pilha de navegação do disco, um driver de suspensão deve dar suporte à desserialização de objetos JSON em implementações concretas do IRoutableViewModel
, por isso usamos a configuração de serializador TypeNameHandling.All
Newtonsoft.Json. Registramos o driver de suspensão na raiz da composição do aplicativo, no método App.OnFrameworkInitializationCompleted
:
public override void OnFrameworkInitializationCompleted() {
A classe AutoSuspendHelper
do pacote Avalonia.ReactiveUI
configura ganchos de ciclo de vida para seu aplicativo, para que a estrutura ReactiveUI saiba quando gravar o estado do aplicativo no disco, usando a implementação ISuspensionDriver
fornecida. Após o lançamento do aplicativo, o driver de suspensão criará um novo arquivo JSON chamado appstate.json
. Depois de fazer alterações na interface do usuário (por exemplo, digite um pouco nos campos de texto ou clique em qualquer botão) e feche o aplicativo, o arquivo appstate.json
será semelhante ao seguinte:
appstate.jsonObserve que cada objeto JSON no arquivo contém uma chave $type
com um nome de tipo completo, incluindo espaço para nome.
{ "$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" } ] } } }
Se você fechar o aplicativo e depois iniciá-lo novamente, verá o mesmo conteúdo na tela como antes! A funcionalidade descrita funciona em cada plataforma suportada pelo ReactiveUI , incluindo UWP, WPF, Xamarin Forms ou Xamarin Native.

Bônus: A interface ISuspensionDriver
pode ser implementada usando o Akavache - um armazenamento de valores-chave persistente e assíncrono. Se você armazenar seus dados na seção UserAccount
ou na seção Secure
, no iOS e na UWP, seus dados serão copiados automaticamente para a nuvem e estarão disponíveis em todos os dispositivos nos quais o aplicativo está instalado. Além disso, existe um BundleSuspensionDriver
no pacote ReactiveUI.AndroidSupport
. As APIs do Xamarin.Essentials SecureStorage também podem ser usadas para armazenar dados. Você também pode armazenar o estado do aplicativo em um servidor remoto ou em um serviço em nuvem independente da plataforma.
Links úteis