mcpField
W
wpf-best-practices

wpf-best-practices

github.com/jcurbelo開発6605 回インストール35 週間前に更新
リポジトリ

wpf-best-practices について

C#、.NET、WPF のデスクトップアプリ開発者向けの実践ガイドです。MVVM や依存性注入から、ウィンドウ管理、ログ、テストまでの設計・実装パターンを扱います。

主な機能

  1. 01C# のスタイルとプロジェクト構成
  2. 02MVVM とデータバインディング
  3. 03依存性注入と非同期処理
  4. 04システムトレイとウィンドウ管理
  5. 05設定、エラー処理、ログ
  6. 06テストとネットワーク処理

活用シーン

  1. 01WPF プロジェクトの構成を計画
  2. 02ViewModel と XAML のコマンドバインディングを実装
  3. 03バックグラウンド処理と UI スレッド更新に対応
  4. 04デスクトップアプリのログとテストを整備

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/jcurbelo/skills --skill wpf-best-practices

インストール後、Claude Code で/wpf-best-practicesを入力して読み込まれたか確認します。

詳細

WPF のベストプラクティス

C#、.NET、WPF デスクトップアプリケーション開発に精通しており、MVVM アーキテクチャ、依存性注入、async/await パターン、Windows デスクトップとの統合について深い知識を備えています。

C# のコードスタイル

基本原則
  • すべてのコードとドキュメントには英語を使用します
  • 変数と関数には、常に型を宣言します(パラメーターと戻り値を含む)
  • プロジェクト全体で nullable 参照型を有効にします
  • ファイルスコープの名前空間を使用します。波括弧や入れ子は使いません
  • 簡潔で保守しやすいコードを書き、過度な設計は避けます
ファイルスコープの名前空間

常にファイルスコープの名前空間を使用します。

namespace MyApp.Services;

public class SettingsService
{
    // No extra indentation level
}
Nullable 参照型

csproj で常に有効にします(<Nullable>enable</Nullable>)。

public class AppNotification
{
    public string Title { get; set; } = "";       // Non-nullable with default
    public string Message { get; set; } = "";
    public string? Channel { get; set; }           // Explicitly nullable
    public string[]? Tags { get; set; }
}
命名規則
  • 型、インターフェイス、プロパティ、メソッド、イベント、定数には PascalCase を使用します
  • ローカル変数とパラメーターには camelCase を使用します
  • プライベートフィールドには _camelCase を使用します
  • インターフェイスには IPascalCase を使用します(I を接頭辞にします)
  • 環境変数に限り UPPERCASE を使用します
  • bool 型のプロパティ/フィールドには Is、Has、Can、Should を接頭辞として付けます
public class ConnectionService
{
    private readonly ILogger _logger;
    private readonly string _serverUrl;
    private bool _isConnected;

    public bool IsConnected => _isConnected;
    public bool CanReconnect { get; private set; }
}
最新の C# 機能

switch 式を使用します。

public string StatusIcon => Status switch
{
    ConnectionStatus.Connected    => "🟢",
    ConnectionStatus.Connecting   => "🟡",
    ConnectionStatus.Disconnected => "🔴",
    _                             => "⚪"
};

範囲/スライス演算子を使用します。

private static string Capitalize(string s) =>
    string.IsNullOrEmpty(s) ? s : char.ToUpper(s[0]) + s[1..];

冗長な既定値の初期化は避けます。

// Good
private bool _disposed;
private int _retryCount;

// Bad — redundant
private bool _disposed = false;
private int _retryCount = 0;

プロジェクト構成

MyApp/
├── MyApp.sln                    # Solution file
├── src/
│   ├── MyApp/                   # Main WPF application
│   │   ├── App.xaml             # Application entry, resource dictionaries
│   │   ├── App.xaml.cs          # DI setup, app lifecycle, crash handlers
│   │   ├── Views/               # XAML windows and user controls
│   │   ├── ViewModels/          # MVVM presentation logic
│   │   ├── Models/              # Data classes and enums
│   │   ├── Services/            # Business logic (DI singletons)
│   │   │   └── Interfaces/      # Service contracts
│   │   ├── Utilities/           # Helpers (encryption, screen, JSON)
│   │   └── Constants/           # App-wide constants
│   └── MyApp.Shared/            # Cross-platform shared library
│       └── MyApp.Shared.csproj
├── tests/
│   └── MyApp.Tests/             # xUnit test project
│       └── MyApp.Tests.csproj
└── .github/
    └── workflows/               # CI/CD
プロジェクトファイルの構成

