かずきのBlog@hatena

すきな言語は C# + XAML の組み合わせ。Azure Functions も好き。最近は Go 言語勉強中。日本マイクロソフトで働いていますが、ここに書いていることは個人的なメモなので会社の公式見解ではありません。

Reactive Extensions とか知らない人向けの ReactiveProperty のはじめかた その 2

blog.okazuki.jp

はじめに

最初の記事を受けて使い始めてきた人向けのちょっと Rx っぽい使い方を紹介します。

ReactiveProperty 値を加工して代入しているケース

ReactiveProperty に値を代入するケースがあると思います。例えば以下のような感じですね。

public ReactiveProperty<string> A { get; } = new ReactiveProperty<string>();

public ReactiveProperty<string> B { get; } = new ReactiveProperty<string>();


public コンストラクタ()
{
   A.Subscribe(x => B.Value = $"Value is {x}");
}

プロパティ A の値が変わったら値を加工してプロパティ B に代入するという流れです。上記のようなコードでも動きますが以下のように書くと Rx っぽくなります。

// using System.Reactive.Linq;

public ReactiveProperty<string> A { get; } = new ReactiveProperty<string>();
public ReactiveProperty<string> B { get; } // B はコンストラクタで初期化する

public コンストラクタ()
{
  B = A.Select(x => $"Value is {x}") // 加工して
    .ToReactiveProperty(); // 流れてきた値を保持するための ReactiveProperty にする
}

これで同じ意味になります。

読み取り専用?

因みに上記のケースでは B は A の値が変わったら自動的に更新されることが期待されてるので、大体はそれ以外のケースで書き換わると嫌なことが多いと思います。 そういうときは ToReadOnlyReactiveProperty か ToReadOnlyReactivePropertySlim が使えます。最初のが自動的に UI スレッドで PropertyChanged イベントを発行してくれるもので後者は、そういったお世話はしてくれないものになります。

早いのは Slim のほうです。

ということでこんな感じになります。

// using System.Reactive.Linq;

public ReactiveProperty<string> A { get; } = new ReactiveProperty<string>();
public ReadOnlyReactivePropertySlim<string> B { get; } // B はコンストラクタで初期化する

public コンストラクタ()
{
  B = A.Select(x => $"Value is {x}") // 加工して
    .ToReadOnlyReactivePropertySlim(); // 流れてきた値を保持するための ReadOnlyReactivePropertySlim にする
}

フィルタリングして値を変更

いやいや、単純に値を加工して代入するんじゃなくてフィルタリングしたりするんですよというケース。例えば以下のような感じ。

public ReactiveProperty<string> A { get; } = new ReactiveProperty<string>();

public ReactiveProperty<string> B { get; } = new ReactiveProperty<string>();


public コンストラクタ()
{
   A.Subscribe(x => 
  {
    if (x == null) { return; }
    return B.Value = $"Value is {x}"
  });
}

まさにそれ LINQ で出来るよ!案件です。

// using System.Reactive.Linq;
public ReactiveProperty<string> A { get; } = new ReactiveProperty<string>();

public ReadOnlyReactivePropertySlim<string> B { get; } 

public コンストラクタ()
{
  B = A.Where(x => x != null)
    .Select(x => $"Value is {x}")
    .ToReadOnlyReactivePropertySlim();
}

ばっちりですね。

複数のプロパティを見てる

いやいや、単一プロパティの値を加工してるんじゃなくて複数プロパティの値を見てやってるんですよ!というケース。

public ReactiveProperty<string> FirstName { get; }
public ReactiveProperty<string> LastName { get; }

public ReactiveProperty<string> FullName { get; }

public コンストラクタ()
{
    FirstName = new ReactiveProperty<string>();
    LastName = new ReactiveProperty<string>();
    FullName = new ReactiveProperty<string>();

    FirstName.Subscribe(_ => SetFullName());
    LastName.Subscribe(_ => SetFullName());
}

public void SetFullName()
{
  FullName.Value = $"{FirstName.Value} {LastName.Value}";
}

これは前記事で案内した CombineLatest の出番ですね。

// using System.Reactive.Linq;
public ReactiveProperty<string> FirstName { get; }
public ReactiveProperty<string> LastName { get; }

public ReadOnlyReactivePropertySlim<string> FullName { get; }

public コンストラクタ()
{
    FirstName = new ReactiveProperty<string>();
    LastName = new ReactiveProperty<string>();

    FullName = Observable.CombineLatest(
        FirstName,
        LastName,
        (firstName, lastName) => $"{firstName} {lastName}")
        .ToReadOnlyReactivePropertySlim();
}

いやいや、フィルタリングとかもしてるんですよ?とかもありますね。例えば

public ReactiveProperty<string> FirstName { get; }
public ReactiveProperty<string> LastName { get; }

public ReactiveProperty<string> FullName { get; }

public コンストラクタ()
{
    FirstName = new ReactiveProperty<string>();
    LastName = new ReactiveProperty<string>();
    FullName = new ReactiveProperty<string>();

    FirstName.Subscribe(_ => SetFullName());
    LastName.Subscribe(_ => SetFullName());
}

