sh1’s diary

プログラミング、読んだ本、資格試験、ゲームとか私を記録するところ

Prism EventAggregator をなぜ使うべきか

f:id:shikaku_sh:20211013180838p:plain:w400

この記事は、ARCTOUCH のブログ記事「Why you should use Prism EventAggregator in .NET app development」を個人的に雑訳したものです。EventAggregator については「過去の記事」で紹介しています。

Prism は、.NET アプリを開発するための豊富な機能を提供するライブラリーで、C# 開発者のコーディングライフをより快適にする多くの優れた機能を備えています。その機能のひとつが EventAggregator です。この記事では、EventAggregator とはなにか、なぜ便利なのか、そして、どこで使うべきなのかを説明します。

EventAggregator is なに?

要するに、EventAggregator とは Publisher-Subscriber パターンを Prism で実装したものです。Publisher-Subscriber パターンとは、アプリケーションの非同期通信を容易にするため設計されたメッセージングのパターンのことです。具体的には、リンクすることが困難なコンポーネント間でメッセージをやり取りするといった、問題を解決します。

このパターンの核となるものは、event bus に対して発行されるイベントです。event bus は、そのイベントを1つ以上のサブスクライバー (subscribers) に渡します。サブスクライバーは、受け取ったイベントを自由に扱うことができます。

より詳細な情報は「こちら」をご覧ください。

f:id:shikaku_sh:20211027173429p:plain
Publisher-Subscriber パターン

.NET アプリ開発に Prism EventAggregator をなぜ使用するのか?

Publisher-Subscriber モデルには、さまざまなメリットがあります:

  • 分離 (Decoupled)
    パブリッシャーとサブスクライバーはお互いのことを知りません
  • 非同期 (Asynchronous)
    パブリッシャーは、サブスクライバーがイベントの処理を終えるまで待つ必要がないので、メッセージを素早く送信し、自分の処理を続けることができる
  • 関心の分離 (Separation of concerns)
    パブリッシャーはサブスクライバーが何をしているのか知る必要はありません。また、サブスクライバーはイベントがどこで発生したのか知る必要はありません。
  • 拡張性 (Scalable)
    パブリッシャー、サブスクライバー、イベントの数がどれだけ多くても、必要なのは、ひとつのイベントバスへの参照だけ

EventAggregator は、上記の利点に加えて .NET アプリの開発に役に立つ利点を持ちます:

  • モリーリークの防止 (Prevent memory leaks)
    EventAggregator は、廃棄されたサブスクライバの参照を保持しません。マニュアルでサブスクライブを解除する必要もありません
  • 複雑なオブジェクトの送信 (Send complex objects)
    イベントを発行する際に複雑なオブジェクトをパラメーターとして送信して、イベントを処理する際にサブスクライバーにより多くの情報を提供する
  • スレッドの柔軟性 (Thread flexibility)
    受信したイベントを処理するスレッドを選択できる
  • イベントのフィルタリング (Filter events)
    サブスクライバーは、処理するイベントと無視するイベントを選択できる

Prism EventAggregator をいつ利用するのか?

要約すると、Publisher-Subscriber パターンは、リンクすることが難しい、または、現実的ではないコンポーネント間の通信用に設計されたメッセージングのパターンです。

Prism のプロジェクトでは、EventAggregator は、2つ以上の ViewModel の間や、お互いに参照を持たないサービスの間でメッセージを送受信するためによく利用されます。

また、1つのイベントを異なる多くのサブスクライバーで処理する必要があって、それぞれのサブスクライバーにパブリッシャーの参照を渡さない(現実的ではない)ときにも利用されます。

Xamarin.Forms の EventAggregator

Xamarin.Forms に慣れている人は、「待って、Xamarin.Forms には、これに似たものがすでにあるんじゃないか?」と思うかもしれません。確かに、Xamarin.Forms には MessagingCenter という独自の Publisher-Subscriber パターンの実装があります。しかし、以下の理由から私は Prism の EventAggregator が気に入っています:

  • モリーリーク (Memory leaks)
    MessagingCenter は、マニュアルでサブスクライブを解除しないと、参照を保持します。(「Xamarin MessagingCenter memory leaks」で検索すると、パフォーマンスの問題に関する多くのフォーラム投稿を見つけることができます)
  • 柔軟性 (Flexibility)
    MessagingCenter は、EventAggregator のようなスレッドの柔軟性やイベントフィルターはありません
  • テストの容易性 (Testability)
    MessagingCenter は、static メソッドを使用しているため、単体テストの作成が難しいです。Prism では、コンストラクターEventAggregatorインスタンスを注入することができるので、簡単にモックを作成することができます

ちょっとした注意点

ここまでの内容で EventAggregator の柔軟性や有用性について、納得していただけたなら幸いですが、(いつものように、)いくつかの注意点があります。

EventAggregator はサブスクライバーへの弱参照しか持ちませんが、パブリッシャーがパラメーターを渡し、サブスクライバーがその参照を持ち続けている場合は強参照として持つ恐れがあります。このような場合は、サブスクライバーが disposed されたときにマニュアルでサブスクライブを解除するようにしましょう。

また、EventAggregator の Decoupled な(分離された)構造は素晴らしいものですが、使いすぎると複雑さが増してしまいます。

パブリッシャーとサブスクライバーの間には関心の分離がありますが、イベント自体は関心の分離がありません。(イベントは、すべて同じ EventAggregator に保持されます)

多くのイベントを扱う大きなプロジェクトでは、これがコミュニケーションの流れを不明瞭にする恐れがあります。私のアドバイスとしては、EventAggregator は、必要なときにだけ使うようにしましょう。プロジェクト全体を EventAggregator を中心に構築することはやめておきましょう。

なんでもそうですが、節度を守ることが大切です。(Like with everything in life, moderation is key.)

参考

WPF Prism サンプルコードの学習6(公式外サンプル1)

f:id:shikaku_sh:20211013180838p:plain:w400

前回まで Prism の公式サンプルを確認しました。補足として、Microsoftokazukiさんもサンプルを公開してくれていたので、こちらも見ていこうと思います。

サンプルは 2017 年 1 月公開のものなので、少し古いです。注意しましょう。

1. Bootstrap

これはサンプル1の内容で、完全にカバーできていると思います。

2. ViewModelLocator

モジュール化していませんが、View と ViewModel を紐づけるミニマムなサンプルです。

Dependency 属性が解決できないかもしれませんが、現在と名前空間が異なると思います。

using Microsoft.Practices.Unity;
using ViewModelLocatorSampleApp.Models;

namespace ViewModelLocatorSampleApp.ViewModels
{
    class ShellViewModel
    {
        [Dependency]
        public MessageProvider MessageProvider { get; set; }
    }
}
using Unity;
using ViewModelLocatorSampleApp.Models;

namespace ViewModelLocatorSampleApp.ViewModels
{
    class ShellViewModel
    {
        [Dependency]
        public MessageProvider MessageProvider { get; set; }
    }
}

3. Module

これはサンプル2の内容です。

4. MVVM の基本クラス

7.1 Modules.AppConfig のやり方でモジュールを実装したやり方です。コマンドは、11 DelegateCommand でフォローしています。

新しい要素は、ErrorsContainer です。

<StackPanel>
    <Label Content="入力"
            Target="{Binding ElementName=TextBoxInput}" />
    <TextBox x:Name="TextBoxInput" 
                Text="{Binding Input, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"/>
    <TextBlock x:Name="TextBlockErrorMessage" 
                Text="{Binding ElementName=TextBoxInput, Path=(Validation.Errors)/ErrorContent}"/>
</StackPanel>
class ErrorsContainerSampleViewModel : BindableBase, INotifyDataErrorInfo
{
    public string HeaderText { get; } = "ErrorContainerSample";

    private string input;

    [Required(ErrorMessage = "入力してください")]
    public string Input
    {
        get { return this.input; }
        set { this.SetProperty(ref this.input, value); }
    }


