Wzorce architektoniczne dla aplikacji komputerowych WinUI 3

W tym artykule pokazano, jak zastosować sprawdzone wzorce architektury do aplikacji klasycznych WinUI 3 utworzonych za pomocą Zestaw SDK do aplikacji systemu Windows. Dowiesz się, jak skonfigurować wstrzykiwanie zależności, zarządzać konfiguracją i strukturyzować kod na potrzeby korporacyjnych scenariuszy biznesowych (LOB).

Wymagania wstępne

  • Zestaw SDK do aplikacji systemu Windows wersji 1.5 lub nowszej
  • .NET 8 lub nowszy
  • Visual Studio 2022 w wersji 17.10 lub nowszej z obciążeniami Programowanie aplikacji klasycznych .NET i Programowanie aplikacji systemu Windows

Wstrzykiwanie zależności

Aplikacje klasyczne WinUI 3 nie zawierają wbudowanego kontenera iniekcji zależności (DI), takiego jak ASP.NET Core, ale można go dodać przy użyciu tego samego Microsoft.Extensions.DependencyInjection pakietu NuGet. Di sprawia, że kod jest testowalny, luźno powiązany i łatwiejszy w obsłudze.

Konfigurowanie kontenera DI

Zainstaluj pakiet NuGet:

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

Skonfiguruj hosta i usługi w 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

Jeśli zapomnisz zarejestrować usługę, GetService<T>() powyższy pomocnik zgłasza błąd ArgumentException w czasie wykonywania z brakującą nazwą typu. Uruchom aplikację i odwiedź każdą stronę w trakcie prac programistycznych, aby sprawdzić, czy wszystkie elementy zostały zarejestrowane poprawnie.

Wstrzykiwanie zależności do modelu ViewModels

Po skonfigurowaniu kontenera moduł ViewModels odbiera zależności za pomocą iniekcji konstruktora:

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

Okresy istnienia usługi

Wybierz odpowiedni okres istnienia podczas rejestrowania usług:

Okres istnienia Metoda Użyj dla
Singleton AddSingleton<T>() Nawigacja, stan całej aplikacji, pamięci podręczne
Zakresu AddScoped<T>() Konteksty poszczególnych okien lub okien dialogowych
Transient AddTransient<T>() ViewModels, usługi bezstanowe

Wskazówka

Zarejestruj ViewModele jako Transient, aby każda nawigacja powodowała utworzenie nowej instancji. Zarejestruj usługi, które przechowują stan całej aplikacji jako singleton.

Zarządzanie konfiguracją

Użyj Microsoft.Extensions.Configuration, aby zarządzać ustawieniami aplikacji w aplikacjach komputerowych, przy użyciu tego samego wzorca, który jest stosowany w ASP.NET Core.

Dodaj obsługę konfiguracji

Zainstaluj wymagane pakiety:

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

Utwórz plik appsettings.json w katalogu głównym projektu. W Eksplorator rozwiązań kliknij plik prawym przyciskiem myszy, wybierz pozycję Właściwości i ustaw opcję Kopiuj do katalogu wyjściowego na Kopiuj, jeśli nowszy. Możesz też dodać <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> do wpisu tego pliku w .csproj.

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

Powiąż konfigurację w konfiguracji 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>();
})

Korzystanie z konfiguracji w usługach

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

Trwałość ustawień użytkownika

W przypadku ustawień dla poszczególnych użytkowników, które przetrwają aktualizacje aplikacji, użyj Windows.Storage.ApplicationData (spakowanych aplikacji) lub lokalnego pliku JSON (aplikacje rozpakowane):

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

Aplikacje spakietowane (MSIX) mogą używać ApplicationData.Current.LocalSettings do prostych par klucz-wartość. Aplikacje bez pakietu muszą zarządzać własną lokalizacją przechowywania danych.

Flagi funkcjonalności

Zaimplementuj flagi funkcji, aby umożliwić stopniowe wdrażanie i testowanie A/B bez ponownego wdrażania.

Flagi funkcji lokalnych z konfiguracją

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

Integracja z Azure App Configuration

W przypadku flag funkcji zarządzanych przez chmurę użyj 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