WPF アプリの csproj:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>WinExe</OutputType>
    <TargetFramework>net10.0-windows</TargetFramework>
    <UseWPF>true</UseWPF>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>
</Project>

クロスプラットフォーム対応の共有ライブラリの csproj:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <InternalsVisibleTo Include="MyApp.Tests" />
  </ItemGroup>
</Project>

MVVM パターン

ViewModelBase

すべての ViewModel は共有の基底クラスを継承します。

namespace MyApp.ViewModels;

public abstract class ViewModelBase : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler? PropertyChanged;

    protected void OnPropertyChanged([CallerMemberName] string? name = null) =>
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));

    protected bool SetProperty<T>(ref T field, T value,
        [CallerMemberName] string? name = null)
    {
        if (EqualityComparer<T>.Default.Equals(field, value)) return false;
        field = value;
        OnPropertyChanged(name);
        return true;
    }
}

使用例:

public class MainViewModel : ViewModelBase
{
    private string _title = "";
    public string Title
    {
        get => _title;
        set => SetProperty(ref _title, value);
    }

    private bool _isLoading;
    public bool IsLoading
    {
        get => _isLoading;
        set => SetProperty(ref _isLoading, value);
    }
}
RelayCommand

XAML のコマンドバインディングには、シンプルな RelayCommand を使用します。

namespace MyApp.ViewModels;

public class RelayCommand : ICommand
{
    private readonly Action<object?> _execute;
    private readonly Func<object?, bool>? _canExecute;

    public RelayCommand(Action<object?> execute, Func<object?, bool>? canExecute = null)
    {
        _execute = execute;
        _canExecute = canExecute;
    }

    public event EventHandler? CanExecuteChanged
    {
        add => CommandManager.RequerySuggested += value;
        remove => CommandManager.RequerySuggested -= value;
    }

    public bool CanExecute(object? parameter) => _canExecute?.Invoke(parameter) ?? true;
    public void Execute(object? parameter) => _execute(parameter);
}

public class RelayCommand<T> : ICommand
{
    private readonly Action<T?> _execute;
    private readonly Func<T?, bool>? _canExecute;

    public RelayCommand(Action<T?> execute, Func<T?, bool>? canExecute = null)
    {
        _execute = execute;
        _canExecute = canExecute;
    }

    public event EventHandler? CanExecuteChanged
    {
        add => CommandManager.RequerySuggested += value;
        remove => CommandManager.RequerySuggested -= value;
    }

    public bool CanExecute(object? parameter) =>
        _canExecute?.Invoke(parameter is T t ? t : default) ?? true;
    public void Execute(object? parameter) =>
        _execute(parameter is T t ? t : default);
}
AsyncRelayCommand

UI にバインドする非同期操作には、次を使用します。

namespace MyApp.ViewModels;

public class AsyncRelayCommand : ICommand
{
    private readonly Func<object?, Task> _execute;
    private readonly Func<object?, bool>? _canExecute;
    private bool _isExecuting;

    public AsyncRelayCommand(Func<object?, Task> execute,
        Func<object?, bool>? canExecute = null)
    {
        _execute = execute;
        _canExecute = canExecute;
    }

    public event EventHandler? CanExecuteChanged
    {
        add => CommandManager.RequerySuggested += value;
        remove => CommandManager.RequerySuggested -= value;
    }

    public bool CanExecute(object? parameter) =>
        !_isExecuting && (_canExecute?.Invoke(parameter) ?? true);

    public async void Execute(object? parameter)
    {
        if (_isExecuting) return;
        _isExecuting = true;
        CommandManager.InvalidateRequerySuggested();

        try
        {
            await _execute(parameter);
        }
        finally
        {
            _isExecuting = false;
            CommandManager.InvalidateRequerySuggested();
        }
    }
}
XAML のデータバインディング
<Window x:Class="MyApp.Views.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="{Binding Title}">
    <Grid>
        <TextBlock Text="{Binding StatusText}"
                   Visibility="{Binding IsLoading,
                     Converter={StaticResource BoolToVisibilityConverter}}" />
        <Button Content="Save"
                Command="{Binding SaveCommand}"
                IsEnabled="{Binding CanSave}" />
    </Grid>
</Window>

コードビハインドでは DataContext の設定だけを行います。

public partial class MainWindow : Window
{
    public MainWindow(MainViewModel viewModel)
    {
        InitializeComponent();
        DataContext = viewModel;
    }
}

依存性注入

すべてのサービスの登録と構成には Microsoft.Extensions.DependencyInjection を使用します。

