LoginSignup
52
45

More than 3 years have passed since last update.

MVVM をリアクティブプログラミングで快適に ReactiveProperty オーバービュー 2020 年版 後編

Last updated at Posted at 2020-08-02

この記事は 2020 年の ReactiveProperty のオーバービューの全 3 編からなる記事の 3 つ目の記事です。

他の記事はこちらです。

イベントから ReactiveProperty や ReactiveCommand を呼ぶ

WPF と UWP 限定の機能としてボタンのクリックなどのイベントが発生したら ReactiveProperty の値を更新したり、ReactiveCommand を呼び出すといった機能を提供しています。EventToReactiveProperty と EventToReactiveCommand を使用して、この機能が利用可能です。

この機能を利用するにはプラットフォーム固有のパッケージをインストールする必要があります。

  • ReactiveProperty.WPF (WPF 用)
  • ReactiveProperty.UWP (UWP 用)

上記パッケージをインストールすると Microsoft.Xaml.Behaviors.Wpf (WPF 用)、
Microsoft.Xaml.Behaviors.Uwp.Managed (UWP 用) パッケージもインストールされます。このパッケージ内にある EventTrigger と EventToReactiveProprety/EventToReactiveCommand を組み合わせて使うことでイベントをハンドリングして ReactiveProperty/ReactiveCommand に伝搬することが出来ます。

また、イベント発生時のイベント引数を変換するための変換レイヤーも提供しています。DelegateConverter<T, U>ReactiveConverter<T, U> を継承して作成します。型引数の T が変換元(普通は XxxEventArgs)で U が変換先 (ReactiveProperty の値の型やコマンドパラメーターの型) です。

例えば WPF でマウスを動かしたときのイベント引数の MouseEventArgs を表示用メッセージに加工するコンバーターは以下のようになります。

MouseEventToStringConverter.cs
using Reactive.Bindings.Interactivity;
using System;
using System.Reactive.Linq;
using System.Windows;
using System.Windows.Input;

namespace RxPropLabWpf
{
    public class MouseEventToStringReactiveConverter : ReactiveConverter<MouseEventArgs, string>
    {
        protected override IObservable<string> OnConvert(IObservable<MouseEventArgs> source) =>
            source
                // MouseEventArgs から GetPosition でマウスポインターの位置を取得(AssociateObject で EventTrigger を設定している要素が取得できる)
                .Select(x => x.GetPosition(AssociateObject as IInputElement))
                // ReactiveProperty に設定する文字列に加工
                .Select(x => $"({x.X}, {x.Y})");
    }

    public class MouseEventToStringDelegateConverter : DelegateConverter<MouseEventArgs, string>
    {
        protected override string OnConvert(MouseEventArgs source)
        {
            // MouseEventArgs から ReactiveProperty に設定する文字列に加工
            var pos = source.GetPosition(AssociateObject as IInputElement);
            return $"({pos.X}, {pos.Y})";
        }
    }
}

2 つのクラスは同じ処理をしています。ReactiveConverter は変換処理を Rx のメソッドチェーンで書けます。DelegateConverter は変換処理を普通の C# のメソッドとして書けます。
このコンバーターを使って View のイベントを ReactiveProperty や ReactiveCommand に伝搬させる先の ViewModel を作成します。今回は確認ようにシンプルに受け取ったメッセージを格納するための ReactiveProperty と、ReactiveCommand を用意しました。

using Reactive.Bindings;
using System.ComponentModel;
using System.Reactive.Linq;

namespace RxPropLabWpf
{
    // WPF のメモリリーク対策で INotifyPropertyChanged は実装しておく
    public class MainWindowViewModel : INotifyPropertyChanged
    {
        public event PropertyChangedEventHandler PropertyChanged;

        public ReactivePropertySlim<string> Message { get; }
        public ReactiveCommand<string> CommandFromViewEvents { get; }
        public ReadOnlyReactivePropertySlim<string> MessageFromCommand { get; }

