2024年8月31日土曜日

ListView Extensions ver.1.1.0

唐突ですがListView Extensions ver.1.1.0をリリースしました。実に前回の更新から6年ぶりです。

Github:

Nuget:
https://www.nuget.org/packages/ListViewExtensions/

今回の変更点

  • Githubでソースコードを公開
  • ライセンスをMITライセンスに変更
  • ターゲットを.NET Framework 4.5.2 / .NET Core 3.1 / .NET 6に変更
  • ListViewのヘッダーサポートを強化
    • SortableGridViewColumnHeader、SortableGridViewColumnを追加
    • SortedHeaderをObsolete指定にした
  • ISortableObservableCollectionのSortメソッドの引数を変更、古いメソッドはObsolete指定にした
  • コードのリファクタリング、Nullableの有効化、単体テストの追加など

主な変更点を説明していきます。

Githubで公開 / MITライセンス化

最近久しぶりにこのListView Extensionsをいじろうとしたとき、そういえばはコードを公開していなかったなと思ってGithubで公開することにしました。併せてライセンスもMITに変更しています(今までは明記なし)。すっかり私もオープンソースの人(?)になってきました。

ターゲットを.NET Framework 4.5.2 / .NET Core 3.1 / .NET 6に変更

直前のver.1.0.1 では.NET Framework 4.5のみでした。これが.NET系統にも対応するようにしました。実はこれが今回のアップデートの大きなモチベーションだったりします。

ListViewのヘッダーサポートを強化

ListViewのヘッダーを簡単に作成

今までのListView Extensionsではヘッダーの記述がとても冗長でした。

<ListView ItemsSource="{Binding People}" >
    <ListView.Resources>
        <lv:SortingConditionConverter x:Key="ConditionToDirectionConverter" />
    </ListView.Resources>
    <ListView.View>
        <GridView>
            <GridViewColumn Width="120" DisplayMemberBinding="{Binding Name}">
                <GridViewColumnHeader Command="{Binding People.SortByPropertyCommand}" CommandParameter="Name" >
                    <lv:SortedHeader Content="Name" SortingDirection="{Binding People.SortingCondition, Mode=OneWay, Converter={StaticResource ConditionToDirectionConverter}, ConverterParameter='Name'}" />
                </GridViewColumnHeader>
            </GridViewColumn>
            <GridViewColumn Width="150" DisplayMemberBinding="{Binding Pronunciation}" >
                <GridViewColumnHeader Command="{Binding People.SortByPropertyCommand}" CommandParameter="Pronunciation" >
                    <lv:SortedHeader Content="Pronunciation" SortingDirection="{Binding People.SortingCondition, Mode=OneWay, Converter={StaticResource ConditionToDirectionConverter}, ConverterParameter='Pronunciation'}" />
                </GridViewColumnHeader>
            </GridViewColumn>
            <GridViewColumn Width="70" DisplayMemberBinding="{Binding Age}" >
                <GridViewColumnHeader Command="{Binding People.SortByPropertyCommand}" CommandParameter="Age" >
                    <lv:SortedHeader Content="Age" SortingDirection="{Binding People.SortingCondition, Mode=OneWay, Converter={StaticResource ConditionToDirectionConverter}, ConverterParameter='Age'}" />
                </GridViewColumnHeader>
            </GridViewColumn>
            <GridViewColumn Width="120" DisplayMemberBinding="{Binding Birthday}" >
                <GridViewColumnHeader Command="{Binding People.SortByPropertyCommand}" CommandParameter="Birthday" >
                    <lv:SortedHeader Content="Birthday" SortingDirection="{Binding People.SortingCondition, Mode=OneWay, Converter={StaticResource ConditionToDirectionConverter}, ConverterParameter='Birthday'}" />
                </GridViewColumnHeader>
            </GridViewColumn>
            <GridViewColumn Width="100" DisplayMemberBinding="{Binding Height}" >
                <GridViewColumnHeader Command="{Binding People.SortByPropertyCommand}" CommandParameter="Height_cm" >
                    <lv:SortedHeader Content="Height" SortingDirection="{Binding People.SortingCondition, Mode=OneWay, Converter={StaticResource ConditionToDirectionConverter}, ConverterParameter='Height_cm'}" />
                </GridViewColumnHeader>
            </GridViewColumn>
        </GridView>
    </ListView.View>
    <!-- 中略 -->