    public ErrorsContainerSampleViewModel()
    {
        this.ErrorsContainer = new ErrorsContainer<string>(
            x => this.ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(x)));
    }

    #region Validation
    private ErrorsContainer<string> ErrorsContainer { get; }

    public event EventHandler<DataErrorsChangedEventArgs> ErrorsChanged;

    protected override bool SetProperty<T>(ref T storage, T value, [CallerMemberName] string propertyName = null)
    {
        if(!base.SetProperty<T>(ref storage, value, propertyName))
        {
            return false;
        }

        var context = new ValidationContext(this)
        {
            MemberName = propertyName
        };

        var errors = new List<ValidationResult>();
        if (!Validator.TryValidateProperty(value, context, errors))
        {
            this.ErrorsContainer.SetErrors(propertyName, errors.Select(x => x.ErrorMessage));
        }
        else
        {
            this.ErrorsContainer.ClearErrors(propertyName);
        }

        return true;
    }

    public bool HasErrors
    {
        get
        {
            return this.ErrorsContainer.HasErrors;
        }
    }

    public IEnumerable GetErrors(string propertyName)
    {
        return this.ErrorsContainer.GetErrors(propertyName);
    }
    #endregion
}

Prism には、INotifyDataErrorInfo の実装を補助する ErrorsContainer が追加されていて、これを利用すると簡単に入力値の検証をすることができるようになる。

には、INotifyDataErrorInfo は以下のインターフェースを実装する必要がある:

  • bool HasErrors { get; }
  • event EventHandler ErrorsChanged;
  • IEnumerable GetErrors(string propertyName);

DataAnnotations は前からある機能なので詳細を割愛しますが、以下のようにインターフェースの実装を ErrorsContainer に対応をおまかせできる。

<Grid>
<Grid.RowDefinitions>
    <RowDefinition Height="Auto" />
    <RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBox x:Name="TextBoxInput" Grid.Row="0" 
            Text="{Binding Text, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
            />
<TextBlock Grid.Row="1"
            Text="{Binding ElementName=TextBoxInput, Path=(Validation.Errors)/ErrorContent}"
            Foreground="Red" Margin="10"/>
</Grid>
[Required(ErrorMessage = "入力してください")]
public string Text
{
    get => _text;
    set
    {
        CheckErrors(value);
        SetProperty(ref _text, value); 
    }
}

public MainWindowViewModel()
{
    ErrorsContainer = new ErrorsContainer<string>(
        p => this.ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(p))
    );
}

private ErrorsContainer<string> ErrorsContainer { get; }

public event EventHandler<DataErrorsChangedEventArgs> ErrorsChanged;
public bool HasErrors => ErrorsContainer.HasErrors;
public IEnumerable GetErrors(string propertyName) => ErrorsContainer.GetErrors(propertyName);

public void CheckErrors(string value, [CallerMemberName] string propertyName = null)
{
    var context = new ValidationContext(this)
    {
        MemberName = propertyName
    };

    var errors = new List<ValidationResult>();

    if (!Validator.TryValidateProperty(value, context, errors))
    {
        ErrorsContainer.SetErrors(propertyName, errors.Select(x => x.ErrorMessage));
    }
    else
    {
        ErrorsContainer.ClearErrors(propertyName);
    }
}

ErrorsContainer にまとめつつ、プロパティ名でさらにまとめてあるので、Text プロパティのエラー検知に加えて、Text2, Text3... と検知したいプロパティを増やしても、INotifyDataErrorInfo インターフェースの実装はもちろん、CheckErrors も修正なしで運用することができる。

CheckErrors は、以下のように SetProperty を override してやってしまうことも。
- protected override bool SetProperty(ref T storage, T value, [CallerMemberName] string propertyName = null)

5. InteractionRequest

おそらくこれは、公式サンプルから消された 25. Interactivity - NotificationRequest に相当する機能だと思います。

IInteractionRequestAware というインターフェースは存在していませんし、INotification というインターフェースも存在しません。(おそらく)

現在は非推奨ということでよいと思います。

6. Navigation

  1. Navigation と同じ内容だけど、コーディングされている内容がバージョン違いでちょっと異なっています。あと、KeepAlive も含まれていたりするので、まとめてある。

7. EventAggregator

  1. UsingEventAggregator と同じ内容。書き方としては、公式サンプルのほうがキレイになっているんで、見るべき点はないと思います。

8. ModuleLoadSeq

7.4 Modules - LoadManual のような内容。コーディングテクニックというよりも、そういうものなので、理解だけしておけばよいと思います。

9. RegionBehavior

IRegionBehavior は現在も存在しているインターフェースです。WPF の Behavior を Region でやるための機能です。サンプルでは IDispose を ViewModel で実行するようなものになっています。

これは公式サンプルにはないものなので、知っておいてもよいと思います。

10. ModuleCatalog

7.1 Modules - AppConfig のような内容です。コーディングテクニックというよりも、そういうものなので、理解だけしておけばよいと思います。

まとめ

2と9が、参考になる可能性があると思います。

参考

WPF Prism サンプルコードの学習5 (Navigation, Invoke)

f:id:shikaku_sh:20211013180838p:plain:w400

WPF + .NET Core (5以降は Core は省略される) で Prism を使ってみよう。

使用している Prism のバージョンは次のとおり:

Prism Full App (.NET Core) テンプレートを体験する」の記事も参考になると思います。

関連記事は以下:

14. UsingEventAggregator

お互いに参照しあわないモジュールプロジェクト A, B 間でメッセージを送受信するサンプルです。

f:id:shikaku_sh:20211021174338g:plain:w500

MainWindow のプロジェクトは何もロジックを持ちません。なので無視していい。

Core プロジェクトは、UsingCompositeCommands のように継承クラスを持ちますが、特になんのメソッドもプロパティも持ちません。PubSubEvent はインターフェースではないから、継承した時点で機能が付与されています。

public class MessageSentEvent : PubSubEvent<string>
{
}

サンプルを見ればわかりますが、ModuleA が ModuleB にテキストデータを送るという関係になるので、先に 受け取り側の ModuleB から確認してみます。

ModuleBModule クラスは、RegionManager に View を割り当てているだけなので、シンプルなものです。View も特に新しく仕込まれるものはないので、ViewModel だけチェックすればいいです。

public class MessageListViewModel : BindableBase
{
    IEventAggregator _ea;

    private ObservableCollection<string> _messages;
    public ObservableCollection<string> Messages
    {
        get { return _messages; }
        set { SetProperty(ref _messages, value); }
    }

    public MessageListViewModel(IEventAggregator ea)
    {
        _ea = ea;
        Messages = new ObservableCollection<string>();

        _ea.GetEvent<MessageSentEvent>().Subscribe(MessageReceived);
    }

    private void MessageReceived(string message)
    {
        Messages.Add(message);
    }
}

IEventAggregator を DI から受け取っていますね。これをクラス変数として持ち、MessageReceived メソッドで待ち受けるといった設定です。シンプルです。

逆に、データを送る ModuleA 側を確認します:

public class MessageViewModel : BindableBase
{
    IEventAggregator _ea;

    private string _message = "Message to Send";
    public string Message
    {
        get { return _message; }
        set { SetProperty(ref _message, value); }
    }

    public DelegateCommand SendMessageCommand { get; private set; }

    public MessageViewModel(IEventAggregator ea)
    {
        _ea = ea;
        SendMessageCommand = new DelegateCommand(SendMessage);
    }

    private void SendMessage()
    {
        _ea.GetEvent<MessageSentEvent>().Publish(Message);
    }
}

ボタンを押下した際に実行されるコマンドは SendMessageCommand です。SendMessageCommand の中で、IEventAggregator から実体の MessageSentEvent を受け取り、Publish してメッセージを送っています。

IEventAggregator を通じて、プロジェクト間 (ModuleA to ModuleB) のデータの受け渡しを疎結合にしていることがわかりました。

Prism 全体を通じて、DI を活用したプロジェクト間のデータ連携が目立つように思いました。

