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

# はじめに

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

 * https://github.com/TORISOUP/ObservableCollections_ViewSamples

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

# ObservableCollectionsとは

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

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

* `UniRx`の`ReactiveCollection<T>`やReactiveDictionary<T, V>`の強化版
    * `UniRx`の提供する`Observable`なコレクションと比較すると`View`（UI）を組みやすくなっている

* [R3](https://github.com/Cysharp/R3)との連携機能もあるため、R3には存在しない`ReactiveDictionary`などの代替として利用できる

## Unityでの活用

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

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

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

# 導入方法

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

1. [NugetForUnity](https://github.com/GlitchEnzo/NuGetForUnity?tab=readme-ov-file#how-do-i-install-nugetforunity)を自身のUnityプロジェクトに導入する
2. UnityEditor上部のメニュー [Nuget] -> [Manage Nuget Package]を開く
3. Search欄に`ObservableCollections`と入力し、Cysharpが提供する`ObservableCollections`パッケージを導入する
4. **[R3](https://github.com/Cysharp/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>`が定義されており、こちらを用いることでコレクションの変動を検知することができます。

```cs
// 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);
}
```

```cs
/// <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>`に要素を追加し、イベントがどのように発行されるのかを見てみます。

```cs
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である[R3](https://github.com/Cysharp/R3)と`ObservableCollections`を連携することができます。
連携するには**ObservableCollections.R3**という別のパッケージをNugetより導入する必要があります。

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

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

```cs
// 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`」を管理するコレクションです。
もう少しざっくりいうと「`Model`と`View`の対応付け」を保持してくれる存在です。

![sv1.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/47146/8e152de0-49cf-805e-73a8-6546590f6640.jpeg)

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

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

```cs
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`が残り続けてしまっています。


```cs
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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/47146/a799bfed-7fb2-60b3-4541-ca1c593767f6.gif)


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

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

![sv2.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/47146/30dfffb6-b43c-2a67-e881-334a33963a8f.jpeg)


## `RoutingCollectionChanged`と`CollectionStateChanged`

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

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

両者の違いとしては`RoutingCollectionChanged`は`NotifyCollectionChangedEventHandler<T>`を扱うのに対し、`CollectionStateChanged`は`NotifyCollectionChangedAction`のみを扱います。

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

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

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


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

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

```cs
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`」のイベントが飛んできた時点で、その`Value`と`View`を紐づけた要素がすでに`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`の状態を変更することができます。
そのため本来は「リストの中から特定の属性のアイテムのみに表示したい」みたいな用途で用いるものです。

```cs: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);
}
```

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

```cs: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は１つの`ISynchronizedView<T, TView>`につき同時に1つしか設定できません。**

### 使用例

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

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

```cs
// 奇数フィルター
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`を用いて適用します。

```cs
// たとえば、キーが押されたらフィルターを切り替える
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}");
}
```


<details>
<summary>（全体）</summary>

```cs
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();
        }
    }
}
```

</details>

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

![svfilter.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/47146/38356194-704f-caae-803b-772fadb43a8c.gif)



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

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

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

```cs
// 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.");
        }
    }
}
```

```cs
_model = new ObservableFixedSizeRingBuffer<int>(MaxCapacity);

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

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


<details>
<summary>（全体）</summary>

```cs
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();
        }
    }
}
```

</details>



![sv3.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/47146/1329e4bd-7176-c565-8936-ef8323001125.gif)


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


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

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

![maxsample.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/47146/6a497304-945d-60b0-2290-d7e668ba12db.gif)


([ここで実際に動かせます(WebGL)](https://torisoup.github.io/ObservableCollections_ViewSamples/ugui_sample/index.html))

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


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

* https://github.com/TORISOUP/ObservableCollections_ViewSamples/tree/master/Assets/TORISOUP/ViewSamples/ugui


## 実装解説

### Model

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

#### Monster

```cs
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`に反映する準備をしています。

```cs
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`を用意します。

```cs
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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/47146/cfeb4c2e-ccdd-6861-4181-e70dcadbb9eb.jpeg)


### Presenter

この`MonsterBackpack`と`MonsterCardView`の繋ぎこみを`Presenter`で実装します。


#### ISynchronizedViewFilterの実装

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

```cs
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
        }
    }
}
```

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

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

```cs
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`です。
このオブジェクトが`MonsterBackpack`の`ObservableCollection`を参照し、`View`への紐づけを行います。

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

```cs
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`の操作は行いません。

```cs
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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/47146/6a497304-945d-60b0-2290-d7e668ba12db.gif)

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


# ObservableCollectionsの考察

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

## `ObservableCollections`に適したUIの設計パターンは何？

`ObservableCollections`は`WPF`や`Blazor`であれば`MVVMパターン`での実装が可能です。

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

* [【Unity】Model-View-(Reactive)Presenterパターンとは何なのか](https://qiita.com/toRisouP/items/5365936fc14c7e7eabf9)


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

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

```cs
// こういう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);
            }
        }
    }
}
```

```cs
var ringBuffer = new ObservableFixedSizeRingBuffer<int>(MaxCapacity);

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

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

## `Replace`をどうViewに反映するか

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

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

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

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

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

```cs
// こういう「要素の位置を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);
        }
    }
}
```

```cs

// あとは通常通り「要素が増えたら新しい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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/47146/d9ad977c-8ad7-ab98-cc0e-fe0876508c47.gif)
（`Replace`発生時に`View`の`Add`と`Remove`がセットで発生している。このイベントを駆使して`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`の順序比較をどうするか」を指定する必要があります。

### 例

```cs
// なんか適当なオブジェクトがあったとして
// 出席番号 + 名前みたいな？
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);
    }
}
```

```cs
// NameTagの順序を何に基づいて行うか
public sealed class NameTagComparer : IComparer<NameTag>
{
    public int Compare(NameTag x, NameTag y)
    {
        return x.Index - y.Index;
    }
}
```

```cs
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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/47146/406544c1-560f-40fb-abbc-ebd1996ff90e.gif)

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


# 感想

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

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

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