</ListView>

これはサンプルコードの抜粋ですが、ListViewで1列作るのに5行もコードが必要でした。そして似たような記述も繰り返し行われとても冗長です。XAMLの構造、というよりもどういう仕組みになっているかはこの状態でわかりやすいんですがね。

これを、今回のバージョンでは以下のように書き換えることができるようになりました。

<ListView ItemsSource="{Binding People}" >
    <ListView.View>
        <GridView>
            <lv:SortableGridViewColumn Width="120" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Name}" Header="Name" />
            <lv:SortableGridViewColumn Width="150" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Pronunciation}" Header="Pronunciation" />
            <lv:SortableGridViewColumn Width="70"  SortableSource="{Binding People}" DisplayMemberBinding="{Binding Age}" Header="Age" />
            <lv:SortableGridViewColumn Width="120" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Birthday}" Header="Birthday" />
            <lv:SortableGridViewColumn Width="120" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Height}" Header="Height" />
        </GridView>
    </ListView.View>
    <!-- 中略 -->
</ListView>

だいぶすっきりしました。

このSortableGridViewColumnはGridViewColumnを継承して作られたものですが、少しトリッキーです。上のコードは下のコードとほぼ同等です。

<ListView ItemsSource="{Binding People}" >
    <ListView.Resources>
        <lv:SortingConditionConverter x:Key="ConditionToDirectionConverter" />
    </ListView.Resources>
    <ListView.View>
        <GridView>
            <GridViewColumn Width="120" DisplayMemberBinding="{Binding Name}">
                <lv:SortableGridViewColumnHeader Content="Name" SortingArrowLocation="Right" SortingDirection="{Binding People.SortingCondition, Mode=OneWay, Converter={StaticResource ConditionToDirectionConverter}, ConverterParameter='Name'}"
                                                 Command="{Binding People.SortByPropertyCommand}" CommandParameter="Name"/>
            </GridViewColumn>
            <GridViewColumn Width="150" DisplayMemberBinding="{Binding Pronunciation}" >
                <lv:SortableGridViewColumnHeader Content="Pronunciation" SortingArrowLocation="Right" SortingDirection="{Binding People.SortingCondition, Mode=OneWay, Converter={StaticResource ConditionToDirectionConverter}, ConverterParameter='Pronunciation'}"
                                                 Command="{Binding People.SortByPropertyCommand}" CommandParameter="Pronunciation"/>
            </GridViewColumn>
            <GridViewColumn Width="70" DisplayMemberBinding="{Binding Age}" >
                <lv:SortableGridViewColumnHeader Content="Age" SortingArrowLocation="Right" SortingDirection="{Binding People.SortingCondition, Mode=OneWay, Converter={StaticResource ConditionToDirectionConverter}, ConverterParameter='Age'}"
                                                 Command="{Binding People.SortByPropertyCommand}" CommandParameter="Age"/>
            </GridViewColumn>
            <GridViewColumn Width="120" DisplayMemberBinding="{Binding Birthday}" >
                <lv:SortableGridViewColumnHeader Content="Birthday" SortingArrowLocation="Right" SortingDirection="{Binding People.SortingCondition, Mode=OneWay, Converter={StaticResource ConditionToDirectionConverter}, ConverterParameter='Birthday'}"
                                                 Command="{Binding People.SortByPropertyCommand}" CommandParameter="Birthday"/>
            </GridViewColumn>
            <GridViewColumn Width="100" DisplayMemberBinding="{Binding Height}" >
                <lv:SortableGridViewColumnHeader Content="Height" SortingArrowLocation="Right" SortingDirection="{Binding People.SortingCondition, Mode=OneWay, Converter={StaticResource ConditionToDirectionConverter}, ConverterParameter='Height'}"
                                                 Command="{Binding People.SortByPropertyCommand}" CommandParameter="Height"/>
            </GridViewColumn>
        </GridView>
    </ListView.View>
    <!-- 中略 -->
</ListView>

SortableGridViewColumnHeaderはGridViewColumnHeaderを継承して作られたもので、GridViewColumnHeaderに並び替えのアイコン「▲」「▼」を表示する機能を追加したものです。SortingDirectionプロパティに並び替えの向きを指定することでアイコンが表示されます。

