Architekturmuster für WinUI 3-Desktop-Apps

In diesem Artikel wird gezeigt, wie bewährte Architekturmuster auf WinUI 3-Desktop-Apps angewendet werden, die mit dem Windows App SDK erstellt wurden. Sie erfahren, wie Sie Abhängigkeitsinjektion einrichten, Konfiguration verwalten und Code für LOB-Geschäftsszenarien in Unternehmen strukturieren.

Voraussetzungen

  • Windows App SDK 1.5 oder höher
  • .NET 8 oder höher
  • Visual Studio 2022, Version 17.10 oder höher, mit den Workloads .NET-Desktopentwicklung und Windows-Anwendungsentwicklung

Abhängigkeitsinjektion

WinUI 3-Desktop-Apps enthalten keinen integrierten Di-Container (Abhängigkeitseinfügung) wie ASP.NET Core, aber Sie können einen Container mit demselben Microsoft.Extensions.DependencyInjection NuGet-Paket hinzufügen. DI macht Ihren Code testbar, locker gekoppelt und einfacher zu warten.

DI-Container einrichten

Installieren Sie das NuGet-Paket:

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

Konfigurieren Sie den Host und die Dienste in Ihrem 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

Wenn Sie vergessen, einen Dienst zu registrieren, löst das obige Hilfsprogramm GetService<T>() zur Laufzeit eine ArgumentException unter Angabe des fehlenden Typnamens aus. Führen Sie die App aus, und navigieren Sie während der Entwicklung zu jeder Seite, um sicherzustellen, dass alle Registrierungen korrekt sind.

Einfügen von Abhängigkeiten in ViewModels

Nachdem der Container konfiguriert wurde, empfangen Ihre ViewModels Abhängigkeiten über die Konstruktoreinfügung:

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

Dienstlebensdauer

Wählen Sie beim Registrieren von Diensten die entsprechende Lebensdauer aus:

Lebensdauer Method Verwendung für
Singleton AddSingleton<T>() Navigation, appweiter Zustand, Caches
Bereichsbezogen AddScoped<T>() Kontexte pro Fenster oder Dialogfeld
Flüchtig AddTransient<T>() ViewModels, zustandslose Dienste

Tip

Registrieren Sie ViewModels als vorübergehend , sodass jede Navigation eine neue Instanz erstellt. Registrieren Sie Dienste, die den anwendungsweiten Zustand verwalten, als Singleton.

Konfigurationsverwaltung

Wird Microsoft.Extensions.Configuration verwendet, um App-Einstellungen in Desktop-Apps zu verwalten, dasselbe Muster, das in ASP.NET Core verwendet wird.

Hinzufügen der Konfigurationsunterstützung

Installieren Sie die erforderlichen Pakete:

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

Erstellen Sie eine appsettings.json Datei im Projektstamm. Klicken Sie im Projektmappen-Explorer mit der rechten Maustaste auf die Datei, wählen Sie Eigenschaften aus, und setzen Sie In Ausgabeverzeichnis kopieren auf Kopieren, wenn neuer. Alternativ fügen Sie <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> zum Eintrag der Datei in Ihrer .csproj hinzu.

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

Binden Sie die Konfiguration in Ihrem DI-Setup:

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

Konfiguration in Diensten verwenden

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

Persistenz der Benutzereinstellungen

Verwenden Sie Windows.Storage.ApplicationData für Benutzereinstellungen, die App-Updates überleben, (verpackte Apps) oder eine lokale JSON-Datei (entpackte Apps):

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

Verpackte Apps (MSIX) können ApplicationData.Current.LocalSettings für einfache Schlüssel-Wert-Paare verwenden. Entpackte Apps müssen ihren eigenen Speicherort verwalten.

Feature-Flags

Implementieren Sie Featurekennzeichnungen, um schrittweises Rollout und A/B-Tests ohne erneute Bereitstellung zu ermöglichen.

Lokale Featurekennzeichnungen mit Konfiguration

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

Azure App Configuration-Integration

Verwenden Sie für in der Cloud verwaltete Featurekennzeichnungen 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

