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); }