SortableGridViewColumnは、HeaderプロパティがSortableGridViewColumnHeaderではない場合に自動的にSortableGridViewColumnHeaderを作ってそのインスタンスにHeaderプロパティに入っていたものを渡しますます。そして、そのSortableGridViewColumnHeaderのSortingDirectionプロパティ、Command / CommandParameterプロパティにSortableSourceプロパティにバインディングされたオブジェクトのプロパティを自動的にバインディングします。ソートのキーにはDisplayMemberBindingの値を使うので、万が一DisplayMemberBindingの値と異なるプロパティをソートのキーにする必要がある場合はGridViewColumnHeaderを直接触る必要があります。

これらの機能が追加されたことによって、SortedHeaderはObsolete扱いとなりました。

ListViewのヘッダーをカスタマイズ① - 並び替え矢印の位置を変更

SortableGridViewColumnはSortingArrowLocationプロパティを持っています。これはDock列挙型になっているので、上下左右好きな場所に配置することができます。直接プロパティを触っても良いのですが、GridView.ColumnHeaderContainerStyleプロパティによる一括スタイル指定をするのが良いでしょう。

<ListView ItemsSource="{Binding People}" >
    <ListView.View>
        <GridView>
            <lv:SortableGridViewColumn Width="120" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Name}" Header="Name" />
            <lv:SortableGridViewColumn Width="150" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Pronunciation}" Header="Pronunciation" />
            <lv:SortableGridViewColumn Width="70"  SortableSource="{Binding People}" DisplayMemberBinding="{Binding Age}" Header="Age" />
            <lv:SortableGridViewColumn Width="120" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Birthday}" Header="Birthday" />
            <lv:SortableGridViewColumn Width="120" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Height}" Header="Height" />
            <GridView.ColumnHeaderContainerStyle>
                <Style TargetType="lv:SortableGridViewColumnHeader">
                    <Setter Property="SortingArrowLocation" Value="Top" />
                </Style>
            </GridView.ColumnHeaderContainerStyle>
        </GridView>
    </ListView.View>
    <!-- 中略 -->
</ListView>

↓Top

Top

↓Right

Right

画像ではTopとRightの例を出していますが、LeftとBottomを指定することもできます。あまり馴染みのないデザインかとは思いますが。

あとはSortingArrowMarginプロパティを使えばArrowの周囲のMarginを設定することができるので、「Name」などのヘッダーテキストの間隔を調整したいときに使えます。

ListViewのヘッダーをカスタマイズ② - 並び替え矢印をカスタマイズ

ListViewのヘッダーの矢印は専用のクラス「AscendingArrow」「DescendingArrow」で表現されています。ですので、このクラスのTemplateを丸々置き換えてしまうことで矢印の見た目をカスタマイズすることができます。

<ListView ItemsSource="{Binding People}" >
    <ListView.Resources>
        <Style TargetType="lv:AscendingArrow">
            <Setter Property="Template">
                <Setter.Value>
                    <ControlTemplate>
                        <Polyline Points="0,5 5,0,10,5" StrokeThickness="1" Stroke="Black" />
                    </ControlTemplate>
                </Setter.Value>
            </Setter>
        </Style>
        <Style TargetType="lv:DescendingArrow">
            <Setter Property="Template">
                <Setter.Value>
                    <ControlTemplate>
                        <Polyline Points="0,0 5,5,10,0" StrokeThickness="1" Stroke="Black" />
                    </ControlTemplate>
                </Setter.Value>
            </Setter>
        </Style>
    </ListView.Resources>
    <ListView.View>
        <GridView>
            <lv:SortableGridViewColumn Width="120" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Name}" Header="Name" />
            <lv:SortableGridViewColumn Width="150" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Pronunciation}" Header="Pronunciation" />
            <lv:SortableGridViewColumn Width="70"  SortableSource="{Binding People}" DisplayMemberBinding="{Binding Age}" Header="Age" />
            <lv:SortableGridViewColumn Width="120" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Birthday}" Header="Birthday" />
            <lv:SortableGridViewColumn Width="120" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Height}" Header="Height" />
            <GridView.ColumnHeaderContainerStyle>
                <Style TargetType="lv:SortableGridViewColumnHeader">
                    <Setter Property="SortingArrowLocation" Value="Top" />
                </Style>
            </GridView.ColumnHeaderContainerStyle>
        </GridView>
    </ListView.View>
    <!-- 中略 -->
