WinUI 3 桌面應用程式的架構模式

本文說明如何將經過驗證的架構模式應用於使用 Windows 應用程式 SDK 建置的 WinUI 3 桌面應用程式。 你將學習如何設定相依性注入、管理組態,以及為企業業務線(LOB)情境組織程式碼結構。

先決條件

  • Windows 應用程式 SDK 1.5 或更新版本
  • .NET 8 或更新版本
  • Visual Studio 2022 版本 17.10 或更新版本,支援 .NET 桌面開發Windows 應用程式開發工作負載

依賴注入

WinUI 3 桌面應用程式不像 ASP.NET Core 那樣內建相依注入(DI)容器,但你可以用同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();
    }
}

Note

如果你忘記註冊服務,上方的 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

將 ViewModels 註冊為 暫態 ,讓每次導航都能建立一個全新的實例。 將保存整個應用程式狀態的服務註冊為 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));
    }
}

Note

打包應用程式(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();

Note

對於局部開發,你可以使用 連接字串 代替 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、作業系統版本)
  • 多因素驗證
  • 網路位置限制

離線資料與快取

桌面 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(套裝應用程式)或 .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 從不參考 UI 類型(PageWindowContentDialog)。
  • 服務定義介面;實作存在於資料層。
  • 在 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 載入(請在除錯器中檢查已繫結的屬性)。
  • 功能旗標會如預期般生效(切換旗標後重新啟動)。
  • 當網路無法使用時,離線快取會回傳資料。