public void SetFullName()
{
  if (string.IsNullOrWhiteSpace(FirstName.Value) || string.IsNullOrWhiteSpace(LastName.Value)) 
  {
    return;
  }
  FullName.Value = $"{FirstName.Value} {LastName.Value}";
}

これはこんな感じ。

// using System.Reactive.Linq;
public ReactiveProperty<string> FirstName { get; }
public ReactiveProperty<string> LastName { get; }

public ReadOnlyReactivePropertySlim<string> FullName { get; }

public コンストラクタ()
{
    FirstName = new ReactiveProperty<string>();
    LastName = new ReactiveProperty<string>();

    FullName = Observable.CombineLatest(
        FirstName,
        LastName,
        (firstName, lastName) => (firstName: firstName, lastName: lastName))
        .Where(x => !string.IsNullOrWhiteSpace(x.firstName) && !string.IsNullOrWhiteSpace(x.lastName))
        .Select(x => $"{x.firstName} {x.lastName}")
        .ToReadOnlyReactivePropertySlim();
}

そもそもダメだったらダメって感じにしたいときのケース。

public ReactiveProperty<string> FirstName { get; }
public ReactiveProperty<string> LastName { get; }

public ReactiveProperty<string> FullName { get; }

public コンストラクタ()
{
    FirstName = new ReactiveProperty<string>();
    LastName = new ReactiveProperty<string>();
    FullName = new ReactiveProperty<string>();

    FirstName.Subscribe(_ => SetFullName());
    LastName.Subscribe(_ => SetFullName());
}

public void SetFullName()
{
  if (string.IsNullOrWhiteSpace(FirstName.Value) || string.IsNullOrWhiteSpace(LastName.Value)) 
  {
    FullName.Value = "Invalid";
    return;
  }
  FullName.Value = $"{FirstName.Value} {LastName.Value}";
}

これは、いろんなやり方がありますがダメな時の処理の流れと OK な時の処理の流れを別立てで考えると…

// using System.Reactive.Linq;
public ReactiveProperty<string> FirstName { get; }
public ReactiveProperty<string> LastName { get; }

public ReadOnlyReactivePropertySlim<string> FullName { get; }

public MainWindowViewModel()
{
    FirstName = new ReactiveProperty<string>();
    LastName = new ReactiveProperty<string>();

    var nameChanged = Observable.CombineLatest(
        FirstName,
        LastName,
        (firstName, lastName) => (firstName: firstName, lastName: lastName));

    var valid = nameChanged.Where(x => !string.IsNullOrWhiteSpace(x.firstName) && !string.IsNullOrWhiteSpace(x.lastName))
        .Select(x => $"{x.firstName} {x.lastName}");

    var invalid = nameChanged.Where(x => string.IsNullOrWhiteSpace(x.firstName) || string.IsNullOrWhiteSpace(x.lastName))
        .Select(_ => "Invalid");

    FullName = Observable.Merge(valid, invalid)
        .ToReadOnlyReactivePropertySlim();
}

こんな感じかなぁ。ただ、入力にエラーがあるかどうかとかはバリデーションとかを使って書いた方がすっきりしますね。

// using System.Reactive.Linq;
// using Reactive.Bindings.Extensions;
[Required(ErrorMessage = "required")]
public ReactiveProperty<string> FirstName { get; }
[Required(ErrorMessage = "required")]
public ReactiveProperty<string> LastName { get; }

public ReadOnlyReactivePropertySlim<string> FullName { get; }

public コンストラクタ()
{
    FirstName = new ReactiveProperty<string>()
        .SetValidateAttribute(() => FirstName);
    LastName = new ReactiveProperty<string>()
        .SetValidateAttribute(() => LastName);

    var isValidInput = new[] 
        {
            // Inverse は Select(x => !x) と同じ
            // つまりエラーが無い時に true になる
            FirstName.ObserveHasErrors.Inverse(),
            LastName.ObserveHasErrors.Inverse()
        }
        // 全てが true なら true になる。 CombineLatest(x => x.All(y => y)) のショートカット
        .CombineLatestValuesAreAllTrue()
        // つまりエラーが無い時 true になる ReactiveProperty の出来上がり
        .ToReadOnlyReactivePropertySlim();

    // FirstName と LastName と エラーの有無を見て FullName の値を加工
    FullName = Observable.CombineLatest(
        FirstName,
        LastName,
        isValidInput,
        (firstName, lastName, valid) => valid ? $"{firstName} {lastName}" : "Invalid")
        .ToReadOnlyReactivePropertySlim();
}

なんか LINQ 書いてる気がしてきますね。

まとめ

途中から、これはちょっとわからん…ってなっても問題ありません。 わかる範囲で使えばおk。

でも、わかろうとする努力は惜しんではいけない気がします。理解の幅がひろがればひろがるほど表現の幅が広がりますしね。

あと、チームでやるときはわからない人合わせるか、わからない人に対してある程度教育コストを払ってある程度理解してもらうか、腹をくくってめっちゃ教育コストを払ってフル機能を使うかの判断が入ると思うのでバランス感覚大事です。