34
20

Cysharp/ObservableCollectionsの紹介とUnityでの使い方考察

Last updated at Posted at 2022-12-04

はじめに

今回のサンプルコードについては次のリポジトリで公開しています。

また今回の内容は2.0.0のバージョンを基準に執筆しました。

ObservableCollectionsとは

ObservableCollectionsとは、Cysharp社が提供するMITライセンスのOSSライブラリです。
コレクション(複数データをまとめて扱う機構)をObservableとして扱え、さらにViewとの連動機能も提供しているライブラリとなっています。
文脈としては.NETのライブラリとしてObservableCollection<T>が存在し、それをベースにさらに機能追加ライブラリがこのObservableCollectionsです。

Unityユーザ向けに、すごく簡単にまとめると次になります。

  • UniRxReactiveCollection<T>やReactiveDictionary`の強化版

    • UniRxの提供するObservableなコレクションと比較するとView(UI)を組みやすくなっている
  • R3との連携機能もあるため、R3には存在しないReactiveDictionaryなどの代替として利用できる

Unityでの活用

ObservableCollections自体はUnityの環境下でも用いることができ、Viewの構築に利用することもできます。

ですが、もともとObservableCollectionsMVVMパターンでの利用を想定しています。
しかしUnityにはMVVMパターンを組むような機構は用意されていません。
そのためUnityでObservableCollectionsを用いてViewを組む場合は手で諸々を用意する必要があります。

UnityでObservableCollectionsを用いたViewの構築例については後に述べます。

導入方法

NugetForUnity経由での導入してください。

  1. NugetForUnityを自身のUnityプロジェクトに導入する
  2. UnityEditor上部のメニュー [Nuget] -> [Manage Nuget Package]を開く
  3. Search欄にObservableCollectionsと入力し、Cysharpが提供するObservableCollectionsパッケージを導入する
  4. R3と連携する場合はObservableCollections.R3も同時に導入する

ObservableCollectionsが提供するObservableなコレクション一覧

ObservableCollectionsは次のObservableなコレクションを提供しています。

  • ObservableList<T>
  • ObservableHashSet<T>
  • ObservableDictionary<TKey, TValue>
  • ObservableStack<T>
  • ObservableQueue<T>
  • ObservableRingBuffer<T>
  • ObservableFixedSizeRingBuffer<T>

それぞれObservable+ベースとなっているコレクションなので、名前から挙動が類推できます。
これらのオブジェクトはIObservableCollection<T>を実装しており、このインタフェースを介することでコレクションの変動を検知することができるようになっています。

IObservableCollection<T>

IObservableCollection<T>ObservableCollectionsが提供するコレクションが実装しているインタフェースです。

eventとしてNotifyCollectionChangedEventHandler<T>が定義されており、こちらを用いることでコレクションの変動を検知することができます。

// MIT License 
// Copyright (c) 2021 Cysharp, Inc.
// https://github.com/Cysharp/ObservableCollections/blob/master/LICENSE

public interface IObservableCollection<T> : IReadOnlyCollection<T>, IEnumerable<T>, IEnumerable
{
    event NotifyCollectionChangedEventHandler<T> CollectionChanged;

    object SyncRoot { get; }

    ISynchronizedView<T, TView> CreateView<TView>(
        Func<T, TView> transform,
        bool reverse = false);
}
/// <summary>
/// Contract:
///     IsSingleItem ? (NewItem, OldItem) : (NewItems, OldItems)
///     Action.Add
///         NewItem, NewItems, NewStartingIndex
///     Action.Remove
///         OldItem, OldItems, OldStartingIndex
///     Action.Replace
///         NewItem, NewItems, OldItem, OldItems, (NewStartingIndex, OldStartingIndex = samevalue)
///     Action.Move
///         NewStartingIndex, OldStartingIndex
///     Action.Reset
///         -
/// </summary>
[StructLayout(LayoutKind.Auto)]
public readonly ref struct NotifyCollectionChangedEventArgs<T>
{
    public readonly NotifyCollectionChangedAction Action;
    public readonly bool IsSingleItem;
    public readonly T NewItem;
    public readonly T OldItem;
    public readonly ReadOnlySpan<T> NewItems;
    public readonly ReadOnlySpan<T> OldItems;
    public readonly int NewStartingIndex;
    public readonly int OldStartingIndex;

    // 以下略...
}

動作例

例としてObservableFixedSizeRingBuffer<T>に要素を追加し、イベントがどのように発行されるのかを見てみます。

using System;
using System.Collections.Specialized;
using ObservableCollections;
using UnityEngine;

namespace Samples
{
    public class ObservableFixedSizeRingBufferSample : MonoBehaviour
    {
        private ObservableFixedSizeRingBuffer<int> _ringBuffer;

        private void Start()
        {
            // Capacity:3 の固定長RingBufferとして生成する
            _ringBuffer = new ObservableFixedSizeRingBuffer<int>(3);

            // コレクション変動イベントを購読
            // NotifyCollectionChangedEventArgs が要素として渡ってくる
            _ringBuffer.CollectionChanged += (in NotifyCollectionChangedEventArgs<int> args) =>
            {
                switch (args.Action)
                {
                    case NotifyCollectionChangedAction.Add:
                        Debug.Log($"Add:[{args.NewStartingIndex}] = {args.NewItem}");
                        break;
                    case NotifyCollectionChangedAction.Move:
                        Debug.Log($"Move:[{args.OldStartingIndex}] => [{args.NewStartingIndex}]");
                        break;
                    case NotifyCollectionChangedAction.Remove:
                        Debug.Log($"Remove:[{args.OldStartingIndex}] = {args.OldItem}");
                        break;
                    case NotifyCollectionChangedAction.Replace:
                        Debug.Log($"Replace:[{args.OldStartingIndex}] = ({args.OldItem} => {args.NewItem})");
                        break;
                    case NotifyCollectionChangedAction.Reset:
                        Debug.Log("Reset");
                        break;
                    default:
                        throw new ArgumentOutOfRangeException();
                }
            };
            
            // 末尾に追加
            _ringBuffer.AddLast(1);
            _ringBuffer.AddLast(2);
            _ringBuffer.AddLast(3);

            // 末尾削除
            _ringBuffer.RemoveLast();
            
            // 先頭に差し込み
            _ringBuffer.AddFirst(0);
            
            // 先頭に差し込み(キャパシティを超える)
            _ringBuffer.AddFirst(-1);
            
            // 真ん中の要素を書き換え
            _ringBuffer[1] = 10;

        }
    }
}
// AddLast(1,2,3)
Add:[0] = 1
Add:[1] = 2
Add:[2] = 3

// RemoveLast()
Remove:[2] = 3

// AddFirst(0)
Add:[0] = 0

// AddFirst(-1)
Remove:[2] = 2
Add:[0] = -1

// _ringBuffer[1] = 10
Replace:[1] = (0 => 10)

ObservableFixedSizeRingBufferは固定長RingBufferです。
コレクションの先端/末尾に要素を追加することができ、その際にキャパシティを超えたら反対側から要素が抜けるという挙動になっています。

そしてその要素の変動をCollectionChangedから購読することができ、リアクティブにコレクションの変動を扱うことができます。

R3のObservableと連携する

次世代RxであるR3ObservableCollectionsを連携することができます。
連携するにはObservableCollections.R3という別のパッケージをNugetより導入する必要があります。

とくにR3には、UniRxにはあったReactiveDictionaryなどのリアクティブなコレクション実装が存在しません。
その代わりにObservableCollectionsと接続することでそれら機能の代替とすることができます(ObservableCollectionsの方が多機能なのでむしろUniRxより大幅強化されることになる)

ObservableCollections.R3が導入された状態であれば、次のようにObservableCollectionsの状態通知をR3.Observableとして扱うことができるようになります。

// ObservableDictionaryをUniRx.ReactiveDictionaryみたく扱うことができる
var observableDictionary = new ObservableDictionary<int, string>();

// 新しい要素が追加されたイベント
observableDictionary.ObserveAdd(destroyCancellationToken)
    .Subscribe(collectionAddEvent =>
    {
        var (key, value) = collectionAddEvent.Value;
        Debug.Log($"Add [{key}]={value}");
    });

observableDictionary.ObserveReplace(destroyCancellationToken)
    .Subscribe(replaceEvent =>
    {
        var key = replaceEvent.NewValue.Key;
        var newValue = replaceEvent.NewValue.Value;
        var oldValue = replaceEvent.OldValue.Value;
        Debug.Log($"Replace [{key}]={oldValue} -> {newValue}");
    });

observableDictionary[1] = "hoge";
observableDictionary[2] = "fuga";
observableDictionary[1] = "piyo";
Add [1]=hoge
Add [2]=fuga
Replace [1]=hoge -> piyo

ObservableCollectionsからUnityのViewに繋ぎこむ

ではObservableCollectionsからUnityのView(uGUIなど)に繋ぎこんでみましょう。
そのためにまずISynchronizedView<T, TView>を把握しておく必要があります。

ISynchronizedView<T, TView>

ISynchronizedView<T, TView>は「ObservableCollectionsがもつコレクションの要素」と「その要素に紐づいたView」を管理するコレクションです。
もう少しざっくりいうと「ModelViewの対応付け」を保持してくれる存在です。

sv1.jpg

ISynchronizedView<T, TView>IObservableCollection<T>.CreateViewから生成できます。

IObservableCollection<T>.CreateViewを実行することでそれに紐づいたISynchronizedView<T, TView>を生成できます。
引数で「もとのコレクション要素が増えた時にどうViewへそれを連動させるか」という処理を渡し、コレクションの要素が増えた時にViewが自動で追加されるようにできます。

void Start()
{
    // IObservableCollection<T>
    var model = new ObservableFixedSizeRingBuffer<int>(10);

    // IObservableCollection<T>に連動したISynchronizedViewを生成する
    // このとき、デリゲートに「要素が増えた時にViewをどう追加するか」の処理を定義できる
    ISynchronizedView<int, GameObject> synchronizedView = model.CreateView(AddView);
}

// T -> TView
private GameObject AddView(int value)
{
    // IObservableCollection<T>の要素が追加されたときに実行される
    // ここではuGUIのPrefabがあったとして、これをInstantiateして内容を設定する処理をしている
    var item = GameObject.Instantiate(prefab, root.transform);
    item.GetComponentInChildren<Text>().text = value.ToString();
    return item.gameObject;
}

CreateViewでViewを追加する

このサンプルでは実際にCreateViewを使ってViewを追加しています。

ただしこの実装には要素が減った時にViewを削除する処理が入っていません。
そのためIObservableCollection<T>から要素が減ったとしてもViewが残り続けてしまっています。

using ObservableCollections;
using UnityEngine;
using UnityEngine.UI;

namespace Samples
{
    public class SynchronizedViewSample : MonoBehaviour
    {
        public Button prefab;
        public GameObject root;
        ObservableFixedSizeRingBuffer<int> _model;
        ISynchronizedView<int, GameObject> _synchronizedView;

        private readonly int MaxCapacity = 5;

        void Start()
        {
            _model = new ObservableFixedSizeRingBuffer<int>(MaxCapacity);

            // Modelに要素が増えたらViewを追加する
            _synchronizedView = _model.CreateView(AddView);
        }

        private GameObject AddView(int value)
        {
            // IObservableCollection<T>の要素が追加されたときに実行される
            var item = Instantiate(prefab, root.transform);
            item.GetComponentInChildren<Text>().text = value.ToString();
            return item.gameObject;
        }

        private void Update()
        {
            // キーを押すごとに要素を1つ追加していく
            if (Input.GetKeyDown(KeyCode.A))
            {
                _model.AddLast(Time.frameCount);
            }
        }

        void OnDestroy()
        {
            // Destroy時に紐づいたViewも一緒に消す
            foreach (var (_, view) in _synchronizedView)
            {
                if (view != null) Destroy(view);
            }
            
            _synchronizedView.Dispose();
        }
    }
}

sv1.gif

(キー入力のたびにObservableFixedSizeRingBufferに要素が追加され、それに反応してView(Button)が生成されている。ObservableFixedSizeRingBufferのキャパシティは5なので、要素数が5を超えた時点でリングバッファ側からは古い要素が消えている。そかしこの変動はViewに反映されるべきだが、この実装では反映されておらずViewが増え続けている。)

CreateViewがサポートするのは「コレクション要素が増えた時にViewをどう生成するか」までです。
「生成したViewをどう配置するか」や「要素が減った時にViewをどう消すか」は手動で定義する必要があります。

sv2.jpg

RoutingCollectionChangedCollectionStateChanged

ISynchronizedView<T, TView>にはRoutingCollectionChangedCollectionStateChangedの2つのイベントが定義されています。
それぞれ元のコレクション(IObservableCollection側)の要素の変化をイベントとして通知します。

event NotifyCollectionChangedEventHandler<T> RoutingCollectionChanged;
event Action<NotifyCollectionChangedAction> CollectionStateChanged;

両者の違いとしてはRoutingCollectionChangedNotifyCollectionChangedEventHandler<T>を扱うのに対し、CollectionStateChangedNotifyCollectionChangedActionのみを扱います。

ざっくりまとめると次です。

  • RoutingCollectionChanged : 「どの要素」が「どうなったのか(追加/削除/置換)」がすべて通知される
  • CollectionStateChanged : 「何が起きたのか(追加/削除/置換)」のみが通知される(具体的にどの要素が変動されたかはわからない)

RoutingCollectionChangedの方が得られる情報は多いです。

コレクションから要素が消えた時にViewも一緒に消すには

IObservableCollection<T>の要素とViewの対応づけはISynchronizedView<T, TView>が管理しています。
そのためRoutingCollectionChangedを監視し、Removeを受けた時に紐づいているViewを消してあげる処理を追加したとします。

void Start()
{
    _model = new ObservableFixedSizeRingBuffer<int>(MaxCapacity);

    // Modelに要素が増えたらViewを追加する
    _synchronizedView = _model.CreateView(AddView);

    _synchronizedView.RoutingCollectionChanged +=
        delegate(in NotifyCollectionChangedEventArgs<int> args)
        {
            // 削除されたデータ本体
            var removedData = args.OldItem;

            // このデータに紐づいていたViewを探す
            var (_, view) =
                _synchronizedView.FirstOrDefault(x => x.Value == removedData);

            // Viewが見つかったら消す
            if (view != null) Destroy(view);
        };
}

しかしこれ、上手く動きません。

理由は「元のObservableCollection側で要素が削除されたとき、ISynchronizedView<T, TView>側にそれを反映してからRoutingCollectionChangedが発火する」からです。
つまり「Remove」のイベントが飛んできた時点で、そのValueViewを紐づけた要素がすでにISynchronizedView<T, TView>から削除されているのです。

(要するに、消したいViewの参照がISynchronizedView<T, TView>から消えてしまってからイベント通知が来るため、消すべきViewを見失ってしまう)

そのため「ObservableCollectionの要素が変化した時にViewを連動して消す」については別の方法を考える必要があります。

そこでViewを消すための手法の1つとして「ISynchronizedViewFilter<T, TView>を使う」というやり方を紹介します。

ISynchronizedViewFilter<T, TView>

(上記で「要素の削除にSynchronizedViewFilter<T, TView>を使う」と話してはいますが、その使い方自体がそもそもイレギュラー感あるので一旦忘れてください。まずは本来のSynchronizedViewFilter<T, TView>の使い方から紹介します。)

ISynchronizedViewFilter<T, TView>ISynchronizedView<T, TView>に適用できるフィルターオブジェクトであり、条件に応じてViewの状態を変更することができます。
そのため本来は「リストの中から特定の属性のアイテムのみに表示したい」みたいな用途で用いるものです。

ISynchronizedViewFilterインタフェース定義
public interface ISynchronizedViewFilter<T, TView>
{
    bool IsMatch(T value, TView view);
    void WhenTrue(T value, TView view);
    void WhenFalse(T value, TView view);
    void OnCollectionChanged(ChangedKind changedKind, T value, TView view, in NotifyCollectionChangedEventArgs<T> eventArgs);
}

OnCollectionChangedChangedKindは「ISynchronizedView<T, TView>の要素に何が起こったか」がわかります。
一方でeventArgs.Actionを参照すると「IObservableCollection<T>側で何が発生したか」がわかります。
この2つを組み合わせることで「Replaceが発生したためにViewAddされた」といった詳細を把握することができます。

Replaceが起きたときに要素の位置を調整する処理
public void OnCollectionChanged(ChangedKind changedKind,
    int value,
    GameObject view,
    in NotifyCollectionChangedEventArgs<int> eventArgs)
{
    // eventArgs.Actionは「大本のコレクションで何が起きたか?」
    // ここではReplaceに限定する
    if (eventArgs.Action != NotifyCollectionChangedAction.Replace) return;

    // Replace起因でViewのAddが発生したことがここでわかるので、
    // その場合にViewの要素の並び替えを行う
    if (changedKind == ChangedKind.Add)
    {
        view.transform.SetSiblingIndex(eventArgs.NewStartingIndex);
    }
}

なおFilterは1つのISynchronizedView<T, TView>につき同時に1つしか設定できません。

使用例

たとえばISynchronizedViewFilter<T, TView>を設定し、「Valueが奇数のときのみ表示するフィルター」を反映してみます。

まずはISynchronizedViewFilter<T, TView>を実装したフィルターを定義します。

// 奇数フィルター
class OddFilter : ISynchronizedViewFilter<int, GameObject>
{
    public bool IsMatch(int value, GameObject view)
    {
        // ヒットする条件
        return value % 2 == 1;
    }

    public void WhenTrue(int value, GameObject view)
    {
        // 奇数なら表示する
        view.SetActive(true);
    }

    public void WhenFalse(int value, GameObject view)
    {
        // 偶数なら表示しない
        view.SetActive(false);
    }

    public void OnCollectionChanged(ChangedKind changedKind,
        int value,
        GameObject view,
        in NotifyCollectionChangedEventArgs<int> eventArgs)
    {
        // 何もしない
    }
}

そしてこのフィルターをISynchronizedView<T, TView>.AttachFilterを用いて適用します。

// たとえば、キーが押されたらフィルターを切り替える
if (Input.GetKeyDown(KeyCode.O))
{
    if (_isFilterOn)
    {
        // フィルター解除
        // 解除時に全View要素を表示する
        _synchronizedView.ResetFilter((_, view) => view.SetActive(true));
        _isFilterOn = false;
    }
    else
    {
        // 奇数だけ表示するフィルターを追加
        _synchronizedView.AttachFilter(new OddFilter());
        _isFilterOn = true;
    }

    Debug.Log($"Filter:{_isFilterOn}");
}
(全体)
using System.Linq;
using ObservableCollections;
using UnityEngine;
using UnityEngine.UI;

namespace Samples
{
    public class SynchronizedViewSample : MonoBehaviour
    {
        public Button prefab;
        public GameObject root;
        ObservableFixedSizeRingBuffer<int> _model;
        ISynchronizedView<int, GameObject> _synchronizedView;

        private readonly int MaxCapacity = 5;
        private bool _isFilterOn;

        void Start()
        {
            _model = new ObservableFixedSizeRingBuffer<int>(MaxCapacity);

            // Modelに要素が増えたらViewを追加する
            _synchronizedView = _model.CreateView(AddView);
        }

        private GameObject AddView(int value)
        {
            // IObservableCollection<T>の要素が追加されたときに実行される
            var item = Instantiate(prefab, root.transform);
            item.GetComponentInChildren<Text>().text = value.ToString();
            return item.gameObject;
        }

        private void Update()
        {
            // キーを押すごとに要素を1つ追加していく
            if (Input.GetKeyDown(KeyCode.A))
            {
                _model.AddLast(Time.frameCount);
            }

            // キーが押されたらフィルターを切り替える
            if (Input.GetKeyDown(KeyCode.O))
            {
                if (_isFilterOn)
                {
                    // フィルター解除
                    // 解除時に全View要素を表示する
                    _synchronizedView.ResetFilter((_, view) => view.SetActive(true));
                    _isFilterOn = false;
                }
                else
                {
                    // 奇数だけ表示するフィルターを追加
                    _synchronizedView.AttachFilter(new OddFilter());
                    _isFilterOn = true;
                }

                Debug.Log($"Filter:{_isFilterOn}");
            }
        }

        void OnDestroy()
        {
            // Destroy時に紐づいたViewも一緒に消す
            foreach (var (_, view) in _synchronizedView)
            {
                if (view != null) Destroy(view);
            }
            
            _synchronizedView.Dispose();
        }
    }
}

実行すると次のような挙動となります。
フィルターを反映したタイミングでViewが非表示となり、フィルターを解除すると再度表示されます。

svfilter.gif

フィルターを応用してViewの削除に用いる

さきほどISynchronizedView<T, TView>.RoutingCollectionChangedを監視しただけではRemoveイベントからViewの削除が連動できないと説明しました。
これを実現するために、ISynchronizedViewFilter<T, TView>悪用して応用してみます。

ISynchronizedViewFilter<int, GameObject>Removeイベントが発火したとき、関連付けられたViewも一緒に通知されるため、これを用いることで「Removeイベントに対応したView」を知ることができます。

// View削除フィルター
class RemoveFilter : ISynchronizedViewFilter<int, GameObject>
{
    public bool IsMatch(int value, GameObject view)
    {
        // 使わないので適当に返す
        return true;
    }

    public void WhenTrue(int value, GameObject view)
    {
    }

    public void WhenFalse(int value, GameObject view)
    {
    }

    public void OnCollectionChanged(ChangedKind changedKind,
        int value,
        GameObject view,
        in NotifyCollectionChangedEventArgs<int> eventArgs)
    {
        // 要素が削除されたらこのイベントに紐づいたViewも消す
        if (changedKind == ChangedKind.Remove)
        {
            if (view != null) Object.Destroy(view);
            Debug.Log($"{value} is removed.");
        }
    }
}
_model = new ObservableFixedSizeRingBuffer<int>(MaxCapacity);

// Modelに要素が増えたらViewを追加する
_synchronizedView = _model.CreateView(AddView);

// 要素を消すフィルターを追加
_synchronizedView.AttachFilter(new RemoveFilter());
(全体)
using ObservableCollections;
using UnityEngine;
using UnityEngine.UI;

namespace Samples
{
    public class SynchronizedViewSample : MonoBehaviour
    {
        public Button prefab;
        public GameObject root;
        ObservableFixedSizeRingBuffer<int> _model;
        ISynchronizedView<int, GameObject> _synchronizedView;

        private readonly int MaxCapacity = 5;

        void Start()
        {
            _model = new ObservableFixedSizeRingBuffer<int>(MaxCapacity);

            // Modelに要素が増えたらViewを追加する
            _synchronizedView = _model.CreateView(AddView);

            // 要素を消すフィルターを追加
            _synchronizedView.AttachFilter(new RemoveFilter());
        }

        private GameObject AddView(int value)
        {
            // IObservableCollection<T>の要素が追加されたときに実行される
            var item = Instantiate(prefab, root.transform);
            item.GetComponentInChildren<Text>().text = value.ToString();
            return item.gameObject;
        }

        private void Update()
        {
            // キーを押すごとに要素を1つ追加していく
            if (Input.GetKeyDown(KeyCode.A))
            {
                _model.AddLast(Time.frameCount);
            }
        }

        void OnDestroy()
        {
            // Destroy時に紐づいたViewも一緒に消す
            foreach (var (_, view) in _synchronizedView)
            {
                if (view != null) Destroy(view);
            }
            
            _synchronizedView.Dispose();
        }
    }
}

sv3.gif

ObservableFixedSizeRingBufferのキャパシティが5なので要素数が5を超えた分については古い要素が削除されていた。この実装でやっと要素の削除がViewにも反映されるようになった)

ObservableCollectionsを用いたちょっと複雑なサンプル

これら解説した機能を組み合わせて、次のようなサンプルを実装してみました。

maxsample.gif

(ここで実際に動かせます(WebGL))

  • ベースはMV(R)Pパターン
  • Monsterを最大6匹まで保持できる
  • Monsterは追加したり、逃したりできる
  • Monsterの手持ちをUIに表示できる
    • このときMonsterをフィルターして表示切り替えができる

実装は次のリポジトリで公開してます。

実装解説

Model

Model(データの実体)の定義は次のようになっています。

Monster

using System;

namespace ViewSamples
{
    // モンスター
    public readonly struct Monster : IEquatable<Monster>
    {
        // 属性
        public MonsterType MonsterType { get; }

        // 名前
        public string Name { get; }

        public Monster(MonsterType monsterType, string name)
        {
            MonsterType = monsterType;
            Name = name;
        }

        public bool Equals(Monster other)
        {
            return MonsterType == other.MonsterType && Name == other.Name;
        }

        public override bool Equals(object obj)
        {
            return obj is Monster other && Equals(other);
        }

        public override int GetHashCode()
        {
            return HashCode.Combine((int)MonsterType, Name);
        }
    }

    // 属性定義
    public enum MonsterType
    {
        Fire, 
        Water,
        Grass
    }
}

MonsterBackpack

このMonsterを最大6匹まで保持する概念としてMonsterBackpackを定義します。
(「手持ちモンスター」を表現する)

このとき、ObservableHashSet<Monster>をコレクションに利用し手持ちモンスターの増減をViewに反映する準備をしています。

using ObservableCollections;
using UnityEngine;

namespace ViewSamples
{
    /// <summary>
    /// モンスターを格納するオブジェクト
    /// </summary>
    public class MonsterBackpack : MonoBehaviour
    {
        // View構築用にpublicにしておく
        public IObservableCollection<Monster> ObservableCollection => _hashSet;

        // ObservableなHashSet
        // HashSetは要素の重複を許さないコレクション
        private readonly ObservableHashSet<Monster> _hashSet = new();

        // BackPackの最大容量
        private readonly int _maxCapacity = 6;

        /// <summary>
        /// モンスターを追加する
        /// </summary>
        public bool TryAddMonster(MonsterType monsterType, string name)
        {
            var monster = new Monster(monsterType, name);

            // 要素を超える場合は追加しない
            if (_hashSet.Count >= _maxCapacity) return false;
            return _hashSet.Add(monster);
        }

        /// <summary>
        /// モンスターを逃がす
        /// </summary>
        public void RemoveMonster(Monster monster)
        {
            _hashSet.Remove(monster);
        }

        /// <summary>
        /// すべてのモンスターを逃がす
        /// </summary>
        public void RemoveAllMonster()
        {
            _hashSet.Clear();
        }
    }
}

View

続いてView(UI側)です。

MonsterCardView

MonsterCardViewというViewを扱うコンポーネントと、そのPrefabを用意します。

using System;
using UnityEngine;
using UnityEngine.UI;

namespace ViewSamples.ugui.View
{
    /// <summary>
    /// Viewを管理するコンポーネント
    /// </summary>
    public class MonsterCardView : MonoBehaviour
    {
        [SerializeField] private Text _typeLabel;
        [SerializeField] private Text _nameLabel;
        [SerializeField] private Image _backgroundImage;
        [SerializeField] private Button _removeButton;

        // 逃がすボタン
        public Button RemoveButton => _removeButton;

        /// <summary>
        /// Viewのセットアップ
        /// </summary>
        public void Initialize(Monster monster)
        {
            // 各種要素を設定
            _nameLabel.text = monster.Name;
            switch (monster.MonsterType)
            {
                case MonsterType.Fire:
                    _typeLabel.text = "ほのお";
                    _backgroundImage.color = new Color(1f, 0.7f, 0.7f);
                    break;
                case MonsterType.Water:
                    _typeLabel.text = "みず";
                    _backgroundImage.color = new Color(0.7f, 0.7f, 1.0f);
                    break;
                case MonsterType.Grass:
                    _typeLabel.text = "くさ";
                    _backgroundImage.color = new Color(0.7f, 1, 0.7f);
                    break;
                default:
                    throw new ArgumentOutOfRangeException();
            }

        }
    }
}

Prefab.jpg

Presenter

このMonsterBackpackMonsterCardViewの繋ぎこみをPresenterで実装します。

ISynchronizedViewFilterの実装

今回はISynchronizedViewFilterの実装は2つあります。

using ObservableCollections;
using ViewSamples.ugui.View;

namespace ViewSamples.ugui
{
    /// <summary>
    /// MonsterのTypeに応じて表示を切り替えるフィルター
    /// </summary>
    internal sealed class MonsterTypeFilter : ISynchronizedViewFilter<Monster, MonsterCardView>
    {
        private MonsterType MonsterType { get; }

        public MonsterTypeFilter(MonsterType monsterType)
        {
            MonsterType = monsterType;
        }

        public bool IsMatch(Monster value, MonsterCardView view)
        {
            // タイプが一致するか
            return value.MonsterType == MonsterType;
        }

        public void WhenTrue(Monster value, MonsterCardView view)
        {
            view.gameObject.SetActive(true);
        }

        public void WhenFalse(Monster value, MonsterCardView view)
        {
            view.gameObject.SetActive(false);
        }

        public void OnCollectionChanged(ChangedKind changedKind,
            Monster value,
            MonsterCardView view,
            in NotifyCollectionChangedEventArgs<Monster> eventArgs)
        {
            // do nothing
        }
    }
}
using ObservableCollections;
using UnityEngine;
using ViewSamples.ugui.View;

namespace ViewSamples.ugui
{
    // Remove時に要素を削除するフィルター
    internal sealed class RemoveFilter : ISynchronizedViewFilter<Monster, MonsterCardView>
    {
        public bool IsMatch(Monster value, MonsterCardView view)
        {
            return true;
        }

        public void WhenTrue(Monster value, MonsterCardView view)
        {
        }

        public void WhenFalse(Monster value, MonsterCardView view)
        {
        }

        public void OnCollectionChanged(ChangedKind changedKind,
            Monster value,
            MonsterCardView view,
            in NotifyCollectionChangedEventArgs<Monster> eventArgs)
        {
            if (changedKind == ChangedKind.Remove)
            {
                if (view != null) Object.Destroy(view.gameObject);
            }
        }
    }
}

このMonsterTypeFilterRemoveFilterを合成するFilterMuxを定義し、2つのフィルターを合成して適用できるようにしておきます。

using System.Linq;
using ObservableCollections;

namespace ViewSamples.ugui
{
    /// <summary>
    /// 複数のFilterを合成するもの
    /// </summary>
    public sealed class FilterMux<T, TView> : ISynchronizedViewFilter<T, TView>
    {
        private readonly ISynchronizedViewFilter<T, TView>[] _filters;

        public FilterMux(params ISynchronizedViewFilter<T, TView>[] filters)
        {
            _filters = filters;
        }

        public bool IsMatch(T value, TView view)
        {
            return _filters.All(x => x.IsMatch(value, view));
        }

        public void WhenTrue(T value, TView view)
        {
            foreach (var filter in _filters)
            {
                filter.WhenTrue(value, view);
            }
        }

        public void WhenFalse(T value, TView view)
        {
            foreach (var filter in _filters)
            {
                filter.WhenFalse(value, view);
            }
        }

        public void OnCollectionChanged(ChangedKind changedKind,
            T value,
            TView view,
            in NotifyCollectionChangedEventArgs<T> eventArgs)
        {
            foreach (var filter in _filters)
            {
                filter.OnCollectionChanged(changedKind, value, view, eventArgs);
            }
        }
    }
}

MonsterPresenter

PresenterレイヤーでメインとなるのがMonsterPresenterです。
このオブジェクトがMonsterBackpackObservableCollectionを参照し、Viewへの紐づけを行います。

そしてさきほどのFilterMuxを用いて、2つのフィルターを同時適用しています。

using ObservableCollections;
using UniRx;
using UnityEngine;
using UnityEngine.UI;
using ViewSamples.ugui.View;

namespace ViewSamples.ugui
{
    /// <summary>
    /// MonsterBackpackの要素をリストへ反映する
    /// </summary>
    public sealed class MonsterPresenter : MonoBehaviour
    {
        [SerializeField] private MonsterBackpack _monsterBackpack;
        [SerializeField] private MonsterCardView _cardPrefab;
        [SerializeField] private Transform _viewRoot;

        [SerializeField] private Dropdown _filterDropdown;

        private ISynchronizedView<Monster, MonsterCardView> _synchronizedView;

        private void Start()
        {
            // コレクションとViewの連動を定義
            _synchronizedView = _monsterBackpack.ObservableCollection.CreateView(m =>
            {
                // Monsterが追加されたらPrefabより新しいViewを追加
                var view = Instantiate(_cardPrefab, _viewRoot.transform);
                view.Initialize(m);

                // Viewのボタンを購読し、逃がすボタンが押されたら要素から消すように定義
                view.RemoveButton.OnClickAsObservable()
                    .Take(1)
                    .Subscribe(_ => _monsterBackpack.RemoveMonster(m))
                    .AddTo(view);

                return view;
            });

            // Dropdownが操作されたらフィルターを設定する
            _filterDropdown.OnValueChangedAsObservable()
                .Subscribe(x =>
                {
                    switch (x)
                    {
                        case 0:
                            // フィルタを一旦解除し、描画設定を元に戻す
                            _synchronizedView.ResetFilter((_, v) =>
                                v.gameObject.SetActive(true));
                            // 改めて削除フィルタのみ再設定
                            _synchronizedView.AttachFilter(new RemoveFilter());
                            break;
                        case 1: // Fire
                            _synchronizedView.AttachFilter(
                                new FilterMux<Monster, MonsterCardView>(
                                    new MonsterTypeFilter(MonsterType.Fire),
                                    new RemoveFilter()));
                            break;
                        case 2: // Water
                            _synchronizedView.AttachFilter(
                                new FilterMux<Monster, MonsterCardView>(
                                    new MonsterTypeFilter(MonsterType.Water),
                                    new RemoveFilter()));
                            break;
                        case 3: // Grass
                            _synchronizedView.AttachFilter(
                                new FilterMux<Monster, MonsterCardView>(
                                    new MonsterTypeFilter(MonsterType.Grass),
                                    new RemoveFilter()));
                            break;
                    }
                })
                .AddTo(this);

            // 初期設定として削除フィルターを追加
            _synchronizedView.AttachFilter(new RemoveFilter());
        }
    }
}

ControllerMenuPresenter

ControllerMenuPresenterは「Monsterの追加」や「まとめて逃がす」といった操作を管理します。
操作をModel(MonsterBackpack)へ伝えるのみで、このPresenter自体がViewの操作は行いません。

using System;
using UniRx;
using UnityEngine;
using UnityEngine.UI;

namespace ViewSamples.ugui
{
    /// <summary>
    /// 各種UIのボタン操作をModelに反映する
    /// </summary>
    public sealed class ControllerMenuPresenter : MonoBehaviour
    {
        [SerializeField] private MonsterBackpack _monsterBackpack;

        [SerializeField] private Dropdown _typeDropdown;
        [SerializeField] private InputField _nameInputField;
        [SerializeField] private Button _addButton;
        [SerializeField] private Button _removeAllButton;
        
        private void Start()
        {
            // 名前入力欄が設定されたら追加ボタンを押せるようにする
            _nameInputField.OnValueChangedAsObservable()
                .Select(x => x.Length > 0)
                .SubscribeToInteractable(_addButton)
                .AddTo(this);

            // 追加ボタンを押したら手持ちにモンスターを加える
            _addButton.OnClickAsObservable()
                .Subscribe(_ =>
                {
                    var monsterType = _typeDropdown.value switch
                    {
                        0 => MonsterType.Fire,
                        1 => MonsterType.Water,
                        2 => MonsterType.Grass,
                        _ => throw new ArgumentOutOfRangeException()
                    };

                    _monsterBackpack.TryAddMonster(monsterType, _nameInputField.text);

                    _nameInputField.text = "";
                })
                .AddTo(this);

            // まとめて逃がすボタン
            _removeAllButton.OnClickAsObservable()
                .Subscribe(_ => _monsterBackpack.RemoveAllMonster())
                .AddTo(this);
        }
    }
}

まとめ

これらを組みわせることでこのような挙動をするUIを組むことができました。

maxsample.gif

たしかに複数のデータを扱うUIを組むのにObservableCollectionsは便利です。

ObservableCollectionsの考察

ObservableCollectionsを使って出てきた疑問を自分なりに調べてまとめてみました。

ObservableCollectionsに適したUIの設計パターンは何?

ObservableCollectionsWPFBlazorであればMVVMパターンでの実装が可能です。

ですがUnityはフレームワークとしてMVVMをサポートしていないため、ModelViewの紐づけは手動になります。
なので「何というデザインパターンか」と名前を聞かれると答えられないのですが、とりあえず「MV(R)Pパターンをベースにそれっぽく書く」が最適解だと思います。

IObservableCollection<T>の要素の削除をViewに反映するには

本文で解説しているとおり、現状はISynchronizedViewFilterを用いてあげるのが一番ラクです。
(そもそもフィルターで副作用起こす使い方が推奨されるのもアレな話なんですが、現状はこれが一番ラクです…)

// こういうFilterを定義して使い回すとか

using ObservableCollections;
using UnityEngine;

namespace Samples
{
    public class GameObjectRemoveFilter<T> : ISynchronizedViewFilter<T, GameObject>
    {
        public bool IsMatch(T value, GameObject view)
        {
            return true;
        }

        public void WhenTrue(T value, GameObject view)
        {
        }

        public void WhenFalse(T value, GameObject view)
        {
        }

        public void OnCollectionChanged(ChangedKind changedKind,
            T value,
            GameObject view,
            in NotifyCollectionChangedEventArgs<T> eventArgs)
        {
            if (changedKind == ChangedKind.Remove && view != null)
            {
                Object.Destroy(view);
            }
        }
    }

    public class ComponentRemoveFilter<T> : ISynchronizedViewFilter<T, UnityEngine.Component>
    {
        public bool IsMatch(T value, Component view)
        {
            return true;
        }

        public void WhenTrue(T value, Component view)
        {
        }

        public void WhenFalse(T value, Component view)
        {
        }

        public void OnCollectionChanged(ChangedKind changedKind,
            T value,
            Component view,
            in NotifyCollectionChangedEventArgs<T> eventArgs)
        {
            if (changedKind == ChangedKind.Remove && view != null)
            {
                Object.Destroy(view);
            }
        }
    }
}
var ringBuffer = new ObservableFixedSizeRingBuffer<int>(MaxCapacity);

// Modelに要素が増えたらViewを追加する
var synchronizedView = ringBuffer.CreateView(AddView);

// 要素を消すフィルターを追加
var synchronizedView.AttachFilter(new GameObjectRemoveFilter<int>());

ReplaceをどうViewに反映するか

Replaceとは、たとえば次のような「指定した要素を上書きする」みたいな処理です。

var buffer = new ObservableFixedSizeRingBuffer<int>(MaxCapacity);

// これは「新規追加(Add)」
buffer.AddLast(1);
buffer.AddFirst(2);

// Replaceはこういう指定した箇所の「上書き」
buffer[1] = 0;

IObservableCollection<T>側で発生したReplaceの発生をどうViewに反映するかですが、現状は「RemoveAddを使ってがんばる」しかありません。つまり既存のView要素を使いまわすことはできず、古いView要素を消して新しいView要素作って適切な場所に再配置するという作業が必要になります。

// こういう「要素の位置をReplace時に配置を調整する」フィルターを定義して
public class ReplaceFilter : ISynchronizedViewFilter<int, GameObject>
{
    public bool IsMatch(int value, GameObject view)
    {
        return true;
    }

    public void WhenTrue(int value, GameObject view)
    {
    }

    public void WhenFalse(int value, GameObject view)
    {
    }

    public void OnCollectionChanged(ChangedKind changedKind,
        int value,
        GameObject view,
        in NotifyCollectionChangedEventArgs<int> eventArgs)
    {
        Debug.Log($"Action={eventArgs.Action}\tChangedKind={changedKind}");

        // eventArgs.Actionは「大本のコレクションで何が起きたか?」
        // ここではReplaceに限定する
        if (eventArgs.Action != NotifyCollectionChangedAction.Replace) return;

        // Replace起因でViewのAddが発生したことがここでわかるので、
        // その場合にViewの要素の並び替えを行う
        if (changedKind == ChangedKind.Add)
        {
            // ヒエラルキーの順序がViewの並び順に一致している
            view.transform.SetSiblingIndex(eventArgs.NewStartingIndex);
        }
    }
}

// あとは通常通り「要素が増えたら新しいViewを作る」「要素が減ったらViewを消す」をまず実行し、
// Replaceは「新しく増えたViewの位置を調整する」で実現する
public class SynchronizedViewWithReplaceSample : MonoBehaviour
{
    public Button prefab;
    public GameObject root;
    ObservableFixedSizeRingBuffer<int> _model;
    ISynchronizedView<int, GameObject> _synchronizedView;

    private readonly int MaxCapacity = 5;

    void Start()
    {
        _model = new ObservableFixedSizeRingBuffer<int>(MaxCapacity);

        // Modelに要素が増えたらViewを追加する
        _synchronizedView = _model.CreateView(AddView);

        // 要素を消す&Replaceするフィルターを設定
        _synchronizedView.AttachFilter(new FilterMux<int, GameObject>(
            new GameObjectRemoveFilter<int>(),
            new ReplaceFilter()));
    }

    private GameObject AddView(int value)
    {
        // IObservableCollection<T>の要素が追加されたときに実行される
        var item = Instantiate(prefab, root.transform);
        item.GetComponentInChildren<Text>().text = value.ToString();
        return item.gameObject;
    }

    private void Update()
    {
        // キーを押すごとに要素を1つ追加していく
        if (Input.GetKeyDown(KeyCode.A))
        {
            _model.AddLast(Time.frameCount);
        }

        if (Input.GetKeyDown(KeyCode.S))
        {
            // とりあえず真ん中の要素を0で上書きする
            _model[2] = 0;
        }
    }

    void OnDestroy()
    {
        // Destroy時に紐づいたViewも一緒に消す
        foreach (var (_, view) in _synchronizedView)
        {
            if (view != null) Destroy(view);
        }

        _synchronizedView.Dispose();
    }
}

replace.gif
Replace発生時にViewAddRemoveがセットで発生している。このイベントを駆使してReplace時にViewを適切な場所に移動させる。)

今回はViewの並び替えが簡単に済みましたが、もっとViewの構成が複雑になる場合は「Replaceが来たらViewを全部消してイチから再構築する」もありかもしれません。
とりあえずObservableCollectionsは「View要素の使い回しには対応していない」です。

Viewのソートをする

ObservableCollectionsにはSortedViewという機能があります。
こちらはソート機能付きのISynchronizedView<T, TView>であり、インタフェースはISynchronizedView<T, TView>のまま変わりありません。

使い方としては、CreateViewの代わりにCreateSortedViewを用いてISynchronizedView<T, TView>を生成してあげればOKです。
ただし内部でSortedDictionaryが用いられている都合上、「Tのどの要素をKeyとして使うか」「TまたTViewの順序比較をどうするか」を指定する必要があります。

// なんか適当なオブジェクトがあったとして
// 出席番号 + 名前みたいな?
public readonly struct NameTag : IEquatable<NameTag>
{
    public int Index { get; }
    public string Name { get; }

    public NameTag(int index, string name)
    {
        Index = index;
        Name = name;
    }

    public bool Equals(NameTag other)
    {
        return Index == other.Index && Name == other.Name;
    }

    public override bool Equals(object obj)
    {
        return obj is NameTag other && Equals(other);
    }

    public override int GetHashCode()
    {
        return HashCode.Combine(Index, Name);
    }
}
// NameTagの順序を何に基づいて行うか
public sealed class NameTagComparer : IComparer<NameTag>
{
    public int Compare(NameTag x, NameTag y)
    {
        return x.Index - y.Index;
    }
}
public class SynchronizedSortedViewSample : MonoBehaviour
{
    public Button prefab;
    public GameObject root;
    ObservableFixedSizeRingBuffer<NameTag> _model;
    ISynchronizedView<NameTag, GameObject> _synchronizedView;
    private readonly int MaxCapacity = 5;

    void Start()
    {
        _model = new ObservableFixedSizeRingBuffer<NameTag>(MaxCapacity);

        // ソート機能付きISynchronizedViewを定義する
        _synchronizedView = _model.CreateSortedView(
            x => $"{x.Index}:{x.Name}", // NameTagからKeyを生成するロジック
            AddView,                    // NameTag -> View
            new NameTagComparer()       // NameTagをどのルールに基づいてソートするか
        ); 

        // 要素削除フィルターの追加
        _synchronizedView.AttachFilter(new GameObjectRemoveFilter<NameTag>());

        // ISynchronizedViewの要素が変動したら、順序に一致するようにViewをすべて並び替える
        _synchronizedView.CollectionStateChanged += _ =>
        {
            foreach (var (_, view, index) in _synchronizedView.Select((x, i) =>
                         (x.Value, x.View, Index: i)))
            {
                view.transform.SetSiblingIndex(index);
            }
        };
    }

    private GameObject AddView(NameTag value)
    {
        // IObservableCollection<T>の要素が追加されたときに実行される
        var item = Instantiate(prefab, root.transform);
        item.GetComponentInChildren<Text>().text = $"{value.Index}:{value.Name}";
        return item.gameObject;
    }

    private void Update()
    {
        if (Input.GetKeyDown(KeyCode.A))
        {
            // キー入力で適当に生成して詰める
            var randomName = new string(Enumerable.Repeat("あいうえおかきくけこ", 3)
                .Select(x => x[UnityEngine.Random.Range(0, 10)])
                .ToArray());
            var randomIndex = UnityEngine.Random.Range(1, 100);
            _model.AddLast(new NameTag(randomIndex, randomName));
        }
    }

    void OnDestroy()
    {
        if (_synchronizedView == null) return;

        // Destroy時に紐づいたViewも一緒に消す
        foreach (var (_, view) in _synchronizedView)
        {
            if (view != null) Destroy(view);
        }

        _synchronizedView.Dispose();
    }
}

Sorted.gif

SortedViewを用いることで、元のコレクションのデータ構造ではなくISynchronizedView<T, TView>側でソートを実現することができます。

感想

UnityにおいてObservableCollectionsを「リアクティブなコレクション」として見た場合は、結構便利に使えます。
UniRx.ReactiveCollectionと比較するとObservableCollectionsの方がサポートしているデータ構造が多いため、その点ではベターUniRxとして使えます。

ではUIへの応用はどうかというと、ISynchronizedView<T, TView>を用いることで複数データを扱うUI周りの実装が結構楽になるという印象です。
とりあえずObservableCollectionsを使ってそれに則って組めるようになればチーム開発でも統一した実装パターンにできるのではないかと感じます。
一方で、そもそもUnityがMVVMパターンをサポートしてくれていないため、ObservableCollectionsの真価を完全に引き出せていない感も否めません。
またISynchronizedView<T, TView>自体に癖があり、若干の扱いづらさもあります。

まとめると「やりたいことを満たす機能は揃って入るが、扱いに癖があり使いこなすのは大変そう」という感想です。
とはいえ、面白さや応用性は感じるので自分は使っていこうと思います。

34
20
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
34
20