Für die lokale Entwicklung können Sie anstelle von DefaultAzureCredential eine Verbindungszeichenfolge verwenden. Speichern Sie die Verbindungszeichenfolge in einer Umgebungsvariable oder der Windows-Anmeldeinformationsverwaltung – niemals in der Quellcodeverwaltung:

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

Tip

Informationen zur storebasierten schrittweisen Paketeinführung auf Paketebene finden Sie unter Schrittweise Paketeinführung.

Unternehmens- und Line-of-Business-Muster

Branchen-Apps verfügen über zusätzliche Anforderungen hinsichtlich Identität, Datenschutz und Geräteverwaltung.

Identität und bedingter Zugriff

Verwenden Sie MSAL (Microsoft Authentication Library (MSAL)) für die Unternehmensauthentifizierung:

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

Der Umleitungs-URI http://localhost eignet sich für die Entwicklung. Verwenden Sie für Produktionsdesktop-Apps stattdessen den Windows Broker (WAM), der SSO mit dem Windows Konto des Benutzers und einem stärkeren Tokenschutz bereitstellt.

Unternehmens-Apps, die über Intune bereitgestellt werden, können Richtlinien für bedingten Zugriff erzwingen, die Folgendes erfordern:

  • Gerätekompatibilität (Verschlüsselung, PIN, Betriebssystemversion)
  • Mehrfaktor-Authentifizierung
  • Einschränkungen für Netzwerkstandorte

Offlinedaten und Zwischenspeichern

Desktop-LOB-Apps müssen häufig offline funktionieren. Implementieren Eines Repositorymusters mit lokaler Zwischenspeicherung:

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

Datenschutz

Verwenden Sie Windows.Security.Cryptography.DataProtection (verpackte Apps) oder die .NET DataProtectionProvider zum Verschlüsseln vertraulicher lokaler Daten.

Installieren Sie das erforderliche Paket:

dotnet add package Microsoft.AspNetCore.DataProtection.Extensions

Registrieren Sie dann den Datenschutz in Ihrem DI-Container:

using Microsoft.AspNetCore.DataProtection;

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

Mehrschichtige Architektur

Strukturieren Sie Ihre WinUI 3-App in Ebenen, um Abhängigkeiten in einer Richtung zu halten:

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

Regeln:

  • Jede Ebene hängt nur von der Ebene direkt darunter ab.
  • ViewModels verweisen niemals auf UI-Typen (Page, Window, ContentDialog).
  • Dienste definieren Schnittstellen; Implementierungen befinden sich auf der Datenebene.
  • Registrieren Sie alle layerübergreifenden Abhängigkeiten im DI-Container.

Abwärtskompatibilität und Versionsverwaltung

Berücksichtigen Sie bei der Veröffentlichung neuer Versionen Ihrer App Folgendes:

  • Datenmigration: Version Ihres lokalen Datenbankschemas. Verwenden Sie einen Migrationsläufer beim Start, um ein Upgrade von einem vorherigen Schema auf das aktuelle Schema durchzuführen.
  • Einstellungsmigration: Speichern Sie eine Schemaversion in Ihrer Einstellungsdatei. Beim Laden Transformationen von alten in neue Formate anwenden.
  • Parallele Installationen: MSIX aktualisiert standardmäßig ein Paket. Um mehrere Hauptversionen nebeneinander auszuführen, weisen Sie jeder Version zur Entwurfszeit einen eindeutigen Paketfamiliennamen zu.
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);
    }
}

Überprüfen Des Setups

Führen Sie die App aus, und navigieren Sie zu jeder Seite, um zu überprüfen, ob die Dienste ordnungsgemäß aufgelöst werden. Wenn ein Dienst nicht registriert ist, wird zur Laufzeit ein InvalidOperationException unter Angabe des fehlenden Typnamens angezeigt. Vergewissern Sie sich außerdem, dass:

  • Konfigurationswerte werden aus appsettings.json geladen (prüfen Sie eine gebundene Eigenschaft im Debugger).
  • Feature-Flags funktionieren wie erwartet (ein Flag umschalten und neu starten).
  • Die Offlinezwischenspeicherung gibt Daten zurück, wenn das Netzwerk nicht verfügbar ist.