Padrões de arquitetura para aplicações de desktop WinUI 3

Este artigo mostra como aplicar padrões comprovados de arquitetura a aplicações de desktop WinUI 3 construídas com o SDK de Aplicações Windows. Vai aprender a configurar injeção de dependências, gerir a configuração e estruturar código para cenários de linha de negócio empresarial (LOB).

Pré-requisitos

  • SDK de Aplicações Windows 1.5 ou posterior
  • .NET 8 ou posterior
  • Visual Studio 2022 versão 17.10 ou posterior com as cargas de desenvolvimento de desktop .NET e desenvolvimento de aplicações Windows

Injeção de dependência

As aplicações de desktop do WinUI 3 não incluem um contentor de injeção de dependências (DI) incorporado como o ASP.NET Core, mas podes adicionar um usando o mesmo Microsoft.Extensions.DependencyInjection pacote NuGet. O DI torna o teu código tavaliável, pouco acoplado e mais fácil de manter.

Configurar um contentor 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 vosso 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 te esqueceres de registar um serviço, a GetService<T>() função auxiliar acima lança uma ArgumentException em tempo de execução, indicando o nome do tipo em falta. Abra a aplicação e navegue até cada página durante o desenvolvimento para verificar se todos os registos estão corretos.

Injetar dependências nos ViewModels

Com o contentor configurado, os seus ViewModels recebem dependências através da injeção de construtores:

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";
    }
}

Duração dos serviços

Escolha a vida útil adequada ao registar serviços:

Duração de vida Método Uso para
Singleton AddSingleton<T>() Navegação, estado em toda a aplicação, caches
Âmbito de aplicação AddScoped<T>() Contextos por janela ou por diálogo
Transient AddTransient<T>() ViewModels, serviços sem estado

Sugestão

Regista ViewModels como Transient para que cada navegação crie uma instância nova. Registe os serviços que contêm o estado global da aplicação como Singleton.

Gestão de configuração

Utilize Microsoft.Extensions.Configuration para gerir as definições da aplicação em aplicações de computador, seguindo o mesmo padrão utilizado no ASP.NET Core.

Adicionar suporte de 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

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

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

Atribui a configuração na tua configuração 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>();
})

Utilize a configuração nos 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 das definições do utilizador

Para definições por utilizador que sobrevivem a atualizações de aplicações, use Windows.Storage.ApplicationData (aplicações empacotadas) ou um ficheiro JSON local (aplicações não empacotadas):

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

As aplicações empacotadas (MSIX) podem utilizar ApplicationData.Current.LocalSettings para pares chave-valor simples. As aplicações não embaladas têm de gerir a sua própria localização de armazenamento.

Marcadores de funcionalidade

Implementar sinalizadores de funcionalidade para permitir uma disponibilização gradual e testes A/B sem necessidade de nova implementação.

Sinalizadores locais de funcionalidades 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 do Azure App Configuration

Para sinalizadores de funcionalidades geridos na cloud, utilize o Azure App Configuration:

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, pode usar uma cadeia de ligação em vez de DefaultAzureCredential. Armazene a cadeia de ligação numa variável de ambiente ou no Windows Credential Manager — nunca no controlo de código-fonte:

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

Sugestão

Para o lançamento gradual baseado na Store ao nível dos pacotes, veja Lançamento gradual de pacotes.

Padrões Enterprise e LOB

As aplicações de negócio têm requisitos adicionais relacionados com identidade, proteção de dados e gestão de dispositivos.

Identidade e acesso condicional

Use MSAL (Biblioteca de Autenticação da Microsoft) para autenticação empresarial:

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 aplicações de ambiente de trabalho em produção, use antes o Windows broker (WAM), que fornece SSO com a conta do Windows do utilizador e uma proteção de tokens mais robusta.

Aplicações empresariais implementadas através do Intune podem impor políticas de acesso condicional que exigem:

  • Conformidade do dispositivo (encriptação, PIN, versão do SO)
  • Autenticação de múltiplos fatores
  • Restrições de localização da rede

Dados offline e cache

As aplicações LOB de ambiente de trabalho frequentemente precisam de funcionar offline. Implemente 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 (aplicações incluídas) ou o .NET DataProtectionProvider para encriptar dados locais sensíveis.

Instale o pacote necessário:

dotnet add package Microsoft.AspNetCore.DataProtection.Extensions

Depois, regista a proteção de dados no teu contentor DI:

using Microsoft.AspNetCore.DataProtection;

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

Arquitetura em camadas

Estrutura a tua aplicação WinUI 3 em camadas para manter as dependências a fluir numa só 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
└─────────────────────────────┘

Regras:

  • Cada camada depende apenas da camada diretamente abaixo.
  • Os ViewModels nunca referenciam tipos de interface (Page, Window, ContentDialog).
  • Os serviços definem interfaces; As implementações vivem na camada de dados.
  • Registar todas as dependências entre camadas no contentor DI.

Compatibilidade retroativa e versionamento

Quando lançar novas versões da sua aplicação, considere:

  • Migração de dados: Versão do esquema da base de dados local. Utiliza uma ferramenta de migração no arranque para atualizar qualquer esquema anterior para o esquema atual.
  • Migração de definições: Armazene uma versão do esquema no seu ficheiro de definições. Ao carregar, aplicar transformações dos formatos antigos para os novos.
  • Instalações lado a lado: O MSIX atualiza um pacote existente por defeito. Para executar várias versões principais lado a lado, atribui a cada versão um nome de família de pacotes distinto no momento do 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);
    }
}

Verifica a tua configuração

Executar a aplicação e aceder a cada página para verificar se os serviços são resolvidos corretamente. Se um serviço não estiver registado, verá um InvalidOperationException em tempo de execução com o nome do tipo em falta. Confirme também que:

  • Os valores de configuração carregam a partir de appsettings.json (verifique uma propriedade limitada no depurador).
  • As flags de funcionalidade são avaliadas como esperado (ative ou desative uma flag e reinicie).
  • A cache offline devolve dados quando a rede não está disponível.