15. FilteringEvents

UsingEventAggregator で受け渡しする際にフィルターを設定するサンプルです。

public class MessageListViewModel : BindableBase
{
    IEventAggregator _ea;

    private ObservableCollection<string> _messages;
    public ObservableCollection<string> Messages
    {
        get { return _messages; }
        set { SetProperty(ref _messages, value); }
    }

    public MessageListViewModel(IEventAggregator ea)
    {
        _ea = ea;
        Messages = new ObservableCollection<string>();

        _ea.GetEvent<MessageSentEvent>().Subscribe(
            MessageReceived, 
            ThreadOption.PublisherThread, 
            false, 
            (filter) => filter.Contains("Brian"));
    }

    private void MessageReceived(string message)
    {
        Messages.Add(message);
    }
}

ModuleB のデータを受け取る部分、Subscribe の箇所で filter を指定しています。これだとテキストに Brian が含まれていないと MessageReceived メソッドが実行されません。

UniRx にしてもこの種のフィルターはよくあるやつなので、なんとなくわかる。

16. RegionContext

RegionContext は、2. Region の機能をもう少し掘り下げた機能になる。

親 Window の XAML はいつもと同じ。でも、そこに割り当てるコントロール PersonList の中でも RegionManager が姿を表していて、かつ、RegionContext で DataContext に割り当てるオブジェクトを指定しています。(これだとリストの選択アイテム)

<Window x:Class="RegionContext.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>
        <ContentControl prism:RegionManager.RegionName="ContentRegion" />
    </Grid>
</Window>
<UserControl x:Class="ModuleA.Views.PersonList"
             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">
    <Grid x:Name="LayoutRoot" Background="White" Margin="10">
        <Grid.RowDefinitions>
            <RowDefinition Height="100"/>
            <RowDefinition Height="Auto"/>
        </Grid.RowDefinitions>

        <ListBox x:Name="_listOfPeople" ItemsSource="{Binding People}"/>
        <ContentControl Grid.Row="1" Margin="10"
                        prism:RegionManager.RegionName="PersonDetailsRegion"
                        prism:RegionManager.RegionContext="{Binding SelectedItem, ElementName=_listOfPeople}"/>
    </Grid>
</UserControl>

このリージョンごとの設定は以下のとおり ModuleAModule になる。

public class ModuleAModule : IModule
{
    public void OnInitialized(IContainerProvider containerProvider)
    {
        var regionManager = containerProvider.Resolve<IRegionManager>();
        regionManager.RegisterViewWithRegion("ContentRegion", typeof(PersonList));
        regionManager.RegisterViewWithRegion("PersonDetailsRegion", typeof(PersonDetail));
    }
}

SelectedItem の正体は、ObservableCollection<Person> People の単体 Person です。これが PersonDetailViewModel の ViewModel に設定されています。

これを受け取る部分は PersonDetail.xaml.cs のコード:

public partial class PersonDetail : UserControl
{
    public PersonDetail()
    {
        InitializeComponent();
        RegionContext.GetObservableContext(this).PropertyChanged += PersonDetail_PropertyChanged;
    }

    private void PersonDetail_PropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e)
    {
        var context = (ObservableObject<object>)sender;
        var selectedPerson = (Person)context.Value;
        (DataContext as PersonDetailViewModel).SelectedPerson = selectedPerson;
    }
}

これで PersonDetailViewModel が DataContext のデータを SelectedPerson として受け取ることを実現しています。

これは正直、悩ましい DataContext コンバーターにも思いました。VM と V は疎結合になっていないのがわかると思います。(as でクラスの要素を特定していますし)、そもそもロジック部分がバラついてしまっていて、ベターなコードとはいいづらいと(個人的に)思います。

RegionContext.GetObservableContext(引数) の部分で view を引数に渡す必要があるため、基本的に View に加筆するコードになります。ただ、これでいいなら ViewModel で View の存在を知っているのと、どのくらいの差があるのかは悩ましい。

ViewModel のコードだけ、疎結合の状態になっているのが特徴だと思いました。

View は基本的にコードを持たないようなところがあるため、慣れるまで違和感があると思います。

17. BasicRegionNavigation

ボタンを押下すると "ViewA" と "ViewB" というテキストを VM にコマンドで送り、コマンドの中で RequestNavigate を使って、リージョンを入れ替えている。

private void Navigate(string navigatePath)
{
    if (navigatePath != null)
        _regionManager.RequestNavigate("ContentRegion", navigatePath);
}

navigatePath に ViewA や ViewB が入ってくる。

このテキストで名前を解決しているのは ModuleAModule の部分。

public void RegisterTypes(IContainerRegistry containerRegistry)
{
    containerRegistry.RegisterForNavigation<ViewA>();
    containerRegistry.RegisterForNavigation<ViewB>();
}

