LoginSignup
16
25

More than 3 years have passed since last update.

MVVMライブラリ別にWPFアプリケーションを作ってみた

Posted at

はじめに

WPFでMVVMパターンのアプリケーション開発をしたい!って人はまず,VMとMの関連付けやら依存性注入やらはある程度勝手にやってくれるMVVMライブラリを何かしら入れることになると思う。
(コーディング量が多いのでプレーンから作る人はあまりいない気がする。)
ただ,ライブラリごとに”クセが強い”のも事実で個人的には学習コストは結構高い印象。
そこで,簡単なアプリケーションをMVVMライブラリ別にコーディングしたらどこがどう違ってくるのかを調べてみた。
MVVMライブラリ,どうしよかな?やとりあえずアプリケーション開発したいんだけど流れがわからんという人になんとなくの参考になればと思う。

対象

  • C#の基礎をだいたい勉強して何かアプリケーションを作りたい
  • MVVMパターンを導入したいけど挫折しそうな(または既にした)人
  • ネットの海を渡り歩いたが,PrismとかMVVMライブラリの情報が少ないor古くてわからない
  • とりあえずちゃんと動くMVVVMアプリケーションのコードが知りたい人

ねらい

  • MVVMアプリケーション作成の上で最低限必要な機能について,MVVMライブラリ別にプログラム(特にViewModelとModel)をコーディングしたらどう違ってくるのかを検証する。
  • ボタン押下によりロジックレイヤーの処理を実行し,結果を表示するような簡単なGUIアプリケーションの構造を理解する。

使用したMVVMライブラリ

  1. Caliburn.Micro   v.3.2.0
    あまり有名ではないが,後に紹介するライブラリよりもバインディングが容易でお手軽。
    挙動な簡単なアプリケーションならこれでも全然OKかと。
    ただし,2020/06/18付の発表で次回メジャーアップデートをもって開発者による更新終了がアナウンスされた。
    *.NET Framework 4.8使用

  2. Prism(Prism Library)   v.7.2.0.1422
    ご存知,MVVMライブラリの定番。非常に多機能のわたるPrismだがここではPrism単体の機能のみでMVVMパターンの記述を試みた。
    *.NET Core 3.0使用

  3. Prism + ReactiveProperty (*リンク先はReactiveProperty   V.7.1.0)
    多くの先人が愛用するMVVMパターンアプリケーション開発の最終到達形(?)。ぶっちゃけReactiveProperty自体はMVVMライブラリとは関係ないが,導入によってコーディングががらっと変わるため項目に加えた。
    *.NET Core 3.0使用

  • あれ,LivetとかMVVM Lightは?? ・・・チョットナニイッテイルノカワカラナイ

  →興味がある人がいれば追記も検討します。

作成したアプリケーション

横山達大:ITエンジニアになる! チャレンジC#プログラミング
上記の書籍にあるパスワード生成アプリケーションとする。
(この書籍,アプリケーションのロジック開発を始めMVCパターンやMVVMパターンが明快に説明されていて,個人的にすごくオススメです。)

スクリーンショット 2020-07-03 00.12.19.png
(上記例は書籍内のアプリケーションの外観を投稿者がPrism + ReactivePropertyにより再現。)

前提条件

開発環境:Microsoft Visual Studio Community 2019    v.16.6.3
 
パスワード生成のロジックは完全にモデルとして分割。
(別途コンソールで開発したロジックレイヤをプロジェクト参照から読み込み)
ロジックレイヤはパスワード文字数,乱数インスタンス,記号の有無に応じたファクトリインスタンスを引数に取ると,引数で指定した文字数のパスワードを返すメソッドを持つ。

パスワード文字数についてのViewModelプロパティをNumOfLetters,記号有りなしについてのフラグのプロパティをIsNonMark,生成したパスワードのプロパティをCreatedPassword,パスワード生成ファクトリ用のインスタンスをfactoryとする。

*なお,本検証例では簡略化のため画面遷移やエラー値検証機能の実装を省いている。
 このため,実質的にはプロパティとコマンドのデータバインディングの違いを見ていくことになる。
 また,一部のプロパティや変数名を参考書籍の記載から変更している。

0. プレーンWPF → (MVVMライブラリなし)

*こちらについては上述の 横山達大:ITエンジニアになる! チャレンジC#プログラミング をお読みください。

INotifyPropertyChangedICommandDelegateCommandのコーディング量が多く,新たなアプリケーションごとに記述が必要となるとけっこう大変な印象。

1. Caliburn.Microによるコーディング

事前準備

