Padrões de arquitetura para aplicativos da área de trabalho do WinUI 3

Este artigo mostra como aplicar padrões de arquitetura comprovados aos aplicativos de área de trabalho do WinUI 3 criados com o SDK do Aplicativo Windows. Você aprenderá a configurar a injeção de dependências, gerenciar configurações e estruturar o código para cenários corporativos de linha de negócios (LOB).

Pré-requisitos

  • SDK do Aplicativo Windows 1.5 ou posterior
  • .NET 8 ou posterior
  • Visual Studio 2022 versão 17.10 ou superior com as cargas de trabalho desenvolvimento para área de trabalho .NET e desenvolvimento de aplicativos do Windows

Injeção de dependência

Os aplicativos WinUI 3 para desktop não incluem um contêiner interno de injeção de dependência (DI), como o ASP.NET Core, mas você pode adicionar um usando o mesmo pacote NuGet Microsoft.Extensions.DependencyInjection. A DI torna seu código testável, flexívelmente acoplado e mais fácil de manter.

Configurar um contêiner de DI

Instale o pacote NuGet:

dotnet add package Microsoft.Extensions.DependencyInjection
dotnet add package Microsoft.Extensions.Hosting

Configure o host e os serviços no seu App.xaml.cs:

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.UI.Xaml;

public partial class App : Application
{
    public IHost Host { get; }

    public static T GetService<T>() where T : class
    {
        if ((App.Current as App)!.Host.Services.GetService(typeof(T)) is not T service)
        {
            throw new ArgumentException(
                $"{typeof(T)} needs to be registered in ConfigureServices.");
        }
        return service;
    }

    public App()
    {
        InitializeComponent();

        Host = Microsoft.Extensions.Hosting.Host.CreateDefaultBuilder()
            .UseContentRoot(AppContext.BaseDirectory)
            .ConfigureServices((context, services) =>
            {
                // Services
                services.AddSingleton<INavigationService, NavigationService>();
                services.AddSingleton<IDataService, DataService>();
                services.AddTransient<IDialogService, DialogService>();

                // ViewModels
                services.AddTransient<MainViewModel>();
                services.AddTransient<SettingsViewModel>();

                // Views
                services.AddTransient<MainPage>();
                services.AddTransient<SettingsPage>();
            })
            .Build();
    }
}

Note

Se você esquecer de registrar um serviço, o auxiliar GetService<T>() acima lançará uma ArgumentException em tempo de execução com o nome do tipo ausente. Execute o aplicativo e navegue até cada página durante o desenvolvimento para verificar se todos os registros estão corretos.

Injetar dependências em ViewModels

Com o contêiner configurado, seus ViewModels recebem dependências por meio da injeção de construtor:

using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;

public partial class MainViewModel : ObservableObject
{
    private readonly IDataService _dataService;
    private readonly INavigationService _navigationService;

    public MainViewModel(IDataService dataService, INavigationService navigationService)
    {
        _dataService = dataService;
        _navigationService = navigationService;
    }

    [ObservableProperty]
    private string _statusMessage = string.Empty;

    [RelayCommand]
    private async Task LoadDataAsync()
    {
        StatusMessage = "Loading...";
        var items = await _dataService.GetItemsAsync();
        StatusMessage = $"Loaded {items.Count} items";
    }
}

Tempo de vida do serviço

Escolha o tempo de vida apropriado ao registrar serviços:

Tempo de vida Método Usar para
Singleton AddSingleton<T>() Navegação, estado em todo o aplicativo, caches
Com escopo AddScoped<T>() Contextos por janela ou por diálogo
Transitório AddTransient<T>() ViewModels, serviços sem estado

Tip

Registre ViewModels como transitório para que cada navegação crie uma nova instância. Registre serviços que mantêm o estado global do aplicativo como Singleton.

Gerenciamento de configuração

Use Microsoft.Extensions.Configuration para gerenciar as configurações do aplicativo em aplicativos da área de trabalho, o mesmo padrão usado em ASP.NET Core.

Adicionar suporte à configuração

Instale os pacotes necessários:

dotnet add package Microsoft.Extensions.Configuration
dotnet add package Microsoft.Extensions.Configuration.Json
dotnet add package Microsoft.Extensions.Options

Crie um appsettings.json arquivo na raiz do projeto. Em Gerenciador de Soluções, clique com o botão direito do mouse no arquivo, selecione Propriedades e defina Copiar para Diretório de Saída para Copiar se for mais recente. Como alternativa, adicione <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> à entrada do arquivo em seu .csproj.