App.xaml.cs でのセットアップ
namespace MyApp;

public partial class App : Application
{
    private ServiceProvider? _serviceProvider;

    protected override void OnStartup(StartupEventArgs e)
    {
        var services = new ServiceCollection();
        ConfigureServices(services);
        _serviceProvider = services.BuildServiceProvider();

        var mainWindow = _serviceProvider.GetRequiredService<MainWindow>();
        mainWindow.Show();

        base.OnStartup(e);
    }

    private static void ConfigureServices(IServiceCollection services)
    {
        // Services — singletons for app-wide state
        services.AddSingleton<ISettingsService, SettingsService>();
        services.AddSingleton<IConnectionService, ConnectionService>();

        // HTTP clients
        services.AddHttpClient("AppClient", client =>
        {
            client.DefaultRequestHeaders.Add("User-Agent", "MyApp/1.0");
            client.Timeout = TimeSpan.FromSeconds(10);
        });

        // ViewModels — transient, created per window
        services.AddTransient<MainViewModel>();

        // Windows
        services.AddTransient<MainWindow>();
    }

    protected override void OnExit(ExitEventArgs e)
    {
        (_serviceProvider as IDisposable)?.Dispose();
        base.OnExit(e);
    }
}
サービスパターン

必ずインターフェイスを定義します。コンストラクター経由で注入します。サービスロケーターは使用しません。

// Interface
namespace MyApp.Services.Interfaces;

public interface ISettingsService
{
    string GetValue(string key, string defaultValue = "");
    void SetValue(string key, string value);
    void Save();
}

// Implementation
namespace MyApp.Services;

public class SettingsService : ISettingsService
{
    private readonly string _settingsPath;
    private Dictionary<string, string> _settings = new();

    public SettingsService()
    {
        _settingsPath = Path.Combine(
            Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
            "MyApp", "settings.json");
        Load();
    }

    public string GetValue(string key, string defaultValue = "") =>
        _settings.TryGetValue(key, out var value) ? value : defaultValue;

    public void SetValue(string key, string value) =>
        _settings[key] = value;

    public void Save()
    {
        var dir = Path.GetDirectoryName(_settingsPath)!;
        Directory.CreateDirectory(dir);
        var json = JsonSerializer.Serialize(_settings,
            new JsonSerializerOptions { WriteIndented = true });
        File.WriteAllText(_settingsPath, json);
    }

    private void Load()
    {
        try
        {
            if (!File.Exists(_settingsPath)) return;
            var json = File.ReadAllText(_settingsPath);
            _settings = JsonSerializer.Deserialize<Dictionary<string, string>>(json) ?? new();
        }
        catch { _settings = new(); }
    }
}
アンチパターン:サービスロケーター

静的プロバイダーからサービスを解決しないでください。必ずコンストラクター経由で注入します。

// BAD — service locator
public class MyViewModel
{
    public void DoSomething()
    {
        var service = App.ServiceProvider.GetService<IMyService>();
        service?.Execute();
    }
}

// GOOD — constructor injection
public class MyViewModel
{
    private readonly IMyService _myService;

    public MyViewModel(IMyService myService)
    {
        _myService = myService;
    }

    public void DoSomething() => _myService.Execute();
}

循環依存が発生する場合は、Lazy<T> を使用します。

private readonly Lazy<IMyService> _myService;

public MyViewModel(Lazy<IMyService> myService)
{
    _myService = myService;
}

Async/Await パターン

ルール
  • .Result、.Wait()、.GetAwaiter().GetResult() は使用しないでください。WPF のシングルスレッド STA ディスパッチャーがデッドロックします
  • 非同期メソッドでは、常に CancellationToken を引き継ぎます
  • ライブラリ/サービスのコードでは ConfigureAwait(false) を使用します(UI コンテキストが必要な ViewModel やコードビハインドでは使用しません)
  • エラー処理なしで fire-and-forget を実行しないでください
UI スレッドへのマーシャリング

バックグラウンドスレッドから UI を更新するには、Dispatcher を使用します。

// From any thread — safe UI update
private void OnDataReceived(object? sender, DataEventArgs e)
{
    Application.Current.Dispatcher.InvokeAsync(() =>
    {
        StatusText = e.Message;
        Items.Add(e.Item);
    });
}

バックグラウンドスレッドから更新する ViewModel では、次を使用します。

public class DashboardViewModel : ViewModelBase
{
    private readonly Dispatcher _dispatcher;

    public DashboardViewModel()
    {
        _dispatcher = Application.Current.Dispatcher;
    }

