注目イベント!
9/1より夏のリレー連載企画をスタートしました!
毎年恒例夏のリレー連載。 今年は9月から開催です。
詳細はこちらから!
event banner

【C#】WPFとMVVM「はじめの一歩」から現場Tipsまで! 〜デスクトップアプリ開発の実践メモ〜

| 17 min read
Author: kazuki-ogawa kazuki-ogawaの画像

この記事は夏のリレー連載2025 12日目の記事です。

お久しぶりです。小川です。

最近開発でWPFを扱ったので初学者の開発Tips的なものを備忘録感覚で記していきたいと思います。

WPF(Windows Presentation Foundation)はWindowsデスクトップアプリ開発の選択肢として候補に挙がるものです。
まずはUIのロジックを作る主要な方法としてのコードビハインドMVVM(Model-View-ViewModel)についてベタに触れていきます。
DI(Dependency Injection)を導入して少しわかった気になりながら、C#の便利な機能や罠など備忘録をまとめていければと思います。

コードビハインドとMVVM

#

時折比較されたり、メリット・デメリットが議論されます。
コードビハインドは「家を建てるための道具や手作業」、MVVMは「家の構造や配管・配線の設計図の描き方」です。
規模や複雑さに応じてMVVMの設計を取り入れましょう。
WPFを理解する第一歩として、まずコードビハインドを知ることが大切です。

コードビハインド

#

概要

#

コードビハインドはUIレイアウトをXAML(*.xaml)で記述し、その動作ロジックをC#(*.xaml.cs)で記述します。
XAMLの裏側にあるコードという意味でコードビハインド(Code-Behind)と呼ばれます。

サンプル

#

簡単なカウントアップアプリを実装してみます。

MainWindow.xaml
<Window
    x:Class="CounterSample.MainWindow"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:local="clr-namespace:CounterSample"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    Title="Counter"
    Width="250"
    Height="150"
    mc:Ignorable="d">
    <Grid>
        <StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
            <TextBlock x:Name="CounterTextBlock" FontSize="30" Text="0" />
            <Button Click="CountUpButton_Click" Content="Count up" />
        </StackPanel>
    </Grid>
</Window>

MainWindow.xaml.cs
using System.Windows;

namespace CounterSample
{
    public partial class MainWindow : Window
    {
        private int _count = 0;

        public MainWindow()
        {
            InitializeComponent();
        }
        private void CountUpButton_Click(object sender, RoutedEventArgs e)
        {
            _count++;
            CounterTextBlock.Text = _count.ToString();
        }
    }
}

UI要素(x:Name="CounterTextBlock")を名前で直接参照して操作しているのが特徴です。

なぜpartial(部分)なのか

WPF のコードビハインドはpublic partial class MainWindowのようにpartialが付いています。
これはXAMLから自動生成されるコードと、開発者が書くコードをひとつのクラスにまとめるためです。

実際、ビルドするとMainWindow.g.i.csというファイルが生成され、XAMLの要素定義やInitializeComponentが自動的に追加されます。
partialがあることで、これらのファイルとMainWindow.xaml.csを同じクラスとして扱えるようになります。

上記コードでカウントアップするアプリケーションができました。

MVVM

#

概要

#

MVVMは、UIとビジネスロジックを分離するための設計パターンです。
アプリケーションを以下の3つのコンポーネントに分割します。

  • Model: アプリケーションのデータとビジネスロジックを担当します。
  • View: UIそのもの。XAMLで記述され、ユーザーに情報を表示し、入力を受け取ります。
  • ViewModel: ViewとModelの橋渡し役でViewに表示すべきデータをプロパティとして公開し、Viewからの操作をコマンドとして受け取ります。

サンプル

#

同様のカウントアップアプリを実装してみます。
サンプルコードを記述する前の準備手順としてCommunityToolkit.Mvvmを導入します。

Information

MVVMToolkitの導入

CommunityToolkit.MvvmはMicrosoft公式のMVVM補助ライブラリです。
INotifyPropertyChangedICommand実装を自動生成してくれます。
手書きでは冗長になりがちな、OnPropertyChangedの呼び出しやRelayCommandの実装を省略できます。

NuGetからCommunityToolkit.Mvvmを追加してください。

  • View
MainWindow.xaml
<Window
    x:Class="CounterSample.MainWindow"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:local="clr-namespace:CounterSample"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    Title="Counter"
    Width="250"
    Height="150"
    mc:Ignorable="d">
    <Window.DataContext>
        <local:MainViewModel />
    </Window.DataContext>

    <Grid>
        <StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
            <TextBlock FontSize="30" Text="{Binding Count}" />
            <Button Command="{Binding CountUpCommand}" Content="Count up" />
        </StackPanel>
    </Grid>
