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の併用で作っていくことになると思います。その辺の話もまたそのうち記事にできればなと思っています。

0 件のコメント:

コメントを投稿