    public async Task RefreshAsync(CancellationToken ct = default)
    {
        var data = await _dataService.FetchAsync(ct).ConfigureAwait(false);

        _dispatcher.InvokeAsync(() =>
        {
            Items.Clear();
            foreach (var item in data)
                Items.Add(item);
        });
    }
}
定期処理のための DispatcherTimer
private DispatcherTimer? _statusTimer;

private void StartStatusPolling()
{
    _statusTimer = new DispatcherTimer
    {
        Interval = TimeSpan.FromSeconds(5)
    };
    _statusTimer.Tick += async (s, e) =>
    {
        try
        {
            await RefreshStatusAsync();
        }
        catch (Exception ex)
        {
            Debug.WriteLine($"Status poll failed: {ex.Message}");
        }
    };
    _statusTimer.Start();
}
安全な fire-and-forget

単独の _ = Task.Run(...) は使用しないでください。必ずエラー処理でラップします。

namespace MyApp.Utilities;

internal static class AsyncHelper
{
    public static async void FireAndForget(Func<Task> operation, string context)
    {
        try
        {
            await operation();
        }
        catch (OperationCanceledException) { }
        catch (Exception ex)
        {
            Debug.WriteLine($"[{context}] Background task failed: {ex.Message}");
        }
    }
}

// Usage
AsyncHelper.FireAndForget(
    () => LoadDataAsync(),
    "MainViewModel.LoadData");

システムトレイとの統合

WPF にはシステムトレイの組み込みサポートがありません。System.Windows.Forms.NotifyIcon を使用します。

namespace MyApp.Services;

public class SystemTrayService : IDisposable
{
    private readonly System.Windows.Forms.NotifyIcon _notifyIcon;

    public SystemTrayService()
    {
        _notifyIcon = new System.Windows.Forms.NotifyIcon
        {
            Text = "My App",
            Visible = true
        };

        // Load icon from resources
        var iconStream = Application.GetResourceStream(
            new Uri("pack://application:,,,/Resources/app.ico"))?.Stream;
        if (iconStream != null)
            _notifyIcon.Icon = new System.Drawing.Icon(iconStream);

        _notifyIcon.DoubleClick += (s, e) => ShowMainWindow();
        BuildContextMenu();
    }

    private void BuildContextMenu()
    {
        var menu = new System.Windows.Forms.ContextMenuStrip();
        menu.Items.Add("Show", null, (s, e) => ShowMainWindow());
        menu.Items.Add(new System.Windows.Forms.ToolStripSeparator());
        menu.Items.Add("Exit", null, (s, e) => Application.Current.Shutdown());
        _notifyIcon.ContextMenuStrip = menu;
    }

    private static void ShowMainWindow()
    {
        var window = Application.Current.MainWindow;
        if (window == null) return;

        window.Show();
        window.WindowState = WindowState.Normal;
        window.Activate();
    }

    public void Dispose()
    {
        _notifyIcon.Visible = false;
        _notifyIcon.Dispose();
    }
}
GDI ハンドルの管理

リークを防ぐため、必ず GDI アイコンハンドルを破棄します。

[DllImport("user32.dll", CharSet = CharSet.Auto)]
private static extern bool DestroyIcon(IntPtr handle);

public static System.Drawing.Icon CreateStatusIcon(System.Drawing.Color color)
{
    using var bitmap = new System.Drawing.Bitmap(16, 16);
    using var g = System.Drawing.Graphics.FromImage(bitmap);
    using var brush = new System.Drawing.SolidBrush(color);

    g.Clear(System.Drawing.Color.Transparent);
    g.FillEllipse(brush, 2, 2, 12, 12);

    var hIcon = bitmap.GetHicon();
    var icon = System.Drawing.Icon.FromHandle(hIcon);
    var result = (System.Drawing.Icon)icon.Clone();  // Clone to own the data

    DestroyIcon(hIcon);  // CRITICAL: release GDI handle
    return result;
}

アイコンはキャッシュし、状態が変化するたびに再作成しないでください。

private static System.Drawing.Icon? _connectedIcon;
private static System.Drawing.Icon? _disconnectedIcon;

public void UpdateStatus(bool connected)
{
    _connectedIcon ??= CreateStatusIcon(System.Drawing.Color.Green);
    _disconnectedIcon ??= CreateStatusIcon(System.Drawing.Color.Red);
    _notifyIcon.Icon = connected ? _connectedIcon : _disconnectedIcon;
}

ウィンドウの管理