        public MainWindowViewModel()
        {
            Message = new ReactivePropertySlim<string>();
            CommandFromViewEvents = new ReactiveCommand<string>();
            MessageFromCommand = CommandFromViewEvents.Select(x => $"Command: {x}")
                .ToReadOnlyReactivePropertySlim();
        }
    }
}

ReactiveCommand は実行されると受け取った文字を加工して MessageFromCommand という名前の ReadOnlyReactiveProperty に流しています。これを XAML にバインドします。EventToReactiveProperty と EventToReactiveCommand は EventTrigger の子要素として配置します。そして EventToReactiveProperty と EventToReactiveCommand の子要素としてコンバーターを指定します。

<Window x:Class="RxPropLabWpf.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:RxPropLabWpf"
        xmlns:behaviors="http://schemas.microsoft.com/xaml/behaviors"
        xmlns:rp="clr-namespace:Reactive.Bindings.Interactivity;assembly=ReactiveProperty.WPF"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800">
    <Window.DataContext>
        <!-- ViewModel を設定して -->
        <local:MainWindowViewModel />
    </Window.DataContext>
    <Grid>
        <Grid.ColumnDefinitions>
            <ColumnDefinition />
            <ColumnDefinition />
        </Grid.ColumnDefinitions>
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto" />
            <RowDefinition />
        </Grid.RowDefinitions>

        <TextBlock Text="ToReactiveProperty" />
        <Border Grid.Row="1"
                Background="Blue"
                Margin="10">
            <!-- MouseMove イベントを MouseEventToStringReactiveConverter で変換して ReactiveProperty に設定する -->
            <behaviors:Interaction.Triggers>
                <behaviors:EventTrigger EventName="MouseMove">
                    <rp:EventToReactiveProperty ReactiveProperty="{Binding Message}">
                        <local:MouseEventToStringReactiveConverter />
                    </rp:EventToReactiveProperty>
                </behaviors:EventTrigger>
            </behaviors:Interaction.Triggers>
            <TextBlock Text="{Binding Message.Value}" Foreground="White" />
        </Border>

        <TextBlock Text="ToReactiveCommand" 
                   Grid.Column="1" />
        <Border Grid.Row="1"
                Grid.Column="1"
                Background="Red"
                Margin="10">
            <!-- MouseMove イベントを MouseEventToStringReactiveConverter で変換して ReactiveCommand を実行する -->
            <behaviors:Interaction.Triggers>
                <behaviors:EventTrigger EventName="MouseMove">
                    <rp:EventToReactiveCommand Command="{Binding CommandFromViewEvents}">
                        <local:MouseEventToStringReactiveConverter />
                    </rp:EventToReactiveCommand>
                </behaviors:EventTrigger>
            </behaviors:Interaction.Triggers>
            <TextBlock Text="{Binding MessageFromCommand.Value}" Foreground="White" />
        </Border>
    </Grid>
</Window>

実行すると以下のようになります。EventToReactiveProperty はコンバーターで変換した結果がそのまま表示されています。EventToReactiveCommand のほうは、コマンドで加工したメッセージが表示されていることが確認できます。

rpc.gif

Notifiers

Reactive.Bindings.Notifiers 名前空間には、いくつかの IObservable を拡張したクラスがあります。

  • BooleanNotifier
  • CountNotifier
  • ScheduledNotifier
  • BusyNotifier
  • MessageBroker
  • AsyncMessageBroker

単品で見ると大したこと無いクラスですが、これらも IObservable なので ReactiveProperty や RreactiveCommand や ReactiveCollection とつないで使うことが出来ます。
とはいっても使用頻度は少なめなので、Notifier 関連の詳細はドキュメントを参照してください。

Notifiers | ReactiveProperty document

ここでは MessageBroker と AsyncMessageBroker を紹介します。

MessageBroker / AsyncMessageBroker

