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を利用してコンパイルエラーにするのも可能かも知れません。