ラベル Prism の投稿を表示しています。 すべての投稿を表示
ラベル Prism の投稿を表示しています。 すべての投稿を表示

2024年10月26日土曜日

Prism + DryIoc 入門③(ダイアログ編)

前回から少し時間が空きましたが、Prism+DryIoc入門の第3弾です。一応これで一区切りのつもりです。 

前回はPrism+DryIocでプロジェクトを立ち上げるお作法を紹介しました。これに加えてWPFの基礎知識がある皆さんはそれなりのソフトを作ることができると思います。ですが、ひとつ足りないことがありました。それはダイアログの表示です。今回はそのダイアログの表示の方法について説明していきます。 

ダイアログの実装

さて、通常、ダイアログを開くとなるとウィンドウを作ることになりますが、PrismではUserControlを作ることになります。ちょうどウィンドウ内でRegionを作るのと同じような感じですね。

順に説明していきます。

1. Viewの作成

「追加」→「新しい項目」からPrism UserControl (WPF)を追加します。

出来上がったXAMLファイルに前回の記事に書いたとおりのお作法でIgnorebleなどを設定します。

<UserControl x:Class="PrismTest.Views.TestDialog"
             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:TestDialogViewModel}"
             prism:ViewModelLocator.AutoWireViewModel="True"
             Height="350" Width="525" >

    <Grid />
</UserControl>

2. VideModelの作成

ViewModelにはIDialogAwareを実装する必要があります。これはPrismにこれはダイアログですよと教えるためのものですね。

public class TestDialogViewModel : BindableBase, IDialogAware
{
    public TestDialogViewModel()
    {
    }

    public string Title => "Test Dialog";

    public event Action<IDialogResult>? RequestClose;

    public bool CanCloseDialog() => true;

    public void OnDialogClosed()
    {    
    }

    public void OnDialogOpened(IDialogParameters parameters)
    {
    }
}

タイトルやいくつかのメソッド、ダイアログを閉じる際に呼ぶイベントなどを実装します。特段説明をしなくても、その名前から何のためのものかはすぐにわかると思います。

3. Viewをダイアログとして登録

App.xaml.csを開き、RegisterTypesメソッド中にて作った型をダイアログとして登録します。これを登録することで、PrismがこのViewがダイアログであることを認識してくれます。

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

    protected override void RegisterTypes(IContainerRegistry containerRegistry)
    {
        containerRegistry.RegisterDialog<TestDialog>("TestDialog");
    }
}

これも前回の記事までを読んだ人なら特に困ることは無いと思います。

4. ダイアログを表示

さて、ダイアログの表示も至って簡単です。ライブラリ無しではMVVMのポリシーを保ちつつViewModelから別ウィンドウを表示するのがとても面倒だったので、さすがPrismという感じですね。

public class ContentRegionViewModel : BindableBase
{
    public ContentRegionViewModel(IDialogService dialog)
    {
        ShowDialogCommand = new DelegateCommand(() => dialog.ShowDialog("TestDialog"));
    }

    public DelegateCommand ShowDialogCommand { get; }
}

適当なViewModelでIDialogServiceを受け取るようにすると、DryIoc経由でダイアログ関係の機能にアクセスできるようになります。ダイアログを表示するにはShowDialogメソッドを呼ぶだけです。引数はApp.xaml.csで設定した名前ですね。

このコマンドを呼び出すと、無事ダイアログが表示されました。

ダイアログを作りこむ

1. ウィンドウスタイル

さて、ダイアログが表示されましたが、よく見ると最大化/最小化ボタンなどが表示されていますね。Windows11のデザインではわかりにくいですが、ウィンドウもリサイズ可能なものになっています。さらにタスクバーを見るとダイアログが単体のアイコンを持っています。イケてないですね。


端的に、普通のウィンドウが表示されたと言えます。ダイアログはもっとダイアログっぽいスタイルでウィンドウを表示したいですね。

これに関しては、UserControlにprism:Dialog.WindowStyle添付プロパティを設定してやれば良いです。