トレイに最小化
public partial class MainWindow : Window
{
    protected override void OnStateChanged(EventArgs e)
    {
        if (WindowState == WindowState.Minimized)
            Hide();
        base.OnStateChanged(e);
    }

    protected override void OnClosing(CancelEventArgs e)
    {
        e.Cancel = true;  // Don't close, minimize to tray
        Hide();
    }
}
透明なオーバーレイウィンドウ

透明なフローティングウィンドウ(ウィジェット、オーバーレイ)には、次を使用します。

<Window AllowsTransparency="True"
        WindowStyle="None"
        Background="Transparent"
        Topmost="True"
        ShowInTaskbar="False">
    
</Window>
Mutex による単一インスタンス化
private static Mutex? _mutex;

protected override void OnStartup(StartupEventArgs e)
{
    const string mutexName = "Global\\MyAppSingleInstance";
    _mutex = new Mutex(true, mutexName, out bool isNewInstance);

    if (!isNewInstance)
    {
        MessageBox.Show("Application is already running.");
        Shutdown();
        return;
    }

    // Continue startup...
}
グローバルホットキーの登録
namespace MyApp.Services;

public class GlobalHotkeyService : IDisposable
{
    private const int HOTKEY_ID = 9001;

    [DllImport("user32.dll")]
    private static extern bool RegisterHotKey(IntPtr hWnd, int id, uint mods, uint vk);

    [DllImport("user32.dll")]
    private static extern bool UnregisterHotKey(IntPtr hWnd, int id);

    private readonly IntPtr _hwnd;

    public void Register(IntPtr windowHandle, uint modifiers, uint key)
    {
        RegisterHotKey(windowHandle, HOTKEY_ID, modifiers, key);
    }

    public void Dispose()
    {
        UnregisterHotKey(_hwnd, HOTKEY_ID);
    }
}

設定のパターン

安全な読み込み/保存に対応した JSON 設定
namespace MyApp.Services;

public class SettingsManager
{
    private static readonly string SettingsDir = Path.Combine(
        Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
        "MyApp");
    private static readonly string SettingsFile =
        Path.Combine(SettingsDir, "settings.json");

    // Properties with defaults
    public string ServerUrl { get; set; } = "http://localhost:8080";
    public bool AutoStart { get; set; }
    public bool ShowNotifications { get; set; } = true;

    public SettingsManager() => Load();

    public void Load()
    {
        try
        {
            if (!File.Exists(SettingsFile)) return;
            var json = File.ReadAllText(SettingsFile);
            var loaded = JsonSerializer.Deserialize<SettingsData>(json);
            if (loaded != null)
            {
                ServerUrl = loaded.ServerUrl ?? ServerUrl;
                AutoStart = loaded.AutoStart;
                ShowNotifications = loaded.ShowNotifications;
            }
        }
        catch (Exception ex)
        {
            Debug.WriteLine($"Failed to load settings: {ex.Message}");
        }
    }

    public void Save()
    {
        try
        {
            Directory.CreateDirectory(SettingsDir);
            var data = new SettingsData
            {
                ServerUrl = ServerUrl,
                AutoStart = AutoStart,
                ShowNotifications = ShowNotifications
            };
            var json = JsonSerializer.Serialize(data,
                new JsonSerializerOptions { WriteIndented = true });
            File.WriteAllText(SettingsFile, json);
        }
        catch (Exception ex)
        {
            Debug.WriteLine($"Failed to save settings: {ex.Message}");
        }
    }

    // Keep serialization shape separate from public API
    private class SettingsData
    {
        public string? ServerUrl { get; set; }
        public bool AutoStart { get; set; }
        public bool ShowNotifications { get; set; } = true;
    }
}
自動起動のためのレジストリ
public static void SetAutoStart(bool enable, string appName)
{
    using var key = Registry.CurrentUser.OpenSubKey(
        @"SOFTWARE\Microsoft\Windows\CurrentVersion\Run", true);
    if (key == null) return;

    if (enable)
    {
        var exe = Environment.ProcessPath;
        key.SetValue(appName, $"\"{exe}\"");
    }
    else
    {
        key.DeleteValue(appName, throwOnMissingValue: false);
    }
}
機密設定のための DPAPI 暗号化
using System.Security.Cryptography;

public static string EncryptForCurrentUser(string plainText)
{
    var data = Encoding.UTF8.GetBytes(plainText);
    var encrypted = ProtectedData.Protect(data, null,
        DataProtectionScope.CurrentUser);
    return Convert.ToBase64String(encrypted);
}