</ListView>

このように全く違う見た目になりました。これで、仮に私のデザインした▲が気に入らない人がいたとしても、クレームを受けることなく「自分で好きに変えてください」と言えるようになりました。こういった表現力の豊かさがさすがWPFという感じですね。

ISortableObservableCollectionのSortメソッドの引数を変更

ISortableObservableCollectionのSortメソッドの形が少し変わっています。破壊的変更です。

public interface ISortableObservableCollection<T> : IList<T>, INotifyPropertyChanged, INotifyCollectionChanged
{
    // 中略

    [Obsolete]
    void Sort(string propertyName, SortingDirection direction);

    /// <summary>
    /// 自身をソートするメソッド
    /// </summary>
    /// <param name="direction">ソート方向。Noneの場合は何もしない。</param>
    /// <param name="propertyName">ソートに使用するプロパティ名。Nullの場合は要素自身をキーとしてソート。</param>
    void Sort(SortingDirection direction, string? propertyName = null);
}

まあ、直接SortableObservableCollectionからソートを手動でかけていない限り影響は無いとは思いますが、注意してください。Obsoleteの警告が出てもパラメーターの順序を入れ替えるだけでOKです。

この変更は、propertyNameをnullでも受け取れるようにしたところにあります。nullにすると要素自身をキーにして並び替えることができます。SortableObservableCollection<int>などの場合を想定しています。

***

以上で今回の変更分の説明は終わりです。あまり使用面で影響のない修正は割愛させてもらいます。

2024年8月23日金曜日

Prism + DryIoc 入門②(お作法編)

どんなフレームワークにも決まったお作法というか、こう使ってほしいという想定があります。逆に言えば、それをマスターすることがそのフレームワークを自由自在に使いこなせるようになる近道というわけです。今回はそのようなPrismのお作法を紹介していきます。

1. プロジェクトテンプレート

Prismを使ったWPFアプリを作る場合は、プロジェクトテンプレートを使いましょう。Visual Studio 2022の場合は、メニューの「拡張機能」→「拡張機能の管理」からPrismと検索するとPrism Template Packが出てきてインストールすることができます。

インストールをすると、プロジェクトの新規作成からPrismを選択できるようになります。今回は「Prism Blank App (WPF)」を選択します。

そうするといつも通りプロジェクトの名前と保存名が聞かれるので適当に入力します。

ここで「作成」を押すと、PrismのDIコンテナを選択するダイアログが出てきますので、「DryIoc」を選択します。

これにて無事Prism+DryIocのプロジェクトが出来上がりました。

最後にお好みで、.NETのバージョンを最新に上げたり、Nullableを有効化したり、Nugetからパッケージを最新にしたりしておきましょう。ビルドすると空のWPFアプリが立ち上がるはずです。

ちなみに、Prismはバージョン8まではMITライセンスですが、バージョン9からはPrism Community License / Prism Commercial Licenseという独自の商用ライセンスに切り替わっているようです。バージョンアップには要注意です。

2. 領域(Region)の作成と配置

ウィンドウにUI要素(コントロールなど)を配置するとき、MainWindowに直接配置することもできますが、もう少し小さいパーツ単位でUI要素を作って配置したいときがあると思います。そうしないとMainWindowに全部入りになってしまいますもんね。そんな時にはRegionと呼ばれるものを作ると良いです。

ちなみに、Regionの実体はただのUserControlです。 新しい項目の追加から、Prismテンプレートの"Prism UserControl (WPF)"を選択します。

今回は、MainWindowの左半分と右半分のRegionということで、LeftRegionとRightRegionの2つを追加しました。UserControlを新規作成すると、対応するViewModelも自動的に作ってくれます。

MainWindow.xamlでは次のようにContentControlにてRegionを配置します。