登録されていない名前を ViewC を RequestNavigate で選択してもリージョンは切り替わらない。(ViewA が表示されているなら表示されたまま

疎結合を図る仕組みが色々提供されている印象です。でも、テキストでの繋がりになるということは、繋がりが弱すぎて検索もしづらいし、どういう設計かを忘れると困ることもありそう。要注意。

18. NavigationCallback

TextBox を表示している部分が Callback の機能。つまり、BasicRegionNavigation の機能にあるおまけの部分。

private void Navigate(string navigatePath)
{
    if (navigatePath != null)
        _regionManager.RequestNavigate("ContentRegion", navigatePath, NavigationComplete);
}

private void NavigationComplete(NavigationResult result)
{
    System.Windows.MessageBox.Show(String.Format("Navigation to {0} complete. ", result.Context.Uri));
}

RequestNavigate メソッドの引数がひとつ増えている。これがそのまま Callback メソッドという関係になっている。

19. NavigationParticipation

Navigation + Participation というのは、意味がわかりづらいところがある。Navigation 機能に参加する際に発生するアレコレという内容です。

具体的には ViewModel が継承する INavigationAware インターフェースの定義するメソッド3種類を指しています。

  • bool IsNavigationTarget(NavigationContext navigationContext);
  • void OnNavigatedFrom(NavigationContext navigationContext);
  • void OnNavigatedTo(NavigationContext navigationContext);

Navigation を実行した際に、追加実行することができるメソッドを実装することができる。(自動実行される)

OnNavigatedTo メソッドは、必ず発生します。表示する View で発生します。ViewA から ViewB に移動する際は、ViewB の OnNavigatedTo メソッドを実行します。(ViewA は発生しません)

OnNavigatedFrom メソッドは、初回には実行されません。表示している View から別の View に移動する際に発生します。ViewA から ViewB に移動する際は、ViewA の OnNavigatedFrom メソッドを実行します。(ViewB は発生しません)

IsNavigationTarget メソッドは、NavigateToExistingViews の内容です。

20. NavigateToExistingViews

初回にボタンをクリックしても実行されません。2回目以降は実行され、true を返却すると同じビューを更新する。(PageViews のカウンターの数を加算する)でも、false を返却すると別のビューであると判断して、カウンターは「1」で異なるインスタンスの View+ViewModel が生成される。

true のときは存在している(履歴に持つ)View に移動して、false のときは新しい View に移動する、くらいの認識になると思います。

public bool IsNavigationTarget(NavigationContext navigationContext)
{
    return PageViews / 3 != 1;
}

PageViews のカウンターは、1,2,3の次はまた「1」に戻る。この動きは内部的には別のインスタンスが生成されていて、Navigate の機能はコレクションとして履歴を保管している。

f:id:shikaku_sh:20211021175044p:plain:w400
Views プロパティなどを参照

21. PassingParameters

コンストラクターの中で、PersonSelectedCommand コマンドを作成して、コマンドが実行されたときに NavigationParameters を作成、このパラメーターオブジェクトに "person" というパラメーターを追加して、RequestNavigate に渡している。

f:id:shikaku_sh:20211021174428g:plain:w500

public PersonListViewModel(IRegionManager regionManager)
{
    _regionManager = regionManager;

    PersonSelectedCommand = new DelegateCommand<Person>(PersonSelected);
    CreatePeople();
}

private void PersonSelected(Person person)
{
    var parameters = new NavigationParameters();
    parameters.Add("person", person);

    if (person != null)
        _regionManager.RequestNavigate("PersonDetailsRegion", "PersonDetail", parameters);
}

ListBox のアイテムを追加する、または、既存のアイテムを再選択するイベントは以下の NavigateToExistingViews の機能を使っている。

先に OnNavigatedTo が実行されたあとに(SelectedPerson の初期化)をして、IsNavigationTarget が実行されます。

public void OnNavigatedTo(NavigationContext navigationContext)
{
    var person = navigationContext.Parameters["person"] as Person;
    if (person != null)
        SelectedPerson = person;
}

public bool IsNavigationTarget(NavigationContext navigationContext)
{
    var person = navigationContext.Parameters["person"] as Person;

    if (person != null)
        return SelectedPerson != null && SelectedPerson.LastName == person.LastName;
    else
        return true;
}

true になる条件が、すこし難しい:

  • NavigationParameters が存在しないときは true(View の新規作成)
  • person が存在するとき
    • SelectedPerson が初期化されており、かつ、名前が一致するとき true

基本的には true になるので、過去の View を再利用する。

22. ConfirmCancelNavigation

IConfirmNavigationRequest インターフェースが定義するメソッド。IConfirmNavigationRequest は INavigationAware を継承している。

public void ConfirmNavigationRequest(NavigationContext navigationContext, Action<bool> continuationCallback)
{
    bool result = true;

    if (MessageBox.Show("Do you to navigate?", "Navigate?", MessageBoxButton.YesNo) == MessageBoxResult.No)
        result = false;

    continuationCallback(result);
}

continuationCallback(bool) に true を渡すと、Navigation の画面遷移が発生する。false だとキャンセルされる。ConfirmNavigationRequest の名前のとおりなので、目的がはっきりしている。確認することこそが適格だと思う。

23. RegionMemberLifetime

IRegionMemberLifetime インターフェースの定義する機能のこと。KeepAlive プロパティを実装している。

  • KeepAlive
public bool KeepAlive
{
    get
    {
        return true;
    }
}

KeepAlive を false にすると、IsNavigationTarget の返り値が false なので、必ず新しい View+ViewModel で画面を生成します。このとき、前回使用した View+ViewModel を破棄するかしないかを決定することができます。

デフォルト値は true なので、過去の View が残っているため、ViewB はボタンを押下するたび MainWindow.xaml の Views に表示されるコレクションが増えていく。ViewA は押下するたびに、前回のデータを削除してから追加している。

24. NavigationJournal

画面をブラウザーのようにページを切り替える機能を提供する。

IRegionNavigationJournal _journal;

public PersonListViewModel(IRegionManager regionManager)
{
    PersonSelectedCommand = new DelegateCommand<Person>(PersonSelected);
    GoForwardCommand = new DelegateCommand(GoForward, CanGoForward);
}

private void PersonSelected(Person person)
{
    var parameters = new NavigationParameters();

    parameters.Add("person", person);

    if (person != null)
    {
        _regionManager.RequestNavigate("ContentRegion", "PersonDetail", parameters);
    }
}

public void OnNavigatedTo(NavigationContext navigationContext)
{
    _journal = navigationContext.NavigationService.Journal;
}

private void GoForward()
{
    _journal.GoForward();
}

private bool CanGoForward()
{
    return _journal != null && _journal.CanGoForward;
}

private void GoBack()
{
    _journal.GoBack();
}

画面を移動したとき (OnNavigatedTo) に _journal を記録している。ListBox の選択を切り替えた時の遷移は GoForward を利用せず、ボタン押下時にだけ利用している。

以下のコマンドは初回表示したときに限ると意味はない:

  • GoForwardCommand.RaiseCanExecuteChanged();

RaiseCanExecuteChanged() を入れることで、GoForward() を利用するボタンを有効にします。初回実行時は、CanGoForward() が false にしかならないので GoForward() が使えません。

GoBack() で戻った時に CanGoForward() を満たすことができるので、RaiseCanExecuteChanged() を実行することで、Forward ボタンが有効になります。

f:id:shikaku_sh:20211021174951p:plain:w400
Stack を持ちます

具体的な機能なので「このように動かすもの」として利用します。_journal の操作は各画面であまり変わらない可能性が高いので Full App であれば、VM の基底として Core に abstract などの抽象・基底クラスを作成しておいてもよいと思いました。(MonoBehavior とも近い印象)

注意:25 ~ 28 は現在、サンプルがなくなっているようです。17 Nov 2020 のコミットで古い双方向性サンプルを削除しているみたいなので、これらは無視します。

commit log: removed old interactivity samples

29. InvokeCommandAction

InvokeCommandAction は、View の中のコントロールで発生したイベントに対応してコマンドを実行する必要があるときに便利です。

InvokeCommandAction is useful when you need to invoke a command in response to an event raised by a control in the view.

サンプルでは、ListBox があり、アイテムが選択されたときに ViewModel にて対応するコマンドを実行します。

<Window xmlns:i="http://schemas.microsoft.com/xaml/behaviors"
        xmlns:prism="http://prismlibrary.com/"
        prism:ViewModelLocator.AutoWireViewModel="True">

<ListBox Grid.Row="1" Margin="5" ItemsSource="{Binding Items}" SelectionMode="Single">
    <i:Interaction.Triggers>
        <!-- This event trigger will execute the action when the corresponding event is raised by the ListBox. -->
        <i:EventTrigger EventName="SelectionChanged">
            <!-- This action will invoke the selected command in the view model and pass the parameters of the event to it. -->
            <prism:InvokeCommandAction Command="{Binding SelectedCommand}" TriggerParameterPath="AddedItems" />
        </i:EventTrigger>
    </i:Interaction.Triggers>
</ListBox>

</Window>

i: 名前空間は prism の機能とは別です。InvokeCommandAction は、i インタラクティブ系機能を使っているイベントトリガーです。ListBox のイベント SelectionChanged がイベント実行条件で、InvokeCommandAction は AddedItems というプロパティを SelectedCommand に渡しています。

発生するイベントのプロパティを選択して取得しています。TriggerParameterPath を空欄にすると、SelectionChangedEventArgs そのものを引数として受け取ることができます。

基本的には View と ViewModel は疎結合にしたい意図があるため、必要なパラメーターを決めて View から送っているという関係になります。

最後に

以上で、サンプルをすべて動かしたことになります。おつかれさまでした。

使い方をある程度理解する上では、かなり要点がまとまっているものだったと思います。個人的なプロジェクトに応用するうえではもう少し咀嚼しないと難しいものもありましたが、具体的なロジックもあるので、テストしておいて損はないと思います。

ライブラリーを使う前にまとまった時間を投資しないとダメなんだな、という典型。

参考

WPF Prism サンプルコードの学習4 (Commands)

f:id:shikaku_sh:20211013180838p:plain:w400

WPF + .NET Core (5以降は Core は省略される) で Prism を使ってみよう。

使用している Prism のバージョンは次のとおり:

Prism Full App (.NET Core) テンプレートを体験する」の記事も参考になると思います。

関連記事は以下:

11. DelegateCommands

ボタンのクリックイベントを Command に置き換えるための機能です。Command は自分でもわりと単純に自作することが可能なので、車輪の再開発をして動きを確認しておくと理解しやすい。

f:id:shikaku_sh:20211021174111g:plain:w500

<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
    <CheckBox IsChecked="{Binding IsEnabled}" Content="Can Execute Command" Margin="10"/>
    <Button Command="{Binding ExecuteDelegateCommand}" Content="DelegateCommand" Margin="10"/>
    <Button Command="{Binding DelegateCommandObservesProperty}" Content="DelegateCommand ObservesProperty" Margin="10"/>
    <Button Command="{Binding DelegateCommandObservesCanExecute}" Content="DelegateCommand ObservesCanExecute" Margin="10"/>
    <Button Command="{Binding ExecuteGenericDelegateCommand}" CommandParameter="Passed Parameter" Content="DelegateCommand Generic" Margin="10"/>
    <TextBlock Text="{Binding UpdateText}" Margin="10" FontSize="22"/>
</StackPanel>
public class MainWindowViewModel : BindableBase
{
    private bool _isEnabled;
    public bool IsEnabled
    {
        get { return _isEnabled; }
        set
        {
            SetProperty(ref _isEnabled, value);
            ExecuteDelegateCommand.RaiseCanExecuteChanged();
        }
    }

    private string _updateText;
    public string UpdateText
    {
        get { return _updateText; }
        set { SetProperty(ref _updateText, value); }
    }


    public DelegateCommand ExecuteDelegateCommand { get; private set; }

    public DelegateCommand<string> ExecuteGenericDelegateCommand { get; private set; }

    public DelegateCommand DelegateCommandObservesProperty { get; private set; }

    public DelegateCommand DelegateCommandObservesCanExecute { get; private set; }


    public MainWindowViewModel()
    {
        ExecuteDelegateCommand = new DelegateCommand(Execute, CanExecute);

        DelegateCommandObservesProperty = new DelegateCommand(Execute, CanExecute).ObservesProperty(() => IsEnabled);

        DelegateCommandObservesCanExecute = new DelegateCommand(Execute).ObservesCanExecute(() => IsEnabled);

        ExecuteGenericDelegateCommand = new DelegateCommand<string>(ExecuteGeneric).ObservesCanExecute(() => IsEnabled);
    }

    private void Execute()
    {
        UpdateText = $"Updated: {DateTime.Now}";
    }

    private void ExecuteGeneric(string parameter)
    {
        UpdateText = parameter;
    }

    private bool CanExecute()
    {
        return IsEnabled;
    }
}

IsEnabled の動きは、Xaml のコード上からだとわかりづらいが、C# のコード上からだと DelegateCommand の引数で制御していることがわかる。ここは好みがわかれると思う。

補足しておくべきコマンドは ExecuteGenericDelegateCommand。CommandProperty で Passed Parameter を返却し、C# コード側でジェネリック とすることで、データを受け取っている。イベントの感覚だと、どこでイベントが発生したのか引数 owner のようなもので、親コントロールを受け取ることができた。しかし、疎結合を意識するつくりのため、デフォルトだと引数が存在していない。

たとえば、ポップアップウィンドウを表示したいときなどは、親ウィンドウのハンドルが必要になる可能性が高い。そうした際は、ジェネリック型で受け取る、または、あらかじめクラス変数として取得するなど、工夫する必要があると思う。

どの程度疎結合にする必要があるのか、によって View と ViewModel の結合度を調整するのがよいと思います。(第何回か忘れましたが、わんくま勉強会でこのような意見があったのを覚えています)

12. UsingCompositeCommands

このプロジェクトの構成は、Prism Full App テンプレートの構成についての理解がないと、わかりづらい者だと思います。これについては以下のとおり:

実際のうごきをまず確認します:

ボタンを押すと日付が各タブに入力されます。親ボタンを押すと3つすべてのタブの日付が更新されます。

プロジェクトがどのようになっているのか整理します。

エントリーポイントは、いつもどおり App.xaml.cs からスタート。モジュールを追加しているので、View と ViewModel が存在することがわかります。また、RegisterTypes で IApplicationCommands というものを DI に登録しているのがわかります。

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

    protected override void ConfigureModuleCatalog(IModuleCatalog moduleCatalog)
    {
        moduleCatalog.AddModule<ModuleA.ModuleAModule>();
    }

    protected override void RegisterTypes(IContainerRegistry containerRegistry)
    {
        containerRegistry.RegisterSingleton<IApplicationCommands, ApplicationCommands>();
    }
}