W przypadku programowania lokalnego można użyć parametry połączenia zamiast DefaultAzureCredential. Przechowuj parametry połączenia w zmiennej środowiskowej lub w Menedżerze poświadczeń systemu Windows — nigdy w systemie kontroli wersji:

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

Wskazówka

Informacje o stopniowym wdrażaniu pakietu za pośrednictwem Sklepu znajdziesz w artykule Stopniowe wdrażanie pakietu.

Wzorce dla przedsiębiorstw i obszarów działalności biznesowej

Aplikacje biznesowe mają dodatkowe wymagania dotyczące tożsamości, ochrony danych i zarządzania urządzeniami.

Tożsamość i dostęp warunkowy

Użyj biblioteki MSAL (Microsoft Authentication Library) na potrzeby uwierzytelniania przedsiębiorstwa:

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

Important

Adres URI przekierowania http://localhost nadaje się do celów programistycznych. W przypadku aplikacji klasycznych w środowisku produkcyjnym należy zamiast tego użyć brokera Windows (WAM), który zapewnia logowanie jednokrotne przy użyciu konta Windows użytkownika i silniejszej ochrony tokenów.

Aplikacje dla przedsiębiorstw wdrożone za pośrednictwem usługi Intune mogą wymuszać zasady dostępu warunkowego, które wymagają:

  • Zgodność urządzenia (szyfrowanie, numer PIN, wersja systemu operacyjnego)
  • Uwierzytelnianie wieloskładnikowe
  • Ograniczenia lokalizacji sieciowej

Dane i buforowanie w trybie offline

Aplikacje biznesowe dla komputerów stacjonarnych często muszą działać w trybie offline. Zaimplementuj wzorzec repozytorium przy użyciu buforowania lokalnego:

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>();
        }
    }
}

Ochrona danych

Użyj Windows.Security.Cryptography.DataProtection (spakowanych aplikacji) lub .NET DataProtectionProvider do szyfrowania poufnych danych lokalnych.

Zainstaluj wymagany pakiet:

dotnet add package Microsoft.AspNetCore.DataProtection.Extensions

Następnie zarejestruj ochronę danych w kontenerze DI:

using Microsoft.AspNetCore.DataProtection;

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

Architektura warstwowa

Utwórz strukturę aplikacji WinUI 3 w warstwach, aby zapewnić przepływ zależności w jednym kierunku:

┌─────────────────────────────┐
│   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
└─────────────────────────────┘

Zasady:

  • Każda warstwa zależy tylko od warstwy bezpośrednio poniżej.
  • Modele view nigdy nie odwołują się do typów interfejsu użytkownika (Page, Window, ContentDialog).
  • Usługi definiują interfejsy; implementacje działają w warstwie danych.
  • Zarejestruj wszystkie zależności między warstwami w kontenerze DI.

Zgodność z poprzednimi wersjami i przechowywanie wersji

Po wydaniu nowych wersji aplikacji rozważ następujące kwestie:

  • Migracja danych: wersja lokalnego schematu bazy danych. Użyj modułu uruchamiającego migrację podczas uruchamiania, aby uaktualnić z dowolnego poprzedniego schematu do bieżącego.
  • Migracja ustawień: przechowywanie wersji schematu w pliku ustawień. Podczas ładowania zastosuj przekształcenia ze starych formatów na nowe.
  • Instalacje równoległe: MSIX domyślnie aktualizuje pakiet na miejscu. Aby uruchomić wiele wersji głównych obok siebie, przypisz każdą wersję unikatową nazwę rodziny pakietów w czasie projektowania.
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);
    }
}

Weryfikowanie konfiguracji

Uruchom aplikację i przejdź kolejno do każdej strony, aby sprawdzić, czy usługi są poprawnie rozwiązywane. Jeśli usługa nie jest zarejestrowana, w czasie wykonywania zostanie wyświetlony element InvalidOperationException z nazwą brakującego typu. Upewnij się również, że:

  • Wartości konfiguracji ładują się z appsettings.json (sprawdź powiązaną właściwość w debuggerze).
  • Flagi funkcji działają zgodnie z oczekiwaniami (przełącz flagę i uruchom ponownie).
  • Buforowanie w trybie offline zwraca dane, gdy sieć jest niedostępna.