<Window x:Class="PrismTest.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="350" Width="525" >
    <Grid>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="1*" />
            <ColumnDefinition Width="1*" />
        </Grid.ColumnDefinitions>

        <ContentControl Grid.Column="0" prism:RegionManager.RegionName="LeftRegion" />
        <ContentControl Grid.Column="1" prism:RegionManager.RegionName="RightRegion" />
    </Grid>
</Window>

これだけでは実は不十分で、Region名と実際のUserControl型を紐づけなければなりません。

public class MainWindowViewModel : BindableBase
{
    private string _title = "Prism Application";
    public string Title
    {
        get { return _title; }
        set { SetProperty(ref _title, value); }
    }

    public MainWindowViewModel(IRegionManager regionManager)
    {
        regionManager.RegisterViewWithRegion("LeftRegion", typeof(LeftRegion));
        regionManager.RegisterViewWithRegion("RightRegion", typeof(RightRegion));
    }
}

MainWindowViewModelのコンストラクタにIRegionManager型の引数を設定します。こうするとDIコンテナが良い感じにIRegionManagerの実体を割り振ってくれるので、ここでRegisterViewWithRegionメソッドを呼び出してRegion名と実際のUserControl型を結び付けておきます。

これでこのようにRegionが所定の場所に割り付けられるようになりました。

ちなみに上の画像に"Left Region"と"Right Region"と表示しているのは、空っぽだと見た目でよくわからないので、そういうコードを追加したからです。

<UserControl x:Class="PrismTest.Views.LeftRegion"
             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">
    <Border BorderThickness="1" BorderBrush="Black" >
        <TextBlock Text="Left Region" HorizontalAlignment="Center" VerticalAlignment="Center" />
    </Border>
</UserControl>
<UserControl x:Class="PrismTest.Views.RightRegion"
             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">
    <Border BorderThickness="1" BorderBrush="Black" >
        <TextBlock Text="Right Region" HorizontalAlignment="Center" VerticalAlignment="Center" />
    </Border>
</UserControl>

3. デザイン時のデータ

実装を進めているとあることに気が付きます。ViewのXAMLを編集する際、ViewModelのプロパティのサジェストがVisual Studioで働かないのです。これはViewModelLocator.AutoWireViewModelにてViewModelが実行時に動的に紐づけられるからで、XAMLにはどこにもViewModelの型を明記していないからです。それはさすがにサジェストが働かなくて当然ですね。

このようなときに便利なのがデザイン時のデータという機能です。

一番大元の要素に以下のような呪文を追加します。

<Window x:Class="PrismTest.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/"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:vm="clr-namespace:PrismTest.ViewModels"
        mc:Ignorable="d"
        d:DataContext="{d:DesignInstance Type=vm:MainWindowViewModel}"
        prism:ViewModelLocator.AutoWireViewModel="True"
        Title="{Binding Title}" Height="350" Width="525" >
    <!-- 中略 -->
</Window>

5から9行目が呪文部分です。これを入力することによって、Visual Studioでのデザイン時のみDataContextをMainWindowViewModelとみなすことができ、プロパティなどのサジェストが働くようになります。

4. インターフェースとクラスのマッピング

最後にインターフェースとクラスのマッピングです。前回の記事でも説明した通り、DIを使用した実装では、各クラス間の依存性を抑えるため、コンストラクタでインターフェースを受け取るようにし、DIコンテナがそれに対応するクラスをインスタンス化して渡してくれます。

それを実現するためには、インターフェースとクラスの対応付けをあらかじめ設定しておく必要があります。その設定は、App.xaml.cs内で記述することになります。

public partial class App
{
    protected override Window CreateShell()
    {
        return Container.Resolve<MainWindow>();
    }

    protected override void RegisterTypes(IContainerRegistry containerRegistry)
    {
        containerRegistry.Register<IMainWindowModel, MainWindowModel>();
    }
}

RegisterTypesメソッドをオーバーライドして、そのなかでRegisterメソッドを呼び出すことでインターフェースとクラスのマッピングをすることができます。

public class MainWindowViewModel : BindableBase
{
    private string _title = "Prism Application";
    public string Title
    {
        get { return _title; }
        set { SetProperty(ref _title, value); }
    }

    public MainWindowViewModel(IRegionManager regionManager, IMainWindowModel model)
    {
        regionManager.RegisterViewWithRegion("LeftRegion", typeof(LeftRegion));
        regionManager.RegisterViewWithRegion("RightRegion", typeof(RightRegion));

        Title = model.Title;
    }
}