この 2 つのクラスはグローバルにメッセージを配信して購読するための機能を提供します。Prism でいう IEventAggregator が近い機能を提供しています。他にはメッセンジャー パターンなどと言われている機能を Rx フレンドリーに実装したものになります。

MessageBroker と AsyncMessageBroker は MessageBroker.DefaultAsyncMessageBroker.Default でシングルトンのインスタンスを取得できます。ただ、これはグローバルにメッセージを配信するユースケースが多いので利便性のために提供しているもので独自に new を使ってインスタンスを生成して使うことも可能です。

MessageBroker は ToObservable を呼ぶことで IObservable に変換できます。AsyncMessageBroker クラスは非同期処理に対応しています。AsyncMessageBroker は IObservable には変換できません。
使用方法を以下に示します。

using Reactive.Bindings.Notifiers;
using System;
using System.Reactive.Linq;
using System.Threading.Tasks;

public class MyClass
{
    public int MyProperty { get; set; }

    public override string ToString()
    {
        return "MP:" + MyProperty;
    }
}
class Program
{
    static void RunMessageBroker()
    {
        // global scope pub-sub messaging
        MessageBroker.Default.Subscribe<MyClass>(x =>
        {
            Console.WriteLine("A:" + x);
        });

        var d = MessageBroker.Default.Subscribe<MyClass>(x =>
        {
            Console.WriteLine("B:" + x);
        });

        // support convert to IObservable<T>
        MessageBroker.Default.ToObservable<MyClass>().Subscribe(x =>
        {
            Console.WriteLine("C:" + x);
        });

        MessageBroker.Default.Publish(new MyClass { MyProperty = 100 });
        MessageBroker.Default.Publish(new MyClass { MyProperty = 200 });
        MessageBroker.Default.Publish(new MyClass { MyProperty = 300 });

        d.Dispose(); // unsubscribe
        MessageBroker.Default.Publish(new MyClass { MyProperty = 400 });
    }

    static async Task RunAsyncMessageBroker()
    {
        // asynchronous message pub-sub
        AsyncMessageBroker.Default.Subscribe<MyClass>(async x =>
        {
            Console.WriteLine($"{DateTime.Now} A:" + x);
            await Task.Delay(TimeSpan.FromSeconds(1));
        });

        var d = AsyncMessageBroker.Default.Subscribe<MyClass>(async x =>
        {
            Console.WriteLine($"{DateTime.Now} B:" + x);
            await Task.Delay(TimeSpan.FromSeconds(2));
        });

        // await all subscriber complete
        await AsyncMessageBroker.Default.PublishAsync(new MyClass { MyProperty = 100 });
        await AsyncMessageBroker.Default.PublishAsync(new MyClass { MyProperty = 200 });
        await AsyncMessageBroker.Default.PublishAsync(new MyClass { MyProperty = 300 });

        d.Dispose(); // unsubscribe
        await AsyncMessageBroker.Default.PublishAsync(new MyClass { MyProperty = 400 });
    }

    static void Main(string[] args)
    {
        Console.WriteLine("MessageBroker");
        RunMessageBroker();

        Console.WriteLine("AsyncMessageBroker");
        RunAsyncMessageBroker().Wait();
    }
}

実行結果は以下のようになります。

MessageBroker
A:MP:100
B:MP:100
C:MP:100
A:MP:200
B:MP:200
C:MP:200
A:MP:300
B:MP:300
C:MP:300
A:MP:400
C:MP:400
AsyncMessageBroker
2020/08/02 10:59:39 A:MP:100
2020/08/02 10:59:39 B:MP:100
2020/08/02 10:59:41 A:MP:200
2020/08/02 10:59:41 B:MP:200
2020/08/02 10:59:43 A:MP:300
2020/08/02 10:59:43 B:MP:300
2020/08/02 10:59:45 A:MP:400

AsyncMessageBroker のほうは、2 秒ごとにログが出ているので await 出来ていることが確認できます。