IApplicationCommands は、別のプロジェクトに分けて設計されており具体的な内容はつぎのとおり:

public interface IApplicationCommands
{
    CompositeCommand SaveCommand { get; }
}

public class ApplicationCommands : IApplicationCommands
{
    private CompositeCommand _saveCommand = new CompositeCommand();
    public CompositeCommand SaveCommand
    {
        get { return _saveCommand; }
    }
}

この部分は、Full App を見ておくとよい部分。

つぎの流れに進みます。ModuleAModule が呼び出されて View と ViewModel の準備が始まります。ここでは、同じ View 3つが同時に生成されて、同じ ViewModel クラスがそれぞれに設定されます。(インスタンスは別々)

public class ModuleAModule : IModule
{
    public void OnInitialized(IContainerProvider containerProvider)
    {
        var regionManager = containerProvider.Resolve<IRegionManager>();
        IRegion region = regionManager.Regions["ContentRegion"];

        var tabA = containerProvider.Resolve<TabView>();
        SetTitle(tabA, "Tab A");
        region.Add(tabA);

        var tabB = containerProvider.Resolve<TabView>();
        SetTitle(tabB, "Tab B");
        region.Add(tabB);

        var tabC = containerProvider.Resolve<TabView>();
        SetTitle(tabC, "Tab C");
        region.Add(tabC);
    }

    void SetTitle(TabView tab, string title)
    {
        (tab.DataContext as TabViewModel).Title = title;
    }
}

この時点で、Prism 独特のコーディング手法になっているのがわかると思います。

View に紐づいた ViewModel を初期化する際に DI を通じて IApplicationCommands を受け取っています。各 Tab 内にあるボタンは UpdateCommand を実行している一方で、UpdateCommand コマンドは IApplicationCommands に登録されます。

View に設定された ViewModel はこれ:

public class TabViewModel : BindableBase
{
    IApplicationCommands _applicationCommands;

    private string _title;
    public string Title
    {
        get { return _title; }
        set { SetProperty(ref _title, value); }
    }

    private bool _canUpdate = true;
    public bool CanUpdate
    {
        get { return _canUpdate; }
        set { SetProperty(ref _canUpdate, value); }
    }

    private string _updatedText;
    public string UpdateText
    {
        get { return _updatedText; }
        set { SetProperty(ref _updatedText, value); }
    }

    public DelegateCommand UpdateCommand { get; private set; }

    public TabViewModel(IApplicationCommands applicationCommands)
    {
        _applicationCommands = applicationCommands;

        UpdateCommand = new DelegateCommand(Update).ObservesCanExecute(() => CanUpdate);

        _applicationCommands.SaveCommand.RegisterCommand(UpdateCommand);
    }

    private void Update()
    {
        UpdateText = $"Updated: {DateTime.Now}";
    }
}

ここで ApplicationCommands はどうなっているのか、という話になります。MainWindow の XAML を確認すると、このコマンドが利用されていることがわかります。

<Window x:Class="UsingCompositeCommands.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">

    <Window.Resources>
        <Style TargetType="TabItem">
            <Setter Property="Header" Value="{Binding DataContext.Title}" />
        </Style>
    </Window.Resources>
    
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="*" />
        </Grid.RowDefinitions>

        <Button Content="Save" Margin="10" Command="{Binding ApplicationCommands.SaveCommand}"/>

        <TabControl Grid.Row="1" Margin="10" prism:RegionManager.RegionName="ContentRegion" />
    </Grid>
</Window>

MainWindow の ViewModel はこれ:

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

    private IApplicationCommands _applicationCommands;
    public IApplicationCommands ApplicationCommands
    {
        get { return _applicationCommands; }
        set { SetProperty(ref _applicationCommands, value); }
    }

    public MainWindowViewModel(IApplicationCommands applicationCommands)
    {
        ApplicationCommands = applicationCommands;
    }
}

DI で受け取った ApplicationCommands をコマンドのプロパティとして公開しています。つまり、各タブ3つの UpdateCommand をまとめたコマンドが ApplicationCommands であり、これのことが Composite Commands(複合コマンド)というわけです。