マッピングしてさえいれば、コンストラクタに引数を追加することで、DIコンテナにて対応するインスタンスを作ってくれます。

なお、グローバルなデータ、設定値などはRegisterSingletonメソッドでDIコンテナに登録することによって、同じインターフェースには唯一のインスタンス(実体が同じもの)が返されるようになります。

***

以上で必要最低限のお作法は紹介できたはずです。 ここから自分の作りたいアプリをどんどん実装していきましょう。

2024年8月14日水曜日

Prism + DryIoc 入門①(DI編)

このブログを普段から読んでくださっている方(どれくらいいるかわかりませんが…)なら、私はMVVMフレームワークとしてLivetを使っていることはよくご存じと思います。ですが、ついにPrismにも手を出してみようと思い、最近Prismを使い始めましたので、その内容をブログに残しておこうと思います。

今回はなぜPrismを使う必要があるのか、DI (Dependency Injection)の対応という観点から説明をしていきたいと思います。

DI (Dependency Injection)とは

DI (Dependency Injection)とは日本語で「依存性の注入」とか言われたりするそうです。ここで言う依存性とは、クラス間の依存性のことです。クラスA内でクラスBを使用していれば、クラスAはクラスBに依存しているということになります。DIでは、それをクラスA内部にクラスBを使った実装を直接書くのではなく、もう少し抽象化して実装して、クラスBを使うということを後から外部から注入できるようにしようとものです。

なぜそんなことをするかというと、ひとえに単体テストをしやすくするためです。クラスA内でクラスBとクラスCとクラスDを使用し…となっていると、クラスAのテストをしたいのにクラスB, C, Dすべて用意してテストしなければならなくなってきます。クラスBがエラーを起こした時の処理をテストしたいけど、クラスBにエラーを起こさせるのが難しいとかいうことがあるかもしれません。もしくはクラスCはサーバーと通信していて、いろいろなパターンを自動でテストしているとサーバーに負担がかかるとか通信で時間がかかるとかがあるかもしれません。ただ単にクラスA内のコードが正しく動くかを試験したいだけなのに、それではテストがとてもしにくいですよね。

そこで、クラスAの実装時にはクラスB, C, Dに対応したインターフェースのみをコンストラクタで受け取って使い、クラスB, C, Dそのものには触れないようにします。そして、実行時にコンストラクタに本物のクラスB, C, Dのインスタンスを与えてあげるという実装のしかたをすれば、テストのときはダミーのクラスB, C, Dを渡してあげることで本物のクラスB, C, Dには依存しないテストをすることができるようになります。これが「Dependency Injection」という考え方です。

LivetでMVVMを実装した場合(DI無し)

さて、LivetはDIをサポートしていませんが、LivetでMVVMなソフトを作った場合どのようになって何が問題になるでしょうか。以下の画像のようにテキストボックスに入力した文字を大文字に変換して表示するソフトを例に考えてみましょう。

これをLivetで実装した場合、おおむね以下のような感じになるかと思います。

<Window x:Class="LivetTest.Views.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:b="http://schemas.microsoft.com/xaml/behaviors"
        xmlns:l="http://schemas.livet-mvvm.net/2011/wpf"
        xmlns:v="clr-namespace:LivetTest.Views"
        xmlns:vm="clr-namespace:LivetTest.ViewModels"
        Title="Livet Test" Width="525" Height="350">

    <Window.DataContext>
        <vm:MainWindowViewModel />
    </Window.DataContext>

    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto" />
            <RowDefinition Height="1*" />
        </Grid.RowDefinitions>
        
        <TextBox Grid.Row="0" Text="{Binding InputText, UpdateSourceTrigger=PropertyChanged}" />
        <TextBlock Grid.Row="1" Text="{Binding OutputText}" />
    </Grid>
</Window>
public class MainWindowViewModel : ViewModel
{
    readonly MainWindowModel model;

    public MainWindowViewModel()
    {
        model = new MainWindowModel();

        model.PropertyChanged += Model_PropertyChanged;
        PropertyChanged += This_PropertyChanged;
    }

