WPFでElm Architecture --- Xaml利用しない版 --- 可変個のコントロール --- データによりコントロールの種類変更
下記記事にて可変個のコントロールを表示できるよう実装しました。
WPFでElm Architecture --- Xaml利用しない版 --- 可変個のコントロール - 何でもプログラミング
ただし、すべてのコントロールが同じで個数のみが可変であったため、今回は入力データによってコントロールが分岐するような実装をしてみたいと思います。
アプリケーションコード
ボタンを押すと対応した図形が追加され、図形上で左クリックでサイズが大きくなり、図形上で右クリックをすると削除されるようなアプリケーションを作成してみます。
itemsとselectorとfactoryが渡せるwith_Childrenを用いてコントロールの切り替えを実現しています。(with_Childrenの実装は後述)
コード内で利用されているLINQオペレータは下記記事を参照してください。
LINQ独自オペレータ メモ - 何でもプログラミング
図形モデル定義
interface IShape { double Size { get; } } class Circle : IShape { public double Size { get; } public Circle(double size) { Size = size; } } class Square : IShape { public double Size { get; } public Square(double size) { Size = size; } } class Triangle : IShape { public double Size { get; } public Triangle(double size) { Size = size; } }
アプリケーションモデル定義
class Model { public IShape[] Shapes { get; } public Model(IShape[] shapes) { Shapes = shapes; } }
モデル更新メッセージ定義
abstract class Message { } class AddCircle : Message { } class AddSquare : Message { } class AddTriangle : Message { } class IncrementSize : Message { public int Index { get; } public IncrementSize(int index) { Index = index; } } class RemoveShape : Message { public int Index { get; } public RemoveShape(int index) { Index = index; } }
モデル更新関数
public static Model Update(Model model, Message message) { switch (message) { case AddCircle msg: return new Model(model.Shapes.InsertLast(new Circle(30)).ToArray()); case AddSquare msg: return new Model(model.Shapes.InsertLast(new Square(30)).ToArray()); case AddTriangle msg: return new Model(model.Shapes.InsertLast(new Triangle(30)).ToArray()); case IncrementSize msg: { IShape increment(IShape src) => src is Circle ? new Circle(src.Size + 10) as IShape : src is Square ? new Square(src.Size + 10) as IShape : new Triangle(src.Size + 10) as IShape; return new Model(model.Shapes.UpdateAt(msg.Index, increment).ToArray()); } case RemoveShape msg: return new Model(model.Shapes.RemoveAt(msg.Index).ToArray()); default: throw new Exception("invalid message"); } }
View作成関数
enum Shape { Circle, Square, Triangle } public static FrameworkElement Create(ICell<Model> model, Action<Message> send) { return new StackPanel() .with_Margin(10, 10, 10, 10) .with_Children( new Button() .with_Content("Circle追加") .with_Margin(5, 5, 5, 5) .with_Click(() => send(new AddCircle())), new Button() .with_Content("Square追加") .with_Margin(5, 5, 5, 5) .with_Click(() => send(new AddSquare())), new Button() .with_Content("Triangle追加") .with_Margin(5, 5, 5, 5) .with_Click(() => send(new AddTriangle())), new StackPanel() .with_Children( model.Map(x => x.Shapes), item => item is Circle ? Shape.Circle : item is Square ? Shape.Square : Shape.Triangle, (id, item, index) => { var element = id == Shape.Circle ? new Ellipse().with_Fill(Brushes.Red) as FrameworkElement: id == Shape.Square ? new Rectangle().with_Fill(Brushes.Green) as FrameworkElement: new Path().with_Data(Geometry.Parse("M0,100 H100 L50,0 Z")).with_Stretch(Stretch.Fill).with_Fill(Brushes.Blue) as FrameworkElement; return element .with_Margin(5, 5, 5, 5) .with_Width(item.Map(x => x.Size)) .with_Height(item.Map(x => x.Size)) .with_MouseLeftButtonDown(x => send(new IncrementSize(index))) .with_MouseRightButtonDown(x => send(new RemoveShape(index))); } ) ); }
with_Children
固定コントロールの場合のwith_Childrenと比べて、入力データからコントロールの識別子を返すselector関数が追加され、factoryの引数にも識別子が渡されるようになっています。
新しいデータのコントロール識別子に変更がなければそのままデータを送出し、識別子が異なる場合はコントロールを作り直しています。
もっと賢い差分アルゴリズムを利用すると、コントロールの生成が最小限に抑えられると思います。
public static TPanel with_Children<TPanel, TItem, TViewID>( this TPanel element, ICell<TItem[]> items, Func<TItem, TViewID> selector, Func<TViewID, ICell<TItem>, int, FrameworkElement> factory) where TPanel : Panel { List<CellSink<TItem>> sinks = new List<CellSink<TItem>>(); List<TViewID> viewIDs = new List<TViewID>(); element.Children.Clear(); items.Listen(xs => { for (int i = 0; i < xs.Length; ++i) { // コントロールが存在 if (i < sinks.Count) { TViewID viewID = selector(xs[i]); // ViewID変更なし if (Equals(viewID, viewIDs[i])) { sinks[i].Send(xs[i]); } // ViewIDが変わった else { sinks[i] = new CellSink<TItem>(xs[i]); viewIDs[i] = viewID; element.Children.RemoveAt(i); element.Children.Insert(i, factory(viewID, sinks[i], i)); } } // コントロールが足りない else { sinks.Add(new CellSink<TItem>(xs[i])); viewIDs.Add(selector(xs[i])); element.Children.Add(factory(viewIDs.Last(), sinks.Last(), i)); } } // コントロールが過剰 for (int i = 0; i < sinks.Count - xs.Length; ++i) { sinks.RemoveAt(sinks.Count - 1); viewIDs.RemoveAt(viewIDs.Count - 1); element.Children.RemoveAt(element.Children.Count - 1); } }); return element; }
factoryの外側のスコープのCellを利用する場合
そのままCellを利用するとリークを起こしてしまうので、別途追加のCellを受け取るwith_Childrenが必要となります。
下記記事にても同様の実装がありますので、こちらを参照してください。
WPFでElm Architecture --- Xaml利用しない版 --- 可変個のコントロール - 何でもプログラミング
Throttle(一定時間リクエストがなければ実行をする)の実装
スライダーなどから大量のリクエストが送られてきたときに、全て処理する必要がない場合、一定時間リクエストがなければ最新の値で処理を行うことがあります。
ReactiveExtensionにもThrottleオペレータは用意されていますので、普段はこれを利用するので問題ないと思います。
GitHub - Reactive-Extensions/Rx.NET: The Reactive Extensions for .NET
今回はThrottle処理を行うクラスを実装してみたいと思います。
Throttleクラス
Delayで処理を遅らせたTaskを利用します。
リクエストが来たら、現在のTaskをキャンセルして新しいTaskを走らせる、とても簡単な内容になっています。
public class Throttle { CancellationTokenSource _cancel = new CancellationTokenSource(); int _milliseconds; public Throttle(int milliseconds) => _milliseconds = milliseconds; public void Post(Action action) { // 現在のTaskをキャンセル _cancel.Cancel(); _cancel = new CancellationTokenSource(); // 指定時間待ったあと処理を行う Task.Delay(_milliseconds, _cancel.Token) .ContinueWith(task => action(), _cancel.Token); } }
動作確認
スライダーを動かすと、値がテキストに反映されるアプリを作成してみます。
スライダーを動かしてから1秒以内にまたスライダーを動かす限り、テキストが更新されません。
現状Postは別スレッドで実行されるため、InvokeAsyncでUIスレッドでテキストを更新しています。
var throttle = new Throttle(1000); slider.ValueChanged += (s, e) => throttle.Post(() => textblock.Dispatcher.InvokeAsync(() => textblock.Text = ((int)e.NewValue).ToString()));
WPFでElm Architecture --- Xaml利用しない版 --- 可変個のコントロール
下記記事にてXamlを利用しないElm Architectureを実装しました。
WPFでElm Architecture --- Xaml利用しない版 - 何でもプログラミング
ただし、固定個のコントロールにしか対応していませんでした。
今回はGridなどのChildrenを動的に変更できるよう実装してみたいと思います。
アプリケーションコード
今回は可変個のカウンタを利用できるアプリケーションを作成してみます。
動的にコントロールを作成するために、with_ChildrenにitemsのCellとFactoryを渡せるよう実装してあります。
利用されているLINQのオペレータは下記記事を参照してください。
LINQ独自オペレータ メモ - 何でもプログラミング
class Model { public int[] Counts { get; } public Model(int[] counts) { Counts = counts; } }
abstract class Message { } class AddCounter : Message { } class RemoveCounter : Message { } class Increment : Message { public int Index { get; } public Increment(int index) { Index = index; } } class Decrement : Message { public int Index { get; } public Decrement(int index) { Index = index; } } class Updater { public static Model Update(Model model, Message message) { switch (message) { case AddCounter msg: return new Model(model.Counts.InsertAt(model.Counts.Length, 0).ToArray()); case RemoveCounter msg: return new Model(model.Counts.RemoveAt(model.Counts.Length - 1).ToArray()); case Increment msg: return new Model(model.Counts.UpdateAt(msg.Index, x => x + 1).ToArray()); case Decrement msg: return new Model(model.Counts.UpdateAt(msg.Index, x => x - 1).ToArray()); default: throw new Exception("invalid message"); } } }
public static FrameworkElement Create(ICell<Model> model, Action<Message> send) { return new StackPanel() .with_Margin(10, 10, 10, 10) .with_Children( new Button() .with_Content("カウンタ追加") .with_Margin(5, 5, 5, 5) .with_Click(() => send(new AddCounter())), new Button() .with_Content("カウンタ削除") .with_Margin(5, 5, 5, 5) .with_Click(() => send(new RemoveCounter())), new StackPanel() .with_Children(model.Map(x => x.Counts), (count, index) => { return new Grid() .with_Margin(5, 5, 5, 5) .with_ColumnDefinitions( new ColumnDefinition() { Width = new GridLength(50) }, new ColumnDefinition() { Width = new GridLength(1, GridUnitType.Star) }, new ColumnDefinition() { Width = new GridLength(50) } ) .with_Children( new MyButton() .with_GridColumn(0) .with_Content("-") .with_Click(() => send(new Decrement(index))), new TextBlock() .with_GridColumn(1) .with_HorizontalAlignment(HorizontalAlignment.Center) .with_VerticalAlignment(VerticalAlignment.Center) .with_Text(count.Map(x => x.ToString())), new Button() .with_GridColumn(2) .with_Content("+") .with_Click(() => send(new Increment(index))) ); }) ); }
with_Children
itemsの要素数が変動した時のみコントロールの追加、削除を行うよう実装してあります。
CellSinkとコントロールは1対1で準備されるため、コントロールを削除しても問題なくガーベッジコレクション対象となります。
public static TPanel with_Children<TPanel, TItem>( this TPanel element, ICell<TItem[]> items, Func<ICell<TItem>, int, FrameworkElement> factory) where TPanel : Panel { List<CellSink<TItem>> sinks = new List<CellSink<TItem>>(); element.Children.Clear(); items.Listen(xs => { for (int i = 0; i < xs.Length; ++i) { // コントロールが存在 : CellSink.Send if (i < sinks.Count) { sinks[i].Send(xs[i]); } // コントロールが足りない : CellSink、コントロール作成 else { sinks.Add(new CellSink<TItem>(xs[i])); element.Children.Add(factory(sinks.Last(), i)); } } // コントロールが過剰 : CellSink、コントロール削除 for (int i = 0; i < sinks.Count - xs.Length; ++i) { sinks.RemoveAt(sinks.Count - 1); element.Children.RemoveAt(element.Children.Count - 1); } }); return element; }
factoryの外側のスコープのCellを利用するとリーク発生
今回の例では、factory内でCellは引数のcountしか利用しませんでした。
.with_Text(count.Map(x => x.ToString()))
もし下記のようにスコープ外のCellを利用した場合、コントロールを削除してもCellのコールバック内に参照が残り続けるため、メモリリークが発生してしまいます。
.with_Text(model.Map(x => x.Counts.Length.ToString()))
追加のCellを受け取るwith_Children
関数の引数とfactoryの引数に、新たにCellを1つ追加してあります。
各コントロールごとにCellSinkを用意することにより、本体のCellのほうにコントロールの参照が残らないようにしています。
parameterを2つ、3つ渡したい場合は、同様の実装方法でwith_Children関数を追加してください。
public static TPanel with_Children<TPanel, TItem, TParam>( this TPanel element, ICell<TItem[]> items, ICell<TParam> parameter, Func<ICell<TItem>, ICell<TParam>, int, FrameworkElement> factory) where TPanel : Panel { List<CellSink<TItem>> sinks = new List<CellSink<TItem>>(); List<CellSink<TParam>> parameters = new List<CellSink<TParam>>(); parameter.Listen(x => parameters.ForEach(sink => sink.Send(x))); element.Children.Clear(); items.Listen(xs => { for (int i = 0; i < xs.Length; ++i) { if (i < sinks.Count) { sinks[i].Send(xs[i]); } else { sinks.Add(new CellSink<TItem>(xs[i])); parameters.Add(new CellSink<TParam>(parameter.Value)); element.Children.Add(factory(sinks.Last(), parameters.Last(), i)); } } for (int i = 0; i < sinks.Count - xs.Length; ++i) { sinks.RemoveAt(sinks.Count - 1); parameters.RemoveAt(parameters.Count - 1); element.Children.RemoveAt(element.Children.Count - 1); } }); return element; }
外側のCellを利用するのを防ぐには
リークのしないwith_Childrenを用意しても、外部のCellを利用する記述を禁止すること自体はできません。
対策例として、factoryのキャプチャ変数の型を確認して、ICellが存在したら例外を出す方法があげられます。
if (factory.Target != null && factory.Target.GetType().GetFields().Any(t => t.FieldType.IsGenericType && t.FieldType.GetGenericTypeDefinition() == typeof(ICell<>))) throw new Exception("factory can't capture ICell");
検討はしていませんが、Roslynを利用してコンパイルエラーにするのも可能かも知れません。
WPFでElm Architecture --- Xaml利用しない版
下記記事にて、ViewModelまでをElm Architectureで実装し、Xamlとバインディングしてアプリケーションを作成する方法を紹介しました。
Elm Architectureを利用したMVVM --- C# WPF - 何でもプログラミング
今回はXaml部分をC#側で実装したものを作成してみたいと思います。
メリットとしては、型チェックや、IDEのC#サポートが利用できるようになり、複雑な挙動を実装しやすくなることです。
デメリットとしては、C#を使える人でないとViewが作れなくなることです。
Virtual-DOMは見送り
Elmでは、Viewの作成でVirtual-DOMを作成すると、フレームワーク側で現在のVirtual-DOMと比較して差分のみを更新してくれます。
この機能を実装するにはかなり大きな作業となってしまいますので、今回はBindingのような機構を利用して実装することにします。
アプリケーションコード
今回も下記のようなカウンタアプリケーションを作成します。
とりあえず最終的なアプリケーションコードを記載します。
構成は下記のようになります。
Model | 状態を保持するクラス |
Updater.Update | 現在のModelとMessageから新しいModelを作成する |
View.Create | ModelのCellとsend(Message)関数からViewを作成する |
CellはFunctional Rective Programming(FRP)でのBehaviorを意図しており、現在の値の保持と、変更されたときに通知を行うクラスだと考えてください。(詳しくは下記記事参照)
SodiumでFunctional Reactive Programming (F#) --- 導入 - 何でもプログラミング
Cellを利用することにより、値が変更されたときに勝手にUIの値が更新され、Bindingと同じ機能を実現できます。
class Model { public int Count { get; } public Model(int count) { Count = count; } }
abstract class Message { } class Increment : Message { } class Decrement : Message { } class Updater { public static Model Update(Model model, Message message) { switch (message) { case Increment msg: return new Model(model.Count + 1); case Decrement msg: return new Model(model.Count - 1); default: throw new Exception("invalid message"); } } }
class View { public static FrameworkElement Create(ICell<Model> model, Action<Message> send) { return new Grid() .with_Margin(10, 10, 10, 10) .with_ColumnDefinitions( new ColumnDefinition() { Width = new GridLength(50) }, new ColumnDefinition() { Width = new GridLength(1, GridUnitType.Star) }, new ColumnDefinition() { Width = new GridLength(50) } ) .with_Children( new Button() .with_GridColumn(0) .with_Content("-") .with_Click(() => send(new Decrement())), new TextBlock() .with_GridColumn(1) .with_HorizontalAlignment(HorizontalAlignment.Center) .with_VerticalAlignment(VerticalAlignment.Center) .with_Text(model.Map(x => x.Count.ToString())), new Button() .with_GridColumn(2) .with_Content("+") .with_Click(() => send(new Increment())) ); } }
Content = WpfEx.Create<Model, Message>(new Model(0), Updater.Update, View.Create);
Cell
SodiumというFRPのライブラリに、ずばりそのもののクラスが存在しますので、NuGetで取得して利用することも可能です。
今回は自前で準備してみます。
Listenした後、コールバックを解除する機能は実装してありませんので、利用する際はリークに注意してください。(静的にViewが記述されている限り問題にはならないと思います。)
public interface ICell<T> { T Value { get; } void Listen(Action<T> f); ICell<U> Map<U>(Func<T, U> f); }
public class CellSink<T> : ICell<T> { List<Action<T>> _callbacks = new List<Action<T>>(); public T Value { get; private set; } public CellSink(T initialValue) => Value = initialValue; public void Listen(Action<T> f) { f(Value); _callbacks.Add(f); } public ICell<U> Map<U>(Func<T, U> f) { var cell = new CellSink<U>(f(Value)); _callbacks.Add(x => cell.Send(f(x))); return cell; } public void Send(T value) { if (Equals(value, Value)) return; Value = value; foreach (var f in _callbacks) f(value); } }
with_ 関数群
Viewの記述を宣言的なものに近づけるため、各プロパティやイベントなどに対し、拡張関数を定義します。
例えばHorizontalAlignemtに固定値を割り当てる拡張関数は、下記のようになります。
public static T with_HorizontalAlignment<T>(this T element, HorizontalAlignment alignment) where T : FrameworkElement { element.HorizontalAlignment = alignment; return element; }
イベントですと下記のようになります。
public static Button with_Click(this Button element, Action f) { element.Click += (s, e) => f(); return element; }
また、Cellを受け取る(Binding)場合は、下記のようになります。
public static TextBlock with_Text(this TextBlock element, ICell<string> text) { text.Listen(x => element.Text = x); return element; }
その他、Childrenは下記のようになります。
public static T with_Children<T>(this T element, params FrameworkElement[] children) where T : Panel { element.Children.Clear(); children.ForEach(x => element.Children.Add(x)); return element; }
対象が膨大にあることと、一つの対象に対し固定値とCellを用意しなければならないため、実際に準備する際はReflectionなどを利用してコードを自動生成するのがいいと思います。
Create
Model、Updater.Update、View.Createから、実際に動作するFrameworkElementを作成する関数になります。
updatingフラグを用意することにより、View更新中にsendが送られてくるのを防いでいます。(SliderのValueをModel側から変更した際、ValueChangedでUIから更新メッセージが飛んでこないようにする等)
public static FrameworkElement Create<TModel, TMessage>( TModel initialModel, Func<TModel, TMessage, TModel> update, Func<ICell<TModel>, Action<TMessage>, FrameworkElement> createView) { var cell = new CellSink<TModel>(initialModel); bool updating = false; Action<TMessage> send = message => { if (updating == false) { updating = true; cell.Send(update(cell.Value, message)); updating = false; } }; return createView(cell, send); }
既存のDependencyPropertyの変更を検知する
自前でDependencyPropertyを実装する場合は、Register時にPropertyMetadataを渡すことで、値の変更時にコールバックを呼び出すことが可能となります。
しかし既存のDependencyPropertyの場合はすでにRegisterされているため、変更を検知するには別のアプローチが必要となります。
今回は2つのアプローチを実装してみたいと思います。
なお実用上は、既存のコントロールには対応するChangedイベントが用意されているため(ActualWidthならSizeChangedなど)、DependencyPropertyの変更の検知が必要なケースはほとんどないと思います。
DependencyPropertyDescriptor
RectangleとTextBlockがあり、RectangleのActualWidthがTextBlockに表示される例を考えます。
FromPropertyでDependencyPropertyDescriptorを取得し、コールバックをAddValueChangedで登録しています。
var desc = DependencyPropertyDescriptor.FromProperty(Rectangle.ActualWidthProperty, typeof(Rectangle));
desc.AddValueChanged(rectangle, (s, e) => textblock.Text = rectangle.ActualHeight.ToString());
この方法の場合、RemoveValueChangedを呼び出さない限りコントロールの参照が持ち続けられるので、コントロールを動的に削除したりする時には注意が必要です。
DependencyObjectにBindingして検知
ターゲットのDependencyPropertyにBindingして変更を検知する仲介クラス、DependencyPropertyChangedを定義してみました。
仲介用のValueプロパティを持っており、値が変化したときに登録したコールバックを呼ぶようになっています。
またConditionalWeakTableにターゲットのコントロールと共に保持することにより、ターゲットと同じ寿命になるようにしてあります。
public class DependencyPropertyChanged<T> : DependencyObject { public T Value { get { return (T)GetValue(ValueProperty); } set { SetValue(ValueProperty, value); } } public static readonly DependencyProperty ValueProperty = DependencyProperty.Register("Value", typeof(T), typeof(DependencyPropertyChanged<T>), new PropertyMetadata(default(T), (obj, e) => (obj as DependencyPropertyChanged<T>).OnValueChanged())); static ConditionalWeakTable<DependencyObject, List<DependencyObject>> _instances = new ConditionalWeakTable<DependencyObject, List<DependencyObject>>(); Action<T> _changed; public DependencyPropertyChanged(DependencyObject target, string path, Action<T> changed) { _changed = changed; _instances.GetOrCreateValue(target).Add(this); BindingOperations.SetBinding(this, DependencyPropertyChanged<double>.ValueProperty, new Binding(path) { Source = target, Mode = BindingMode.OneWay }); } void OnValueChanged() => _changed(Value); }
new DependencyPropertyChanged<double>(rectangle, nameof(rectangle.ActualWidth), x => textboxWidth.Text = x.ToString());
この方法であれば、コントロールを動的に削除しても参照が残り続けることはなくなります。
スライダー等からの大量リクエストの、最新値のみを別スレッド処理する
下記のような、スライダーの存在するアプリケーションを作成し、スライダーの値が変わる度に何かの処理をするとします。
もしこの処理が重たい場合、普通に実装しただけでは、UIのレスポンスが悪くなってしまいます。
slider.ValueChanged += (s, e) => Thread.Sleep(1000); // 仮想的な重い処理
そこで処理を別スレッドに移動するのですが、下記のように実装してしまうと、大量のスレッドが走ってしまうこととなります。
slider.ValueChanged += (s, e) =>
Task.Run(() => Thread.Sleep(1000));
スライダーの最新の値の処理結果しか利用しない場合、全リクエストを処理するのは無駄になります。
今回はこのような場合に利用できるクラスを実装してみたいと思います。
LatestTaskWorker
処理を行うスレッドが一つ存在し、スレッドが利用可能になったときに、PostされたActionのうち最新のものを処理するよう実装してあります。
BlockingCollectionを利用すると、lockを自前で書く必要がなく、要素が存在しない間はRead側がブロックされるので便利です。
Post時に既存のActionを削除することにより、最新のActionのみ処理されるようにしてあります。
class LatestTaskWorker { BlockingCollection<Action> _actions = new BlockingCollection<Action>(); public LatestTaskWorker() => Task.Run(() => { // 要素が存在しない場合はブロックされる foreach (var action in _actions.GetConsumingEnumerable()) action(); }); public void Post(Action action) { // 既存のActionを削除(実際はTryTake一回で問題ないと思います) while (_actions.TryTake(out var a)) { } _actions.Add(action); } }
利用例
下記の様に実装すると、スレッドが利用可能になった時点で最後にPostされたActionを処理するようになります。
これにより、UIをフリーズさせることなく、可能な限り処理を行いつつ、最新値は必ず処理されるようになります。
var worker = new LatestTaskWorker(); slider.ValueChanged += (s, e) => { worker.Post(() => { Thread.Sleep(1000); Console.WriteLine($"{e.NewValue}"); }); };
安全なスレッド終了対応
このクラスを利用した状態でアプリケーションを閉じると、処理中のActionは強制終了させられてしまいます。
きちんと処理の終了を待ちたい時用にFinish関数を追加しました。
アプリケーション終了前にFinishを呼べば、処理が終了するのを待つことが可能です。
class LatestTaskWorker { BlockingCollection<Action> _actions = new BlockingCollection<Action>(); Task _task; public LatestTaskWorker() => _task = Task.Run(() => { foreach (var action in _actions.GetConsumingEnumerable()) action(); }); public void Post(Action action) { while (_actions.TryTake(out var a)) { } _actions.Add(action); } public void Finish() { _actions.CompleteAdding(); _task.Wait(); } }
Functional Reactive Programming + MVVMでのUserControl --- C# WPF
下記記事にてFRPライブラリのSodiumとWPFを連携させてみました。
Functional Reactive Programming + MVVM --- C# WPF - 何でもプログラミング
今回はUserControlでも利用できるよう実装してみたいと思います。
アプリケーションコード
いつも通り、カウンタアプリケーションを実装します。
Main側は純粋にValueの保存と更新を行うのみの実装になっています。
引き続きCounterコントロールを実装していきます。
<Window x:Class="WpfApp.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:local="clr-namespace:WpfApp" Title="MainWindow" Height="100" Width="200"> <Grid> <local:Counter Value="{Binding Value}" ValueChanged="{Binding SetValue}" Margin="10" Height="23" /> </Grid> </Window>
class ViewModel { public DiscreteCell<int> Value { get; } public ViewModel(Stream<int> setValue) => Value = setValue.Hold(0); }
public partial class MainWindow : Window { public MainWindow() { InitializeComponent(); DataContext = new DataContext<ViewModel>(); } }
Counterコントロール
通常のロジックに対し、DependencyPropertyからのDiscreteCellの入力への追加、ICommandへのイベント通知の追加を行います。
それに伴う、DataContextクラスの拡張、DependencyPropertyクラスの作成、Listen拡張メソッドの作成は、後述いたします。
<UserControl x:Class="WpfApp.Counter" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" mc:Ignorable="d" d:DesignHeight="23" d:DesignWidth="200"> <Grid Height="23"> <Grid.ColumnDefinitions> <ColumnDefinition Width="23"/> <ColumnDefinition Width="*"/> <ColumnDefinition Width="23"/> </Grid.ColumnDefinitions> <Button Command="{Binding Decrement}" Content="◀" Grid.Column="0" /> <TextBox Text="{Binding Count}" Grid.Column="1" IsReadOnly="True" TextAlignment="Center" VerticalContentAlignment="Center" /> <Button Command="{Binding Increment}" Content="▶" Grid.Column="2" /> </Grid> </UserControl>
class CounterVM { List<IListener> listeners = new List<IListener>(); public DiscreteCell<int> Count { get; } public CounterVM(UserControl1 uc, Stream<Unit> increment, Stream<Unit> decrement) { Count = _value.Cell(uc); increment.MapTo(1).OrElse(decrement.MapTo(-1)).Snapshot(Count, (dx, x) => x + dx) .Listen(listeners, () => uc.ValueChanged); } }
public partial class Counter: UserControl { static DependencyProperty<Counter, int> _value = new DependencyProperty<Counter, int>(); public static readonly DependencyProperty ValueProperty = _value.Register(); public int Value { get => _value.Get(this); set => _value.Set(this, value); } static DependencyProperty<Counter, ICommand> _valueChanged = new DependencyProperty<Counter, ICommand>(); public static readonly DependencyProperty ValueChangedProperty = _valueChanged.Register(); public ICommand ValueChanged { get => _valueChanged.Get(this); set => _valueChanged.Set(this, value); } public Counter() { InitializeComponent(); ((FrameworkElement)Content).DataContext = new DataContext<CounterVM>(this); } }
DependencyProperty
通常のRegister、Get、Setのほかに、DescreteCellが取得できるようになっています。
DescreteCellがきちんと解放されるよう、インスタンスはConditionalWeakTableで管理しています。
今回はViewModelからの通知を、DescreteCellSinkにPostSendしています。(ViewModel側もSodiumを利用している場合、Listenの中でSendを呼ぶ可能性があり、その場合例外で止まるので、それを避けるため。)
もしFRPの回路を途切らせたくない場合は、型をDescreteCell
public class DependencyProperty<TClass, TValue> where TClass : DependencyObject { DependencyProperty _property; ConditionalWeakTable<TClass, DiscreteCellSink<TValue>> _sinks = new ConditionalWeakTable<TClass, DiscreteCellSink<TValue>>(); public DependencyProperty Register(TValue defaultValue = default(TValue), [CallerMemberName]string name = "") { name = name.Substring(0, name.Length - "Property".Length); var metadata = new PropertyMetadata(defaultValue, (obj, args) => { if (_sinks.TryGetValue((TClass)obj, out var sink)) Transaction.Post(() => sink.Send((TValue)args.NewValue)); }); _property = DependencyProperty.Register(name, typeof(TValue), typeof(TClass), metadata); return _property; } public TValue Get(TClass obj) => (TValue)obj.GetValue(_property); public void Set(TClass obj, TValue value) => obj.SetValue(_property, value); public DiscreteCell<TValue> Cell(TClass obj) => _sinks.GetValue(obj, x => new DiscreteCellSink<TValue>(Get(x))); }
Listen拡張メソッド
StreamとICommandを結びつける関数です。
ViewModel側がSodiumを利用している可能性があるため、Transaction.PostでExecuteしています。(理由はDependencyPropertyクラスの時と同じです。)
FRP回路を途切らせたくない場合はICommandではなく、型をDiscreteCell
public static void Listen<T>(this Stream<T> source, List<IListener> listeners, Func<ICommand> getCommand) => listeners.Add(source.Listen(x => Transaction.Post(() => getCommand()?.Execute(x))));
DataContext
引数に好きな値を設定できるように修正しています。
public DataContext(params object[] preArgs) { var constructor = typeof(T).GetConstructors().Single(); var args = preArgs.Concat(constructor.GetParameters().Skip(preArgs.Length).Select(parameter => ...