WPFでElm Architecture --- Xaml利用しない版 --- 可変個のコントロール

下記記事にてXamlを利用しないElm Architectureを実装しました。
WPFでElm Architecture --- Xaml利用しない版 - 何でもプログラミング

ただし、固定個のコントロールにしか対応していませんでした。

今回はGridなどのChildrenを動的に変更できるよう実装してみたいと思います。

アプリケーションコード

今回は可変個のカウンタを利用できるアプリケーションを作成してみます。
f:id:any-programming:20170712201450p:plain

動的にコントロールを作成するために、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を利用してコンパイルエラーにするのも可能かも知れません。