WinUI 3 桌面应用的体系结构模式

本文介绍如何将经过验证的体系结构模式应用于使用 Windows 应用 SDK生成的 WinUI 3 桌面应用。 你将了解如何为企业业务线(LOB)方案设置依赖项注入、管理配置和结构代码。

先决条件

  • Windows 应用 SDK 1.5 或更高版本
  • .NET 8 或更高版本
  • 安装了 .NET 桌面开发Windows 应用程序开发 工作负载的 Visual Studio 2022 版本 17.10 或更高版本

依赖注入

WinUI 3 桌面应用不包含内置的依赖项注入(DI)容器(如 ASP.NET Core),但可以使用同Microsoft.Extensions.DependencyInjection一 NuGet 包添加一个。 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,其中包含缺失的类型名称。 运行应用并在开发过程中导航到每个页面,以验证所有注册是否正确。

将依赖项注入到 ViewModel 中

配置容器后,ViewModels 通过构造函数注入接收依赖项:

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

服务生存期

注册服务时选择适当的生存期:

使用寿命 方法 用于
单例 AddSingleton<T>() 导航,应用范围状态,缓存
作用域 AddScoped<T>() 按窗口或按对话框划分的上下文
瞬态的 AddTransient<T>() ViewModels、无状态服务

Tip

将 ViewModel 注册为 暂时性 模型,以便每个导航创建一个新的实例。 将持有应用范围内状态的服务注册为 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文件。 在解决方案资源管理器中,右键单击该文件,选择“属性”,并将“复制到输出目录”设置为“复制”(如果更新)。 或者,将 <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

有关基于商店、在软件包级别进行的逐步推出,请参阅 软件包逐步推出

企业和 LOB 模式

业务线应用对标识、数据保护和设备管理有其他要求。

标识和条件访问

使用 MSAL (Microsoft 身份验证库) 进行企业身份验证:

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

http://localhost 重定向 URI 适合用于开发。 对于生产桌面应用,请改用 Windows 中转站 (WAM),后者为用户的Windows帐户提供 SSO 和更强的令牌保护。

通过 Intune 部署的企业应用可以强制实施需要的条件访问策略:

  • 设备符合性(加密、PIN、OS 版本)
  • 多重身份验证
  • 网络位置限制

脱机数据和缓存

桌面 LOB 应用通常需要脱机工作。 使用本地缓存实现存储库模式:

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(打包的应用)或.NETDataProtectionProvider来加密敏感的本地数据。

安装所需的包:

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 从不引用 UI 类型 (Page, , WindowContentDialog)。
  • 服务定义接口;实现位于数据层中。
  • 在 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 加载(在调试器中检查某个已绑定的属性)。
  • 功能标志的求值结果符合预期(切换标志并重启)。
  • 当网络不可用时,脱机缓存将返回数据。