public static string DecryptForCurrentUser(string encrypted)
{
    var data = Convert.FromBase64String(encrypted);
    var decrypted = ProtectedData.Unprotect(data, null,
        DataProtectionScope.CurrentUser);
    return Encoding.UTF8.GetString(decrypted);
}

エラー処理

クラッシュハンドラー

App.xaml.cs で、次の3つのクラッシュハンドラーレイヤーをすべて登録します。

public App()
{
    InitializeComponent();

    // WPF UI thread exceptions
    DispatcherUnhandledException += (s, e) =>
    {
        LogCrash("DispatcherUnhandled", e.Exception);
        e.Handled = true;
    };

    // CLR unhandled exceptions (all threads)
    AppDomain.CurrentDomain.UnhandledException += (s, e) =>
        LogCrash("DomainUnhandled", e.ExceptionObject as Exception);

    // Unobserved async Task exceptions
    TaskScheduler.UnobservedTaskException += (s, e) =>
    {
        LogCrash("UnobservedTask", e.Exception);
        e.SetObserved();
    };
}

private static readonly string CrashLogPath = Path.Combine(
    Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
    "MyApp", "crash.log");

private static void LogCrash(string source, Exception? ex)
{
    try
    {
        Directory.CreateDirectory(Path.GetDirectoryName(CrashLogPath)!);
        var msg = $"\n[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] {source}\n{ex}\n";
        File.AppendAllText(CrashLogPath, msg);
    }
    catch { }
}
異常終了検出用の実行マーカー
private static readonly string RunMarkerPath = Path.Combine(
    Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
    "MyApp", "running.marker");

protected override void OnStartup(StartupEventArgs e)
{
    if (File.Exists(RunMarkerPath))
    {
        Debug.WriteLine("Previous session did not exit cleanly");
        File.Delete(RunMarkerPath);
    }
    File.WriteAllText(RunMarkerPath, DateTime.Now.ToString("O"));

    // ... rest of startup

    base.OnStartup(e);
}

protected override void OnExit(ExitEventArgs e)
{
    try { File.Delete(RunMarkerPath); } catch { }
    base.OnExit(e);
}
空の catch ブロック

空の catch ブロックは使用しないでください。最低限、DEBUG ではログに記録します。

// BAD
catch { }

// GOOD — use a safe execution wrapper
namespace MyApp.Utilities;

internal static class SafeExec
{
    public static void Try(Action action,
        [CallerMemberName] string? caller = null)
    {
        try { action(); }
        catch (Exception ex)
        {
            Debug.WriteLine($"[SafeExec:{caller}] {ex.Message}");
        }
    }

    public static async Task TryAsync(Func<Task> action,
        [CallerMemberName] string? caller = null)
    {
        try { await action(); }
        catch (Exception ex)
        {
            Debug.WriteLine($"[SafeExec:{caller}] {ex.Message}");
        }
    }
}

ロギング

スレッドセーフなファイルロガー
namespace MyApp.Services;

public static class Logger
{
    private static readonly object _lock = new();
    private static readonly string _logFilePath;

    static Logger()
    {
        var dir = Path.Combine(
            Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
            "MyApp");
        Directory.CreateDirectory(dir);
        _logFilePath = Path.Combine(dir, "app.log");

        // Rotate if > 5MB
        try
        {
            var fi = new FileInfo(_logFilePath);
            if (fi.Exists && fi.Length > 5 * 1024 * 1024)
            {
                var backup = Path.Combine(dir, "app.log.old");
                if (File.Exists(backup)) File.Delete(backup);
                File.Move(_logFilePath, backup);
            }
        }
        catch { }
    }

    public static void Info(string message)  => Log("INFO", message);
    public static void Warn(string message)  => Log("WARN", message);
    public static void Error(string message) => Log("ERROR", message);

    private static void Log(string level, string message)
    {
        var line = $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] [{level}] {message}";
        lock (_lock)
        {
            try { File.AppendAllText(_logFilePath, line + Environment.NewLine); }
            catch { }
        }
#if DEBUG
        System.Diagnostics.Debug.WriteLine(line);
#endif
    }
}
DI 用のロガーインターフェイス
public interface IAppLogger
{
    void Info(string message);
    void Debug(string message);
    void Warn(string message);
    void Error(string message, Exception? ex = null);
}

テスト

xUnit プロジェクトのセットアップ
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <IsPackable>false</IsPackable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.*" />
    <PackageReference Include="xunit" Version="2.*" />
    <PackageReference Include="xunit.runner.visualstudio" Version="3.*" />
    <PackageReference Include="coverlet.collector" Version="6.*" />
  </ItemGroup>

  <ItemGroup>
    <Using Include="Xunit" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\..\src\MyApp.Shared\MyApp.Shared.csproj" />
  </ItemGroup>