<UserControl x:Class="PrismTest.Views.TestDialog"
             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:TestDialogViewModel}"
             prism:ViewModelLocator.AutoWireViewModel="True"
             Height="350" Width="525" >

    <prism:Dialog.WindowStyle>
        <Style TargetType="Window">
            <Setter Property="prism:Dialog.WindowStartupLocation" Value="CenterOwner" />
            <Setter Property="ResizeMode" Value="NoResize"/>
            <Setter Property="ShowInTaskbar" Value="False"/>
            <Setter Property="SizeToContent" Value="WidthAndHeight"/>
        </Style>
    </prism:Dialog.WindowStyle>

    <Grid />
</UserControl>

Windows11のデザインではわかりにくいですが、ウィンドウ枠が細枠(サイズ変更不可)になっており、最小化/最大化のボタンもなくなっています。もちろんタスクバーにも表示はされていません。

2. データの受け渡しをする

さて、ダイアログを表示するときは何かしらのデータを引き渡したいし、ダイアログを閉じた時には入力されたデータを受け取りたいことがあります。安心してください、Prismではそのような機能もサポートしています。

public class ContentRegionViewModel : BindableBase
{
    public ContentRegionViewModel(IDialogService dialog)
    {
        ShowDialogCommand = new DelegateCommand(() => {
            var param = new DialogParameters() { { "Text", Text }, };

            dialog.ShowDialog("TestDialog", param, result => {
                if(result.Result == ButtonResult.OK)
                    Text = result.Parameters.GetValue<string>("Text");
            });
        });
    }

    public DelegateCommand ShowDialogCommand { get; }

    public string Text
    {
        get => _Text;
        set => SetProperty(ref _Text, value);
    }
    string _Text = "";
}

呼び出し側のViewModelはこんな感じです。ShowDialogの第2引数にDialogParametersを渡します。名前こそDialogParametersですが、単純なディクショナリ型ですので、任意のデータを入れ込むことができます。

第3引数はダイアログから制御が返ってきたときに呼び出されるデリゲートです。下のダイアログ側のViewModelを見てからのほうがわかりやすいと思いますので、ひとまずそちらを見てから解説します。

public class TestDialogViewModel : BindableBase, IDialogAware
{
    public TestDialogViewModel()
    {
        OkButtonCommand = new DelegateCommand(() => RaiseRequestClose(ButtonResult.OK, new DialogParameters() { { "Text", Text } }));
        CancelButtonCommand = new DelegateCommand(() => RaiseRequestClose(ButtonResult.Cancel));
    }

    public string Title => "Test Dialog";

    public void OnDialogOpened(IDialogParameters parameters)
    {
        Text = parameters.GetValue<string>("Text");
    }

    public void OnDialogClosed()
    {
    }

    public bool CanCloseDialog() => true;

    protected void RaiseRequestClose(ButtonResult btnres, IDialogParameters param)
    {
        RequestClose?.Invoke(new DialogResult(btnres, param));
    }
    protected void RaiseRequestClose(ButtonResult btnres)
    {
        RequestClose?.Invoke(new DialogResult(btnres));
    }
    public event Action<IDialogResult>? RequestClose;


    public DelegateCommand OkButtonCommand { get; }
    public DelegateCommand CancelButtonCommand { get; }

    public string Text
    {
        get => _Text;
        set => SetProperty(ref _Text, value);
    }
    string _Text = "";
}

OnDialogOpenedメソッドはIDialogAwareインターフェースのメンバーの一つですが、ダイアログが開いたときにPrismがこのメソッドを呼んでくれます。この引数が、呼び出し元がパラメーターとして指定したものになります。今回は呼び出し物が指定したTextを読み取って、Textプロパティに代入しています。 

OKボタン/Cancelボタンが押されたときにそれぞれRequestCloseイベントを発動させています。その引数にはDialogResultクラスで、どのボタンが押されたかとDialogParametersをコンストラクタに渡してあげることで、その値を呼び出し元に返すことができます。