今までのサンプルでやってきたことを組み合わせて使うサンプルなので、複雑なものだと思います。なので、ひとつひとつ理解を進める必要があり、理解に不足のある機能は振り返りましょう。

補足として、いつもの StackPanel に ContentRegion を適用しているのではなくて、TabControl に ContentRegion を適用して、TabView を3つ追加した、というところも Prism の機能を使いこなす上ではポイントになると思います。

機能としても便利なので理解しておくほうがよいです。

13. IActiveAwareCommands

UsingCompositeCommands とほぼ同じプロパティ構成になっているので、12 を先に理解していないとどうにもなりません。

かなりわかりづらいですが、 IApplicationCommands に違いがあります。

public interface IApplicationCommands
{
    CompositeCommand SaveCommand { get; }
}

public class ApplicationCommands : IApplicationCommands
{
    private CompositeCommand _saveCommand = new CompositeCommand(true);
    public CompositeCommand SaveCommand
    {
        get { return _saveCommand; }
    }
}

変更点はここ:

  • new CompositeCommand(true);

この true の箇所は、monitorCommandActivity 引数です。

Indicates when the command activity is going to be monitored.

コマンドアクティビティを監視し、コマンドを実行するかどうかの判定が入るようになります。

判定には IActiveAware を利用しているので、定義を確認します:

public interface IActiveAware
{
    // 概要:
    //     Gets or sets a value indicating whether the object is active.
    // 値:
    //     true if the object is active; otherwise false.
    bool IsActive { get; set; }

    // 概要:
    //     Notifies that the value for Prism.IActiveAware.IsActive property has changed.
    event EventHandler IsActiveChanged;
}

ViewModel の実装箇所はこのとおり:

public class TabViewModel : BindableBase, IActiveAware
{
    bool _isActive;
    public bool IsActive
    {
        get { return _isActive; }
        set
        {
            _isActive = value;
            OnIsActiveChanged();
        }
    }
    private void OnIsActiveChanged()
    {
        UpdateCommand.IsActive = IsActive;

        IsActiveChanged?.Invoke(this, new EventArgs());
    }

    public event EventHandler IsActiveChanged;
}

タブを切り替えたときに OnIsActiveChanged が呼び出され、UpdateCommand.IsActive の値が更新されます。非表示のときはコマンド自体の IsActive プロパティが false になっているため、実行されないという流れです。

monitorCommandActivity 引数は前提条件です。設定しなくても IsActive に関するプロパティやメソッドは実行されますが、IActiveAwareCommands と見なさず、CompositeCommands として動作します。

まとめ

コマンドの使い方についてまとめた。

  • 基本となるコマンドは DelegateCommand
    • DelegateCommand にはジェネリック型があり、引数をとることができる
  • コマンドは CompositeCommands を使うことでコマンドをまとめることができる
    • まとめ方には DI が利用されている
    • 実行可否を細かく設定するときは IActiveAwareCommands を利用する

参考

WPF Prism サンプルコードの学習3 (ViewModelLocator)

f:id:shikaku_sh:20211013180838p:plain:w400

WPF + .NET Core (5以降は Core は省略される) で Prism を使ってみよう。

使用している Prism のバージョンは次のとおり:

Prism Full App (.NET Core) テンプレートを体験する」の記事も参考になると思います。

関連記事は以下:

8. ViewModelLocator

View と ViewModel を紐づけて MVVM を成立させる基本となる機能。

<Window x:Class="ViewModelLocator.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>
        <ContentControl prism:RegionManager.RegionName="ContentRegion" />
    </Grid>
</Window>
public class MainWindowViewModel : BindableBase
{
}

View では prism:ViewModelLocator.AutoWireViewModel が、VM では BaindableBase が、それぞれ具体的に役割を担っている。

デフォルトでは、Prism ライブラリー ViewModelLocationProvider が対応する ViewModel を取得している。

  • フォルダー名(+配置)の規則
    View は「Views.ファイル名」、ViewModel は「ViewModels.ファイル名」
  • ファイル名(+配置)の規則
    View は「名称の末尾に View」、ViewModel は「名称の末尾に ViewModel」
  • 例 (Sample という名称をつける場合) Views.SampleView.xaml
    ViewModel.SampleViewModel.cs

基本的には、意識せずに機能することが目的のものなので、Prism を利用すると View と ViewModel は自動で紐づくようになる、くらいの理解がよいと思います。

9. ChangeConvention

Change Convention の名前のとおり、約束(しきたり)を変更する機能です。

デフォルトでは、Prism ライブラリー ViewModelLocationProvider が View に対応する ViewModel を取得している、「フォルダー名(+配置)の規則」「ファイル名(+配置)の規則」を変更するということです。

public partial class App : PrismApplication
{
    protected override void ConfigureViewModelLocator()
    {
        base.ConfigureViewModelLocator();

        ViewModelLocationProvider.SetDefaultViewTypeToViewModelTypeResolver((viewType) =>
        {
            var viewName = viewType.FullName;
            var viewAssemblyName = viewType.GetTypeInfo().Assembly.FullName;
            var viewModelName = $"{viewName}ViewModel, {viewAssemblyName}";
            return Type.GetType(viewModelName);
        });
    }
}

わかりにくいけど、以下の約束(しきたり)で View と ViewModel をつなぐように変更されています:

  • フォルダー名(+配置)の規則
    View は「Views.ファイル名」、ViewModel は「ViewModels.ファイル名」
  • ファイル名(+配置)の規則
    View は「名称の末尾に View」、ViewModel は「名称の末尾に ViewModel」
  • 例 (Sample という名称をつける場合) Views.SampleView.xaml
    Views.SampleViewModel.cs

ViewModelLocator の機能は、On Rails の規約に則ってコーディングすることで、開発者の負担を減じることのようなものです。でも、柔軟性に欠けるので独自の On Rails に微修正するためのもの、というカスタマイズの位置です。

10. CustomRegistrations

Custom Registrations は、Change Convention とは異なり、約束(しきたり)を変更するのではなくて、View に対応する ViewModel の型を指定しておくことで、Custom Registrations に View と ViewModel の紐づけを優先的に解決する手法です。

public partial class App : PrismApplication
{
    protected override void ConfigureViewModelLocator()
    {
        base.ConfigureViewModelLocator();

        // type / type
        //ViewModelLocationProvider.Register(typeof(MainWindow).ToString(), typeof(CustomViewModel));

        // type / factory
        //ViewModelLocationProvider.Register(typeof(MainWindow).ToString(), () => Container.Resolve<CustomViewModel>());

        // generic factory
        //ViewModelLocationProvider.Register<MainWindow>(() => Container.Resolve<CustomViewModel>());

        // generic type
        ViewModelLocationProvider.Register<MainWindow, CustomViewModel>();
    }
}

Custom Registrations のやり方は、View と ViewModel を具体的にひとつひとつ結びつける手法で、Change Convention はまとめて結びつける約束(しきたり)を定めること、なので用途が異なります。

注意する点として、View に複数の ViewModel を割り当てすることができてしまう。実行時もエラーにならず、どちらかの ViewModel とのみ紐づけられる。

ViewModelLocationProvider.Register<MainWindow>(() => Container.Resolve<CustomViewModel>());
ViewModelLocationProvider.Register<MainWindow>(() => Container.Resolve<CustomViewModel2>());

意図しない挙動をする恐れがあるため、Custom Registrations は開発者が管理できる範囲で少数利用することが望ましいと思われる。

View と ViewModel の紐づけは約束(しきたり)を守るものであったり、On Rails(レールに従う)であることが望ましくて、紐づけの規則がないと混乱するだけなので注意したい。

まとめ

ViewModelLocator についてまとめた。

ViewModelLocator は View と ViewModel を自動的に View の DataContext に設定してくれる。原則的には、約束(しきたり)に則ったフォルダー+ファイル名の名付け方をすること。

どうしても、デフォルトの約束(しきたり)では、具合がわるいなら ChangeConvention か CustomRegistrations を検討する。

  • すべての規則を変更したい = ChangeConvention
  • 一部分だけ変更したい = CustomRegistrations