</Project>
テストパターン
public class SettingsTests : IDisposable
{
    private readonly string _tempDir;

    public SettingsTests()
    {
        _tempDir = Path.Combine(Path.GetTempPath(),
            $"myapp-test-{Guid.NewGuid():N}");
        Directory.CreateDirectory(_tempDir);
    }

    public void Dispose()
    {
        try { Directory.Delete(_tempDir, true); } catch { }
    }

    [Fact]
    public void GetValue_ReturnsDefault_WhenKeyMissing()
    {
        var settings = CreateSettings();
        var result = settings.GetValue("nonexistent", "fallback");
        Assert.Equal("fallback", result);
    }

    [Theory]
    [InlineData("key1", "value1")]
    [InlineData("key2", "value2")]
    public void SetValue_StoresAndRetrieves(string key, string value)
    {
        var settings = CreateSettings();
        settings.SetValue(key, value);
        Assert.Equal(value, settings.GetValue(key));
    }
}

テストを実行します:

dotnet test
dotnet test --filter "FullyQualifiedName~SettingsTests"

ネットワーク

指数バックオフによる再接続
private static readonly int[] BackoffMs = { 1000, 2000, 4000, 8000, 15000, 30000, 60000 };
private int _reconnectAttempts;

private async Task ReconnectWithBackoffAsync(CancellationToken ct)
{
    var index = Math.Min(_reconnectAttempts, BackoffMs.Length - 1);
    var delay = BackoffMs[index];

    Debug.WriteLine($"Reconnecting in {delay}ms (attempt {_reconnectAttempts + 1})");

    await Task.Delay(delay, ct);
    await ConnectAsync(ct);
    _reconnectAttempts++;
}

// Reset on successful connect
private void OnConnected()
{
    _reconnectAttempts = 0;
}
名前付き HTTP クライアント

DI に一度登録すれば、どこからでも使用できます:

// Registration
services.AddHttpClient("AppApi", client =>
{
    client.DefaultRequestHeaders.Add("User-Agent", "MyApp/1.0");
    client.Timeout = TimeSpan.FromSeconds(10);
});

// Usage in services
public class ApiService
{
    private readonly IHttpClientFactory _httpClientFactory;

    public ApiService(IHttpClientFactory httpClientFactory)
    {
        _httpClientFactory = httpClientFactory;
    }

    public async Task<string> GetDataAsync(CancellationToken ct = default)
    {
        using var client = _httpClientFactory.CreateClient("AppApi");
        var response = await client.GetAsync("/api/data", ct);
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadAsStringAsync(ct);
    }
}

ディープリンクと IPC

URI スキームの登録
public static void RegisterUriScheme(string scheme, string friendlyName)
{
    var exePath = Environment.ProcessPath ?? "";

    using var key = Registry.CurrentUser.CreateSubKey(
        $@"Software\Classes\{scheme}");
    key.SetValue("", $"URL:{friendlyName}");
    key.SetValue("URL Protocol", "");

    using var iconKey = key.CreateSubKey("DefaultIcon");
    iconKey?.SetValue("", $"\"{exePath}\",1");

    using var commandKey = key.CreateSubKey(@"shell\open\command");
    commandKey?.SetValue("", $"\"{exePath}\" \"%1\"");
}
名前付きパイプ IPC(シングルインスタンス)
private const string PipeName = "MyApp-IPC";

// Sender (second instance)
private static void SendToRunningInstance(string message)
{
    using var client = new NamedPipeClientStream(".", PipeName, PipeDirection.Out);
    client.Connect(1000);
    using var writer = new StreamWriter(client);
    writer.WriteLine(message);
    writer.Flush();
}

// Receiver (primary instance)
private void StartIpcServer()
{
    _ = Task.Run(async () =>
    {
        while (!_disposed)
        {
            try
            {
                using var server = new NamedPipeServerStream(PipeName, PipeDirection.In);
                await server.WaitForConnectionAsync();
                using var reader = new StreamReader(server);
                var message = await reader.ReadLineAsync();
                if (!string.IsNullOrEmpty(message))
                {
                    Application.Current.Dispatcher.InvokeAsync(
                        () => HandleIpcMessage(message));
                }
            }
            catch
            {
                await Task.Delay(1000);
            }
        }
    });
}

ビルドとパッケージ化

ビルドコマンド
# Debug build
dotnet build

# Release build
dotnet build -c Release