    public string InputText
    {
        get => _InputText;
        set
        { 
            if(_InputText == value)
                return;
            _InputText = value;
            RaisePropertyChanged();
        }
    }
    private string _InputText;

    public string OutputText
    {
        get => _OutputText;
        private set
        { 
            if(_OutputText == value)
                return;
            _OutputText = value;
            RaisePropertyChanged();
        }
    }
    private string _OutputText;

    private void Model_PropertyChanged(object sender, PropertyChangedEventArgs e)
    {
        switch(e.PropertyName) {
            case nameof(model.OutputText):
                OutputText = model.OutputText;
                break;
        }
    }

    private void This_PropertyChanged(object sender, PropertyChangedEventArgs e)
    {
        switch(e.PropertyName) {
            case nameof(InputText):
                model.InputText = InputText;
                break;
        }
    }
}
public class MainWindowModel : NotificationObject
{
    public MainWindowModel()
    {
        PropertyChanged += This_PropertyChanged;
    }

    public string InputText
    {
        get => _InputText;
        set
        {
            if(_InputText == value)
                return;
            _InputText = value;
            RaisePropertyChanged();
        }
    }
    private string _InputText;

    public string OutputText
    {
        get => _OutputText;
        private set
        {
            if(_OutputText == value)
                return;
            _OutputText = value;
            RaisePropertyChanged();
        }
    }
    private string _OutputText;

    private void This_PropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e)
    {
        switch(e.PropertyName) {
            case nameof(InputText):
                OutputText = InputText.ToUpper();
                break;
        }
    }
}

少し長いですが、重要なのはMainWindow.xaml内で

<Window.DataContext>
    <vm:MainWindowViewModel />
</Window.DataContext>

と名指しでMainWindowViewModelをインスタンス化しているところ、およびMainWindowViewModel.cs内で

readonly MainWindowModel model;

public MainWindowViewModel()
{
    model = new MainWindowModel();

    // 中略
}

と名指しでMainWindowModelをインスタンス化しているところです。MainWindow.xamlをテストするにはMainWindowViewModelの本物が必要で、MainWindowViewModelのテストをするにはMainWindowModelの本物が必要になります。これでは単体テストと呼べず、ただの結合テストになってしまいますね。

PrismでMVVMを実装した場合(DryIoc)

Prismは標準でDIをサポートしていますが、Prismで直接DIコンテナが実装されているわけではなく、別のライブラリを参照する形となっています。WPFではUnityDryIocが使えるようですが、ここではDryIocを使います。DryIocを選んだ理由としては、こちらのほうが速度が速く、かつ、UnityはC#がよく使われているゲームライブラリと名前が同じで検索汚染がひどく、目的の情報にたどり着きにくいからです。

さて、先ほどのLivetと同様にPrism+DryIocで実装すると以下のようになります。

<Window x:Class="PrismTest.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="Prism Test" Height="350" Width="525" >

    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto" />
            <RowDefinition Height="1*" />
        </Grid.RowDefinitions>

        <TextBox Grid.Row="0" Text="{Binding InputText, UpdateSourceTrigger=PropertyChanged}" />
        <TextBlock Grid.Row="1" Text="{Binding OutputText}" />
    </Grid>
</Window>
public class MainWindowViewModel : BindableBase
{
    readonly IMainWindowModel model;

    public MainWindowViewModel(IMainWindowModel model)
    {
        this.model = model;

        model.PropertyChanged += Model_PropertyChanged;
        PropertyChanged += This_PropertyChanged;
    }

    public string InputText
    {
        get => _InputText;
        set
        {
            if(_InputText == value)
                return;
            _InputText = value;
            RaisePropertyChanged();
        }
    }
    private string _InputText;

    public string OutputText
    {
        get => _OutputText;
        private set
        {
            if(_OutputText == value)
                return;
            _OutputText = value;
            RaisePropertyChanged();
        }
    }
    private string _OutputText;

    private void Model_PropertyChanged(object sender, PropertyChangedEventArgs e)
    {
        switch(e.PropertyName) {
            case nameof(model.OutputText):
                OutputText = model.OutputText;
                break;
        }
    }