下記のリンクなどを参考にメインウィンドウが表示されるところまで準備
Caliburn.Microを用いてWPFアプリケーションを作成する(1)
【C#】【WPF】令和の今、MVVMならCaliburn.Microが良いです(^^♪
Caliburn.Micro Part 1: Getting Started をやってみる。
Caliburn.Micro に入門してみる

一応,文章にするとVisual Studioで新規プロジェクト作成後,ロジックレイヤーのプロジェクトを既存のプロジェクトとして追加。
その後,NuGetからCaliburn.Microのパッケージを導入し,上記リンクを参考にメインウィンドウが表示できるようビルドしてみる。

Viewのコード(xaml)

ShellView.xaml
Window x:Class="Caliburn_PasswordCreator.Views.ShellView"
        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:Caliburn_PasswordCreator.Views"
        mc:Ignorable="d"
        Title="ShellView" Height="180" Width="360" WindowStartupLocation="CenterScreen" Background="DarkOrange"
        MinHeight="160" MaxHeight="200" MinWidth="320">

    <Grid Margin="10">
        <Grid.RowDefinitions>
            <RowDefinition Height="auto"></RowDefinition>
            <RowDefinition Height="*"></RowDefinition>
            <RowDefinition Height="auto"></RowDefinition>
        </Grid.RowDefinitions>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="auto"></ColumnDefinition>
            <ColumnDefinition Width="2*"></ColumnDefinition>
            <ColumnDefinition Width="5*"></ColumnDefinition>
        </Grid.ColumnDefinitions>
        <TextBlock Text="文字数:" FontSize="14" Foreground="White" />
        <TextBox Name="NumOfLetters" Grid.Column="1" FontSize="16" TextAlignment="Right" />
        <Button Name="MakePasswordExecute" Grid.Column="2" Grid.RowSpan="2" Content="Create Password" 
                FontSize="20" Background="CadetBlue" Foreground="White" Margin="10 0 0 10" />
        <TextBox Name="CreatedPassword" Grid.Row="2" Grid.ColumnSpan="3" FontSize="20" />
        <CheckBox Name="IsNonMark" Grid.Row="1" Grid.ColumnSpan="2" Content="記号なし" 
                  FontSize="14" Margin="0 10 0 0" Foreground="White" />
    </Grid>

</Window>

ViewModelのコード

ShellViewModel.cs
using Caliburn.Micro;
using PasswordCreator;
using System;

namespace Caliburn_PasswordCreator.ViewModels
{
    public interface IShell { }

    public class ShellViewModel : Screen, IShell
    {
        private ILetterFactory factory;

        //プロパティ
        private bool isNonMark;
        public bool IsNonMark
        {
            get { return isNonMark; }
            set
            {
                isNonMark = value;
                if (isNonMark)
                {
                    factory = new NonMarkLetterFactory();
                }
                else
                {
                    factory = new AllLetterFactory();
                }
                NotifyOfPropertyChange(() => IsNonMark);
            }
        }

        private int numOfLetters;
        public int NumOfLetters
        {
            get { return numOfLetters; }
            set
            {
                numOfLetters = value;
                NotifyOfPropertyChange(() => NumOfLetters);
                NotifyOfPropertyChange(() => CanMakePasswordExecute());
            }
        }

        private string createdPassword;
        public string CreatedPassword
        {
            get { return createdPassword; }
            set
            {
                createdPassword = value;
                NotifyOfPropertyChange(() => CreatedPassword);
            }
        }

        //コンストラクタ
        public ShellViewModel()
        {
            NumOfLetters = 20;
            IsNonMark = false;
            CreatedPassword = "This is Caliburn.Micro WPF App.";
        }

        //メソッド
        public void MakePasswordExecute()
        {
            Random random = new Random();
            var generator = new PasswordGenerator(random);
            CreatedPassword = generator.MakePassword(NumOfLetters, factory);
        }

        public bool CanMakePasswordExecute()
        {
            return numOfLetters > 10 ;
        }
    }
}

ポイント

ViewとViewModelの命名規則がきちんとしていると勝手に紐付けしてくれる。
この紐付けはデータバインディングがとにかく楽で,Viewのxamlでは各コントロールのNameにViewModelのプロパティ名を指定するだけでOK。
これはCommandにも当てはまり,ボタンコントロールのNameにViewModelの実行したいメソッド名を指定するとバインディングが完了する。
しかも,実行可能かの判定も前述のメソッドの接頭に"Can"が付与されたメソッドに対して自動で実行してくれる。
(正直,バインディングはめっちゃ楽だった。でもこれだとコマンド実行に応じてボタンの色変えたい場合はどこにどうバインディングするんだろう?)