</Window>
MainWindow.xaml.cs
using System.Windows;

namespace CounterSample
{
    public partial class MainWindow : Window
    {
        public MainWindow()
        {
            InitializeComponent();
        }
    }
}
  • ViewModel
MainViewModel.cs
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;

namespace CounterSample
{
    public partial class MainViewModel : ObservableObject
    {
        [ObservableProperty]
        private int count = 0;

        [RelayCommand]
        private void CountUp()
        {
            Count++;
        }
    }
}

MVVMではClickイベントやx:Nameが不要になり、代わりにBindingを使います。
UI(View)とロジック(ViewModel)が分離されるので再利用性が上がり、テストがしやすくなります。

View(XAML)とViewModel(C#)がどのように連携しているのか補足します。

<TextBlock Text="{Binding Count}" />
<Button Command="{Binding CountUpCommand}" />
[ObservableProperty]
private int count = 0;
[RelayCommand]
private void CountUp()
{
    Count++;
}

この連携はCommunityToolkit.Mvvmが提供するアトリビュート([ ]で囲まれた部分)によるコードの自動生成です。

  • データ(Count)の連携
    XAMLの{Binding Count}は「Countという名前の公開プロパティの値を表示して」という指示です。
    ViewModelの[ObservableProperty]は、private int countフィールドを元に、public int Countというプロパティをコンパイル時に自動で生成します。値が変更されたらUIに通知する機能も込みです。

  • 操作(CountUp)の連携
    XAMLの{Binding CountUpCommand}は、「CountUpCommandという名前のコマンドを実行して」という指示です。
    ViewModelの[RelayCommand]は、private void CountUp()メソッドを元に、public ICommand CountUpCommandというコマンドを自動で生成します。

あれ、そういえばサンプルコードにModelがないのでは?
単なるカウントアップを保持するだけのシンプルなサンプルだと、Modelをわざわざ分ける必要はありません。
しかし、Modelに書くべきコードをViewModelに書いてしまうのはよくある間違いなので注意が必要です。
あくまでViewとModelの橋渡し役なのでViewModelをゴリゴリ書き始めたときは責務を疑ってみます。

サンプルVer2

#

カウントアップアプリにリセットを追加してみましょう。
カウンターの値をセーブ、ロードするサービスも作ってみます。

  • Model
CounterModel.cs
namespace CounterSample.Models
{
    internal class CounterModel(int initialValue = 0)
    {
        public int Value { get; private set; } = initialValue;
        public void Increment() => Value++;
        public void Reset() => Value = 0;
        public void SetValue(int value) => Value = value;
    }
}
  • Service
CounterStorageService.cs
using CounterSample.Models;

namespace CounterSample.Services
{
    internal class CounterStorageService
    {
        private int _storedValue;

        public void Save(CounterModel model)
        {
            _storedValue = model.Value;
        }

        public void Load(CounterModel model)
        {
            model.SetValue(_storedValue);
        }
    }
}
  • ViewModel
CounterViewModel.cs
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CounterSample.Models;
using CounterSample.Services;

namespace CounterSample.ViewModels
{
    internal partial class CounterViewModel : ObservableObject
    {
        private readonly CounterStorageService _service;
        private readonly CounterModel _model = new();

        [ObservableProperty]
        private int count;

        public CounterViewModel(CounterStorageService service)
        {
            _service = service;
            Count = _model.Value;
        }

        [RelayCommand]
        private void CountUp()
        {
            _model.Increment();
            Count = _model.Value;
        }

        [RelayCommand]
        private void Reset()
        {
            _model.Reset();
            Count = _model.Value;
        }

        [RelayCommand]
        private void Save()
        {
            _service.Save(_model);
        }

        [RelayCommand]
        private void Load()
        {
            _service.Load(_model);
            Count = _model.Value;
        }
    }
}
  • View
MainWindow.xaml
<Window
    x:Class="CounterSample.MainWindow"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:local="clr-namespace:CounterSample"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    Title="Counter"
    Width="250"
    Height="150"
    mc:Ignorable="d">
    <Grid>
        <StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
            <TextBlock HorizontalAlignment="Center" FontSize="30" Text="{Binding Count}" />
            <StackPanel HorizontalAlignment="Center" Orientation="Horizontal">
                <Button Margin="2" Command="{Binding CountUpCommand}" Content="Count up" />
                <Button Margin="2" Command="{Binding ResetCommand}" Content="Reset" />
                <Button Margin="2" Command="{Binding SaveCommand}" Content="Save" />
                <Button Margin="2" Command="{Binding LoadCommand}" Content="Load" />
            </StackPanel>
        </StackPanel>
    </Grid>
</Window>
MainWindow.xaml.cs
using CounterSample.Services;
using CounterSample.ViewModels;
using System.Windows;

namespace CounterSample
{
    public partial class MainWindow : Window
    {
        private readonly CounterStorageService _service = new();
        public MainWindow()
        {
            InitializeComponent();
            DataContext = new CounterViewModel(_service);
        }
    }
}

機能が追加されてコードの量は増えましたが、クラスごとに責務が分かれていて保守はしやすそうに思います。
実行してみると少しリッチなカウンターになりましたね。

DI

#

ちょうど良さそうなサンプルになったのでDI(Dependency Injection)を使ってみます。

Information

DependencyInjectionの導入

WPFでMVVMを活用するなら、Microsoft.Extensions.DependencyInjectionを使ったDIが便利です。
DIを導入すると、ViewModelやサービスを必要な場所で簡単に受け渡すことができ、テストや保守がしやすくなります。

ServiceCollectionにサービスやViewModelを登録し、ServiceProviderから取得するだけで依存関係を解決できます。
Viewや他のViewModelから直接newする必要がなくなります。

NuGetからMicrosoft.Extensions.DependencyInjectionを追加してください。

サンプルVer3

#

サンプルVer2を元にDIを導入したサンプルを作ってみます。

CounterStorageServiceCounterViewModelをサービス登録します。
後述するインスタンスのライフサイクルを学ぶため、StartupUriでApp.xaml(変更の必要なし)とコード上から2つウィンドウを起動してみます。

App.xaml.cs
using CommunityToolkit.Mvvm.DependencyInjection;
using CounterSample.Services;
using CounterSample.ViewModels;
using Microsoft.Extensions.DependencyInjection;
using System.Windows;

namespace CounterSample
{
    public partial class App : Application
    {
        protected override void OnStartup(StartupEventArgs e)
        {
            base.OnStartup(e);

            // ServiceCollection でサービスを登録
            ServiceCollection services = new();
            services.AddSingleton<CounterStorageService>();
            services.AddTransient<CounterViewModel>();

            // Ioc.Default に登録
            Ioc.Default.ConfigureServices(services.BuildServiceProvider());

            // もう1つWindowを起動
            MainWindow window = new();
            window.Show();
        }
    }
}

登録したサービスをIoc.Defaultから取得します。
CounterViewModelのインスタンスを要求すると、コンストラクタでCounterStorageServiceが要求されるのでDIコンテナからインスタンスを持ってきてくれます。

MainWindow.xaml.cs
using CommunityToolkit.Mvvm.DependencyInjection;
using CounterSample.ViewModels;
using System.Windows;

namespace CounterSample
{
    public partial class MainWindow : Window
    {
        public MainWindow()
        {
            InitializeComponent();
            DataContext = Ioc.Default.GetRequiredService<CounterViewModel>();
        }
    }
}
サンプルVer2(抜粋)
private readonly CounterStorageService _service = new();
public MainWindow()
{
    InitializeComponent();
    DataContext = new CounterViewModel(_service);
}

実行するとこんな感じです。

あれ、別の画面なのに最後にSaveしたやつが別画面でLoadされるぞ?
その秘密はサービス登録時のコードにあります。
services.AddSingleton<CounterStorageService>();

呼び出すメソッドによってライフサイクルが異なります。

メソッド ライフサイクル
AddSingleton アプリケーションで1つのインスタンス
AddScoped 1つの要求の間で1つのインスタンス
AddTransient 都度新しいインスタンス

AddSingletonで登録していたため、サービスのインスタンスがアプリケーション内で唯一となり、同じ内部の保持値を見ていたわけです。
AddTransientに変えると以下の動作になります。

AddScopedは例えば以下のようなときに同じインスタンスになります。

// コンストラクタでServiceとFugaを要求
Hoge(Service service, Fuga fuga)
// コンストラクタでServiceを要求
Fuga(Service service)

// サービス登録
var services = new ServiceCollection();
services.AddScoped<Service>(); // Scoped で登録
services.AddTransient<Hoge>();
services.AddTransient<Fuga>();
var provider = services.BuildServiceProvider();

// Hogeを要求、この時HogeとFugaが受け取るServiceのインスタンスは同一になる
var hoge = provider.GetRequiredService<Hoge>();
Caution

Ioc.Defaultは静的なコンテナでアプリケーション全体が単一のルートスコープ内で実行されます。
そのためIoc.Defaultからインスタンスを取得する場合、AddScopedAddSingletonと全く同じように動作します。

という感じでサンプルと向き合う時間は終わりです。
サンプルなのでインターフェースに切るなどはやってません。
テストコードも入れて有用なことを示したいですが、長くなるので断念しました。

C#のWPF開発でつまづいたこと一覧

#

以降はただの備忘録なので、参考になるかもしれないし、ならないかもしれません。

LINQ

#

当方SQLが嫌いなので、最初はかなり読みづらかったです。
LINQについてはデベロッパーサイト内に詳しい記事がありますので以下ご参照ください。
現場で迷わない!C#のLINQをサンプルコード付きで徹底攻略

よく使うものを簡単に紹介します。
勝手に苦手意識がありますが、割とメソッド名通りの動きです。

Where

#
var numbers = new[] { 1, 2, 3, 4, 5 };
var even = numbers.Where(n => n % 2 == 0);

Console.WriteLine(string.Join(",", even)); // 2,4

First/FirstOrDefault

#
var words = new[] { "apple", "banana", "cherry" };
var first = words.First(); // "apple"
var startsWithB = words.FirstOrDefault(w => w.StartsWith("b")); // "banana"
var notFound = words.FirstOrDefault(w => w.StartsWith("z")); // null

Select/SelectMany

#
var names = new[] { "Alice", "Bob" };
var lengths = names.Select(n => n.Length); // [5, 3]

var groups = new[] { new[] {1,2}, new[] {3,4} };
var flat = groups.SelectMany(g => g); // [1,2,3,4]

GroupBy

#
var fruits = new[] { "apple", "apricot", "banana", "blueberry" };
var grouped = fruits.GroupBy(f => f[0]);
foreach (var g in grouped)
{
    Console.WriteLine($"{g.Key}: {string.Join(",", g)}");
}
// a: apple, apricot
// b: banana, blueberry

Any/All

#
var numbers = new[] { 1, 2, 3 };
bool hasEven = numbers.Any(n => n % 2 == 0); // true
bool allPositive = numbers.All(n => n > 0); // true

IEnumerableの遅延評価

#

LINQつながりで、陥った罠を紹介します。
LINQは基本「遅延評価」なので、ToList()やToArray()で確定しないと、意図しない結果になることがあります。

var numbers = new List<int> { 1, 2, 3 };
var query = numbers.Where(n => n > 1); // ToList()を呼ばない

// ここでリストを変更
numbers.Add(4);

// この時点でクエリを評価
Console.WriteLine(string.Join(",", query)); // 2,3,4

WPFのDispatcher

#

InvokeBeginInvokeの違いで更新されているはずのUIが更新されないことがありました。
同期的に処理したい場合はInvokeを使い、重い処理はUIが固まるので渡さないようにしました。

// Invoke: 完了するまで呼び出し元が止まる
Dispatcher.Invoke(() =>
{
    Console.WriteLine("UI更新: 完了まで待つ");
});
Console.WriteLine("←これは必ずUI更新後に実行される");

// BeginInvoke: 依頼だけして次へ進む
Dispatcher.BeginInvoke(() =>
{
    Console.WriteLine("UI更新: 非同期で実行される");
});
Console.WriteLine("←これはUI更新前に先に実行される可能性あり");

Nullable

#

C#8.0以降nullableが導入されました。
常にnullチェックを書く必要が減って、コンパイル時に安全性を確保できます。

string notNull = "hello";  // null 非許容
string? canBeNull = null;  // null 許容

// notNull = null; // コンパイルエラー

[NotNullWhen] 属性について
System.Diagnostics.CodeAnalysis.NotNullWhenを使うと、メソッドの戻り値と引数のnull関係をコンパイラに伝えられます。
この仕組みを使うと、余計なnullチェックを省きつつ、安全にコードを書けます。

using System.Diagnostics.CodeAnalysis;

bool TryGetValue([NotNullWhen(true)] out string? value)
{
    value = DateTime.Now.Second % 2 == 0 ? "even" : null;
    return value != null;
}

if (TryGetValue(out var text))
{
    // textがnullでないとコンパイラが理解しているので安全に使える
    Console.WriteLine(text.Length);
}

まとめ

#

WPFやC#は初めて触ってみましたが、面白いことがたくさんあるなという感じでした。
最初はつまづきましたが、ひとつずつ理解するとだんだん整理されて楽になりました。
まだまだ知らないことばかりですが、ゆっくり深めていければいいなと思います。
少々雑多になりましたが、この記事が少しでも役立てば幸いです。

以上、お疲れ様でした。

豆蔵では共に高め合う仲間を募集しています!

recruit

具体的な採用情報はこちらからご覧いただけます。