    private void This_PropertyChanged(object sender, PropertyChangedEventArgs e)
    {
        switch(e.PropertyName) {
            case nameof(InputText):
                model.InputText = InputText;
                break;
        }
    }
}
public interface IMainWindowModel : INotifyPropertyChanged
{
    string InputText { get; set; }

    string OutputText { get; }
}
    public class MainWindowModel : BindableBase, IMainWindowModel
    {
        public MainWindowModel()
        {
            PropertyChanged += This_PropertyChanged;
        }

        public string InputText
        {
            get => _InputText;
            set
            {
                if(_InputText == value)
                    return;
                _InputText = value;
                RaisePropertyChanged();
            }
        }
        private string _InputText;

        public string OutputText
        {
            get => _OutputText;
            private set
            {
                if(_OutputText == value)
                    return;
                _OutputText = value;
                RaisePropertyChanged();
            }
        }
        private string _OutputText;

        private void This_PropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e)
        {
            switch(e.PropertyName) {
                case nameof(InputText):
                    OutputText = InputText.ToUpper();
                    break;
            }
        }
    }
public partial class App
{
    protected override Window CreateShell()
    {
        return Container.Resolve<MainWindow>();
    }

    protected override void RegisterTypes(IContainerRegistry containerRegistry)
    {
        containerRegistry.Register<IMainWindowModel, MainWindowModel>();
    }
}

こんな感じになります。注目していただきたいところは、

prism:ViewModelLocator.AutoWireViewModel="True"

と書いてあるところと、

readonly IMainWindowModel model;

public MainWindowViewModel(IMainWindowModel model)
{
    this.model = model;

    // 中略
}

と書いてあるところです。

Viewでは、Windowに添付プロパティとしてViewModelLocator.AutoWireViewModelを追加することができます。これをTrueにすると、Windowのクラス名の末尾に「ViewModel」を付けたクラスをそのViewに対応したViewModelとして認識し、自動的にDataContextプロパティにそれをインスタンス化してくっつけてくれるようになります。敢えて名指しでViewModelを指定しなくて良くなるわけです。

ViewModelでは、直接内部でModelをインスタンス化する代わりに、コンストラクタの引数でIMainWindowModelというインターフェースを受け取っています。IMainWindowModelインターフェースはその名の通りMainWindowModelのpublicなプロパティのみを抜き出したインターフェースですが、こうすることによって、本番ではMainWindowModelのインスタンスをコンストラクタでに渡してあげれば良いですし、単体テストをするときはIMainWindowModelを実装した適当なダミーのクラスを渡してあげればテストができるようになります。こうすることで、ViewModelを動かす際に対応するModelが必須ではなくなり(=依存性が弱まり)、テストの自由度が上がるわけです。

ちなみに、実際にMainWindowModelをインスタンス化するところを我々が自分で実装することは無く、DIコンテナが良い感じにインスタンス化してくれます。その際に、MainWindowModelのコンストラクタに必要なインスタンスを作って渡してくれるのですが、あらかじめ「IMainWindowModelの本番実装はMainWindowModelだから、IMainWindowModelが出てきたらMainWindowModelをインスタンス化して渡してあげてね」と教えてあげなければなりません。そのことは、App.xaml.cs内に記述するようになっています。

まとめ

ここまで、Prismを使うにあたって必要なDIの基本的な考え方を説明してきました。今までは実装全体でしかテストできなかったものが、パーツごとに分けてテストができるようになるというのはとても良いですね。

ただ、その「依存性を減らす」という目的と裏表ですが、DIはコードの見通しが悪くなるのが欠点です。上の例だとIMainWindowModelの本番実装が何かというのはVisual StudioのF12キーだけで追いかけるのは難しく、App.xaml.csの中身を能動的に見に行かなければならなくなります。自分で実装したのならそういうマナーはわかっていますが、世の中に転がっている任意の流儀で実装されたDIのコードを追いかけるのは至難の業です…。

ちなみに、今回の記事で「LivetはDIに対応していない」と何度も書きましたが、LivetがPrismに対して劣っているという意図はありません。次回の記事くらいで出てくるかもしれませんが、LivetはできるけどPrismができないことというのもあります。ですので、実際のアプリ開発においては、PrismとLivetの二者択一ではなく、PrismとLivetの併用で作っていくことになると思います。その辺の話もまたそのうち記事にできればなと思っています。