各種拡張メソッド

IObservable 向けの便利な拡張メソッドをいくつか用意しています。使う場合は Reactive.Bindings.Extensions 名前空間を using してください。

ここでは特に使用頻度が高いと思うものだけを紹介します。完全なリストは以下のドキュメントを参照してください。

Extension methods | ReactiveProperty document

CombineLatestValuesAreAllTrue/CombineLatestValuesAreAllFalse

IEnumerable<IObservable<bool>> に対して最後の値がすべて true かどうか、もしくは false かどうかを表す bool を後続に流す IObservable<bool> に変換します。
例えば複数の ReactiveProperty の ObserveHasErros が全て false (エラーなし) になったら実行できるコマンドの生成などで便利です。以下のようになります。

// rp1, rp2, rp3 は ReactiveProperty 
SomeCommand = new[] // ReactiveProperty の ObserveHasErrors が
  {
    rp1.ObserveHasErrors,
    rp2.ObserveHasErrors,
    rp3.ObserveHasErrors,
  }
  .CombineLatestValuesAreAllFalse() // 全て false の場合に
  .ToReactiveCommand(); // 実行可能なコマンド

ObserveElementProperty

ObservableCollection<T> の型引数 T が INotifyPropertyChanged の場合に利用できる拡張メソッドです。ObservableCollection<T> の全ての要素の PropertyChanged イベントを監視できます。

コード例を以下に示します。

using Reactive.Bindings.Extensions;
using System;
using System.Collections.ObjectModel;
using System.ComponentModel;

namespace ReactivePropertyEduApp
{
    public class Person : INotifyPropertyChanged
    {
        public event PropertyChangedEventHandler PropertyChanged;
        private string _name;
        public string Name
        {
            get => _name;
            set
            {
                _name = value;
                PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Name)));
            }
        }
    }
    class Program
    {
        static void Main(string[] args)
        {
            var c = new ObservableCollection<Person>();
            c.ObserveElementProperty(x => x.Name)
                .Subscribe(x => Console.WriteLine($"Subscribe: {x.Instance}, {x.Property.Name}, {x.Value}"));

            var neuecc = new Person { Name = "neuecc" };
            var xin9le = new Person { Name = "xin9le" };
            var okazuki = new Person { Name = "okazuki" };

            Console.WriteLine("Add items");
            c.Add(neuecc);
            c.Add(xin9le);
            c.Add(okazuki);

            Console.WriteLine("Change okazuki name to Kazuki Ota");
            okazuki.Name = "Kazuki Ota";

            Console.WriteLine("Remove okazuki from collection");
            c.Remove(okazuki);

            Console.WriteLine("Change okazuki name to okazuki");
            okazuki.Name = "okazuki";
        }
    }
}

実行すると以下のようになります。

Add items
Subscribe: ReactivePropertyEduApp.Person, Name, neuecc
Subscribe: ReactivePropertyEduApp.Person, Name, xin9le
Subscribe: ReactivePropertyEduApp.Person, Name, okazuki
Change okazuki name to Kazuki Ota
Subscribe: ReactivePropertyEduApp.Person, Name, Kazuki Ota
Remove okazuki from collection
Change okazuki name to okazuki

コレクションにある要素のプロパティの変更が監視できていることがわかります。またコレクションから削除した要素(この場合は okazuki 変数)は削除後は変更してもコールバックが呼ばれていないことも確認できます。

コレクションの要素が POCO ではなく、ReactiveProperty を持つクラスの場合も ObserveElementObservableProperty 拡張メソッドを使うとコレクション内のオブジェクトの ReactiveProperty の監視を行えます。

using Reactive.Bindings;
using Reactive.Bindings.Extensions;
using System;
using System.Collections.ObjectModel;
using System.ComponentModel;

namespace ReactivePropertyEduApp
{
    public class Person
    {
        public ReactiveProperty<string> Name { get; }