バッキングフィールドへのProptertyChangedの記載もプレーンのものよりは減少しているが,メソッドの記述自体はCaliburn.Micro独特でラムダ式の記述に慣れていないとコケそう。
でもその分(?),ViewModelでINotifyPropertyChangedを継承する必要はないようだ。

一方で,Command関係も自動で紐付けしてくれる結果,ViewModelのコンストラクタにCommand関係の記述をする必要がなく,コンストラクタがスッキリしている。
個人的にMVVMパターンでつまづくポイントは,”イベントハンドラなどをコードビハインドに記述しない”基本ルールに対して,コンストラクタにDelegateCommandのインスタンスを生成したりと意外にコマンドバインディング流れがややこしいところかなと思っている。
Caliburn.Microでは「もうそのへんの流れはもうこっちで全部やっとくよ!」と言わんばかりに意識する必要がなくなるのはいいなと思った。 
 

2. Prismによるコーディング

事前準備

あらかじめ,Visual Studioの拡張機能に PrismTemplatePack を導入しておいたほうが早い。
Visual Studioで新規プロジェクト作成時にPrismベースのテンプレート( Prism Blank App (.NET Core)とか )を選択し新規プロジェクトを作成する。
その後,ロジックレイヤーのプロジェクトを既存のプロジェクトとして追加。
メインウィンドウが表示できるようビルドしてみる。

Viewのコード(xaml)

MainWindowView.xaml
<Window x:Class="Prism_PasswordCreator.Views.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:prism="http://prismlibrary.com/"
        prism:ViewModelLocator.AutoWireViewModel="True"
        Title="{Binding Title}" Height="180" Width="360" WindowStartupLocation="CenterScreen" Background="
        darkBlue"
        MinHeight="160" MaxHeight="200" MinWidth="320"
        >

    <Grid Margin="10">
        <Grid.RowDefinitions>
            <RowDefinition Height="auto"></RowDefinition>
            <RowDefinition Height="*"></RowDefinition>
            <RowDefinition Height="auto"></RowDefinition>
        </Grid.RowDefinitions>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="auto"></ColumnDefinition>
            <ColumnDefinition Width="2*"></ColumnDefinition>
            <ColumnDefinition Width="5*"></ColumnDefinition>
        </Grid.ColumnDefinitions>
        <TextBlock Text="文字数:" FontSize="14" Foreground="White"/>
        <Button Command="{Binding Generate}" Grid.Column="2" Grid.RowSpan="2" Content="Create Password" 
                FontSize="20" Background="CadetBlue" Foreground="White" Margin="10 0 0 10"/>
        <TextBox Text="{Binding NumOfLetters}" Grid.Column="1" FontSize="16" TextAlignment="Right"/>
        <TextBox Text="{Binding CreatedPassword}" Grid.Row="2" Grid.ColumnSpan="3" FontSize="20"/>

        <CheckBox IsChecked="{Binding IsNonMark}" Grid.Row="1" Grid.ColumnSpan="2" Content="記号なし" 
                  FontSize="14" Margin="0 10 0 0" Foreground="White"/>

    </Grid>
</Window>

ViewModelのコード

MainWindowViewModel.cs
using PasswordCreator;
using Prism.Commands;
using Prism.Mvvm;
using System;

namespace Prism_PasswordCreator.ViewModels
{
    public class MainWindowViewModel : BindableBase
    {
        private ILetterFactory factory;

        //プロパティ
        private string _title = "Prism Application";
        public string Title
        {
            get { return _title; }
            set { SetProperty(ref _title, value); }
        }

        private bool isNonMark = false;
        public bool IsNonMark
        {
            get { return isNonMark; }
            set
            {
                SetProperty(ref isNonMark, value);
                if (isNonMark)
                {
                    factory = new NonMarkLetterFactory();
                }
                else
                {
                    factory = new AllLetterFactory();
                }
            }
        }

        private int numOfLetters;
        public int NumOfLetters
        {
            get { return numOfLetters; }
            set { SetProperty(ref numOfLetters, value); }
        }

        private string createdPassword;
        public string CreatedPassword
        {
            get { return createdPassword; }
            set
            {
                SetProperty(ref createdPassword, value);
            }
        }

        public DelegateCommand Generate { get; private set; }

        //コンストラクタ
        public MainWindowViewModel()
        {
            NumOfLetters = 20;
            IsNonMark = false;
            CreatedPassword = "This is Prism WPF App.";
            Generate = new DelegateCommand(
                () => { CreatePasswordExecute(); }, // ボタンが押された時
                () => CanMakePasswordExecute()); // コマンドの実行可否(trueで実行可)
        }

