WinUI 3 デスクトップ アプリのアーキテクチャ パターン

この記事では、Windows アプリ SDKで構築された WinUI 3 デスクトップ アプリに実証済みのアーキテクチャ パターンを適用する方法について説明します。 エンタープライズ基幹業務 (LOB) シナリオの依存関係の挿入、構成の管理、および構造コードを設定する方法について説明します。

前提条件

  • Windows アプリ SDK 1.5 以降
  • .NET 8 以降
  • .NET デスクトップ開発およびWindows アプリケーション開発ワークロードを含む Visual Studio 2022 バージョン 17.10 以降

依存関係の挿入

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 に依存関係を挿入する

コンテナーが構成されている場合、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";
    }
}

サービスの有効期間

サービスを登録するときに適切な有効期間を選択します。

有効期間 Method 用途
シングルトン AddSingleton<T>() ナビゲーション、アプリ全体の状態、キャッシュ
スコープ AddScoped<T>() ウィンドウごとまたはダイアログごとのコンテキスト
一時的な AddTransient<T>() ViewModels、ステートレスサービス

ヒント

各ナビゲーションで新しいインスタンスが作成されるように、ViewModels を 一時的 として登録します。 アプリ全体の状態を保持するサービスを シングルトンとして登録します。

構成管理

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 App Configuration との統合

クラウド管理機能フラグの場合は、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

ローカル開発では、DefaultAzureCredentialの代わりに接続文字列を使用できます。 接続文字列を環境変数またはWindows資格情報マネージャーに格納します。ソース管理には格納されません。

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

ヒント

パッケージ レベルでのストア ベースの段階的なロールアウトについては、「 段階的なパッケージのロールアウト」を参照してください。

エンタープライズパターンと LOB パターン

基幹業務アプリには、ID、データ保護、デバイス管理に関する追加の要件があります。

ID と条件付きアクセス

エンタープライズ認証には 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

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 (パッケージ アプリ) または.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
└─────────────────────────────┘

準則:

  • 各レイヤーは、その真下のレイヤーにのみ依存します。
  • ViewModel は 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 から読み込まれます (デバッガーでバインドされたプロパティを確認します)。
  • 機能フラグは期待どおりに評価されます (フラグを切り替えて再起動します)。
  • オフライン キャッシュは、ネットワークが使用できない場合にデータを返します。