参考

WPF Prism サンプルコードの学習2 (Module)

f:id:shikaku_sh:20211013180838p:plain:w400

WPF + .NET Core (5以降は Core は省略される) で Prism を使ってみよう。

使用している Prism のバージョンは次のとおり:

Prism Full App (.NET Core) テンプレートを体験する」の記事も参考になると思います。

関連記事は以下:

7. Modules

Modules の考え方は、Prism Full App テンプレートを見ればわかるとおりですが、プロジェクトを分けることでもあります。

プロジェクトを分ける程度は、プログラムの規模にもよりますが、モジュールを利用するとプロジェクトを分ける分だけ面倒でもあります。「PrismのRegionをなるべく仰々しくない方法で使う」という記事もあり、これは実質のところプロジェクトからの Module 外しです。

サンプルやテストなど、ミニマムな状態で考えるべきテクニックと Modules はちょっとズレているかも、と留意するとよいと思います。

7.1 Modules - AppConfig

App.config から ModuleA とリンクする手法です。

<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <configSections>
    <section name="modules" type="Prism.Modularity.ModulesConfigurationSection, Prism.Wpf" />
  </configSections>
  <startup>
  </startup>
  <modules>
    <module assemblyFile="ModuleA.dll" moduleType="ModuleA.ModuleAModule, ModuleA, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" moduleName="ModuleAModule" startupLoaded="True" />
  </modules>
</configuration>

ポイントは、Modules プロジェクト App.xaml.cs ファイルの内容です。

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

    protected override void RegisterTypes(IContainerRegistry containerRegistry)
    {

    }

    protected override IModuleCatalog CreateModuleCatalog()
    {
        return new ConfigurationModuleCatalog();
    }
}

ConfigurationModuleCatalog を利用すると、App.config を読み込み、IModule を継承した ModuleAModule が実行され、ContentRegion に ViewA が読み込まれる、といった流れのはずです。

実際やっていることはコーディングと大差ないことや、テキストとして処理しているので * の部分をタイプミスしてもエラーは実行時にしかわかりません。

<module assemblyFile="*.dll" moduleType="*, *, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" moduleName="*" startupLoaded="True" />

それほどメリットのある記述ではないかもしれないです。

デバッグするときは、ModuleA プロジェクトを選択してあらかじめリビルドしてください。dll ファイルが先に生成されていないとデバッグでエラーになります。

7.2 Modules - Code

ModuleA とリンクする手法、その2。

単純に AddModule メソッドでモジュールを追加している。シンプルでわかりやすいです。

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

    protected override void RegisterTypes(IContainerRegistry containerRegistry)
    {

    }

    protected override void ConfigureModuleCatalog(IModuleCatalog moduleCatalog)
    {
        moduleCatalog.AddModule<ModuleA.ModuleAModule>();
    }
}

個人的には、AppConfig よりもスマートに思う。たしかに、AppConfig はコンパイル後もファイルのテキスト書き換えで修正できるメリットを享受しやすいものの、記述の内容が具体的すぎると思う。

Code でも設定ファイル等を事前に読み込むことで、およそのところは対応できると思いました。(どちらにしても、実装部分はコンパイルしないといけないので)

7.3 Modules - Directory

ModuleA とリンクする手法、その3。

Modules フォルダーに存在するモジュール群を読み込む手法です。

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

    protected override void RegisterTypes(IContainerRegistry containerRegistry)
    {

    }

    protected override IModuleCatalog CreateModuleCatalog()
    {
        return new DirectoryModuleCatalog() { ModulePath = @".\Modules" };
    }
}

プロジェクトの構成に依存することになります。目に見えづらい設定になる部分が好みの分かれ目なのかな、と思います。

7.4 Modules - LoadManual

ModuleA とリンクする手法、その4。

モジュールをカタログに追加しておいて、後からモジュールを利用、設定する手法。

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

    protected override void RegisterTypes(IContainerRegistry containerRegistry)
    {
        
    }

    protected override void ConfigureModuleCatalog(IModuleCatalog moduleCatalog)
    {
        var moduleAType = typeof(ModuleAModule);
        moduleCatalog.AddModule(new ModuleInfo()
        {
            ModuleName = moduleAType.Name,
            ModuleType = moduleAType.AssemblyQualifiedName,
            InitializationMode = InitializationMode.OnDemand
        });
    }
}

f:id:shikaku_sh:20211015154059g:plain:w400

DI からモジュールマネージャーを取得して、モジュールを読み込んでいる。 最初からモジュールを読み込ませるときは InitializationMode を InitializationMode.WhenAvailable に設定しておこう。

public partial class MainWindow : Window
{
    IModuleManager _moduleManager;

    public MainWindow(IModuleManager moduleManager)
    {
        InitializeComponent();
        _moduleManager = moduleManager;
    }

    private void Button_Click(object sender, RoutedEventArgs e)
    {
        _moduleManager.LoadModule("ModuleAModule");
    }
}

7.5 Modules - Xaml

ModuleA とリンクする手法、その5。

App.config と xaml を利用するパターンで、パターン1を拡張したような書き方になる。App.config はパターン1そっくり。

<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <configSections>
    <section name="modules" type="Prism.Modularity.ModulesConfigurationSection, Prism.Wpf" />
  </configSections>
  <startup>
  </startup>
  <modules>
    <module assemblyFile="ModuleA.dll" moduleType="ModuleA.ModuleAModule, ModuleA, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" moduleName="ModuleAModule" startupLoaded="True" />
  </modules>
</configuration>

加えて、カタログを表す xaml を容易する。

<m:ModuleCatalog xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
                    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
                    xmlns:m="clr-namespace:Prism.Modularity;assembly=Prism.Wpf">

    <m:ModuleInfo ModuleName="ModuleAModule" 
                  ModuleType="ModuleA.ModuleAModule, ModuleA, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" />

</m:ModuleCatalog>

最後にコード上からカタログを読み込んでいる。Directory ではフォルダーのファイル構成で表していた部分を xaml で記述し、カタログを読み込む形になった。

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

    protected override void RegisterTypes(IContainerRegistry containerRegistry)
    {

    }

    protected override IModuleCatalog CreateModuleCatalog()
    {
        return new XamlModuleCatalog(new Uri("/Modules;component/ModuleCatalog.xaml", UriKind.Relative));
    }
}

個人的には、あまりしっくりとはこなかった。多くの場合では冗長ではないか、というのが一番のところ。

まとめ

Modules はメインのエントリープロジェクトに View や VM を読み込む手法。ウィンドウと疎結合の関係を保つことができる。

読み込む手法の例:

  1. App.config から読み込む
  2. コード上で AddModule して読み込む
  3. ディレクトリー上(フォルダー)から、まとめて読み込む
  4. カタログにマニュアルで追加して読み込む(細かくコーディングしやすい)
  5. Xaml を容易して 3 に近い手法で読み込む

参考

WPF Prism サンプルコードの学習1 (Bootstrapper, Region)

f:id:shikaku_sh:20211013180838p:plain:w400

WPF + .NET Core (5以降は Core は省略される) で Prism を使ってみよう。

使用している Prism のバージョンは次のとおり:

  • 8.1.97 (2021/05/25)

使いはじめるに際して、Prism の開発チームが公開しているサンプルがあるので、それをテストしてみることにしました。

Prism Full App (.NET Core) テンプレートを体験する」の記事も参考になると思います。

サンプルは DI コンテナに Unity を利用しているみたいです。Unity の名前は ゲームエンジンの Unity と関係がなくて、「IoC の Unity」になります。

ほかにも DryIoc という選択肢があります。機能的な違いは(ほぼ)無いということで、とりあえずさわり始めるときはデフォルトが Unity みたいくらいでよいと思います。

コードに説明がないので、ちょっと困るんだけどなにをしているのか調べていく記事です。MVVM あたりの知識はちょっと必要。