{
  "AppSettings": {
    "ApiBaseUrl": "https://api.contoso.com/v2",
    "MaxRetryCount": 3,
    "EnableTelemetry": true
  },
  "Logging": {
    "LogLevel": "Information"
  }
}

Vincule a configuração na sua configuração de DI:

.ConfigureServices((context, services) =>
{
    // Bind settings to a strongly-typed class
    services.Configure<AppSettings>(
        context.Configuration.GetSection("AppSettings"));

    // Inject IOptions<AppSettings> into services
    services.AddSingleton<IApiClient, ApiClient>();
})

Usar configuração em serviços

using Microsoft.Extensions.Options;

public class ApiClient : IApiClient
{
    private readonly AppSettings _settings;
    private readonly HttpClient _httpClient;

    public ApiClient(IOptions<AppSettings> options)
    {
        _settings = options.Value;
        _httpClient = new HttpClient
        {
            BaseAddress = new Uri(_settings.ApiBaseUrl)
        };
    }
}

Persistência de configurações do usuário

Para configurações por usuário que sobrevivem às atualizações do aplicativo, use Windows.Storage.ApplicationData (aplicativos empacotados) ou um arquivo JSON local (aplicativos não empacotados):

public class UserSettingsService : IUserSettingsService
{
    private readonly string _settingsPath;

    public UserSettingsService()
    {
        var localAppData = Environment.GetFolderPath(
            Environment.SpecialFolder.LocalApplicationData);
        _settingsPath = Path.Combine(localAppData, "Contoso", "MyApp", "settings.json");
    }

    public async Task SaveAsync<T>(string key, T value)
    {
        var settings = await LoadAllAsync();
        settings[key] = JsonSerializer.Serialize(value);
        Directory.CreateDirectory(Path.GetDirectoryName(_settingsPath)!);
        await File.WriteAllTextAsync(
            _settingsPath, JsonSerializer.Serialize(settings));
    }
}

Note

Aplicativos empacotados (MSIX) podem usar ApplicationData.Current.LocalSettings para pares de chave-valor simples. Aplicativos não empacotados devem gerenciar seu próprio local de armazenamento.

Flags de funcionalidade

Implemente flags de funcionalidade para permitir o lançamento gradual e testes A/B sem nova implantação.

Sinalizadores de recursos locais com configuração

public interface IFeatureFlagService
{
    bool IsEnabled(string featureName);
}

public class FeatureFlagService : IFeatureFlagService
{
    private readonly Dictionary<string, bool> _flags;

    public FeatureFlagService(IConfiguration configuration)
    {
        _flags = configuration.GetSection("FeatureFlags")
            .Get<Dictionary<string, bool>>() ?? new();
    }

    public bool IsEnabled(string featureName) =>
        _flags.TryGetValue(featureName, out var enabled) && enabled;
}

Integração com o Configuração de Aplicativos do Azure

Para sinalizadores de funcionalidade gerenciados na nuvem, use o Configuração de Aplicativos do Azure:

dotnet add package Microsoft.Extensions.Configuration.AzureAppConfiguration
dotnet add package Microsoft.FeatureManagement
using Azure.Identity;

Host = Microsoft.Extensions.Hosting.Host.CreateDefaultBuilder()
    .ConfigureAppConfiguration((context, config) =>
    {
        config.AddAzureAppConfiguration(options =>
        {
            options.Connect(
                    new Uri("https://<your-store>.azconfig.io"),
                    new DefaultAzureCredential())
                .UseFeatureFlags(flagOptions =>
                {
                    flagOptions.CacheExpirationInterval = TimeSpan.FromMinutes(5);
                });
        });
    })
    .ConfigureServices((context, services) =>
    {
        services.AddFeatureManagement(context.Configuration);
    })
    .Build();

Note

Para desenvolvimento local, você pode usar um cadeia de conexão em vez de DefaultAzureCredential. Armazene a cadeia de conexão em uma variável de ambiente ou no Gerenciador de Credenciais do Windows — nunca no controle de versão:

options.Connect(Environment.GetEnvironmentVariable("APP_CONFIG_CONNECTION_STRING"))

Tip

Para a distribuição gradual baseada na Store no nível do pacote, consulte distribuição gradual de pacotes.

Padrões corporativos e de linha de negócios

Os aplicativos de linha de negócios têm requisitos adicionais em relação à identidade, à proteção de dados e ao gerenciamento de dispositivos.

Identidade e acesso condicional

Use a biblioteca MSAL (Biblioteca do Microsoft Authenticator) para autenticação corporativa:

services.AddSingleton<IAuthService>(sp =>
{
    var app = PublicClientApplicationBuilder
        .Create("your-client-id")
        .WithAuthority(AzureCloudInstance.AzurePublic, "your-tenant-id")
        .WithRedirectUri("http://localhost")
        .Build();
    return new AuthService(app);
});

Importante

O http://localhost URI de redirecionamento é adequado para desenvolvimento. Para aplicativos de área de trabalho de produção, use o Windows broker (WAM), que fornece SSO com a conta Windows do usuário e uma proteção de token mais forte.

Os aplicativos empresariais implantados por meio do Intune podem impor políticas de acesso condicional que exigem:

  • Conformidade do dispositivo (criptografia, PIN, versão do sistema operacional)
  • Autenticação multifator
  • Restrições de localização de rede

Dados offline e cache

Os aplicativos LOB para desktop frequentemente precisam funcionar offline. Implementar um padrão de repositório com cache local:

public class CachedRepository<T> : IRepository<T> where T : class, IEntity
{
    private readonly IApiClient _apiClient;
    private readonly ILocalDatabase _localDb;

    public async Task<IReadOnlyList<T>> GetAllAsync(bool forceRefresh = false)
    {
        if (!forceRefresh)
        {
            var cached = await _localDb.GetAllAsync<T>();
            if (cached.Any())
                return cached;
        }

        try
        {
            var items = await _apiClient.GetAsync<List<T>>();
            await _localDb.UpsertAllAsync(items);
            return items;
        }
        catch (HttpRequestException)
        {
            // Offline fallback
            return await _localDb.GetAllAsync<T>();
        }
    }
}

Proteção de dados

Use Windows.Security.Cryptography.DataProtection (aplicativos empacotados) ou o DataProtectionProvider do .NET para criptografar dados locais confidenciais.

Instale o pacote necessário:

dotnet add package Microsoft.AspNetCore.DataProtection.Extensions

Em seguida, registre a proteção de dados em seu contêiner de DI:

using Microsoft.AspNetCore.DataProtection;

services.AddDataProtection()
    .SetApplicationName("Contoso.LOBApp")
    .ProtectKeysWithDpapi();

Arquitetura em camadas

Estruturar seu aplicativo WinUI 3 em camadas para manter as dependências fluindo em uma direção:

┌─────────────────────────────┐
│   Views (XAML + code-behind)│  ← UI layer, no business logic
├─────────────────────────────┤
│   ViewModels (MVVM Toolkit) │  ← Presentation logic, commands
├─────────────────────────────┤
│   Services / Use Cases      │  ← Business rules, orchestration
├─────────────────────────────┤
│   Repositories / Data       │  ← Data access, API clients, caching
└─────────────────────────────┘

Réguas:

  • Cada camada depende apenas da camada diretamente abaixo dela.
  • ViewModels nunca fazem referência a tipos de interface do usuário (Page, , Window). ContentDialog
  • Os serviços definem interfaces; as implementações residem na camada de dados.
  • Registre todas as dependências entre camadas no contêiner de DI.

Compatibilidade com versões anteriores e controle de versão

Ao lançar novas versões do seu aplicativo, considere:

  • Migração de dados: versão do esquema de banco de dados local. Use um executor de migração na inicialização para atualizar de qualquer esquema anterior para o atual.
  • Migração de configurações: armazene uma versão de esquema no arquivo de configurações. Ao carregar, aplicar transformações dos formatos antigos para os novos.
  • Instalações lado a lado: o MSIX atualiza um pacote em vigor por padrão. Para executar várias versões principais lado a lado, atribua a cada versão um nome de família de pacotes distinto em tempo de design.
public class DatabaseMigrator
{
    public async Task MigrateAsync(SqliteConnection db)
    {
        var currentVersion = await GetSchemaVersionAsync(db);

        if (currentVersion < 2)
            await ApplyMigration_v2(db);
        if (currentVersion < 3)
            await ApplyMigration_v3(db);

        await SetSchemaVersionAsync(db, LatestVersion);
    }
}

Verificar sua configuração

Execute o aplicativo e navegue até cada página para verificar se os serviços são resolvidos corretamente. Se um serviço não estiver registrado, você verá um InvalidOperationException em tempo de execução com o nome do tipo ausente. Confirme também que:

  • Os valores de configuração são carregados de appsettings.json (verifique uma propriedade vinculada no depurador).
  • Sinalizadores de recursos são avaliados conforme o esperado (alterne um sinalizador e reinicie).
  • O cache offline retorna dados quando a rede não está disponível.