Шаблоны архитектуры для классических приложений WinUI 3

В этой статье показано, как применять проверенные шаблоны архитектуры к классическим приложениям WinUI 3, созданным с помощью Windows App SDK. Вы узнаете, как настроить внедрение зависимостей, управлять конфигурацией и структурировать код для сценариев корпоративных бизнес-приложений (LOB).

Необходимые условия

  • Windows App SDK 1.5 или более поздней версии
  • .NET 8 или более поздней версии
  • Visual Studio 2022 версии 17.10 или более поздней версии с рабочими нагрузками разработки классических приложений .NET и разработки приложений Windows

Внедрение зависимостей

Настольные приложения WinUI 3 не включают встроенный контейнер для внедрения зависимостей (DI), как ASP.NET Core, но его можно добавить с помощью того же пакета NuGet Microsoft.Extensions.DependencyInjection. DI позволяет тестировать код, свободно сочетать и упростить обслуживание.

Настройка контейнера DI

Установите пакет NuGet:

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

Настройте хост и службы в 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();
    }
}

Замечание

Если вы забудете зарегистрировать службу, то вспомогательный метод GetService<T>() выше во время выполнения выбросит исключение ArgumentException с указанием имени отсутствующего типа. Запустите приложение и перейдите на каждую страницу во время разработки, чтобы проверить правильность всех регистраций.

Внедрение зависимостей в ViewModels

После настройки контейнера ваши ViewModel получают зависимости через конструкторное внедрение зависимостей:

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

Срок службы

Выберите правильное время жизни при регистрации сервисов:

Продолжительность жизни Метод Применение для
Singleton AddSingleton<T>() Навигация, состояние приложения, кэши
Сфокусированные AddScoped<T>() Контексты для каждого окна или диалогового окна
Transient AddTransient<T>() ViewModels, сервисы без состояния

Tip

Зарегистрируйте модели представления как Transient, чтобы при каждой навигации создавался новый экземпляр. Зарегистрируйте службы, которые хранят состояние всего приложения, как Singleton.

Управление конфигурацией

Используйте Microsoft.Extensions.Configuration для управления параметрами приложения в настольных приложениях, используя тот же шаблон, что и в ASP.NET Core.

Добавление поддержки конфигурации

Установите необходимые пакеты:

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

Создайте файл appsettings.json в корневом каталоге проекта. В Обозреватель решений щелкните файл правой кнопкой мыши, выберите "Свойства" и задайте для параметра Copy to Output Directory значение Copy if new. Или добавьте <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> к записи файла в вашем .csproj.

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

Привяжите конфигурацию при настройке 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>();
})

Использование конфигурации в службах

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

Сохраняемость параметров пользователя

Для пользовательских параметров, которые сохраняются после обновлений приложения, используйте Windows.Storage.ApplicationData (упакованные приложения) или локальный JSON-файл (неупакованные приложения):

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

Замечание

Упакованные приложения (MSIX) могут использовать ApplicationData.Current.LocalSettings для простых пар "ключ — значение". Приложения без упаковки должны самостоятельно управлять своим местом хранения.

Флаги функций

Реализуйте флаги функций, чтобы обеспечить постепенное развертывание и тестирование A/B без повторного развертывания.

Флаги локальных компонентов с конфигурацией

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

Для флагов функций, управляемых облаком, используйте Конфигурация приложений 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();

Замечание

Для локальной разработки можно использовать строка подключения вместо DefaultAzureCredential. Сохраните строку подключения в переменной среды или в диспетчере учетных данных Windows — никогда не в системе контроля версий:

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

Tip

Сведения о постепенном развертывании через Магазин на уровне пакета см. в разделе Постепенное развертывание пакета.

Корпоративные и бизнес-шаблоны

Бизнес-приложения имеют дополнительные требования к идентификации, защите данных и управлению устройствами.

Удостоверение и условный доступ

Используйте MSAL (Microsoft Authentication Library) для корпоративной проверки подлинности:

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

URI перенаправления http://localhost подходит для разработки. Для классических приложений, используемых в рабочей среде, вместо этого используйте брокер Windows (WAM), который обеспечивает единый вход с использованием учетной записи Windows пользователя и более надежную защиту токенов.

Корпоративные приложения, развернутые с помощью Intune, могут применять политики условного доступа, требующие:

  • Соответствие устройств (шифрование, ПИН-код, версия ОС)
  • Многофакторная проверка подлинности
  • Ограничения сетевого расположения

Данные в автономном режиме и кэширование

Бизнес-приложения для настольных компьютеров часто должны работать в автономном режиме. Реализуйте шаблон репозитория с помощью локального кэширования:

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

Защита данных

Используйте Windows.Security.Cryptography.DataProtection (упакованные приложения) или .NET DataProtectionProvider для шифрования конфиденциальных локальных данных.

Установите необходимый пакет:

dotnet add package Microsoft.AspNetCore.DataProtection.Extensions

Затем зарегистрируйте защиту данных в контейнере DI:

using Microsoft.AspNetCore.DataProtection;

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

Многоуровневая архитектура

Структурируйте приложение WinUI 3 на уровнях, чтобы обеспечить поток зависимостей в одном направлении:

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

Правила:

  • Каждый слой зависит только от слоя непосредственно под ним.
  • ViewModels никогда не ссылается на типы пользовательского интерфейса (Page, Window, ). ContentDialog
  • Службы определяют интерфейсы; реализации живут на уровне данных.
  • Зарегистрируйте все межуровневые зависимости в контейнере DI.

Обратная совместимость и управление версиями

При выпуске новых версий приложения рассмотрите следующие возможности:

  • Миграция данных: версия схемы локальной базы данных. Используйте средство выполнения миграции при запуске, чтобы обновить любую предыдущую схему до текущей.
  • Миграция параметров. Сохраните версию схемы в файле параметров. При загрузке примените преобразования из старых форматов к новым.
  • Параллельные установки: MSIX по умолчанию обновляет пакет на месте. Чтобы запустить несколько основных версий параллельно, назначьте каждую версию отдельному имени семейства пакетов во время разработки.
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);
    }
}

Проверка настройки

Запустите приложение и перейдите на каждую страницу, чтобы проверить правильность разрешения служб. Если служба не зарегистрирована, во время выполнения вы увидите InvalidOperationException с именем отсутствующего типа. Также убедитесь, что:

  • Значения конфигурации загружают из appsettings.json (проверьте привязанное свойство в отладчике).
  • Флаги функций работают как ожидается (переключите флаг и перезапустите).
  • Автономное кэширование возвращает данные, когда сеть недоступна.