はじめに
恥ずかしながら未だにWinForms案件ばかりでWPF、ましてやMVVMなんて使ったことがない時代遅れなプログラマーです。恥ずかしい・・・
そんな私の元にも遂にWPF案件が舞い降りてきてしまった。
ここ数日「WPFでどうやって作るねん」から調べ始めて、今さらMVVMという存在を知り、「MVVMってなんやねん」をずっと調べているけど・・・
なんだかよく分からない!
「View
-ViewModel
-Model
に分けて作りましょう」
「テストしやすくなるよ」
とかざっくりとは分かる。
でも具体的なその分け方が調べていても人によって微妙に違って何が正解か分からない。1
そもそも正解なんて無いのかもしれない。
で、考え込みすぎて訳が分からなくなってきたので、自分なりの考えを整理するためにも、MVVMについて書いてみることに。
WinForms脳な人間がMVVMについて数日調べただけの内容なので間違えてる部分が多々あるかもしれません。その時は優しく教えてくれると嬉しいです。優しく・・・
なんでMVVMに分けるのだろう?
V-VM-M
の分け方について調べているだけだと明確な物が見えてこないので、視点を変えて
「なんでMVVMに分けるのだろう?」
という目的から考えてみることにした。
目的
テスタビリティ(テスト容易性)向上
画面とコードがくっついてるとユニットテストがし辛い。
そこで画面(View)とコード(ViewModel/Model)に分離してやるとやりやすくなる。
なるほど、たしかに。
プロダクティヴィティ(生産性)向上
画面(View)とコード(ViewModel/Model)が分離してると、デザイナは画面(View)、プログラマはコード(ViewModel/Model)と明確に分業できる。
もっともウチにはデザイナなんていない。
ポータビリティ(移植性)向上(?)
画面(View)とコード(ViewModel/Model)が分離してると、画面(View)を用意するだけで他のプラットフォームに対応できる・・・かもしれない。
と言うのも、これについて触れられている情報を殆ど見かけなくて、あくまで勝手なイメージ。
ただ理論上は可能なはず・・・2
分け方が曖昧な原因はここにある?
恐らく大まかにこの3つが目的で、特にテスタビリティ向上が重要っぽい。
あれ?これらの目的って、とりあえず画面さえ分離しちゃえば一応は満たせる?
そのせいでV-VM-M
の分け方が曖昧なんだろうか。
理想的なプロジェクト構成を考えてみる
↑に挙げた目的を元に、まずはどんなプロジェクト構成が理想的かを大まかに考えてみる。
こんな感じ?
- MvvmTest (.Net Core/WPF)
- Views
- MainView.xaml
- MainView.xaml.cs
- App.xaml
- App.xaml.cs
- AssemblyInfo.cs
- MvvmTest.MVM (.Net Standard)
- MainViewModel.cs
- MainModel.cs
あくまで最小の基本構成。DIコンテナとか画面遷移とか考え出すと多分これじゃあ足りない。
MvvmTest プロジェクト
Viewとアプリケーションを定義・実装するプロジェクト。
ViewをMvvmTest.Views
プロジェクトに分けるのも有りかも。意味があるかは謎。
あとこれをMvvmTest.NetCore
にして、XamarinでMvvmTest.iOS
とか作れると幸せ。
MvvmTest.MVM プロジェクト
ViewModel と Model を定義・実装するプロジェクト。
.Net Standard
にしたい!!3
MVM・・・もう少し良い名前はないものか。
MvvmTest.ViewModels
とMvvmTest.Models
に分けるのも有りかも。意味があるかは謎。
ViewModel と Model は同じ階層で近い位置にいた方が便利のような?
MVVMそれぞれの役割を考えてみる
次にView
、ViewModel
、Model
それぞれの役割を大まかに考えてみる。
View
画面。それ以上でもそれ以下でもない。
ViewModel
が提供するプロパティに沿って画面を定義する。
デザインに関すること(XAML)以外は書きたくない。
Model
ロジック。やるべき処理をやる人。
ViewModel
からパラメータを貰って処理を行う。
どんなデザインの画面かは知らない。4
ViewModel
画面とロジックを結ぶ橋渡し役。
画面からどんな情報が欲しいか定義して、ロジックの求める形で情報を渡して、ロジックから結果を貰って、結果を画面に渡してあげる。
大体どんな画面か知っていて5、画面寄りの処理(入力チェックとか)はこの人がやるべき?(不明瞭)
それぞれ持つべきものを考えてみる
↑の役割をふまえて、View
、ViewModel
、Model
がそれぞれ持つべきもの(やるべきこと)を大まかに考えてみる。
View
- 画面デザイン
- 対応する
ViewModel
のインスタンス(View
はViewModel
に依存、DataContext) - コントロールが
ViewModel
のどのプロパティと連動するか(Binding)
ViewModel
- 対応する
Model
のインスタンス(ViewModel
はModel
に依存) - 画面の入力データを受け取る6
- 入力データが
Model
に渡せるかどうかの入力チェック7 - エラーを
View
に通知(INotifyDataErrorInfo) - ボタンなどのイベント処理(ICommand)
- 入力データを
Model
の求める形に変換して渡す -
Model
のプロパティ変更通知を受け取る - プロパティ変更を
View
に通知(INotifyPropertyChanged)
Model
-
ViewModel
からパラメータを受け取る - パラメータのエラーチェック
- エラーを
ViewModel
に通知(INotifyDataErrorInfo) - 実際にやりたい処理
- 結果をプロパティに格納
- プロパティ変更を
View
に通知(INotifyPropertyChanged)
実際に書いてみる
今まで考えたことをふまえて、実際にコードを書いてみる。
数値を2つを渡して実行すると足し算した結果が返ってくる超単純なアプリをMVVMで作ってみた。
Model(MainModel.cs)
まずは画面は気にせずやるべき処理を書いてみる。
class MainModel : ViewModelBase, IMainModel
{
private Property<int> ans = new Property<int>();
public int ParamA { get; set; }
public int ParamB { get; set; }
public int Answer
{
get => this.ans;
set => this.ans.SetValue(value, this);
}
public void Sum()
=> this.Answer = this.ParamA + this.ParamB;
}
2つの数字を受け取って足し算するだけ。ややこしいのでパラメータエラーはなし。
因みにViewModelBase
クラスにプロパティ変更通知が実装されていて、Property<T>
のSetValue
で良い感じに通知してる。
IMainModel
は、MainModel
の定義そのまま。
ViewModel(MainViewModel.cs)
次に画面を想像しつつModel
と繋ぐViewModel
を書いてみる。
数字を入力するテキストボックス2個(ParamA,ParamB)と、実行ボタン1個(SumCommand)と、結果を表示するラベルが1個(Answer)かな。
public class MainViewModel : ViewModelBase
{
private IMainModel Model { get; }
private Property<string> paramA = new Property<string>("0",
(value) =>
{
if (value.Length == 0)
return "未入力エラー";
else if (!int.TryParse(value, out int _))
return "フォーマットエラー";
else
return null;
});
private Property<string> paramB = new Property<string>("0",
(value) =>
{
if (value.Length == 0)
return "未入力エラー";
else if (!int.TryParse(value, out int _))
return "フォーマットエラー";
else
return null;
});
private Property<int> ans = new Property<int>();
public string ParamA
{
get => this.paramA;
set
{
// 入力エラーがなければModelに設定
if (this.paramA.SetValue(value, this))
this.Model.ParamA = int.Parse(this.ParamA);
}
}
public string ParamB
{
get => this.paramB;
set
{
// 入力エラーがなければModelに設定
if (this.paramB.SetValue(value, this))
this.Model.ParamB = int.Parse(this.ParamB);
}
}
public int Answer
{
get => this.ans;
set => this.ans.SetValue(value, this);
}
public ICommand SumCommand { get; }
public MainViewModel()
{
// TODO: 本来ならDIコンテナから取得
this.Model = new MainModel();
this.Model.PropertyChanged += Model_PropertyChanged;
// Sumボタン
this.SumCommand = new Command(() =>
{
// 実行
this.Model.Sum();
}, paramA, paramB);
}
private void Model_PropertyChanged(object sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(this.Model.Answer))
this.Answer = this.Model.Answer;
}
}
View
からデータを受け取って、Model
に渡せるデータかチェックして、SumCommandの有効・無効を制御。
SumCommandの処理でModel
にパラメータを渡して実行。
Model
のプロパティ変更通知で結果を取り出してViewModel
に通知。
独自のクラスが多くて分かりにくい・・・
ひとまずProperty<T>
には検証機能もあって、Command
クラスには指定したProperty<T>
のエラー状態と連動して有効・無効が勝手に切り替わる・・・くらいのイメージで・・・8
View(MainView.xaml)
最後に画面。
ViewModel
に従って数字を入力するテキストボックス2個と、実行ボタン1個と、結果を表示するラベルが1個。
<Window x:Class="MvvmTest.Views.MainView"
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:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:MvvmTest.Views"
xmlns:vm="clr-namespace:MvvmTest.VM;assembly=MvvmTest.VM"
mc:Ignorable="d"
Title="Main" SizeToContent="WidthAndHeight">
<Window.DataContext>
<vm:MainViewModel />
</Window.DataContext>
<Grid MinWidth="300" MinHeight="200">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="100" />
<ColumnDefinition/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition/>
<RowDefinition/>
<RowDefinition/>
<RowDefinition/>
</Grid.RowDefinitions>
<!-- ParamA -->
<TextBlock Grid.Row="0" Grid.Column="0" Margin="0,0,10,0" HorizontalAlignment="Right" VerticalAlignment="Center" Text="ParamA"/>
<TextBox Grid.Row="0" Grid.Column="1" MaxHeight="24" Margin="10, 0, 10, 0" VerticalContentAlignment="Center" Text="{Binding ParamA, UpdateSourceTrigger=PropertyChanged}" />
<!-- ParamB -->
<TextBlock Grid.Row="1" Grid.Column="0" Margin="0,0,10,0" HorizontalAlignment="Right" VerticalAlignment="Center" Text="ParamB"/>
<TextBox Grid.Row="1" Grid.Column="1" MaxHeight="24" Margin="10, 0, 10, 0" VerticalContentAlignment="Center" Text="{Binding ParamB, UpdateSourceTrigger=PropertyChanged}" />
<!-- Button -->
<Button Grid.Row="2" Grid.Column="0" Grid.ColumnSpan="2" Margin="10,10,10,10" Content="Sum" Command="{Binding SumCommand}"/>
<!-- Answer -->
<TextBlock Grid.Row="3" Grid.Column="0" Margin="0,0,10,0" HorizontalAlignment="Right" VerticalAlignment="Center" Text="Answer"/>
<TextBlock Grid.Row="3" Grid.Column="1" HorizontalAlignment="Left" Margin="10, 0, 10, 0" VerticalAlignment="Center" Text="{Binding Ans}" />
</Grid>
</Window>
画面のデザインと、ViewModelが誰かと、コントロール毎のバインディングを定義するだけ。
コードビハインド(MainView.xaml.cs)
/// <summary>
/// MainView.xaml の相互作用ロジック
/// </summary>
public partial class MainView : Window
{
public MainView()
{
InitializeComponent();
}
}
生成されたコードそのまま。絶対に触らない(鉄の意志)
ソースコードの全てはGitHubに上げてみた
mitsu-at3/MvvmTest: Mvvm Test Project
仕事でGitLabは使っているけど、GitHubは初めて使った・・・(今さら)
合ってるだろうか?
色々と調べたりMVVMの目的を考えると、この分け方が1番しっくり来るけど、合ってるのか分からない・・・
特にViewModel
に定義するView
からデータを受け取るためのプロパティの型を、例えば数値入力でもテキストボックスならstring型みたいに、View
のコントロールの都合を考慮した型にするのが正しいのかどうかが謎。
調べても明確にそういう考え方してる情報に出会えなかった。
ただViewModel
のプロパティをintにしてしまうと、View
の段階で入力エラーが出てしまって、そのままだとViewModel
側でエラーを検知できないから、View
からViewModel
に伝える仕組みが必要になってしまう。
でもView
にはなるべく余計なものは書きたくない。
って考えると、ViewModel
側はとりあえずstringで受け入れて、入力チェックはViewModel
側でやるのがベター?9
調べてる中で気になったこととか
最後に調べてるときに見かけた情報で「それ合ってるの?」と気になったことなど。
あちこちの情報をひたすら漁ってる中で見かけた情報なのでどこで見たのかは覚えてない・・・10
Modelがただのデータクラス
public class PersonModel
{
public string Name { get; set; }
public int Age { get; set; }
}
Modelの意味を勘違いしてるのか、ロジックはこれから実装するつもりだったのか・・・
「ViewModelとModelは同じプロパティが並びます」
必ずしも同じ必要は無いのではなかろうか?
同じだとViewModel
が架け橋する意味も薄くなっちゃう。
ViewModel
とModel
の関係が崩れなければ、違っていても良い気がする。
ViewModelが完全にModelへの受け渡しだけ
↑と似ているけど・・・
public class PersonModelView
{
public string Name
{
get => this.Model.Name;
set => this.Model.Name = value;
}
public int Age
{
get => this.Model.Age;
set => this.Model.Age= value;
}
// ・・・Modelから通知を受けたり、コマンドを受けてModelを実行したりだけ
}
入力チェックも何もない画面なら有りなのかもしれないけど、ここまで来るとVMとMを分ける意味がないような・・・
超小規模なプロジェクトならModel
なしも有り?
あなたのモデルはどこから?
public MainViewModel(IMainModel model)
{
this.Model = model;
}
なんやかんやの仕組みがあって自動的に依存性注入で渡ってくるならこれも良いと思う。
でもこれは嫌だ(MainView.xaml.cs)
/// <summary>
/// MainView.xaml の相互作用ロジック
/// </summary>
public partial class MainView : Window
{
public MainView()
{
IMainModel model = DIコンテナ的なやつ.GetService(typeof(IMainModel));
this.DataContext = new MainViewModel(model);
InitializeComponent();
}
}
コードビハインドには書きたくない!!!
ViewModelのコンストラクタで作れば良くない?
public MainViewModel()
{
this.Model = DIコンテナ的なやつ.GetService(typeof(IMainModel));
}
ViewModel
がModel
に依存するのは間違いじゃないので、素直にこっちの方が良いような。
Modelがシングルトン
これが正しいのかが1番分からない。
今までの考えからすると、View
-ViewModel
-Model
は1つのセットと見なして、生存期間は同じにしておく方が自然のような気がする。
シングルトンで扱いたい機能は、Model
よりも下に定義して、Model
の中でそのシングルトンクラスを使った方がMVVMの関係性が明確で分かりやすくない?
でも厳密には「View
とViewModel
以外全てがModel
」らしいから11、シングルトンでも間違いではないのかな・・・
個人的には少なくともViewModel
が参照するModel
は、生存期間を同じにしておきたいなあ。
おわりに
長々と書いていて「こう考えるのが正しいのではないか?」というのはところまでは来たけども、まだ確信を持って「この考えで正しい!」とは言えない・・・
しっくりは来てるんだけどなあ。
でも序盤に挙げた「目的」を明確に認識しておくだけでも「その形が間違ってるか?」の判断材料にはなりそう。
少しは最新の開発スタイルに近付けただろうか・・・?
あと画面遷移とかダイアログ表示とかDIコンテナとか調べだすと、Prismとか外部フレームワークに頼りたくなる理由が分かってきた・・・
何となく自力でも実装できそうな雰囲気だけど、かなり面倒そうな印象。
おまけ:DIコンテナって・・・
MVVMについて調べる中で初めて「DIコンテナ」を知ったのだけど(今さら)、解説を読んだときに真っ先にあいつを思い出した・・・
HRESULT CoCreateInstance(
REFCLSID rclsid,
LPUNKNOWN pUnkOuter,
DWORD dwClsContext,
REFIID riid,
LPVOID *ppv
);
取得したいInterfaceの情報を渡すとインスタンスが返ってくるってこれじゃん。
そう思うと急に親しみが湧いてきたのでした。おしまい。
-
理解力が足りないだけかもしれない。 ↩
-
Xamarinを使えば出来たりしないかな?(Xamarin未経験) ↩
-
.Net Standard大好き ↩
-
何をする画面かは知ってる。じゃないと何の処理をすれば良いのか分からない。あくまでデザインを知らないだけ。 ↩
-
このパラメータはテキストボックス的なやつで、このパラメータはチェックボックス的なやつで・・・みたいな。 ↩
-
View
がバインディングするところでエラーが出ないようにするべき?(数値入力前提のテキストボックスでも、とりあえずstring型のプロパティで受け取るとか) ↩ -
Model
の処理に依存するエラーは知らない。あくまで「渡せるかどうか」だけ。 ↩ -
INotifyPropertyChanged
やINotifyDataErrorInfo
はどういう実装が便利だろう?とお試しで作ったクラス。実際にこれで良いかは分からない。(全容はGitHubで・・・) ↩ -
stringにしておけばどんな入力でも受け入れられるテキストボックスだからってだけで、どんな型にしてもエラーが発生しうるコントロールの場合は、
View
→ViewModel
の通知は避けられない? ↩ -
ただその情報のせいで余計に混乱した ↩
-
混乱の素 ↩