# Self-contained single-file EXE
dotnet publish -c Release -r win-x64 --self-contained -p:PublishSingleFile=true

# ARM64 build
dotnet publish -c Release -r win-arm64 --self-contained -p:PublishSingleFile=true
MSIX パッケージ化

csproj に条件付き MSIX サポートを追加します:


<PropertyGroup Condition="'$(PackageMsix)' != 'true'">
  <WindowsPackageType>None</WindowsPackageType>
</PropertyGroup>


<PropertyGroup Condition="'$(PackageMsix)' == 'true'">
  <WindowsPackageType>MSIX</WindowsPackageType>
  <AppxPackageSigningEnabled>false</AppxPackageSigningEnabled>
  <GenerateAppxPackageOnBuild>true</GenerateAppxPackageOnBuild>
</PropertyGroup>

MSIX をビルドします:

dotnet publish -c Release -p:PackageMsix=true
GitHub Actions による CI/CD
name: Build and Test

on:
  push:
    branches: [main]
    tags: ['v*']
  pull_request:

jobs:
  test:
    runs-on: windows-latest
    steps:
    - uses: actions/checkout@v4
    - uses: actions/setup-dotnet@v4
      with: { dotnet-version: 10.0.x }
    - run: dotnet restore
    - run: dotnet build -c Debug
    - run: dotnet test --no-build -c Debug

  build:
    needs: test
    runs-on: windows-latest
    strategy:
      matrix:
        rid: [win-x64, win-arm64]
    steps:
    - uses: actions/checkout@v4
    - uses: actions/setup-dotnet@v4
      with: { dotnet-version: 10.0.x }
    - run: >
        dotnet publish src/MyApp/MyApp.csproj
        -c Release -r ${{ matrix.rid }}
        --self-contained
        -p:PublishSingleFile=true
        -o publish
    - uses: actions/upload-artifact@v4
      with:
        name: myapp-${{ matrix.rid }}
        path: publish/

テーマの検出

public static class ThemeHelper
{
    public static bool IsDarkMode()
    {
        try
        {
            using var key = Registry.CurrentUser.OpenSubKey(
                @"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize");
            var value = key?.GetValue("AppsUseLightTheme");
            return value is int i && i == 0;
        }
        catch { return false; }
    }
}

XAML リソース

アプリレベルのリソースディクショナリ
<Application.Resources>
    <ResourceDictionary>
        <ResourceDictionary.MergedDictionaries>
            <ResourceDictionary Source="Themes/Colors.xaml" />
            <ResourceDictionary Source="Themes/Styles.xaml" />
        </ResourceDictionary.MergedDictionaries>

        <BooleanToVisibilityConverter x:Key="BoolToVisibilityConverter" />
    </ResourceDictionary>
</Application.Resources>
カスタムスタイル

<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
                    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">

    <Style x:Key="PrimaryButton" TargetType="Button">
        <Setter Property="Background" Value="{StaticResource PrimaryBrush}" />
        <Setter Property="Foreground" Value="White" />
        <Setter Property="Padding" Value="16,8" />
        <Setter Property="BorderThickness" Value="0" />
        <Setter Property="Cursor" Value="Hand" />
        <Style.Triggers>
            <Trigger Property="IsMouseOver" Value="True">
                <Setter Property="Opacity" Value="0.9" />
            </Trigger>
            <Trigger Property="IsEnabled" Value="False">
                <Setter Property="Opacity" Value="0.5" />
            </Trigger>
        </Style.Triggers>
    </Style>
</ResourceDictionary>

してはいけないこと

  • Dispatcher.Invoke(同期処理)は使用しないでください。Dispatcher.InvokeAsync を使用してください。
  • Tasks に対して .Result や .Wait() を呼び出さないでください。UI スレッドがデッドロックします。
  • App.ServiceProvider を直接使用しないでください。コンストラクター経由でインジェクションしてください。
  • _ = Task.Run(...) で結果を待たずに処理を実行しないでください。AsyncHelper.FireAndForget を使用してください。
  • 空の catch { } ブロックを残さないでください。SafeExec.Try() を使用するか、エラーをログに記録してください。
  • UI の処理に System.Timers.Timer を使用しないでください。DispatcherTimer を使用してください。
  • GDI オブジェクトを破棄せずに作成しないでください。必ず DestroyIcon を呼び出してください。
  • ユーザーに確認せずに NuGet パッケージを追加しないでください。
  • 視覚的に確認せずに XAML スタイルを変更しないでください。
  • 要求された範囲を超えてリファクタリングしないでください。スコープの拡大は禁物です。

コメント