        //メソッド
        private void CreatePasswordExecute()
        {
            Random random = new Random();
            var generator = new PasswordGenerator(random);
            CreatedPassword = generator.MakePassword(NumOfLetters, factory);
        }

        public bool CanMakePasswordExecute()
        {
            return numOfLetters > 10;
        }
    }
}

(サンプルの名残で必要ないTitleプロパティまで生きているのはご勘弁を。。。) 

ポイント

Caliburn.Microとは異なり,Viewへのバインディングの際にはきちんとコントロールの各プロパティに対して,きちんとViewModelのプロパティを紐付ける必要がある。
といっても,そんなに記述が増えることもない。各コントロールのプロパティがそれぞれバインディングできていればViewModel上でいろいろ制御できるのが良い。
ViewModelのコードは,プレーンのものとほぼ大差ない。(*ViewModelでINotifyPropertyChanged(BindableBase)を継承する必要がある)
プロパティのセッターでのPropaertyChangedの呼び出しメソッド(SetProperty)がプレーンよりも少しスッキリ書けるくらい?
あと,コンストラクタではコマンドをインスタンス化する必要がある。
(Caliburn.Microでは省略されていたが,プレーンではこれが普通。)
PrismはViewとViewModelの紐付けやPropaertyChangedの呼び出しメソッド(SetProperty)は用意してくれるが,これだけだとプレーンのWPFの自作テンプレートを作っても大差ないんじゃね?というのが個人的な印象。
 
 
 

3. Prism + ReactivePropertyによるコーディング

事前準備

基本的に2.Prismの場合と同じ。最後に作成したプロジェクトに対して,NuGetにて ReactiveProperty のパッケージを追加する。

Viewのコード(xaml)

MainWindowView.xaml
<Window x:Class="PrismReactiveProperty_PasswordCreator.Views.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:prism="http://prismlibrary.com/"
        prism:ViewModelLocator.AutoWireViewModel="True"
        Title="{Binding Title.Value}" Height="180" Width="360" WindowStartupLocation="CenterScreen" Background="DarkRed"
        MinHeight="160" MaxHeight="200" MinWidth="320"
        >

    <Grid Margin="10">
        <Grid.RowDefinitions>
            <RowDefinition Height="auto"></RowDefinition>
            <RowDefinition Height="*"></RowDefinition>
            <RowDefinition Height="auto"></RowDefinition>
        </Grid.RowDefinitions>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="auto"></ColumnDefinition>
            <ColumnDefinition Width="2*"></ColumnDefinition>
            <ColumnDefinition Width="5*"></ColumnDefinition>
        </Grid.ColumnDefinitions>
        <TextBlock Text="文字数:" FontSize="14" Foreground="White"/>
        <Button Command="{Binding Generate}" Grid.Column="2" Grid.RowSpan="2" Content="Create Password" 
                FontSize="20" Background="CadetBlue" Foreground="White" Margin="10 0 0 10"/>
        <TextBox Text="{Binding NumOfLetters.Value}" Grid.Column="1" FontSize="16" TextAlignment="Right"/>
        <TextBox Text="{Binding CreatedPassword.Value}" Grid.Row="2" Grid.ColumnSpan="3" FontSize="20"/>

        <CheckBox IsChecked="{Binding IsNonMark.Value}" Grid.Row="1" Grid.ColumnSpan="2" Content="記号なし" 
                  FontSize="14" Margin="0 10 0 0" Foreground="White"/>

    </Grid>
</Window>

ViewModelのコード

MainWindowViewModel.cs
using PasswordCreator;
using Prism.Mvvm;
using Reactive.Bindings;
using Reactive.Bindings.Extensions;
using System;
using System.Reactive.Disposables;
using System.Reactive.Linq;

namespace PrismReactiveProperty_PasswordCreator.ViewModels
{
    public class MainWindowViewModel : BindableBase
    {
        private readonly CompositeDisposable _cd = new CompositeDisposable();
        public void Dispose() => this._cd.Dispose();

        private ILetterFactory factory;

        //プロパティ
        public ReactiveProperty<bool> IsNonMark { get; private set; }
            = new ReactiveProperty<bool>(false);

        public ReactiveProperty<string> Title { get; private set; }
            = new ReactiveProperty<string>("Prism Application");

        public ReactiveProperty<int> NumOfLetters { get; private set; }
            = new ReactiveProperty<int>(20);

        public ReactiveProperty<string> CreatedPassword { get; private set; }
            = new ReactiveProperty<string>("Prism+RectiveProperty WPF App.");

        public ReactiveCommand Generate { get; }