そして、呼び出し元のコードでは、OKボタンが押されたことを受けて、ShowDialogメソッドの第3引数のデリゲートが呼ばれますので、そこでButtonResultやDialogParametersを読み取って、適宜何かしらの処理を追加してやれば良いです。

ちなみに、ShowDialogメソッドは同期的に呼ばれます。すなわち、第3引数のデリゲートが制御を返した後にShowDialogメソッドは制御を返します。じゃあなんでShowDialogメソッドの戻り値をIDialogResultにしてくれなかったんだ…。

番外編:標準ダイアログを表示する

さて、ダイアログの表示のしかたは分かったけど、MessageBoxやファイルを開くダイアログなどのWindows標準ダイアログを表示させるにはどうしたら良いでしょう?

結論から言うと、Prismではサポートしていません。今まで説明してきた通り、ダイアログを実装するための支援が充実しているから、MessageBoxやファイルを開くダイアログは自作してねということのようです。

確かに、最近そういったダイアログも独自実装されたアプリをしばしば見かけますが、個人的にはソウジャナイ感をどうしても感じてしまいます。そういう標準ダイアログは、アプリを作る側の省力化だけでなく、使う側としてもどのアプリを使っても同じデザインだからOSとして統一感があり使いやすいわけです。それをわざわざ「自分で実装しろ」というのはいかがなものか…。

というわけで、標準ダイアログを表示するためにはLivetを使いましょう。大丈夫、PrismとLivetも共存できます。

まずは NugetからLivet.Messagingを入れます。

public class ContentRegionViewModel : BindableBase
{
    public ContentRegionViewModel()
    {
        Messenger = new InteractionMessenger();

        ShowMessageBoxCommand = new DelegateCommand(() => Messenger.Raise(new InformationMessage("MessageBox Text", "Caption", System.Windows.MessageBoxImage.Information, "InformationMessageBox")));
    }

    public InteractionMessenger Messenger { get; }

    public DelegateCommand ShowMessageBoxCommand { get; }
}

その後、ViewModelにInteractionMessengerクラスのプロパティを一つ用意してあげます。Livet標準のViewModel基底型ならもとからこのプロパティを持っているのですが、当然ながらPrismには無いので後付けしてやる必要があります。

<UserControl x:Class="PrismTest.Views.ContentRegion"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:i="http://schemas.microsoft.com/xaml/behaviors"
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
             xmlns:prism="http://prismlibrary.com/"
             xmlns:l="http://schemas.livet-mvvm.net/2011/wpf"
             xmlns:vm="clr-namespace:PrismTest.ViewModels"
             mc:Ignorable="d"
             d:DataContext="{d:DesignInstance Type=vm:ContentRegionViewModel}"
             prism:ViewModelLocator.AutoWireViewModel="True"
             d:Height="350" d:Width="525" >

    <i:Interaction.Triggers>
        <l:InteractionMessageTrigger Messenger="{Binding Messenger}" MessageKey="InformationMessageBox">
            <l:InformationDialogInteractionMessageAction InvokeActionOnlyWhenWindowIsActive="False" />
        </l:InteractionMessageTrigger>
    </i:Interaction.Triggers>
    
    <Grid>
        <Button Content="Show MessageBox" Command="{Binding ShowMessageBoxCommand}" Width="150" Height="40" />
    </Grid>
</UserControl>