        public Person(string name)
        {
            Name = new ReactiveProperty<string>(name);
        }
    }
    class Program
    {
        static void Main(string[] args)
        {
            var c = new ObservableCollection<Person>();
            c.ObserveElementObservableProperty(x => x.Name)
                .Subscribe(x => Console.WriteLine($"Subscribe: {x.Instance}, {x.Property.Name}, {x.Value}"));

            var neuecc = new Person("neuecc");
            var xin9le = new Person("xin9le");
            var okazuki = new Person("okazuki");

            Console.WriteLine("Add items");
            c.Add(neuecc);
            c.Add(xin9le);
            c.Add(okazuki);

            Console.WriteLine("Change okazuki name to Kazuki Ota");
            okazuki.Name.Value = "Kazuki Ota";

            Console.WriteLine("Remove okazuki from collection");
            c.Remove(okazuki);

            Console.WriteLine("Change okazuki name to okazuki");
            okazuki.Name.Value = "okazuki";
        }
    }
}

実行すると以下のようになります。

Add items
Subscribe: ReactivePropertyEduApp.Person, Name, neuecc
Subscribe: ReactivePropertyEduApp.Person, Name, xin9le
Subscribe: ReactivePropertyEduApp.Person, Name, okazuki
Change okazuki name to Kazuki Ota
Subscribe: ReactivePropertyEduApp.Person, Name, Kazuki Ota
Remove okazuki from collection
Change okazuki name to okazuki

IObservable<bool> の反転

Inverse 拡張メソッドを使うと ox.Select(x => !x)ox.Inverse() のように書けます。それだけ。

IObservable で最新の値以外を Dispose したい

IObservable の拡張メソッドとして DisposePreviousValue があります。
これを使うと、最新の値以外を自動的に Dispose 出来ます。

var source = new Subject<string>();
var rrp = source
    // 文字列から Dispose が必要なオブジェクトに変換
    .Select(x => new SomeDisposableClass(x))
    // 最新の値以外は Dispose する
    .DisposePreviousValue()
    // ReadOnlyReactivePropertySlim 化
    .ToReadOnlyReactivePropertySlim();

// first を元に SomeDisposableClass が作られる
source.OnNext("first");
// second を元に SomeDisposableClass が作られる
// first を元に作られた SomeDisposableClass は Dispose が呼ばれる
source.OnNext("second");
// OnComplete が呼ばれると最新の値に対して Dispose が呼ばれる
source.OnComplete();

await と使いたい

ReactiveProperty は Rx の機能を使ってメソッドチェーンが綺麗にきまると気持ちいいですが、やりすぎると可読性の低下や、知らない人にはトリッキーなコードになってしまうといった問題があります。
ReactiveProperty や async/await にも対応しているので、そちらを使って値の変更があったタイミングで処理を書くということも出来ます。ただ、現状まだちょっと非力です。

値が変わるまで await したい

WaitUntilValueChangedAsync メソッドで await で待つことが出来ます。コード例を以下に示します。

using Reactive.Bindings;
using Reactive.Bindings.Notifiers;
using System;
using System.Reactive.Linq;
using System.Threading.Tasks;

class Program
{
    static void Main(string[] args)
    {
        var rp = new ReactivePropertySlim<string>();

        _ = WaitAndOutputAsync(rp);

        rp.Value = "Hello world";
    }

    static async ValueTask WaitAndOutputAsync(IReactiveProperty<string> rp)
    {
        var value = await rp.WaitUntilValueChangedAsync();
        Console.WriteLine($"await してゲットした値: {value}");
    }
}

実行すると以下のような結果になります。

await してゲットした値: Hello world

コマンドも同様に await が可能です。

まとめ

ということで前編・中編・後編終わりました。
適当機能強化とかがあったら、ここを更新していこうと思います。

52
45
6

Register as a new user and use Qiita more conveniently

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