【C#】WPFとMVVM「はじめの一歩」から現場Tipsまで! 〜デスクトップアプリ開発の実践メモ〜
Back to Top
この記事は夏のリレー連載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)と呼ばれます。
サンプル
#簡単なカウントアップアプリを実装してみます。
<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>
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"
)を名前で直接参照して操作しているのが特徴です。
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を導入します。
MVVMToolkit
の導入
CommunityToolkit.MvvmはMicrosoft公式のMVVM補助ライブラリです。
INotifyPropertyChanged
やICommand
実装を自動生成してくれます。
手書きでは冗長になりがちな、OnPropertyChanged
の呼び出しやRelayCommand
の実装を省略できます。
NuGet
からCommunityToolkit.Mvvm
を追加してください。
- View
<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>
using System.Windows;
namespace CounterSample
{
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
}
}
- ViewModel
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
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
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
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
<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>
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)を使ってみます。
DependencyInjection
の導入
WPFでMVVMを活用するなら、Microsoft.Extensions.DependencyInjection
を使ったDIが便利です。
DIを導入すると、ViewModelやサービスを必要な場所で簡単に受け渡すことができ、テストや保守がしやすくなります。
ServiceCollection
にサービスやViewModelを登録し、ServiceProvider
から取得するだけで依存関係を解決できます。
Viewや他のViewModelから直接newする必要がなくなります。
サンプルVer3
#サンプルVer2を元にDIを導入したサンプルを作ってみます。
CounterStorageService
とCounterViewModel
をサービス登録します。
後述するインスタンスのライフサイクルを学ぶため、StartupUriでApp.xaml(変更の必要なし)とコード上から2つウィンドウを起動してみます。
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コンテナからインスタンスを持ってきてくれます。
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>();
}
}
}
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>();
Ioc.Default
は静的なコンテナでアプリケーション全体が単一のルートスコープ内で実行されます。
そのためIoc.Default
からインスタンスを取得する場合、AddScoped
はAddSingleton
と全く同じように動作します。
という感じでサンプルと向き合う時間は終わりです。
サンプルなのでインターフェースに切るなどはやってません。
テストコードも入れて有用なことを示したいですが、長くなるので断念しました。
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
#Invoke
とBeginInvoke
の違いで更新されているはずの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#は初めて触ってみましたが、面白いことがたくさんあるなという感じでした。
最初はつまづきましたが、ひとつずつ理解するとだんだん整理されて楽になりました。
まだまだ知らないことばかりですが、ゆっくり深めていければいいなと思います。
少々雑多になりましたが、この記事が少しでも役立てば幸いです。
以上、お疲れ様でした。