ViewではInteractionMessageTriggerを使い、ViewModelからのメッセージを受け取ってやれば良いです。今回はOKボタンのみのMessageBoxを表示させていますが、その他の種類のMessageBoxだったりファイルを開くダイアログだったりは、対応する***Message型/***InteractionMessageAction型に差し替えれば表示することができます。ここらへんはLivetの使い方ですので、だいぶ昔に書いたこちらの記事を見ていただければ良いかと思います。

Prismの入門記事でPrismではなく別のライブラリを使えというのも変な話ですが、ライブラリによって当然得手不得手というものはありますから、それによって組み合わせが発生するのはやむを得ないことと思います。

***

以上で、3回にわたって連載(?)してきたPrism+DryIocの入門編ですが、ひとまず今回で終わりにしたいと思います。また何か思いついたテーマがあれば随時記事にします。

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

2017年4月22日土曜日

Visual Studio 2017でXamarin.Forms入門

「申し訳ないがJavaはNG」

この言葉を座右の銘に置く私としては、常にAndroidアプリ開発は戦いです。
AndroidはJava開発が最も基本で、C#で開発するにはXamarinなどの環境が必要です。

Xamarinは昔は有料だったうえ、ドキュメンテーションも少なく非常にとっつきにくいものでした。しかし、Visual Studio 2015にはオプションでXamarinをインストールできるようになり、ついに2016年にはMicrosoftに買収され、Visual Studio 2017ではモバイル開発の中核に置かれるようになりました。

というわけで、私もXamarin初心者ですが、VS2017もインストールしたことだし本格的にいじってみようと思います。

Visual Studio 2017のインストール時にXamarin関係のオプションを選択してインストールすると、プロジェクトテンプレートにXamarin.Formsが現れます。
そうするとテンプレートの選択が出てきます。
空のアプリを選択してOKを押します。
そして、プロジェクトをビルドして実行すると…
見事にエミュレーター上でAndroidアプリが動きました。

しかし、このプロジェクトテンプレートには問題が1つあります。
上の画像にも書きましたが、最初のページに相当する「MainPage.xaml」がどのプロジェクトにも含まれていません。
そのため、Visual Studioがコードの依存関係等を理解できずに、IntelliSenseがほとんど働きません。編集しているとコードのいたるところに参照が解決できずに波線が引かれます。とても鬱陶しいです。

この解決には苦労しましたが、とりあえず次のようにやるとできるようです。

まずは、ソリューションにXamarin.Forms用のPCL(Portable Class Library)を追加します。
次は各プラットフォームのプロジェクトでこのプロジェクトへの参照を追加します。
さっき追加したプロジェクトに適当なMainPage.xamlを作ります。
このテンプレートには、XAMLだけでなくコードビハインドにも余計なコードが書いてあるので注意してください。コードビハインドで架空のViewModelクラス(ContentPageViewModel)をインスタンス化しようとしてくるのでコンパイルエラーが出ます。
public partial class App : Application
{
    public App ()
    {
        InitializeComponent();

        //MainPage = new XamarinFormsTest.MainPage();
        MainPage = new XamarinFormsTest.PCL.Views.MainPage();
    }

    protected override void OnStart ()
    {
        // Handle when your app starts
    }

    protected override void OnSleep ()
    {
        // Handle when your app sleeps
    }

    protected override void OnResume ()
    {
        // Handle when your app resumes
    }
}
最後はApp.xamlのコードビハインドでMainPageのインスタンスを自分が追加してやったPCLのほうへ書き換えてやればおkです。もとあったMainPage.xamlはいらないので削除してしまってもよいでしょう。
これでMainPageをPCL上に移動することに成功しました。

PCL上にUIを置けるということは、Nugetなどを活用して適当なライブラリを入れることもできるということですので、例えばXamarin.Formsに対応しているMVVMライブラリ「Prism」を入れてみましょう。
そうすれば、ほとんどWPFと変わらずにXAML+C#でAndroidアプリを開発することができます。
このアプリでは、スライダーを 動かすとそれに追従して上に表示されている数字が変わります。「ADJUST TO 50」ボタンを押すと数字とスライダーが50の位置に移動します。
XAML+C#のデータバインディングのデモをするにはこれくらいやれば十分でしょう。

例えばコントロールの配置が「HorizontalOptions」「VerticalOptions」とかいう名前のプロパティだったりと、若干WPFと違うところもありますが、まあこれくらいならフィーリングでわかるレベルでしょう。
あとはXAMLデザイナーでリアルタイムに配置の絵が表示される機能は内容ですが、この程度ならば個人的にはまあギリギリ許容できます。

これくらいWPFの開発にそっくりだと、普段からWPFで開発している身としてはとてもありがたいですね。

というわけで、いろいろといじってみようと思います。はい。