関連記事は以下:

1. BootstrapperShell

まず、公式の解説。

古い Legacy (Prism 6) の説明です。

違いは App が継承していたけど、現在はただのクラスが継承する形式になっています。ざっくりと、これはアプリケーションの起動シーケンスを担うクラスです。

protected override DependencyObject CreateShell() => Container.Resolve<MainWindow>();

protected override void RegisterTypes(IContainerRegistry containerRegistry)
{
    
}

CreateShell メソッドで最初のウィンドウを指定しており、これが表示されます。Prism 6 の頃のサンプルコードを読むと、InitializeShell で Window を Show していますが、現在はやらなくてもいいみたいですね。

とりあえず、これが最小の Prism アプリケーション構成になるかと思います。

2. Region

Region のサンプルはとてもシンプルで XAML に以下があるだけ。 RegionManager があることはわかります。しかし、なにをするものなのか、説明が足りません。

<Window x:Class="Regions.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/"
        Title="Shell" Height="350" Width="525">
    <Grid>
        <StackPanel prism:RegionManager.RegionName="ContentRegion" />
    </Grid>
</Window>

Region がどういう役割をするのかといえば、StackPanel の要素を入れ替える(詰め込む)ための機能になっています。試しに、View を詰め込むサンプルは次のとおり:

まず、Bootstrapper を App で書くとこんな感じ。RegisterTypes に DI コンテナー(表示する画面)を用意します。

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

    protected override void RegisterTypes(IContainerRegistry containerRegistry)
    {
        containerRegistry.RegisterForNavigation<Views.UserControl1>();
        containerRegistry.RegisterForNavigation<Views.UserControl2>();
    }
}

UserControl1.xaml をプロジェクトに追加しています。(どんな画面でもよい)

MainWindow はこんな感じ:

<Window x:Class="Regions.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/"
        Title="Shell" Height="350" Width="525"
        prism:ViewModelLocator.AutoWireViewModel="True">
    <Grid>
        <ContentControl prism:RegionManager.RegionName="ContentRegion" />
    </Grid>
</Window>

prism:ViewModelLocator.AutoWireViewModel="True" の部分が新しく追加されていますが、デフォルトで True みたいです。むしろ、明示的に False にしないと自動で View は ViewModel とバインディングされます。

MainWindow の ViewModel は次のとおり:

using Prism.Mvvm;
using Prism.Regions;

namespace Regions.ViewModels
{
    public class MainWindowViewModel : BindableBase
    {
        private readonly IRegionManager _RegionManager;

        public MainWindowViewModel(IRegionManager regionManager)
        {
            _RegionManager = regionManager;

            _RegionManager.RegisterViewWithRegion("ContentRegion", typeof(Views.UserControl1));
        }
    }
}

これで MainWindow の画面に UserControl1 が表示されます。

注意:このやり方は、サービスロケーターの謗りを免れない恐れがあります。

まとめると、

  • DI コンテナーに View を入れた
  • ViewModel から View のコンテンツを切り替えた
  • View と ViewModel の繋がりは ContentRegion というテキストであって、疎結合といえる
  • Region マネージャーなので Model を管理する DI ではない(はず)
    • DI の考え方が必要になるので必要なら Autofac などを確認

3. Custom Region Adapter

2 で利用した Region の機能を拡張するときに使用する機能です。

ConfigureRegionAdapterMappings がスタート。

protected override void ConfigureRegionAdapterMappings(RegionAdapterMappings regionAdapterMappings)
{
    base.ConfigureRegionAdapterMappings(regionAdapterMappings);

    regionAdapterMappings.RegisterMapping(typeof(StackPanel), Container.Resolve<StackPanelRegionAdapter>());
}

見ての通り2つのことしかしていないです。

  • アプリケーションが利用するデフォルトの RegionAdapterMappings を設定している
  • RegionAdapterMappingsに StackPanel 用の Custom Region Adapter を追加している

Custom Region Adapter で作成したクラスは次のとおり:

public class StackPanelRegionAdapter : RegionAdapterBase<StackPanel>
{
    public StackPanelRegionAdapter(IRegionBehaviorFactory regionBehaviorFactory)
        : base(regionBehaviorFactory)
    {

    }

    protected override void Adapt(IRegion region, StackPanel regionTarget)
    {
        region.Views.CollectionChanged += (s, e) =>
        {
            if (e.Action == System.Collections.Specialized.NotifyCollectionChangedAction.Add)
            {
                foreach (FrameworkElement element in e.NewItems)
                {
                    regionTarget.Children.Add(element);
                }
            }

            //handle remove
        };
    }

    protected override IRegion CreateRegion()
    {
        return new AllActiveRegion();
    }
}

基本的な実装は、サンプルのままでコントロールを表示することができるので、これを拡張する形になります。

個人的には StackPanel より ContentControl で作ったほうがいいんじゃないかと思います。StackPanel は、サイズが自動で Auto (最小のサイズ) になっちゃうので。

DI は 2020 年にも Qiita で話題になり、様々な意見交換がありました。結局、DI を利用している人は多いんで、一家言を持ってる人も多い。でも、DI 自体の定義は抽象的(大きな考えを指してる)なんで、微妙に考え方の違いがあるみたい。

また、サービスロケーターみたいな書き方がアンチパターンに挙がっている(他言語でも言うと思うけど)みたいなこともあるんだけど、情報が散らばっていて「コーディングの基礎を知りたいならリーダブルコードを読んどけ」みたいにいかないのかもしれない。

個人的な考えは以下にまとめた:

4. View Discovey

MainWindow.xaml.cs で、直接 View をコンテンツに設定している手法のこと。

public MainWindow(IRegionManager regionManager)
{
    InitializeComponent();
    //view discovery
    regionManager.RegisterViewWithRegion("ContentRegion", typeof(ViewA));
}

簡易な手法のひとつになるが Prism Full App Template のように、丁寧にやる方法もあるのでケースバイケースで考えること。

5. View Injection

コンテナとリージョンを直接操作して、追加したり削除したりする。 下手をするとサービスロケータになってしまうため、注意が必要。

public partial class MainWindow : Window
{
    IContainerExtension _container;
    IRegionManager _regionManager;

    public MainWindow(IContainerExtension container, IRegionManager regionManager)
    {
        InitializeComponent();
        _container = container;
        _regionManager = regionManager;
    }

    private void Button_Click(object sender, RoutedEventArgs e)
    {
        var view = _container.Resolve<ViewA>();
        IRegion region = _regionManager.Regions["ContentRegion"];
        region.Add(view);
    }
}

6. ActivationDeactivation

View の表示・非表示を操作することができます。5と比べるとかなり重要。

View 自体も非アクティブ化しても削除したわけではないので、例えば、テキストボックスの入力は前回の状態が残ったままになる。

public partial class MainWindow : Window
{
    IContainerExtension _container;
    IRegionManager _regionManager;
    IRegion _region;

    ViewA _viewA;
    ViewB _viewB;

    public MainWindow(IContainerExtension container, IRegionManager regionManager)
    {
        InitializeComponent();
        _container = container;
        _regionManager = regionManager;

        this.Loaded += MainWindow_Loaded;
    }

    private void MainWindow_Loaded(object sender, RoutedEventArgs e)
    {
        _viewA = _container.Resolve<ViewA>();
        _viewB = _container.Resolve<ViewB>();

        _region = _regionManager.Regions["ContentRegion"];

        _region.Add(_viewA);
        _region.Add(_viewB);
    }
}

ちなみに、こんな感じで新しい ViewA と差し替えることもできた。

private void Button_Click_4(object sender, RoutedEventArgs e)
{
    var newViewA = _container.Resolve<ViewA>();
    
    if (_viewA != null)
    {
        _region.Remove(_viewA);
    }

    _region.Add(newViewA);
    _viewA = newViewA;
}

Prism の DI はじめの部分から、VM と V の連携と操作が1~6といった雰囲気だと思います。 次回はモジュール関係。

参考