        //コンストラクタ
        public MainWindowViewModel()
        {
            this.Title.AddTo(_cd);
            this.NumOfLetters.AddTo(_cd);
            this.CreatedPassword.AddTo(_cd);
            this.IsNonMark.AddTo(_cd);
            IsNonMark.Subscribe(_ => SetFactory());
            Generate = NumOfLetters.Select(x => x > 10).ToReactiveCommand();
            Generate.Subscribe(_ => CreatePasswordExecute());
        }

        //メソッド
        private void CreatePasswordExecute()
        {
            Random random = new Random();
            var generator = new PasswordGenerator(random);
            CreatedPassword.Value = generator.MakePassword(NumOfLetters.Value, factory);
        }

        private void SetFactory()
        {
            if (IsNonMark.Value)
            {
                factory = new NonMarkLetterFactory();
            }
            else
            {
                factory = new AllLetterFactory();
            }
        }
    }
}

ポイント

偉大な先人たちが推すだけあって,ViewModelがかなりスッキリする。
各プロパティはReactivePropaerty型の変数として宣言することになり,この宣言文ひとつで実質的にPropaertyChangedが有効化するというスグレモノ。
プロパティ変更後の処理についてはコンストラクタで対象プロパティのSubscribe()(購読)を宣言することによって表現している。
(要するにインスタンス化の段階で「対象プロパティの値が変化したら指定した処理をするぞ!」って宣言している)
コマンド関連に関しては,ReactiveCommandを用いて宣言することになる。
こちらもコンストラクタにて,まず所定の条件を満たす場合はコマンドを有効化するToReactiveCommand()が記述される。
その後,コマンドの状態を監視し,コマンド実行(ボタン押下)時に所定のメソッド実行することをやはりSubscribe()によって宣言している。
Subscribe()(購読)という概念に慣れる必要があるものの,言い換えるとコンストラクタの実行コードにどのプロパティを監視しているかやコマンド有効化の条件がすべて記載されること,これに伴いバッキングフィールドに余計なことを書かなくなることにより結果的にコードの可読性は上がっているのかなと思う。

ただ,初心者がみるとReactiveProperty特有の作法はやはり”クセが強い”という印象は否めないなと思った。
ReactivePropaerty型における値の設定や取り出しにはValueプロパティを用いる必要があり,例えばViewModelのTitleプロパティのバインディングでは,ViewのxamlにおいてTitle.Valueと指定する必要があるのをはじめ,あらゆるプロパティの値の取得は.Valueと追記する必要がある。
(*ただし,コマンド(ReactiveCommand型)に対しては.Valueの追記は不要!)
また,ViewModelはINotifyPropertyChanged(BindableBase)を継承する必要があるが,これは仕様上INotifyPropertyChangedしないとメモリリークするため。
さらに,ReactivePropertyReactiveCommandIDisposableを継承しているため,使用後はDisposeする必要があるとのこと。
上記の例では,これもまとめてDisposeできるようにコンストラクタでCompositeDisposable型の変数に登録している。
簡単なアプリケーションの作成であればこれらの作法については盲目的に丸暗記でもいいので覚えておきたい。
(これら作法をちゃんとやってないと,ビルド時エラーのよくある原因となる。。。)

あとがき

自分自身も,最初は「とあるファイルを処理・変換してくれるコンソールアプリケーションのGUIが作りたい」という動機で調べていったつもりだったのに,MVVMについて調べるうちにネットの海で迷子になり,なんとか岸辺に戻ってくるのに約2年かかったクチですw
(おまけに必要なものはそこまでゲットできてない気が・・・)

ただ,調べていて印象に残ったのは海外のstackoverflow的な掲示板で回答者が”ライブラリなんて自分の都合のいいとこだけ使えばいいじゃん”という言葉でした。
ライブラリのそれぞれを完璧に理解するよりは,各ライブラリで最低限アプリケーションが動くレベルのものを作るとするとどうなるか並列的・横断的に考えていこうとした結果,それぞれの理解が少し深まった気がして,今回なんとか文章化できました。

自分自身,正直全然理解が追いついていない段階で書き殴った文章ですが,誰かの参考になればと思います。

最後にお気づきの点ありましたらコメントいただければと思います。
(ラムダ式やLINQをあまり理解できておらず,中途半端にメソッドが混在したり不格好なコードですので,良いコーディングありましたらアドバイスお願いします。)
 
コードの公開は・・・コメントが多ればするかも。。。
(ただ,参考書籍買って自分でコーディングしたほうがよっぽど理解深まると思われ・・)
 

参考リンク

16
25
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
16
25